Merge pull request #256 from nextcloud/enh/design-fixes
Enh/design fixes
This commit is contained in:
commit
2764e92da9
|
@ -1,25 +1,9 @@
|
|||
# EditorConfig is awesome: http://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# Unix-style newlines with a newline ending every file
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
|
||||
# Set default charset
|
||||
charset = utf-8
|
||||
|
||||
# 4 space tab indentation
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
|
||||
# Line length form NC coding guidelines
|
||||
trim_trailing_whitespace = true
|
||||
max_line_length = 80
|
||||
|
||||
# 2 space indentation for .yml files
|
||||
[.*.yml]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
|
@ -27,25 +27,32 @@ return [
|
|||
|
||||
// Before /{hash} to avoid conflict
|
||||
['name' => 'page#createForm', 'url' => '/new', 'verb' => 'GET'],
|
||||
['name' => 'page#editForm', 'url' => '/{hash}/edit/', 'verb' => 'GET'],
|
||||
['name' => 'page#cloneForm', 'url' => '/{hash}/clone/', 'verb' => 'GET'],
|
||||
['name' => 'page#getResult', 'url' => '/{hash}/results/', 'verb' => 'GET'],
|
||||
['name' => 'page#editForm', 'url' => '/{hash}/edit', 'verb' => 'GET'],
|
||||
['name' => 'page#cloneForm', 'url' => '/{hash}/clone', 'verb' => 'GET'],
|
||||
['name' => 'page#getResult', 'url' => '/{hash}/results', 'verb' => 'GET'],
|
||||
|
||||
['name' => 'page#goto_form', 'url' => '/{hash}', 'verb' => 'GET'],
|
||||
['name' => 'page#insert_submission', 'url' => '/insert/submission', 'verb' => 'POST'],
|
||||
|
||||
['name' => 'api#getForms', 'url' => 'api/v1/forms', 'verb' => 'GET'],
|
||||
['name' => 'api#newForm', 'url' => 'api/v1/form', 'verb' => 'POST'],
|
||||
['name' => 'api#getForm', 'url' => 'api/v1/form/{id}', 'verb' => 'GET'],
|
||||
['name' => 'api#updateForm', 'url' => 'api/v1/form/update/', 'verb' => 'POST'],
|
||||
['name' => 'api#deleteForm', 'url' => 'api/v1/form/{id}', 'verb' => 'DELETE'],
|
||||
['name' => 'api#updateQuestion', 'url' => 'api/v1/question/update/', 'verb' => 'POST'],
|
||||
['name' => 'api#reorderQuestions', 'url' => 'api/v1/question/reorder/', 'verb' => 'POST'],
|
||||
['name' => 'api#newQuestion', 'url' => 'api/v1/question/', 'verb' => 'POST'],
|
||||
['name' => 'api#deleteQuestion', 'url' => 'api/v1/question/{id}', 'verb' => 'DELETE'],
|
||||
['name' => 'api#newOption', 'url' => 'api/v1/option/', 'verb' => 'POST'],
|
||||
['name' => 'api#deleteOption', 'url' => 'api/v1/option/{id}', 'verb' => 'DELETE'],
|
||||
['name' => 'api#getSubmissions', 'url' => 'api/v1/submissions/{hash}', 'verb' => 'GET'],
|
||||
// Forms
|
||||
['name' => 'api#getForms', 'url' => '/api/v1/forms', 'verb' => 'GET'],
|
||||
['name' => 'api#newForm', 'url' => '/api/v1/form', 'verb' => 'POST'],
|
||||
['name' => 'api#getForm', 'url' => '/api/v1/form/{id}', 'verb' => 'GET'],
|
||||
['name' => 'api#updateForm', 'url' => '/api/v1/form/update', 'verb' => 'POST'],
|
||||
['name' => 'api#deleteForm', 'url' => '/api/v1/form/{id}', 'verb' => 'DELETE'],
|
||||
|
||||
// Questions
|
||||
['name' => 'api#newQuestion', 'url' => '/api/v1/question', 'verb' => 'POST'],
|
||||
['name' => 'api#updateQuestion', 'url' => '/api/v1/question/update', 'verb' => 'POST'],
|
||||
['name' => 'api#reorderQuestions', 'url' => '/api/v1/question/reorder', 'verb' => 'POST'],
|
||||
['name' => 'api#deleteQuestion', 'url' => '/api/v1/question/{id}', 'verb' => 'DELETE'],
|
||||
|
||||
// Answers
|
||||
['name' => 'api#newOption', 'url' => '/api/v1/option', 'verb' => 'POST'],
|
||||
['name' => 'api#updateOption', 'url' => '/api/v1/option/update', 'verb' => 'POST'],
|
||||
['name' => 'api#deleteOption', 'url' => '/api/v1/option/{id}', 'verb' => 'DELETE'],
|
||||
|
||||
['name' => 'api#getSubmissions', 'url' => '/api/v1/submissions/{hash}', 'verb' => 'GET'],
|
||||
|
||||
['name' => 'system#get_site_users_and_groups', 'url' => '/get/siteusers', 'verb' => 'POST'],
|
||||
]
|
||||
|
|
|
@ -2,8 +2,11 @@ module.exports = {
|
|||
plugins: ['@babel/plugin-syntax-dynamic-import'],
|
||||
presets: [
|
||||
[
|
||||
'@babel/preset-env'
|
||||
]
|
||||
]
|
||||
'@babel/preset-env',
|
||||
{
|
||||
corejs: 3,
|
||||
useBuiltIns: 'entry',
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
|
||||
|
|
|
@ -20,4 +20,11 @@
|
|||
*
|
||||
*/
|
||||
|
||||
@import 'icons'
|
||||
// Various variables used by this app
|
||||
:root {
|
||||
--header-height: $header-height;
|
||||
--top-bar-height: 60px;
|
||||
}
|
||||
|
||||
@import 'variables';
|
||||
@import 'icons';
|
||||
|
|
|
@ -2,6 +2,11 @@
|
|||
// Icon definitions
|
||||
@include icon-black-white('forms', 'forms', 3);
|
||||
@include icon-black-white('clone', 'forms', 1);
|
||||
@include icon-black-white('answer-short', 'forms', 1);
|
||||
@include icon-black-white('answer-long', 'forms', 1);
|
||||
@include icon-black-white('answer-checkbox', 'forms', 1);
|
||||
@include icon-black-white('answer-multiple', 'forms', 1);
|
||||
@include icon-black-white('drag-handle', 'forms', 1);
|
||||
|
||||
.icon-yes {
|
||||
@include icon-color('checkmark', 'actions', $color-success, 1, true);
|
||||
|
|
3
img/answer-checkbox.svg
Normal file
3
img/answer-checkbox.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16">
|
||||
<path d="M1.5 1c-.277 0-.5.223-.5.5v13c0 .277.223.5.5.5h13c.277 0 .5-.223.5-.5v-13c0-.277-.223-.5-.5-.5h-13zm10.756 3L13.5 5.242 6.773 12 2.5 7.701l1.217-1.226L6.783 9.54 12.256 4z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 257 B |
6
img/answer-long.svg
Normal file
6
img/answer-long.svg
Normal file
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<rect width="12" height="2" x="2" y="2" rx="0" ry="0" paint-order="markers stroke fill"/>
|
||||
<rect width="6" height="2" x="2" y="11" rx="0" ry="0" paint-order="markers stroke fill"/>
|
||||
<rect width="12" height="2" x="2" y="5" rx="0" ry="0" paint-order="markers stroke fill"/>
|
||||
<rect width="12" height="2" x="2" y="8" rx="0" ry="0" paint-order="markers stroke fill"/>
|
||||
</svg>
|
After Width: | Height: | Size: 439 B |
3
img/answer-multiple.svg
Normal file
3
img/answer-multiple.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<path d="M2 3v2h2V3zM6 3v2h8V3zM2 7v2h2V7zM6 7v2h8V7zM2 11v2h2v-2zM6 11v2h8v-2z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 156 B |
4
img/answer-short.svg
Normal file
4
img/answer-short.svg
Normal file
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<rect width="12" height="2" x="2" y="5" rx="0" ry="0" paint-order="markers stroke fill"/>
|
||||
<rect width="6" height="2" x="2" y="8" rx="0" ry="0" paint-order="markers stroke fill"/>
|
||||
</svg>
|
After Width: | Height: | Size: 254 B |
3
img/drag-handle.svg
Normal file
3
img/drag-handle.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10.7 10.7" width="16" height="16">
|
||||
<path d="M1.34 3.34h8.02v1.34H1.34zM1.34 6.02h8.02v1.34H1.34z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 162 B |
|
@ -95,7 +95,7 @@ class ApiController extends Controller {
|
|||
|
||||
} catch (DoesNotExistException $e) {
|
||||
//handle silently
|
||||
}finally{
|
||||
} finally {
|
||||
return $optionList;
|
||||
}
|
||||
}
|
||||
|
@ -119,6 +119,7 @@ class ApiController extends Controller {
|
|||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* Read Form-List only with necessary information for Listing.
|
||||
*/
|
||||
public function getForms(): Http\JSONResponse {
|
||||
|
@ -130,7 +131,7 @@ class ApiController extends Controller {
|
|||
'id' => $form->getId(),
|
||||
'hash' => $form->getHash(),
|
||||
'title' => $form->getTitle(),
|
||||
'expired' => $form->getExpired(),
|
||||
'expires' => $form->getExpires(),
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -150,7 +151,7 @@ class ApiController extends Controller {
|
|||
}
|
||||
|
||||
$result = $form->read();
|
||||
$result['questions'] = getQuestions();
|
||||
$result['questions'] = $this->getQuestions($id);
|
||||
|
||||
return new Http\JSONResponse($result);
|
||||
}
|
||||
|
@ -186,7 +187,9 @@ class ApiController extends Controller {
|
|||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* Writes the given key-value pairs into Database.
|
||||
*
|
||||
* @param int $id FormId of form to update
|
||||
* @param array $keyValuePairs Array of key=>value pairs to update.
|
||||
*/
|
||||
|
@ -241,7 +244,7 @@ class ApiController extends Controller {
|
|||
// Delete Submissions(incl. Answers), Questions(incl. Options) and Form.
|
||||
$this->submissionMapper->deleteByForm($id);
|
||||
$this->questionMapper->deleteByForm($id);
|
||||
$this->formMapper->delete($formToDelete);
|
||||
$this->formMapper->delete($form);
|
||||
|
||||
return new Http\JSONResponse($id);
|
||||
}
|
||||
|
@ -256,11 +259,16 @@ class ApiController extends Controller {
|
|||
'text' => $text,
|
||||
]);
|
||||
|
||||
if (array_search($type, Question::TYPES) === false) {
|
||||
$this->logger->debug('Invalid type');
|
||||
return new Http\JSONResponse(['message' => 'Invalid type'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$form = $this->formMapper->findById($formId);
|
||||
} catch (IMapperException $e) {
|
||||
$this->logger->debug('Could not find form');
|
||||
return new Http\JSONResponse([], Http::STATUS_BAD_REQUEST);
|
||||
return new Http\JSONResponse(['message' => 'Could not find form'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($form->getOwnerId() !== $this->userId) {
|
||||
|
@ -286,10 +294,8 @@ class ApiController extends Controller {
|
|||
|
||||
$question = $this->questionMapper->insert($question);
|
||||
|
||||
$response = [
|
||||
'id' => $question->getId(),
|
||||
'order' => $question->getOrder()
|
||||
];
|
||||
$response = $question->read();
|
||||
$response['options'] = [];
|
||||
|
||||
return new Http\JSONResponse($response);
|
||||
}
|
||||
|
@ -298,7 +304,7 @@ class ApiController extends Controller {
|
|||
* @NoAdminRequired
|
||||
* Updates the Order of all Questions of a Form.
|
||||
* @param int $formId Id of the form to reorder
|
||||
* @param int $newOrder Array of Question-Ids in new order.
|
||||
* @param int[] $newOrder Array of Question-Ids in new order.
|
||||
*/
|
||||
public function reorderQuestions(int $formId, array $newOrder): Http\JSONResponse {
|
||||
$this->logger->debug('Reordering Questions on Form {formId} as Question-Ids {newOrder}', [
|
||||
|
@ -467,16 +473,15 @@ class ApiController extends Controller {
|
|||
/**
|
||||
* @NoAdminRequired
|
||||
*/
|
||||
public function newOption(int $formId, int $questionId, string $text): Http\JSONResponse {
|
||||
$this->logger->debug('Adding new option: formId: {formId}, questionId: {questionId}, text: {text}', [
|
||||
'formId' => $formId,
|
||||
public function newOption(int $questionId, string $text): Http\JSONResponse {
|
||||
$this->logger->debug('Adding new option: questionId: {questionId}, text: {text}', [
|
||||
'questionId' => $questionId,
|
||||
'text' => $text,
|
||||
]);
|
||||
|
||||
try {
|
||||
$form = $this->formMapper->findById($formId);
|
||||
$question = $this->questionMapper->findById($questionId);
|
||||
$form = $this->formMapper->findById($question->getFormId());
|
||||
} catch (IMapperException $e) {
|
||||
$this->logger->debug('Could not find form or question so option can\'t be added');
|
||||
return new Http\JSONResponse([], Http::STATUS_BAD_REQUEST);
|
||||
|
@ -487,11 +492,6 @@ class ApiController extends Controller {
|
|||
return new Http\JSONResponse([], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
if ($question->getFormId() !== $formId) {
|
||||
$this->logger->debug('This question is not part of the current form');
|
||||
return new Http\JSONResponse([], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
$option = new Option();
|
||||
|
||||
$option->setQuestionId($questionId);
|
||||
|
@ -499,6 +499,45 @@ class ApiController extends Controller {
|
|||
|
||||
$option = $this->optionMapper->insert($option);
|
||||
|
||||
return new Http\JSONResponse([
|
||||
'id' => $option->getId()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
* Writes the given key-value pairs into Database.
|
||||
|
||||
* @param int $id OptionId of option to update
|
||||
* @param array $keyValuePairs Array of key=>value pairs to update.
|
||||
*/
|
||||
public function updateOption(int $id, array $keyValuePairs): Http\JSONResponse {
|
||||
$this->logger->debug('Updating option: option: {id}, values: {keyValuePairs}', [
|
||||
'id' => $id,
|
||||
'keyValuePairs' => $keyValuePairs
|
||||
]);
|
||||
|
||||
try {
|
||||
$option = $this->optionMapper->findById($id);
|
||||
$question = $this->questionMapper->findById($option->getQuestionId());
|
||||
$form = $this->formMapper->findById($question->getFormId());
|
||||
} catch (IMapperException $e) {
|
||||
$this->logger->debug('Could not find option, question or form');
|
||||
return new Http\JSONResponse(['message' => 'Could not find option, question or form'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($form->getOwnerId() !== $this->userId) {
|
||||
$this->logger->debug('This form is not owned by the current user');
|
||||
return new Http\JSONResponse([], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
// Create OptionEntity with given Params & Id.
|
||||
$option = Option::fromParams($keyValuePairs);
|
||||
$option->setId($id);
|
||||
|
||||
// Update changed Columns in Db.
|
||||
$this->optionMapper->update($option);
|
||||
|
||||
return new Http\JSONResponse($option->getId());
|
||||
}
|
||||
|
||||
|
@ -526,7 +565,6 @@ class ApiController extends Controller {
|
|||
|
||||
$this->optionMapper->delete($option);
|
||||
|
||||
//TODO useful response
|
||||
return new Http\JSONResponse($id);
|
||||
}
|
||||
|
||||
|
|
|
@ -107,7 +107,7 @@ class PageController extends Controller {
|
|||
*/
|
||||
public function index(): TemplateResponse {
|
||||
Util::addScript($this->appName, 'forms');
|
||||
Util::addStyle($this->appName, 'icons');
|
||||
Util::addStyle($this->appName, 'forms');
|
||||
return new TemplateResponse($this->appName, 'main');
|
||||
}
|
||||
|
||||
|
@ -119,7 +119,7 @@ class PageController extends Controller {
|
|||
*/
|
||||
public function createForm(): TemplateResponse {
|
||||
Util::addScript($this->appName, 'forms');
|
||||
Util::addStyle($this->appName, 'icons');
|
||||
Util::addStyle($this->appName, 'forms');
|
||||
return new TemplateResponse($this->appName, 'main');
|
||||
}
|
||||
|
||||
|
@ -131,7 +131,7 @@ class PageController extends Controller {
|
|||
*/
|
||||
public function cloneForm(): TemplateResponse {
|
||||
Util::addScript($this->appName, 'forms');
|
||||
Util::addStyle($this->appName, 'icons');
|
||||
Util::addStyle($this->appName, 'forms');
|
||||
return new TemplateResponse($this->appName, 'main');
|
||||
}
|
||||
|
||||
|
@ -143,7 +143,7 @@ class PageController extends Controller {
|
|||
*/
|
||||
public function editForm(): TemplateResponse {
|
||||
Util::addScript($this->appName, 'forms');
|
||||
Util::addStyle($this->appName, 'icons');
|
||||
Util::addStyle($this->appName, 'forms');
|
||||
return new TemplateResponse($this->appName, 'main');
|
||||
}
|
||||
|
||||
|
@ -155,7 +155,7 @@ class PageController extends Controller {
|
|||
*/
|
||||
public function getResult(): TemplateResponse {
|
||||
Util::addScript($this->appName, 'forms');
|
||||
Util::addStyle($this->appName, 'icons');
|
||||
Util::addStyle($this->appName, 'forms');
|
||||
return new TemplateResponse($this->appName, 'main');
|
||||
}
|
||||
|
||||
|
@ -173,13 +173,8 @@ class PageController extends Controller {
|
|||
return new TemplateResponse('forms', 'no.acc.tmpl', []);
|
||||
}
|
||||
|
||||
if ($form->getExpiresTimestamp() === 0) {
|
||||
$expired = false;
|
||||
} else {
|
||||
$expired = time() > $form->getExpiresTimestamp();
|
||||
}
|
||||
|
||||
if ($expired) {
|
||||
// If form expired, return Expired-Template
|
||||
if ( ($form->getExpires() !== 0) && (time() > $form->getExpires()) ) {
|
||||
return new TemplateResponse('forms', 'expired.tmpl');
|
||||
}
|
||||
|
||||
|
|
|
@ -41,8 +41,8 @@ use OCP\AppFramework\Db\Entity;
|
|||
* @method void setAccess(array $value)
|
||||
* @method integer getCreated()
|
||||
* @method void setCreated(integer $value)
|
||||
* @method integer getExpiresTimestamp()
|
||||
* @method void setExpiresTimestamp(integer $value)
|
||||
* @method integer getExpires()
|
||||
* @method void setExpires(integer $value)
|
||||
* @method integer getIsAnonymous()
|
||||
* @method void setIsAnonymous(bool $value)
|
||||
* @method integer getSubmitOnce()
|
||||
|
@ -56,7 +56,7 @@ class Form extends Entity {
|
|||
protected $ownerId;
|
||||
protected $accessJson;
|
||||
protected $created;
|
||||
protected $expiresTimestamp;
|
||||
protected $expires;
|
||||
protected $isAnonymous;
|
||||
protected $submitOnce;
|
||||
|
||||
|
@ -65,7 +65,7 @@ class Form extends Entity {
|
|||
*/
|
||||
public function __construct() {
|
||||
$this->addType('created', 'integer');
|
||||
$this->addType('expiresTimestamp', 'integer');
|
||||
$this->addType('expires', 'integer');
|
||||
$this->addType('isAnonymous', 'bool');
|
||||
$this->addType('submitOnce', 'bool');
|
||||
}
|
||||
|
@ -80,20 +80,6 @@ class Form extends Entity {
|
|||
$this->setAccessJson(json_encode($access));
|
||||
}
|
||||
|
||||
// Get virtual column expires. Set should only be done by setExpiresTimestamp().
|
||||
public function getExpires(): bool {
|
||||
return (bool) $this->getExpiresTimestamp();
|
||||
}
|
||||
|
||||
// Get virtual column expired. Set should only be done by setExpiresTimestamp().
|
||||
public function getExpired(): bool {
|
||||
if ($this->getExpires()) {
|
||||
return time() > $this->getExpiresTimestamp();
|
||||
}
|
||||
// else - does not expire
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read full form
|
||||
public function read() {
|
||||
return [
|
||||
|
@ -102,12 +88,9 @@ class Form extends Entity {
|
|||
'title' => $this->getTitle(),
|
||||
'description' => $this->getDescription(),
|
||||
'ownerId' => $this->getOwnerId(),
|
||||
'ownerDisplayName' => \OC_User::getDisplayName($this->getOwnerId()),
|
||||
'created' => $this->getCreated(),
|
||||
'access' => $this->getAccess(),
|
||||
'expires' => $this->getExpires(),
|
||||
'expired' => $this->getExpired(),
|
||||
'expiresTimestamp' => $this->getExpiresTimestamp(),
|
||||
'isAnonymous' => $this->getIsAnonymous(),
|
||||
'submitOnce' => $this->getSubmitOnce()
|
||||
];
|
||||
|
|
|
@ -43,6 +43,13 @@ class Question extends Entity {
|
|||
protected $mandatory;
|
||||
protected $text;
|
||||
|
||||
const TYPES = [
|
||||
'short',
|
||||
'long',
|
||||
'multiple',
|
||||
'multiple_unique'
|
||||
];
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('formId', 'integer');
|
||||
$this->addType('order', 'integer');
|
||||
|
|
|
@ -39,7 +39,7 @@ use \DateTime;
|
|||
* Installation class for the forms app.
|
||||
* Initial db creation
|
||||
*/
|
||||
class Version010200Date2020323141300 extends SimpleMigrationStep {
|
||||
class Version010200Date20200323141300 extends SimpleMigrationStep {
|
||||
|
||||
/** @var IDBConnection */
|
||||
protected $connection;
|
||||
|
@ -47,6 +47,15 @@ class Version010200Date2020323141300 extends SimpleMigrationStep {
|
|||
/** @var IConfig */
|
||||
protected $config;
|
||||
|
||||
/** Map of questionTypes to change */
|
||||
private $questionTypeMap = [
|
||||
'radiogroup' => 'multiple_unique',
|
||||
'checkbox' => 'multiple',
|
||||
'text' => 'short',
|
||||
'comment' => 'long',
|
||||
'dropdown' => 'multiple_unique'
|
||||
];
|
||||
|
||||
/**
|
||||
* @param IDBConnection $connection
|
||||
* @param IConfig $config
|
||||
|
@ -96,8 +105,9 @@ class Version010200Date2020323141300 extends SimpleMigrationStep {
|
|||
'notnull' => false,
|
||||
'comment' => 'unix-timestamp',
|
||||
]);
|
||||
$table->addColumn('expires_timestamp', Type::INTEGER, [
|
||||
$table->addColumn('expires', Type::INTEGER, [
|
||||
'notnull' => false,
|
||||
'default' => 0,
|
||||
'comment' => 'unix-timestamp',
|
||||
]);
|
||||
$table->addColumn('is_anonymous', Type::BOOLEAN, [
|
||||
|
@ -131,7 +141,7 @@ class Version010200Date2020323141300 extends SimpleMigrationStep {
|
|||
]);
|
||||
$table->addColumn('mandatory', Type::BOOLEAN, [
|
||||
'notnull' => true,
|
||||
'default' => 1,
|
||||
'default' => 0,
|
||||
]);
|
||||
$table->addColumn('text', Type::STRING, [
|
||||
'notnull' => true,
|
||||
|
@ -227,7 +237,7 @@ class Version010200Date2020323141300 extends SimpleMigrationStep {
|
|||
'owner_id' => $qb_restore->createNamedParameter($event['owner'], IQueryBuilder::PARAM_STR),
|
||||
'access_json' => $qb_restore->createNamedParameter($newAccessJSON, IQueryBuilder::PARAM_STR),
|
||||
'created' => $qb_restore->createNamedParameter($this->convertDateTime($event['created']), IQueryBuilder::PARAM_INT),
|
||||
'expires_timestamp' => $qb_restore->createNamedParameter($this->convertDateTime($event['expire']), IQueryBuilder::PARAM_INT),
|
||||
'expires' => $qb_restore->createNamedParameter($this->convertDateTime($event['expire']), IQueryBuilder::PARAM_INT),
|
||||
'is_anonymous' => $qb_restore->createNamedParameter($event['is_anonymous'], IQueryBuilder::PARAM_BOOL),
|
||||
'submit_once' => $qb_restore->createNamedParameter($event['unique'], IQueryBuilder::PARAM_BOOL)
|
||||
]);
|
||||
|
@ -257,7 +267,7 @@ class Version010200Date2020323141300 extends SimpleMigrationStep {
|
|||
->values([
|
||||
'form_id' => $qb_restore->createNamedParameter($id_mapping['events'][$question['form_id']]['newId'], IQueryBuilder::PARAM_INT),
|
||||
'order' => $qb_restore->createNamedParameter($id_mapping['events'][$question['form_id']]['nextQuestionOrder']++, IQueryBuilder::PARAM_INT),
|
||||
'type' => $qb_restore->createNamedParameter($question['form_question_type'], IQueryBuilder::PARAM_STR),
|
||||
'type' => $qb_restore->createNamedParameter($this->questionTypeMap[$question['form_question_type']], IQueryBuilder::PARAM_STR),
|
||||
'text' => $qb_restore->createNamedParameter($question['form_question_text'], IQueryBuilder::PARAM_STR)
|
||||
]);
|
||||
$qb_restore->execute();
|
138
package-lock.json
generated
138
package-lock.json
generated
|
@ -1415,11 +1415,11 @@
|
|||
}
|
||||
},
|
||||
"@babel/runtime": {
|
||||
"version": "7.8.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.8.4.tgz",
|
||||
"integrity": "sha512-neAp3zt80trRVBI1x0azq6c57aNBqYZH8KhMm3TaB7wEI5Q4A2SHfBHE8w9gOhI/lrqxtEbXZgQIrHP+wvSGwQ==",
|
||||
"version": "7.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz",
|
||||
"integrity": "sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==",
|
||||
"requires": {
|
||||
"regenerator-runtime": "^0.13.2"
|
||||
"regenerator-runtime": "^0.13.4"
|
||||
}
|
||||
},
|
||||
"@babel/template": {
|
||||
|
@ -1518,13 +1518,6 @@
|
|||
"requires": {
|
||||
"@nextcloud/event-bus": "^1.1.3",
|
||||
"core-js": "^3.6.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": {
|
||||
"version": "3.6.5",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz",
|
||||
"integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@nextcloud/axios": {
|
||||
|
@ -1586,9 +1579,9 @@
|
|||
}
|
||||
},
|
||||
"@nextcloud/event-bus": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@nextcloud/event-bus/-/event-bus-1.1.4.tgz",
|
||||
"integrity": "sha512-It27KzmUaSQ7w22nHFwOn8XgeVG0HYYOSNG9gs4UkP5VqcZ16m4ydt3GkMpWcyFec4OUjJc+yf7omRc3pNxsSw==",
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@nextcloud/event-bus/-/event-bus-1.1.3.tgz",
|
||||
"integrity": "sha512-/f3OMh9Tu3bn17sCc1Sb5AaC/fjegP9bjFmlsPDFNcCAHrKKM5B2X+2eUDF2osLirYaBjVqypBmD87zyiE0WjQ==",
|
||||
"requires": {
|
||||
"@types/semver": "^6.2.1",
|
||||
"core-js": "^3.6.2",
|
||||
|
@ -1596,9 +1589,9 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"core-js": {
|
||||
"version": "3.6.5",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz",
|
||||
"integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA=="
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.4.tgz",
|
||||
"integrity": "sha512-4paDGScNgZP2IXXilaffL9X7968RuvwlkK3xWtZRVqgd8SYNiVKRJvkFd1aqqEuPfN7E68ZHEp9hDj6lHj4Hyw=="
|
||||
},
|
||||
"semver": {
|
||||
"version": "6.3.0",
|
||||
|
@ -1614,16 +1607,6 @@
|
|||
"requires": {
|
||||
"core-js": "^3.6.4",
|
||||
"node-gettext": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-gettext": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-gettext/-/node-gettext-3.0.0.tgz",
|
||||
"integrity": "sha512-/VRYibXmVoN6tnSAY2JWhNRhWYJ8Cd844jrZU/DwLVoI4vBI6ceYbd8i42sYZ9uOgDH3S7vslIKOWV/ZrT2YBA==",
|
||||
"requires": {
|
||||
"lodash.get": "^4.4.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@nextcloud/moment": {
|
||||
|
@ -1657,10 +1640,13 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"core-js": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.4.tgz",
|
||||
"integrity": "sha512-4paDGScNgZP2IXXilaffL9X7968RuvwlkK3xWtZRVqgd8SYNiVKRJvkFd1aqqEuPfN7E68ZHEp9hDj6lHj4Hyw=="
|
||||
"node-gettext": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-gettext/-/node-gettext-2.1.0.tgz",
|
||||
"integrity": "sha512-vsHImHl+Py0vB7M2UXcFEJ5NJ3950gcja45YclBFtYxYeZiqdfQdcu+G9s4L7jpRFSh/J/7VoS3upR4JM1nS+g==",
|
||||
"requires": {
|
||||
"lodash.get": "^4.4.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -2867,14 +2853,14 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"caniuse-lite": {
|
||||
"version": "1.0.30001040",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001040.tgz",
|
||||
"integrity": "sha512-Ep0tEPeI5wCvmJNrXjE3etgfI+lkl1fTDU6Y3ZH1mhrjkPlVI9W4pcKbMo+BQLpEWKVYYp2EmYaRsqpPC3k7lQ=="
|
||||
"version": "1.0.30001042",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001042.tgz",
|
||||
"integrity": "sha512-igMQ4dlqnf4tWv0xjaaE02op9AJ2oQzXKjWf4EuAHFN694Uo9/EfPVIPJcmn2WkU9RqozCxx5e2KPcVClHDbDw=="
|
||||
},
|
||||
"electron-to-chromium": {
|
||||
"version": "1.3.403",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.403.tgz",
|
||||
"integrity": "sha512-JaoxV4RzdBAZOnsF4dAlZ2ijJW72MbqO5lNfOBHUWiBQl3Rwe+mk2RCUMrRI3rSClLJ8HSNQNqcry12H+0ZjFw=="
|
||||
"version": "1.3.412",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.412.tgz",
|
||||
"integrity": "sha512-4bVdSeJScR8fT7ERveLWbxemY5uXEHVseqMRyORosiKcTUSGtVwBkV8uLjXCqoFLeImA57Z9hbz3TOid01U4Hw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -3469,9 +3455,9 @@
|
|||
"integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40="
|
||||
},
|
||||
"core-js": {
|
||||
"version": "3.6.5",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz",
|
||||
"integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA=="
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.4.tgz",
|
||||
"integrity": "sha512-4paDGScNgZP2IXXilaffL9X7968RuvwlkK3xWtZRVqgd8SYNiVKRJvkFd1aqqEuPfN7E68ZHEp9hDj6lHj4Hyw=="
|
||||
},
|
||||
"core-js-compat": {
|
||||
"version": "3.6.5",
|
||||
|
@ -3591,6 +3577,11 @@
|
|||
"randomfill": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"crypto-js": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.0.0.tgz",
|
||||
"integrity": "sha512-bzHZN8Pn+gS7DQA6n+iUmBfl0hO5DJq++QP3U6uTucDtk/0iGpXd/Gg7CGR0p8tJhofJyaKoWBuJI4eAO00BBg=="
|
||||
},
|
||||
"css-loader": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.5.3.tgz",
|
||||
|
@ -3641,12 +3632,6 @@
|
|||
"supports-color": "^6.1.0"
|
||||
}
|
||||
},
|
||||
"postcss-value-parser": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.0.3.tgz",
|
||||
"integrity": "sha512-N7h4pG+Nnu5BEIzyeaaIYWs0LI5XC40OrRh5L60z0QjFsqGWcHcbkBvpe1WYpcIS9yQ8sOi/vIPt1ejQCrMVrg==",
|
||||
"dev": true
|
||||
},
|
||||
"schema-utils": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.6.6.tgz",
|
||||
|
@ -4638,6 +4623,11 @@
|
|||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
|
||||
},
|
||||
"eventemitter3": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz",
|
||||
"integrity": "sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg=="
|
||||
},
|
||||
"events": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.0.0.tgz",
|
||||
|
@ -7815,9 +7805,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"node-gettext": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-gettext/-/node-gettext-2.0.0.tgz",
|
||||
"integrity": "sha1-8dwSN83FRvUVk9o0AwS4vrpbhSU=",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-gettext/-/node-gettext-3.0.0.tgz",
|
||||
"integrity": "sha512-/VRYibXmVoN6tnSAY2JWhNRhWYJ8Cd844jrZU/DwLVoI4vBI6ceYbd8i42sYZ9uOgDH3S7vslIKOWV/ZrT2YBA==",
|
||||
"requires": {
|
||||
"lodash.get": "^4.4.2"
|
||||
}
|
||||
|
@ -8221,6 +8211,11 @@
|
|||
"os-tmpdir": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"p-debounce": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-debounce/-/p-debounce-2.1.0.tgz",
|
||||
"integrity": "sha512-M9bMt62TTnozdZhqFgs+V7XD2MnuKCaz+7fZdlu2/T7xruI3uIE5CicQ0vx1hV7HIUYF0jF+4/R1AgfOkl74Qw=="
|
||||
},
|
||||
"p-defer": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz",
|
||||
|
@ -8230,8 +8225,7 @@
|
|||
"p-finally": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
|
||||
"integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=",
|
||||
"dev": true
|
||||
"integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4="
|
||||
},
|
||||
"p-is-promise": {
|
||||
"version": "2.1.0",
|
||||
|
@ -8257,6 +8251,23 @@
|
|||
"p-limit": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"p-queue": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.3.0.tgz",
|
||||
"integrity": "sha512-fg5dJlFpd5+3CgG3/0ogpVZUeJbjiyXFg0nu53hrOYsybqSiDyxyOpad0Rm6tAiGjgztAwkyvhlYHC53OiAJOA==",
|
||||
"requires": {
|
||||
"eventemitter3": "^4.0.0",
|
||||
"p-timeout": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"p-timeout": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
|
||||
"integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
|
||||
"requires": {
|
||||
"p-finally": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
|
@ -8804,9 +8815,9 @@
|
|||
}
|
||||
},
|
||||
"postcss-value-parser": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.0.2.tgz",
|
||||
"integrity": "sha512-LmeoohTpp/K4UiyQCwuGWlONxXamGzCMtFxLq4W1nZVGIQLYvMCJx3yAF9qyyuFpflABI9yVdtJAqbihOsCsJQ==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.0.3.tgz",
|
||||
"integrity": "sha512-N7h4pG+Nnu5BEIzyeaaIYWs0LI5XC40OrRh5L60z0QjFsqGWcHcbkBvpe1WYpcIS9yQ8sOi/vIPt1ejQCrMVrg==",
|
||||
"dev": true
|
||||
},
|
||||
"prelude-ls": {
|
||||
|
@ -9371,9 +9382,9 @@
|
|||
}
|
||||
},
|
||||
"regenerator-runtime": {
|
||||
"version": "0.13.3",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz",
|
||||
"integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw=="
|
||||
"version": "0.13.5",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
|
||||
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA=="
|
||||
},
|
||||
"regenerator-transform": {
|
||||
"version": "0.14.4",
|
||||
|
@ -9998,6 +10009,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"sortablejs": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.10.2.tgz",
|
||||
"integrity": "sha512-YkPGufevysvfwn5rfdlGyrGjt7/CRHwvRPogD/lC+TnvcN29jDpCifKP+rBqf+LRldfXSTh+0CGLcSg0VIxq3A=="
|
||||
},
|
||||
"source-list-map": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
|
||||
|
@ -11863,6 +11879,14 @@
|
|||
"date-format-parse": "^0.2.5"
|
||||
}
|
||||
},
|
||||
"vuedraggable": {
|
||||
"version": "2.23.2",
|
||||
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-2.23.2.tgz",
|
||||
"integrity": "sha512-PgHCjUpxEAEZJq36ys49HfQmXglattf/7ofOzUrW2/rRdG7tu6fK84ir14t1jYv4kdXewTEa2ieKEAhhEMdwkQ==",
|
||||
"requires": {
|
||||
"sortablejs": "^1.10.1"
|
||||
}
|
||||
},
|
||||
"watchpack": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.6.1.tgz",
|
||||
|
|
10
package.json
10
package.json
|
@ -72,15 +72,23 @@
|
|||
"@nextcloud/auth": "^1.2.3",
|
||||
"@nextcloud/axios": "^1.3.2",
|
||||
"@nextcloud/dialogs": "^1.2.2",
|
||||
"@nextcloud/event-bus": "^1.1.3",
|
||||
"@nextcloud/l10n": "^1.2.3",
|
||||
"@nextcloud/moment": "^1.1.1",
|
||||
"@nextcloud/router": "^1.0.2",
|
||||
"@nextcloud/vue": "^1.5.0",
|
||||
"core-js": "^3.6.4",
|
||||
"crypto-js": "^4.0.0",
|
||||
"debounce": "^1.2.0",
|
||||
"json2csv": "5.0.0",
|
||||
"p-debounce": "^2.1.0",
|
||||
"p-queue": "^6.3.0",
|
||||
"regenerator-runtime": "^0.13.5",
|
||||
"v-click-outside": "^3.0.1",
|
||||
"vue": "^2.6.11",
|
||||
"vue-clipboard2": "^0.3.1",
|
||||
"vue-router": "^3.1.6"
|
||||
"vue-router": "^3.1.6",
|
||||
"vuedraggable": "^2.23.2"
|
||||
},
|
||||
"browserslist": [
|
||||
"extends @nextcloud/browserslist-config"
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
<Content app-name="forms">
|
||||
<AppNavigation>
|
||||
<AppNavigationNew button-class="icon-add" :text="t('forms', 'New form')" @click="onNewForm" />
|
||||
<AppNavigationForm v-for="form in formattedForms"
|
||||
<AppNavigationForm v-for="form in forms"
|
||||
:key="form.id"
|
||||
:form="form"
|
||||
@delete="onDeleteForm" />
|
||||
|
@ -52,7 +52,7 @@
|
|||
|
||||
<!-- No errors show router content -->
|
||||
<template v-else>
|
||||
<router-view :form="selectedForm" />
|
||||
<router-view :form.sync="selectedForm" />
|
||||
<router-view :form="selectedForm" name="sidebar" />
|
||||
</template>
|
||||
</Content>
|
||||
|
@ -71,8 +71,6 @@ import Content from '@nextcloud/vue/dist/Components/Content'
|
|||
import AppNavigationForm from './components/AppNavigationForm'
|
||||
import EmptyContent from './components/EmptyContent'
|
||||
|
||||
import { formatForm } from './utils/FormsUtils'
|
||||
|
||||
export default {
|
||||
name: 'Forms',
|
||||
|
||||
|
@ -97,17 +95,20 @@ export default {
|
|||
return this.forms && this.forms.length === 0
|
||||
},
|
||||
|
||||
formattedForms() {
|
||||
return this.forms.map(formatForm)
|
||||
},
|
||||
|
||||
hash() {
|
||||
return this.$route.params.hash
|
||||
},
|
||||
|
||||
selectedForm() {
|
||||
// TODO: replace with form.hash
|
||||
return this.forms.find(form => form.form.hash === this.hash)
|
||||
selectedForm: {
|
||||
get() {
|
||||
return this.forms.find(form => form.hash === this.hash)
|
||||
},
|
||||
set(form) {
|
||||
const index = this.forms.findIndex(search => search.hash === this.hash)
|
||||
if (index > -1) {
|
||||
this.$set(this.forms, index, form)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
@ -141,7 +142,7 @@ export default {
|
|||
const response = await axios.post(generateUrl('/apps/forms/api/v1/form'))
|
||||
const newForm = response.data
|
||||
this.forms.push(newForm)
|
||||
this.$router.push({ name: 'edit', params: { hash: newForm.form.hash } })
|
||||
this.$router.push({ name: 'edit', params: { hash: newForm.hash } })
|
||||
} catch (error) {
|
||||
showError(t('forms', 'Unable to create a new form'))
|
||||
console.error(error)
|
||||
|
|
|
@ -66,6 +66,7 @@ import ActionSeparator from '@nextcloud/vue/dist/Components/ActionSeparator'
|
|||
import AppNavigationIconBullet from '@nextcloud/vue/dist/Components/AppNavigationIconBullet'
|
||||
import AppNavigationItem from '@nextcloud/vue/dist/Components/AppNavigationItem'
|
||||
import axios from '@nextcloud/axios'
|
||||
import moment from '@nextcloud/moment'
|
||||
import Vue from 'vue'
|
||||
import VueClipboard from 'vue-clipboard2'
|
||||
|
||||
|
@ -106,7 +107,7 @@ export default {
|
|||
*/
|
||||
bulletColor() {
|
||||
const style = getComputedStyle(document.body)
|
||||
if (this.form.expired) {
|
||||
if (this.form.expires && moment().unix() > this.form.expires) {
|
||||
return style.getPropertyValue('--color-error').slice(-6)
|
||||
}
|
||||
return style.getPropertyValue('--color-success').slice(-6)
|
||||
|
|
|
@ -21,9 +21,11 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div class="emptycontent" role="note">
|
||||
<div :class="icon" role="img" />
|
||||
<h2><slot /></h2>
|
||||
<div class="empty-content" role="note">
|
||||
<div class="empty-content__icon" :class="icon" role="img" />
|
||||
<h2 class="empty-content__title">
|
||||
<slot />
|
||||
</h2>
|
||||
<p v-show="$slots.desc">
|
||||
<slot name="desc" />
|
||||
</p>
|
||||
|
@ -44,8 +46,25 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.emptycontent {
|
||||
.empty-content {
|
||||
margin-top: 20vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
&__icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 15px;
|
||||
opacity: .4;
|
||||
background-size: 64px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
183
src/components/Questions/AnswerInput.vue
Normal file
183
src/components/Questions/AnswerInput.vue
Normal file
|
@ -0,0 +1,183 @@
|
|||
<template>
|
||||
<li class="question__item">
|
||||
<!-- TODO: properly choose max length -->
|
||||
<input
|
||||
ref="input"
|
||||
:aria-label="t('forms', 'An answer for the {index} option', { index: index + 1 })"
|
||||
:placeholder="t('forms', 'Answer number {index}', { index: index + 1 })"
|
||||
:value="answer.text"
|
||||
class="question__input"
|
||||
maxlength="256"
|
||||
minlength="1"
|
||||
type="text"
|
||||
@input="onInput"
|
||||
@keydown.delete="deleteEntry"
|
||||
@keydown.enter.prevent="addNewEntry">
|
||||
|
||||
<!-- Delete answer -->
|
||||
<Actions>
|
||||
<ActionButton icon="icon-close" @click="deleteEntry">
|
||||
{{ t('forms', 'Delete answer') }}
|
||||
</ActionButton>
|
||||
</Actions>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import axios from '@nextcloud/axios'
|
||||
import pDebounce from 'p-debounce'
|
||||
import PQueue from 'p-queue'
|
||||
|
||||
import Actions from '@nextcloud/vue/dist/Components/Actions'
|
||||
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
|
||||
|
||||
export default {
|
||||
name: 'AnswerInput',
|
||||
|
||||
components: {
|
||||
Actions,
|
||||
ActionButton,
|
||||
},
|
||||
|
||||
props: {
|
||||
answer: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
queue: new PQueue({ concurrency: 1 }),
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Focus the input
|
||||
*/
|
||||
focus() {
|
||||
this.$refs.input.focus()
|
||||
},
|
||||
|
||||
/**
|
||||
* Option changed, processing the data
|
||||
*/
|
||||
async onInput() {
|
||||
// clone answer
|
||||
const answer = Object.assign({}, this.answer)
|
||||
answer.text = this.$refs.input.value
|
||||
|
||||
if (this.answer.local) {
|
||||
|
||||
// Dispatched for creation. Marked as synced
|
||||
this.answer.local = false
|
||||
const newAnswer = await this.debounceCreateAnswer(answer)
|
||||
this.$emit('update:answer', answer.id, newAnswer)
|
||||
} else {
|
||||
this.debounceUpdateAnswer(answer)
|
||||
this.$emit('update:answer', answer.id, answer)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Request a new answer
|
||||
*/
|
||||
addNewEntry() {
|
||||
this.$emit('add')
|
||||
},
|
||||
|
||||
/**
|
||||
* Emit a delete request for this answer
|
||||
* when pressing the delete key on an empty input
|
||||
*
|
||||
* @param {Event} e the event
|
||||
*/
|
||||
async deleteEntry(e) {
|
||||
if (e.type !== 'click' && this.$refs.input.value.length !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Dismiss delete key action
|
||||
e.preventDefault()
|
||||
const answer = Object.assign({}, this.answer)
|
||||
const index = this.index
|
||||
|
||||
if (!answer.local) {
|
||||
// let's not await, deleting in background
|
||||
axios.delete(generateUrl('/apps/forms/api/v1/option/{id}', { id: this.answer.id }))
|
||||
.catch(error => {
|
||||
showError(t('forms', 'There was an issue deleting this option'))
|
||||
console.error(error)
|
||||
// restore option
|
||||
this.$emit('restore', answer, index)
|
||||
})
|
||||
}
|
||||
|
||||
this.$emit('delete', answer.id, index)
|
||||
},
|
||||
|
||||
/**
|
||||
* Create an unsynced answer to the server
|
||||
*
|
||||
* @param {Object} answer the answer to sync
|
||||
* @returns {Object} answer
|
||||
*/
|
||||
async createAnswer(answer) {
|
||||
try {
|
||||
const response = await axios.post(generateUrl('/apps/forms/api/v1/option'), {
|
||||
questionId: answer.question_id,
|
||||
text: answer.text,
|
||||
})
|
||||
console.debug('Created answer', answer)
|
||||
|
||||
// Was synced once, this is now up to date with the server
|
||||
delete answer.local
|
||||
return Object.assign({}, answer, response.data)
|
||||
} catch (error) {
|
||||
showError(t('forms', 'Error while saving the answer'))
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
return answer
|
||||
},
|
||||
debounceCreateAnswer: pDebounce(function(answer) {
|
||||
return this.queue.add(() => this.createAnswer(answer))
|
||||
}, 100),
|
||||
|
||||
/**
|
||||
* Save to the server, only do it after 500ms
|
||||
* of no change
|
||||
*
|
||||
* @param {Object} answer the answer to sync
|
||||
*/
|
||||
async updateAnswer(answer) {
|
||||
try {
|
||||
await axios.post(generateUrl('/apps/forms/api/v1/option/update'), {
|
||||
id: this.answer.id,
|
||||
keyValuePairs: {
|
||||
text: answer.text,
|
||||
},
|
||||
})
|
||||
console.debug('Updated answer', answer)
|
||||
} catch (error) {
|
||||
showError(t('forms', 'Error while saving the answer'))
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
debounceUpdateAnswer: pDebounce(function(answer) {
|
||||
return this.queue.add(() => this.updateAnswer(answer))
|
||||
}, 500),
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
224
src/components/Questions/Question.vue
Normal file
224
src/components/Questions/Question.vue
Normal file
|
@ -0,0 +1,224 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
|
||||
-
|
||||
- @author John Molakvoæ <skjnldsv@protonmail.com>
|
||||
-
|
||||
- @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/>.
|
||||
-
|
||||
-->
|
||||
|
||||
<template>
|
||||
<li v-click-outside="disableEdit"
|
||||
:class="{ 'question--edit': edit }"
|
||||
:aria-label="t('forms', 'Question number {index}', {index})"
|
||||
class="question"
|
||||
@click="enableEdit">
|
||||
<!-- Drag handle -->
|
||||
<!-- TODO: implement arrow key mapping to reorder question -->
|
||||
<div class="question__drag-handle icon-drag-handle"
|
||||
:aria-label="t('forms', 'Drag to re-order the questions')" />
|
||||
|
||||
<!-- Header -->
|
||||
<div class="question__header">
|
||||
<input v-if="edit"
|
||||
:placeholder="t('forms', 'Enter a title for this question')"
|
||||
:aria-label="t('forms', 'The title of the question number {index}', {index})"
|
||||
:value="text"
|
||||
class="question__header-title"
|
||||
type="text"
|
||||
minlength="1"
|
||||
maxlength="256"
|
||||
required
|
||||
@input="onInput"
|
||||
@keyup="onTitleChange">
|
||||
<h3 v-else class="question__header-title" v-text="text" />
|
||||
<Actions class="question__header-menu" :force-menu="true">
|
||||
<ActionButton icon="icon-delete" @click="onDelete">
|
||||
{{ t('forms', 'Delete question') }}
|
||||
</ActionButton>
|
||||
</Actions>
|
||||
</div>
|
||||
|
||||
<!-- Question content -->
|
||||
<slot />
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from '@nextcloud/axios'
|
||||
import debounce from 'debounce'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { directive as ClickOutside } from 'v-click-outside'
|
||||
|
||||
import Actions from '@nextcloud/vue/dist/Components/Actions'
|
||||
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
|
||||
|
||||
export default {
|
||||
name: 'Question',
|
||||
|
||||
directives: {
|
||||
ClickOutside,
|
||||
},
|
||||
|
||||
components: {
|
||||
Actions,
|
||||
ActionButton,
|
||||
},
|
||||
|
||||
props: {
|
||||
id: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
edit: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onInput({ target }) {
|
||||
this.$emit('update:text', target.value)
|
||||
},
|
||||
|
||||
/**
|
||||
* Enable the edit mode
|
||||
*/
|
||||
enableEdit() {
|
||||
this.$emit('update:edit', true)
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable the edit mode
|
||||
*/
|
||||
disableEdit() {
|
||||
this.$emit('update:edit', false)
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete this question
|
||||
*/
|
||||
onDelete() {
|
||||
this.$emit('delete')
|
||||
},
|
||||
|
||||
onTitleChange: debounce(function() {
|
||||
this.saveQuestionProperty('text')
|
||||
}, 200),
|
||||
|
||||
async saveQuestionProperty(key) {
|
||||
try {
|
||||
// TODO: add loading status feedback ?
|
||||
await axios.post(generateUrl('/apps/forms/api/v1/question/update'), {
|
||||
id: this.id,
|
||||
keyValuePairs: {
|
||||
[key]: this[key],
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
showError(t('forms', 'Error while saving question'))
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.question {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
justify-content: stretch;
|
||||
margin-bottom: 22px;
|
||||
padding-left: 44px;
|
||||
// room for the new question menu
|
||||
padding-right: 44px;
|
||||
user-select: none;
|
||||
background-color: var(--color-main-background);
|
||||
|
||||
> * {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__drag-handle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 44px;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
&__title,
|
||||
&__content {
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
margin: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 100%;
|
||||
justify-content: space-between;
|
||||
width: auto;
|
||||
margin: 20px;
|
||||
margin-bottom: 0;
|
||||
|
||||
// Using type to have a higher order than the input styling of server
|
||||
&-title,
|
||||
&-title[type=text] {
|
||||
flex: 1 1 100%;
|
||||
min-height: 22px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-bottom: 6px;
|
||||
color: var(--color-text-light);
|
||||
border: 0;
|
||||
border-bottom: 1px dotted transparent;
|
||||
border-radius: 0;
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
&-title[type=text] {
|
||||
border-bottom-color: var(--color-border-dark);
|
||||
}
|
||||
|
||||
&-menu.action-item {
|
||||
position: sticky;
|
||||
top: var(--header-height);
|
||||
// above other actions
|
||||
z-index: 50;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
96
src/components/Questions/QuestionLong.vue
Normal file
96
src/components/Questions/QuestionLong.vue
Normal file
|
@ -0,0 +1,96 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
|
||||
-
|
||||
- @author John Molakvoæ <skjnldsv@protonmail.com>
|
||||
-
|
||||
- @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/>.
|
||||
-
|
||||
-->
|
||||
|
||||
<template>
|
||||
<Question
|
||||
v-bind.sync="$attrs"
|
||||
:text="text"
|
||||
:edit.sync="edit"
|
||||
@delete="onDelete"
|
||||
@update:text="onTitleChange">
|
||||
<div class="question__content">
|
||||
<!-- TODO: properly choose max length -->
|
||||
<textarea ref="textarea"
|
||||
:aria-label="t('forms', 'A long answer for the question “{text}”', { text })"
|
||||
:placeholder="t('forms', 'Long answer text')"
|
||||
:readonly="edit"
|
||||
:value="values[0]"
|
||||
class="question__text"
|
||||
maxlength="1024"
|
||||
minlength="1"
|
||||
@input="onInput"
|
||||
@keydown="autoSizeText" />
|
||||
</div>
|
||||
</Question>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import QuestionMixin from '../../mixins/QuestionMixin'
|
||||
|
||||
export default {
|
||||
name: 'QuestionLong',
|
||||
|
||||
mixins: [QuestionMixin],
|
||||
|
||||
data() {
|
||||
return {
|
||||
height: 1,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.autoSizeText()
|
||||
},
|
||||
|
||||
methods: {
|
||||
onInput() {
|
||||
const textarea = this.$refs.textarea
|
||||
this.$emit('update:values', [textarea.value])
|
||||
this.autoSizeText()
|
||||
},
|
||||
autoSizeText() {
|
||||
const textarea = this.$refs.textarea
|
||||
textarea.style.cssText = 'height:auto; padding:0'
|
||||
textarea.style.cssText = `height: ${textarea.scrollHeight + 20}px`
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
// Using type to have a higher order than the input styling of server
|
||||
.question__text {
|
||||
// make sure height calculations are correct
|
||||
box-sizing: content-box !important;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 44px;
|
||||
max-height: 10rem;
|
||||
margin: 0;
|
||||
padding: 6px 0;
|
||||
border: 0;
|
||||
border-bottom: 1px dotted var(--color-border-dark);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
</style>
|
268
src/components/Questions/QuestionMultiple.vue
Normal file
268
src/components/Questions/QuestionMultiple.vue
Normal file
|
@ -0,0 +1,268 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
|
||||
-
|
||||
- @author John Molakvoæ <skjnldsv@protonmail.com>
|
||||
-
|
||||
- @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/>.
|
||||
-
|
||||
-->
|
||||
|
||||
<template>
|
||||
<Question
|
||||
:id="id"
|
||||
v-bind.sync="$attrs"
|
||||
:text="text"
|
||||
:edit.sync="edit"
|
||||
@delete="onDelete"
|
||||
@update:text="onTitleChange">
|
||||
<ul class="question__content" :role="isUnique ? 'radiogroup' : ''">
|
||||
<template v-for="(answer, index) in options">
|
||||
<li v-if="!edit" :key="answer.id" class="question__item">
|
||||
<!-- Answer radio/checkbox + label -->
|
||||
<!-- TODO: migrate to radio/checkbox component once available -->
|
||||
<input :id="`${id}-answer-${answer.id}`"
|
||||
ref="checkbox"
|
||||
:aria-checked="isChecked(answer.id)"
|
||||
:checked="isChecked(answer.id)"
|
||||
:class="{
|
||||
'radio question__radio': isUnique,
|
||||
'checkbox question__checkbox': !isUnique,
|
||||
}"
|
||||
:name="`${id}-answer`"
|
||||
:readonly="true"
|
||||
:type="isUnique ? 'radio' : 'checkbox'">
|
||||
<label v-if="!edit"
|
||||
ref="label"
|
||||
:for="`${id}-answer-${answer.id}`"
|
||||
class="question__label">{{ answer.text }}</label>
|
||||
</li>
|
||||
|
||||
<!-- Answer text input edit -->
|
||||
<AnswerInput v-else
|
||||
:key="index /* using index to keep the same vnode after new answer creation */"
|
||||
ref="input"
|
||||
:answer="answer"
|
||||
:index="index"
|
||||
@add="addNewEntry"
|
||||
@delete="deleteAnswer"
|
||||
@update:answer="updateAnswer"
|
||||
@restore="restoreAnswer" />
|
||||
</template>
|
||||
|
||||
<li v-if="(edit && !isLastEmpty) || hasNoAnswer" class="question__item">
|
||||
<!-- TODO: properly choose max length -->
|
||||
<input
|
||||
:aria-label="t('forms', 'Add a new answer')"
|
||||
:placeholder="t('forms', 'Add a new answer')"
|
||||
class="question__input"
|
||||
maxlength="256"
|
||||
minlength="1"
|
||||
type="text"
|
||||
@click="addNewEntry">
|
||||
</li>
|
||||
</ul>
|
||||
</Question>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AnswerInput from './AnswerInput'
|
||||
import QuestionMixin from '../../mixins/QuestionMixin'
|
||||
import GenRandomId from '../../utils/GenRandomId'
|
||||
|
||||
// Implementations docs
|
||||
// https://www.w3.org/TR/2016/WD-wai-aria-practices-1.1-20160317/examples/radio/radio.html
|
||||
// https://www.w3.org/TR/2016/WD-wai-aria-practices-1.1-20160317/examples/checkbox/checkbox-2.html
|
||||
export default {
|
||||
name: 'QuestionMultiple',
|
||||
|
||||
components: {
|
||||
AnswerInput,
|
||||
},
|
||||
|
||||
mixins: [QuestionMixin],
|
||||
|
||||
props: {
|
||||
id: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
isLastEmpty() {
|
||||
const value = this.options[this.options.length - 1]
|
||||
return value?.text?.trim().length === 0
|
||||
},
|
||||
|
||||
isUnique() {
|
||||
return this.model.unique === true
|
||||
},
|
||||
|
||||
hasNoAnswer() {
|
||||
return this.options.length === 0
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
edit(edit) {
|
||||
if (!edit) {
|
||||
// Filter out empty options and update question
|
||||
this.$emit('update:options', this.options.filter(answer => !!answer.text))
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
/**
|
||||
* Is the provided index checked
|
||||
* @param {number} index the option index
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isChecked(index) {
|
||||
// TODO implement based on answers
|
||||
return false
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the options
|
||||
* @param {Array} options options to change
|
||||
*/
|
||||
updateOptions(options) {
|
||||
this.$emit('update:options', options)
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing answer locally
|
||||
*
|
||||
* @param {string|number} id the answer id
|
||||
* @param {Object} answer the answer to update
|
||||
*/
|
||||
updateAnswer(id, answer) {
|
||||
const options = this.options.slice()
|
||||
const answerIndex = options.findIndex(option => option.id === id)
|
||||
options[answerIndex] = answer
|
||||
|
||||
this.updateOptions(options)
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a new empty answer locally
|
||||
*/
|
||||
addNewEntry() {
|
||||
// Add local entry
|
||||
const options = this.options.slice()
|
||||
options.push({
|
||||
id: GenRandomId(),
|
||||
question_id: this.id,
|
||||
text: '',
|
||||
local: true,
|
||||
})
|
||||
|
||||
// Update question
|
||||
this.updateOptions(options)
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.focusIndex(options.length - 1)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Restore an answer locally
|
||||
*
|
||||
* @param {Object} answer the answer
|
||||
* @param {number} index the answer index in this.options
|
||||
*/
|
||||
restoreAnswer(answer, index) {
|
||||
const options = this.options.slice()
|
||||
options.splice(index, 0, answer)
|
||||
|
||||
this.updateOptions(options)
|
||||
this.focusIndex(index)
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an answer locally
|
||||
*
|
||||
* @param {number} id the answer is
|
||||
* @param {number} index the answer index in this.options
|
||||
*/
|
||||
deleteAnswer(id, index) {
|
||||
// Remove entry
|
||||
const options = this.options.slice()
|
||||
const optionIndex = options.findIndex(option => option.id === id)
|
||||
options.splice(optionIndex, 1)
|
||||
|
||||
// Update question
|
||||
this.updateOptions(options)
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.focusIndex(index + 1)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Focus the input matching the index
|
||||
*
|
||||
* @param {Number} index the value index
|
||||
*/
|
||||
focusIndex(index) {
|
||||
const inputs = this.$refs.input
|
||||
if (inputs && inputs[index]) {
|
||||
const input = inputs[index]
|
||||
input.focus()
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.question__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.question__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 44px;
|
||||
|
||||
.question__label {
|
||||
flex: 1 1 100%;
|
||||
&::before {
|
||||
margin: 14px !important;
|
||||
}
|
||||
}
|
||||
|
||||
// make sure to respect readonly on radio/checkbox
|
||||
input[readonly] {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Using type to have a higher order than the input styling of server
|
||||
.question__input[type=text] {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin: 0;
|
||||
padding: 6px 0;
|
||||
border: 0;
|
||||
border-bottom: 1px dotted var(--color-border-dark);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
</style>
|
75
src/components/Questions/QuestionShort.vue
Normal file
75
src/components/Questions/QuestionShort.vue
Normal file
|
@ -0,0 +1,75 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
|
||||
-
|
||||
- @author John Molakvoæ <skjnldsv@protonmail.com>
|
||||
-
|
||||
- @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/>.
|
||||
-
|
||||
-->
|
||||
|
||||
<template>
|
||||
<Question
|
||||
v-bind.sync="$attrs"
|
||||
:text="text"
|
||||
:edit.sync="edit"
|
||||
@delete="onDelete"
|
||||
@update:text="onTitleChange">
|
||||
<div class="question__content">
|
||||
<!-- TODO: properly choose max length -->
|
||||
<input ref="input"
|
||||
:aria-label="t('forms', 'A short answer for the question “{text}”', { text })"
|
||||
:placeholder="t('forms', 'Short answer text')"
|
||||
:readonly="edit"
|
||||
:value="values[0]"
|
||||
class="question__input"
|
||||
maxlength="256"
|
||||
minlength="1"
|
||||
type="text"
|
||||
@input="onInput">
|
||||
</div>
|
||||
</Question>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import QuestionMixin from '../../mixins/QuestionMixin'
|
||||
|
||||
export default {
|
||||
name: 'QuestionShort',
|
||||
|
||||
mixins: [QuestionMixin],
|
||||
|
||||
methods: {
|
||||
onInput() {
|
||||
const input = this.$refs.input
|
||||
this.$emit('update:values', [input.value])
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
// Using type to have a higher order than the input styling of server
|
||||
.question__input[type=text] {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin: 0;
|
||||
padding: 6px 0;
|
||||
border: 0;
|
||||
border-bottom: 1px dotted var(--color-border-dark);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
</style>
|
72
src/components/TopBar.vue
Normal file
72
src/components/TopBar.vue
Normal file
|
@ -0,0 +1,72 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
|
||||
-
|
||||
- @author John Molakvoæ <skjnldsv@protonmail.com>
|
||||
-
|
||||
- @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/>.
|
||||
-
|
||||
-
|
||||
- UPDATE: Adds Quiz option and takes the input:
|
||||
- is yet to store input of quizzes and cannot represtent them
|
||||
- requires quizFormItem.vue (should be added to svn)
|
||||
-->
|
||||
<template>
|
||||
<div class="top-bar" role="toolbar">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'TopBar',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$top-bar-height: 60px;
|
||||
|
||||
.top-bar {
|
||||
position: sticky;
|
||||
z-index: 10;
|
||||
top: var(--header-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: flex-end;
|
||||
justify-content: flex-end;
|
||||
height: var(--top-bar-height);
|
||||
margin-top: calc(var(--top-bar-height) * -1);
|
||||
padding: 0 6px;
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
&:not(.primary) {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
&:hover, a:active, a:focus {
|
||||
background-color: var(--color-background-darker);
|
||||
}
|
||||
}
|
||||
|
||||
> span {
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
background-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
|
@ -1,67 +0,0 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2018 René Gieling <github@dartcafe.de>
|
||||
-
|
||||
- @author René Gieling <github@dartcafe.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/>.
|
||||
-
|
||||
-->
|
||||
|
||||
/* global Vue, oc_userconfig */
|
||||
<template>
|
||||
<div class="cloud">
|
||||
<span v-if="options.expired" class="expired">
|
||||
{{ t('forms', 'Expired') }}
|
||||
</span>
|
||||
<span v-if="options.expires" class="open">
|
||||
{{ t('forms', 'Expires %n', 1, expirationDate) }}
|
||||
</span>
|
||||
<span v-else class="open">
|
||||
{{ t('forms', 'Expires never') }}
|
||||
</span>
|
||||
|
||||
<span class="information">
|
||||
{{ options.access }}
|
||||
</span>
|
||||
<span v-if="options.isAnonymous" class="information">
|
||||
{{ t('forms', 'Anonymous form') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from '@nextcloud/moment'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
options: {
|
||||
type: Object,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
computed: {
|
||||
expirationDate() {
|
||||
const date = moment(this.options.expirationDate, moment.localeData().longDateFormat('L')).fromNow()
|
||||
return date
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
|
@ -1,114 +0,0 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2018 René Gieling <github@dartcafe.de>
|
||||
-
|
||||
- @author René Gieling <github@dartcafe.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/>.
|
||||
-
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="controls">
|
||||
<div class="breadcrumb">
|
||||
<button class="button btn primary" @click="helpPage">
|
||||
{{ "Help" }}
|
||||
</button>
|
||||
<div class="crumb svg crumbhome">
|
||||
<router-link :to="{ name: 'list'}" class="icon-home">
|
||||
Home
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-show="intitle ===''" class="crumb svg last">
|
||||
<span v-text="intitle" />
|
||||
</div>
|
||||
<div class="action">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<slot name="after" class="after" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
|
||||
props: {
|
||||
intitle: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
imagePath: OC.imagePath('core', 'places/home.svg'),
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
helpPage() {
|
||||
window.open('https://github.com/nextcloud/forms/blob/master/Forms_Support.md')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-main-background);
|
||||
width: 100%;
|
||||
height: 45px;
|
||||
z-index: 1001;
|
||||
|
||||
.action {
|
||||
order: 999;
|
||||
}
|
||||
|
||||
.button, button {
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
display: flex;
|
||||
height: 36px;
|
||||
padding: 9px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 7px;
|
||||
&.symbol {
|
||||
width: 36px;
|
||||
}
|
||||
&.primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
min-width: 35px;
|
||||
// div.crumb:last-child {
|
||||
// flex-shrink: 1;
|
||||
// overflow: hidden;
|
||||
// > span {
|
||||
// flex-shrink: 1;
|
||||
// text-overflow: ellipsis;
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,31 +0,0 @@
|
|||
<template>
|
||||
<div class="loading-overlay">
|
||||
<span class="icon-loading" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
opacity: 0.9;
|
||||
z-index: 1001;
|
||||
.icon-loading {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
margin-left: -35px;
|
||||
margin-top: -10px;
|
||||
&::after {
|
||||
border: 10px solid var(--color-loading-light);
|
||||
border-top-color: var(--color-primary-element);
|
||||
height: 70px;
|
||||
width: 70px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,495 +0,0 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2018 René Gieling <github@dartcafe.de>
|
||||
-
|
||||
- @author René Gieling <github@dartcafe.de>
|
||||
- @author Natalie Gilbert
|
||||
- @author Nick Gallo
|
||||
-
|
||||
- @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/>.
|
||||
-
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div v-if="header" class="wrapper group-master table-row table-header">
|
||||
<div class="wrapper group-1">
|
||||
<div class="wrapper group-1-1">
|
||||
<div class="name">
|
||||
{{ t('forms', 'Title') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapper group-2">
|
||||
<div class="wrapper-group-2-5">
|
||||
<div class="deletetwo">
|
||||
{{ t('forms',"Delete form") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapper-group-2-5">
|
||||
<div class="copyL">
|
||||
{{ t('forms', "Copy link") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapper-group-2-5">
|
||||
<div class="result">
|
||||
{{ t('forms', "View results") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapper group-2-1">
|
||||
<div class="access">
|
||||
{{ t('forms', 'Access') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="owner">
|
||||
{{ t('forms', 'Owner') }}
|
||||
</div>
|
||||
<div class="wrapper group-2-2">
|
||||
<div class="created">
|
||||
{{ t('forms', 'Created') }}
|
||||
</div>
|
||||
<div class="expiry">
|
||||
{{ t('forms', 'Expires') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="wrapper table-row table-body group-master">
|
||||
<div class="wrapper group-1">
|
||||
<div>
|
||||
<img class="icontwo">
|
||||
</div>
|
||||
<div class="symbol icon-voted" />
|
||||
<a :href="submitUrl" class="wrapper group-1-1">
|
||||
<div class="name">
|
||||
{{ form.form.title }}
|
||||
</div>
|
||||
<div class="description">
|
||||
{{ form.form.description }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="wrapper group-2">
|
||||
<div class="wrapper group-2-8">
|
||||
<a class="icon icon-delete svg delete-form" @click="deleteForm" />
|
||||
<span class="hidden-visually">
|
||||
{{ t('forms', 'Delete form') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="wrapper group-2-9">
|
||||
<a class="icon icon-clippy svg delete-form" @click="copyLink" />
|
||||
<span class="hidden-visually">
|
||||
{{ t('forms', 'Copy link') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="list-results wrapper">
|
||||
<a class="icon icon-toggle svg delete-form" @click="viewResults" />
|
||||
<span class="hidden-visually">
|
||||
{{ t('forms', 'View results') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="wrapper group-2-1">
|
||||
<div v-tooltip="accessType" class="thumbnail access" :class="form.form.access">
|
||||
{{ accessType }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="owner">
|
||||
<UserDiv :user-id="form.form.ownerId" :display-name="form.form.ownerDisplayName" />
|
||||
</div>
|
||||
<div class="wrapper group-2-2">
|
||||
<div class="created ">
|
||||
{{ timeSpanCreated }}
|
||||
</div>
|
||||
<div class="expiry" :class="{ expired : form.form.expired }">
|
||||
{{ timeSpanExpiration }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from '@nextcloud/moment'
|
||||
import UserDiv from '../components/_base-UserDiv'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
UserDiv,
|
||||
},
|
||||
props: {
|
||||
header: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
form: {
|
||||
type: Object,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
openedMenu: false,
|
||||
hostName: this.$route.query.page,
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
computed: {
|
||||
accessType() {
|
||||
if (this.form.form.access === 'public') {
|
||||
return t('forms', 'Public access')
|
||||
} else if (this.form.form.access === 'select') {
|
||||
return t('forms', 'Only shared')
|
||||
} else if (this.form.form.access === 'registered') {
|
||||
return t('forms', 'Registered users only')
|
||||
} else if (this.form.form.access === 'hidden') {
|
||||
return t('forms', 'Hidden form')
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
|
||||
timeSpanCreated() {
|
||||
return moment(this.form.form.created, 'YYYY-MM-DD HH:mm')
|
||||
},
|
||||
|
||||
timeSpanExpiration() {
|
||||
if (this.form.form.expires) {
|
||||
return moment(this.form.form.expirationDate)
|
||||
} else {
|
||||
return t('forms', 'never')
|
||||
}
|
||||
},
|
||||
|
||||
countShares() {
|
||||
return this.form.shares.length
|
||||
},
|
||||
|
||||
submitUrl() {
|
||||
return OC.generateUrl('apps/forms/form/') + this.form.form.hash
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
methods: {
|
||||
toggleMenu() {
|
||||
this.openedMenu = !this.openedMenu
|
||||
},
|
||||
|
||||
hideMenu() {
|
||||
this.openedMenu = false
|
||||
},
|
||||
|
||||
copyLink() {
|
||||
// this.$emit('copyLink')
|
||||
this.$copyText(window.location.origin + this.submitUrl).then(
|
||||
function(e) {
|
||||
OC.Notification.showTemporary(t('forms', 'Link copied to clipboard'))
|
||||
},
|
||||
function(e) {
|
||||
OC.Notification.showTemporary(t('forms', 'Error, while copying link to clipboard'))
|
||||
}
|
||||
)
|
||||
this.hideMenu()
|
||||
},
|
||||
|
||||
deleteForm() {
|
||||
this.$emit('deleteForm')
|
||||
},
|
||||
|
||||
viewResults() {
|
||||
this.$emit('viewResults')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
$row-padding: 15px;
|
||||
$table-padding: 4px;
|
||||
|
||||
$date-width: 130px;
|
||||
$participants-width: 95px;
|
||||
$group-2-2-width: max($date-width, $participants-width);
|
||||
|
||||
$owner-width: 140px;
|
||||
$access-width: 44px;
|
||||
$group-2-1-width: max($access-width, $date-width);
|
||||
$group-2-width: $owner-width + $group-2-1-width + $group-2-2-width;
|
||||
|
||||
$action-width: 44px;
|
||||
$thumbnail-width: 44px;
|
||||
$thumbnail-icon-width: 32px;
|
||||
$name-width: 150px;
|
||||
$description-width: 150px;
|
||||
$group-1-1-width: max($name-width, $description-width);
|
||||
$group-1-width: $thumbnail-width + $group-1-1-width + $action-width;
|
||||
|
||||
$group-master-width: max($group-1-width, $group-2-width);
|
||||
|
||||
$mediabreak-1: ($group-1-width + $owner-width + $access-width + $date-width + $date-width + $participants-width + $row-padding * 2);
|
||||
$mediabreak-2: ($group-1-width + $group-2-width + $row-padding * 2);
|
||||
$mediabreak-3: $group-1-width + $owner-width + max($group-2-1-width, $group-2-2-width) + $row-padding *2 ;
|
||||
|
||||
.table-row {
|
||||
width: 100%;
|
||||
padding-left: $row-padding;
|
||||
padding-right: $row-padding;
|
||||
|
||||
line-height: 2em;
|
||||
transition: background-color 0.3s ease;
|
||||
background-color: var(--color-main-background);
|
||||
min-height: 4em;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
&.table-header {
|
||||
.name, .description {
|
||||
padding-left: ($thumbnail-width + $table-padding *2);
|
||||
}
|
||||
.owner {
|
||||
padding-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
&.table-body {
|
||||
&:hover, &:focus, &:active, &.mouseOver {
|
||||
transition: background-color 0.3s ease;
|
||||
background-color: var(--color-background-dark);
|
||||
}
|
||||
.icon-more {
|
||||
right: 14px;
|
||||
opacity: 0.3;
|
||||
cursor: pointer;
|
||||
height: 44px;
|
||||
width: 44px;
|
||||
}
|
||||
|
||||
.symbol {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
&.table-header {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
width: $name-width;
|
||||
}
|
||||
|
||||
.description {
|
||||
width: $description-width;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.name, .description {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: $action-width;
|
||||
position: relative;
|
||||
overflow: initial;
|
||||
}
|
||||
|
||||
.result {
|
||||
width: 60px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.deletetwo {
|
||||
width: 60px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
.copyL {
|
||||
width: 60px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.access {
|
||||
width: $access-width;
|
||||
}
|
||||
|
||||
.owner {
|
||||
width: $owner-width;
|
||||
}
|
||||
|
||||
.created {
|
||||
width: $date-width;
|
||||
}
|
||||
|
||||
.expiry {
|
||||
width: $date-width;
|
||||
&.expired {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-form {
|
||||
padding-right:70px;
|
||||
}
|
||||
|
||||
.list-results {
|
||||
width: 65px;
|
||||
}
|
||||
|
||||
.group-1, .group-1-1 {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.group-1-1 {
|
||||
flex-direction: column;
|
||||
width: $group-1-1-width;
|
||||
> div {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (max-width: ($mediabreak-1) ) {
|
||||
.group-1 {
|
||||
width: $group-1-width;
|
||||
}
|
||||
.group-2-1, .group-2-2 {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.created {
|
||||
width: $group-2-1-width;;
|
||||
}
|
||||
.expiry, .participants {
|
||||
width: $group-2-2-width;;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (max-width: ($mediabreak-2) ) {
|
||||
.table-row {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.group-2-1 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (max-width: ($mediabreak-3) ) {
|
||||
.group-2 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.icontwo {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding-right: 4px;
|
||||
font-size: 0;
|
||||
background-color: var(--color-text-light);
|
||||
mask-image: var(--icon-organization-000) no-repeat 50% 50%;
|
||||
-webkit-mask: var(--icon-organization-000) no-repeat 50% 50%;
|
||||
mask-size: 16px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding-right: 4px;
|
||||
font-size: 0;
|
||||
background-color: var(--color-text-light);
|
||||
&.dateForm {
|
||||
mask-image: var(--icon-calendar-000) no-repeat 50% 50%;
|
||||
-webkit-mask: var(--icon-calendar-000) no-repeat 50% 50%;
|
||||
mask-size: 16px;
|
||||
}
|
||||
&.textForm {
|
||||
mask-image: var(--icon-organization-000) no-repeat 50% 50%;
|
||||
-webkit-mask: var(--icon-organization-000) no-repeat 50% 50%;
|
||||
mask-size: 16px;
|
||||
}
|
||||
&.expired {
|
||||
background-color: var(--color-background-darker);
|
||||
}
|
||||
&.access {
|
||||
display: inherit;
|
||||
&.hidden {
|
||||
mask-image: var(--icon-password-000) no-repeat 50% 50%;
|
||||
-webkit-mask: var(--icon-password-000) no-repeat 50% 50%;
|
||||
mask-size: 16px;
|
||||
}
|
||||
&.public {
|
||||
mask-image: var(--icon-link-000) no-repeat 50% 50%;
|
||||
-webkit-mask: var(--icon-link-000) no-repeat 50% 50%;
|
||||
mask-size: 16px;
|
||||
}
|
||||
&.select {
|
||||
mask-image: var(--icon-share-000) no-repeat 50% 50%;
|
||||
-webkit-mask: var(--icon-share-000) no-repeat 50% 50%;
|
||||
mask-size: 16px;
|
||||
}
|
||||
&.registered {
|
||||
mask-image: var(--icon-group-000) no-repeat 50% 50%;
|
||||
-webkit-mask: var(--icon-group-000) no-repeat 50% 50%;
|
||||
mask-size: 16px;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.icon-voted {
|
||||
background-image: var(--icon-checkmark-fff);
|
||||
}
|
||||
|
||||
.app-navigation-entry-utils-counter {
|
||||
padding-right: 0 !important;
|
||||
overflow: hidden;
|
||||
text-align: right;
|
||||
font-size: 9pt;
|
||||
line-height: 44px;
|
||||
padding: 0 12px;
|
||||
// min-width: 25px;
|
||||
&.highlighted {
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
span {
|
||||
padding: 2px 5px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.symbol.icon-voted {
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 16px;
|
||||
background-size: 0;
|
||||
min-width: 8px;
|
||||
min-height: 8px;
|
||||
background-color: var(--color-success);
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
|
@ -1,48 +0,0 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2018 René Gieling <github@dartcafe.de>
|
||||
-
|
||||
- @author René Gieling <github@dartcafe.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/>.
|
||||
-
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="close flex-row">
|
||||
<a id="closeDetails"
|
||||
:title="closeDetailLabel"
|
||||
:alt="closeDetailLabelAlt"
|
||||
class="close icon-close has-tooltip-bottom"
|
||||
href="#"
|
||||
@:click="hideSidebar" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
closeDetailLabel: t('Close details'),
|
||||
closeDetailLabelAlt: t('Close'),
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
hideSidebar() {
|
||||
OC.Apps.hideAppSidebar()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
113
src/mixins/QuestionMixin.js
Normal file
113
src/mixins/QuestionMixin.js
Normal file
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
|
||||
*
|
||||
* @author John Molakvoæ <skjnldsv@protonmail.com>
|
||||
*
|
||||
* @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/>.
|
||||
*/
|
||||
import Question from '../components/Questions/Question'
|
||||
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
|
||||
/**
|
||||
* The question title
|
||||
*/
|
||||
text: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* The user answers
|
||||
*/
|
||||
values: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* The question list of answers
|
||||
*/
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* Answer type model object
|
||||
*/
|
||||
model: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
components: {
|
||||
Question,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
// Do we display this question in edit or fill mode
|
||||
edit: false,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Forward the title change to the parent
|
||||
*
|
||||
* @param {string} text the title
|
||||
*/
|
||||
onTitleChange(text) {
|
||||
this.$emit('update:text', text)
|
||||
},
|
||||
|
||||
/**
|
||||
* Forward the answer(s) change to the parent
|
||||
*
|
||||
* @param {Array} values the array of answers
|
||||
*/
|
||||
onValuesChange(values) {
|
||||
this.$emit('update:values', values)
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete this question
|
||||
*/
|
||||
onDelete() {
|
||||
this.$emit('delete')
|
||||
},
|
||||
|
||||
/**
|
||||
* Focus the first focusable element
|
||||
*/
|
||||
focus() {
|
||||
this.edit = true
|
||||
this.$el.scrollIntoView({ behavior: 'smooth' })
|
||||
this.$nextTick(() => {
|
||||
const title = this.$el.querySelector('.question__header-title')
|
||||
if (title) {
|
||||
title.select()
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
|
@ -19,16 +19,36 @@
|
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import axios from '@nextcloud/axios'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
hash: {
|
||||
type: String,
|
||||
default: null,
|
||||
default: '',
|
||||
},
|
||||
form: {
|
||||
type: Object,
|
||||
// TODO: use default Form object ?
|
||||
default: {},
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async saveFormProperty(key) {
|
||||
try {
|
||||
// TODO: add loading status feedback ?
|
||||
await axios.post(generateUrl('/apps/forms/api/v1/form/update'), {
|
||||
id: this.form.id,
|
||||
keyValuePairs: {
|
||||
[key]: this.form[key],
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
showError(t('forms', 'Error while saving form'))
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -20,34 +20,42 @@
|
|||
*
|
||||
*/
|
||||
|
||||
export default class {
|
||||
import QuestionLong from '../components/Questions/QuestionLong'
|
||||
import QuestionShort from '../components/Questions/QuestionShort'
|
||||
import QuestionMultiple from '../components/Questions/QuestionMultiple'
|
||||
|
||||
#data
|
||||
/**
|
||||
* @typedef {Object} AnswerTypes
|
||||
* @property {string} multiple_unique
|
||||
* @property {string} multiple
|
||||
* @property {string} short
|
||||
* @property {string} long
|
||||
*/
|
||||
export default {
|
||||
|
||||
/**
|
||||
* Construct the form
|
||||
* @param {Object} data the form data
|
||||
*/
|
||||
constructor(data) {
|
||||
// Id check
|
||||
if (!('id' in data && typeof data.id === 'number')) {
|
||||
throw new Error('A new form must at least contain a valid id')
|
||||
}
|
||||
multiple_unique: {
|
||||
component: QuestionMultiple,
|
||||
icon: 'icon-answer-multiple',
|
||||
label: t('forms', 'Multiple choice'),
|
||||
unique: true,
|
||||
},
|
||||
|
||||
// Hash check
|
||||
if (!('hash' in data && typeof data.id === 'string')) {
|
||||
throw new Error('A new form must at least contain a valid hash')
|
||||
}
|
||||
multiple: {
|
||||
component: QuestionMultiple,
|
||||
icon: 'icon-answer-checkbox',
|
||||
label: t('forms', 'Checkboxes'),
|
||||
},
|
||||
|
||||
this.#data = data
|
||||
}
|
||||
short: {
|
||||
component: QuestionShort,
|
||||
icon: 'icon-answer-short',
|
||||
label: t('forms', 'Short answer'),
|
||||
},
|
||||
|
||||
get id() {
|
||||
return this.#data.id
|
||||
}
|
||||
|
||||
get hash() {
|
||||
return this.#data.hash
|
||||
}
|
||||
long: {
|
||||
component: QuestionLong,
|
||||
icon: 'icon-answer-long',
|
||||
label: t('forms', 'Long text'),
|
||||
},
|
||||
|
||||
}
|
|
@ -56,7 +56,7 @@ export default new Router({
|
|||
{
|
||||
path: '/:hash',
|
||||
name: 'fill',
|
||||
props: true,
|
||||
props: { default: true },
|
||||
},
|
||||
{
|
||||
path: '/:hash/edit',
|
||||
|
@ -65,13 +65,13 @@ export default new Router({
|
|||
sidebar: Sidebar,
|
||||
},
|
||||
name: 'edit',
|
||||
props: true,
|
||||
props: { default: true },
|
||||
},
|
||||
{
|
||||
path: '/:hash/results',
|
||||
component: Results,
|
||||
name: 'results',
|
||||
props: true,
|
||||
props: { default: true },
|
||||
},
|
||||
{
|
||||
path: '/:hash/clone',
|
||||
|
@ -80,7 +80,7 @@ export default new Router({
|
|||
sidebar: Sidebar,
|
||||
},
|
||||
name: 'clone',
|
||||
props: true,
|
||||
props: { default: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
57
src/utils/CancelableRequest.js
Normal file
57
src/utils/CancelableRequest.js
Normal file
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* @copyright Copyright (c) 2019 Marco Ambrosini <marcoambrosini@pm.me>
|
||||
*
|
||||
* @author Marco Ambrosini <marcoambrosini@pm.me>
|
||||
* @author John Molakvoæ <skjnldsv@protonmail.com>
|
||||
*
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
|
||||
/**
|
||||
* Creates a cancelable axios 'request object'.
|
||||
*
|
||||
* @param {function} request the axios promise request
|
||||
* @returns {Object}
|
||||
*/
|
||||
const CancelableRequest = function(request) {
|
||||
/**
|
||||
* Generate an axios cancel token
|
||||
*/
|
||||
const CancelToken = axios.CancelToken
|
||||
const source = CancelToken.source()
|
||||
|
||||
/**
|
||||
* Execute the request
|
||||
*
|
||||
* @param {string} url the url to send the request to
|
||||
* @param {Object} [options] optional config for the request
|
||||
*/
|
||||
const fetch = async function(url, options) {
|
||||
return request(
|
||||
url,
|
||||
Object.assign({ cancelToken: source.token }, { options })
|
||||
)
|
||||
}
|
||||
return {
|
||||
request: fetch,
|
||||
cancel: source.cancel,
|
||||
}
|
||||
}
|
||||
|
||||
export default CancelableRequest
|
|
@ -1,3 +1,4 @@
|
|||
|
||||
/**
|
||||
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
|
||||
*
|
||||
|
@ -12,26 +13,19 @@
|
|||
*
|
||||
* 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
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format a form object prior to forms v2.0
|
||||
*
|
||||
* @param {Object} form the form raw object
|
||||
* @returns {Object} properly formatted form object
|
||||
*/
|
||||
const formatForm = function(form) {
|
||||
// clone form
|
||||
const newForm = Object.assign({}, form, form.form)
|
||||
|
||||
// cleanup
|
||||
delete newForm.event
|
||||
return newForm
|
||||
const GenRandomId = (length) => {
|
||||
return Math.random()
|
||||
.toString(36)
|
||||
.replace(/[^a-z]+/g, '')
|
||||
.substr(0, length || 5)
|
||||
}
|
||||
|
||||
export { formatForm }
|
||||
export default GenRandomId
|
|
@ -3,6 +3,7 @@
|
|||
-
|
||||
- @author René Gieling <github@dartcafe.de>
|
||||
- @author Nick Gallo
|
||||
- @author John Molakvoæ <skjnldsv@protonmail.com>
|
||||
-
|
||||
- @license GNU AGPL version 3 or any later version
|
||||
-
|
||||
|
@ -26,284 +27,344 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<AppContent>
|
||||
<div class="workbench">
|
||||
<div>
|
||||
<h2>{{ t('forms', 'Form description') }}</h2>
|
||||
<AppContent v-if="isLoadingForm">
|
||||
<EmptyContent icon="icon-loading">
|
||||
{{ t('forms', 'Loading form “{title}”', { title: form.title }) }}
|
||||
</EmptyContent>
|
||||
</AppContent>
|
||||
|
||||
<label>{{ t('forms', 'Title') }}</label>
|
||||
<input id="formTitle"
|
||||
v-model="form.form.title"
|
||||
:class="{ error: titleEmpty }"
|
||||
type="text">
|
||||
<AppContent v-else>
|
||||
<!-- Show results & sidebar button -->
|
||||
<TopBar>
|
||||
<button class="primary" @click="showResults">
|
||||
<span class="icon-forms-white" role="img" />
|
||||
{{ t('forms', 'Show results') }}
|
||||
</button>
|
||||
<button v-tooltip="t('forms', 'Toggle settings')"
|
||||
:aria-label="t('forms', 'Toggle settings')"
|
||||
@click="toggleSidebar">
|
||||
<span class="icon-settings" role="img" />
|
||||
</button>
|
||||
</TopBar>
|
||||
|
||||
<label>{{ t('forms', 'Description') }}</label>
|
||||
<textarea id="formDesc" v-model="form.form.description" style="resize: vertical; width: 100%;" />
|
||||
<!-- Forms title & description-->
|
||||
<header>
|
||||
<label class="hidden-visually" for="form-title">{{ t('forms', 'Title') }}</label>
|
||||
<input
|
||||
id="form-title"
|
||||
v-model="form.title"
|
||||
:minlength="0"
|
||||
:placeholder="t('forms', 'Title')"
|
||||
:required="true"
|
||||
autofocus
|
||||
type="text"
|
||||
@click="selectIfUnchanged"
|
||||
@keyup="onTitleChange">
|
||||
<label class="hidden-visually" for="form-desc">{{ t('forms', 'Description') }}</label>
|
||||
<textarea
|
||||
id="form-desc"
|
||||
ref="description"
|
||||
v-model="form.description"
|
||||
:placeholder="t('forms', 'Description')"
|
||||
@change="autoSizeDescription"
|
||||
@keydown="autoSizeDescription"
|
||||
@keyup="onDescChange" />
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<!-- Add new questions toolbar -->
|
||||
<div class="question-toolbar" role="toolbar">
|
||||
<Actions ref="questionMenu"
|
||||
v-tooltip="t('forms', 'Add a question to this form')"
|
||||
:aria-label="t('forms', 'Add a question to this form')"
|
||||
:open.sync="questionMenuOpened"
|
||||
:default-icon="isLoadingQuestions ? 'icon-loading-small' : 'icon-add-white'">
|
||||
<ActionButton v-for="(answer, type) in answerTypes"
|
||||
:key="answer.label"
|
||||
:close-after-click="true"
|
||||
:disabled="isLoadingQuestions"
|
||||
:icon="answer.icon"
|
||||
class="question-toolbar__question"
|
||||
@click="addQuestion(type)">
|
||||
{{ answer.label }}
|
||||
</ActionButton>
|
||||
</Actions>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>{{ t('forms', 'Make a Form') }}</h2>
|
||||
<div id="quiz-form-selector-text">
|
||||
<!--shows inputs for question types: drop down box to select the type, text box for question, and button to add-->
|
||||
<label for="ans-type">Answer Type: </label>
|
||||
<select v-model="selected">
|
||||
<option value="" disabled>
|
||||
Select
|
||||
</option>
|
||||
<option v-for="type in questionTypes" :key="type.value" :value="type.value">
|
||||
{{ type.text }}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="newQuestion"
|
||||
:placeholder=" t('forms', 'Add Question') "
|
||||
maxlength="2048"
|
||||
@keyup.enter="addQuestion()">
|
||||
<button id="questButton"
|
||||
@click="addQuestion()">
|
||||
{{ t('forms', 'Add Question') }}
|
||||
<!-- No questions -->
|
||||
<EmptyContent v-if="hasQuestions">
|
||||
{{ t('forms', 'This form does not have any questions') }}
|
||||
<template #desc>
|
||||
<button class="empty-content__button primary" @click="openQuestionMenu">
|
||||
<span class="icon-add-white" />
|
||||
{{ t('forms', 'Add a new one') }}
|
||||
</button>
|
||||
</div>
|
||||
<!--Transition group to list the already added questions (in the form of quizFormItems)-->
|
||||
<transitionGroup
|
||||
id="form-list"
|
||||
name="list"
|
||||
tag="ul"
|
||||
class="form-table">
|
||||
<QuizFormItem
|
||||
v-for="(question, index) in form.questions"
|
||||
:key="question.id"
|
||||
:question="question"
|
||||
:type="question.type"
|
||||
@addOption="addOption"
|
||||
@deleteOption="deleteOption"
|
||||
@deleteQuestion="deleteQuestion(question, index)" />
|
||||
</transitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</EmptyContent>
|
||||
|
||||
<!-- Questions list -->
|
||||
<Draggable v-model="form.questions"
|
||||
:animation="200"
|
||||
tag="ul"
|
||||
@change="onQuestionOrderChange"
|
||||
@start="isDragging = true"
|
||||
@end="isDragging = false">
|
||||
<Questions
|
||||
:is="answerTypes[question.type].component"
|
||||
v-for="(question, index) in form.questions"
|
||||
ref="questions"
|
||||
:key="question.id"
|
||||
:model="answerTypes[question.type]"
|
||||
:index="index + 1"
|
||||
v-bind.sync="question"
|
||||
@delete="deleteQuestion(question)" />
|
||||
</Draggable>
|
||||
</section>
|
||||
</AppContent>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import axios from '@nextcloud/axios'
|
||||
import moment from '@nextcloud/moment'
|
||||
import debounce from 'debounce'
|
||||
import Draggable from 'vuedraggable'
|
||||
|
||||
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
|
||||
import Actions from '@nextcloud/vue/dist/Components/Actions'
|
||||
import AppContent from '@nextcloud/vue/dist/Components/AppContent'
|
||||
import { showError, showSuccess } from '@nextcloud/dialogs'
|
||||
|
||||
import answerTypes from '../models/AnswerTypes'
|
||||
import EmptyContent from '../components/EmptyContent'
|
||||
import Question from '../components/Questions/Question'
|
||||
import QuestionLong from '../components/Questions/QuestionLong'
|
||||
import QuestionShort from '../components/Questions/QuestionShort'
|
||||
import QuestionMultiple from '../components/Questions/QuestionMultiple'
|
||||
import QuizFormItem from '../components/quizFormItem'
|
||||
|
||||
import TopBar from '../components/TopBar'
|
||||
import ViewsMixin from '../mixins/ViewsMixin'
|
||||
|
||||
window.axios = axios
|
||||
|
||||
export default {
|
||||
name: 'Create',
|
||||
components: {
|
||||
ActionButton,
|
||||
Actions,
|
||||
AppContent,
|
||||
Draggable,
|
||||
EmptyContent,
|
||||
Question,
|
||||
QuestionLong,
|
||||
QuestionShort,
|
||||
QuestionMultiple,
|
||||
QuizFormItem,
|
||||
TopBar,
|
||||
},
|
||||
|
||||
mixins: [ViewsMixin],
|
||||
|
||||
data() {
|
||||
return {
|
||||
placeholder: '',
|
||||
newOption: '',
|
||||
newQuestion: '',
|
||||
nextOptionId: 1,
|
||||
nextQuestionId: 1,
|
||||
writingForm: false,
|
||||
loadingForm: true,
|
||||
titleEmpty: false,
|
||||
selected: '',
|
||||
uniqueQuestionText: false,
|
||||
uniqueOptionText: false,
|
||||
allHaveOpt: false,
|
||||
questionTypes: [
|
||||
{ text: 'Radio Buttons', value: 'radiogroup' },
|
||||
{ text: 'Checkboxes', value: 'checkbox' },
|
||||
{ text: 'Short Response', value: 'text' },
|
||||
{ text: 'Long Response', value: 'comment' },
|
||||
{ text: 'Drop Down', value: 'dropdown' },
|
||||
],
|
||||
questionMenuOpened: false,
|
||||
answerTypes,
|
||||
|
||||
// Various states
|
||||
isLoadingForm: true,
|
||||
isLoadingQuestions: false,
|
||||
errorForm: false,
|
||||
|
||||
isDragging: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
langShort() {
|
||||
return this.lang.split('-')[0]
|
||||
},
|
||||
|
||||
title() {
|
||||
if (this.form.form.title === '') {
|
||||
if (this.form.title === '') {
|
||||
return t('forms', 'Create new form')
|
||||
} else {
|
||||
return this.form.form.title
|
||||
|
||||
return this.form.title
|
||||
}
|
||||
},
|
||||
|
||||
saveButtonTitle() {
|
||||
if (this.writingForm) {
|
||||
return t('forms', 'Writing form')
|
||||
} else if (this.form.mode === 'edit') {
|
||||
return t('forms', 'Update form')
|
||||
} else {
|
||||
return t('forms', 'Done')
|
||||
}
|
||||
hasQuestions() {
|
||||
return this.form.questions && this.form.questions.length === 0
|
||||
},
|
||||
|
||||
localeData() {
|
||||
return moment.localeData(moment.locale(this.locale))
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
watch: {
|
||||
title() {
|
||||
// only used when the title changes after page load
|
||||
document.title = t('forms', 'Forms') + ' - ' + this.title
|
||||
},
|
||||
|
||||
form: {
|
||||
deep: true,
|
||||
handler: function() {
|
||||
this.debounceWriteForm()
|
||||
},
|
||||
// Fetch full form on change
|
||||
hash() {
|
||||
// TODO: cancel previous request if not done
|
||||
this.fetchFullForm(this.form.id)
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.$route.name === 'create') {
|
||||
// TODO: manage this from Forms.vue, request a new form to the server
|
||||
this.form.form.owner = OC.getCurrentUser().uid
|
||||
this.loadingForm = false
|
||||
} else if (this.$route.name === 'edit') {
|
||||
// TODO: fetch & update form?
|
||||
this.form.mode = 'edit'
|
||||
} else if (this.$route.name === 'clone') {
|
||||
// TODO: CLONE
|
||||
}
|
||||
beforeMount() {
|
||||
this.fetchFullForm(this.form.id)
|
||||
},
|
||||
|
||||
updated() {
|
||||
this.autoSizeDescription()
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Fetch the full form data and update parent
|
||||
*
|
||||
* @param {number} id the unique form hash
|
||||
*/
|
||||
async fetchFullForm(id) {
|
||||
this.isLoadingForm = true
|
||||
console.debug('Loading form', id)
|
||||
|
||||
switchSidebar() {
|
||||
this.sidebar = !this.sidebar
|
||||
},
|
||||
|
||||
checkQuestionText() {
|
||||
this.uniqueQuestionText = true
|
||||
this.form.questions.forEach(q => {
|
||||
if (q.text === this.newQuestion) {
|
||||
this.uniqueQuestionText = false
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async addQuestion() {
|
||||
this.checkQuestionText()
|
||||
if (this.selected === '') {
|
||||
showError(t('forms', 'Select a question type!'), { duration: 3000 })
|
||||
} else if (!this.uniqueQuestionText) {
|
||||
showError(t('forms', 'Cannot have the same question!'))
|
||||
} else {
|
||||
if (this.newQuestion !== null & this.newQuestion !== '' & (/\S/.test(this.newQuestion))) {
|
||||
const response = await axios.post(generateUrl('/apps/forms/api/v1/question/'), { formId: this.form.id, type: this.selected, text: this.newQuestion })
|
||||
const respData = response.data
|
||||
|
||||
this.form.questions.push({
|
||||
id: respData.id,
|
||||
order: respData.order,
|
||||
text: this.newQuestion,
|
||||
type: this.selected,
|
||||
options: [],
|
||||
})
|
||||
}
|
||||
this.newQuestion = ''
|
||||
try {
|
||||
const form = await axios.get(generateUrl('/apps/forms/api/v1/form/{id}', { id }))
|
||||
this.$emit('update:form', form.data)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
this.errorForm = true
|
||||
} finally {
|
||||
this.isLoadingForm = false
|
||||
}
|
||||
},
|
||||
|
||||
async deleteQuestion(question, index) {
|
||||
await axios.delete(generateUrl('/apps/forms/api/v1/question/{id}', { id: question.id }))
|
||||
// TODO catch Error
|
||||
this.form.questions.splice(index, 1)
|
||||
},
|
||||
|
||||
checkOptionText(item, question) {
|
||||
this.uniqueOptionText = true
|
||||
question.options.forEach(o => {
|
||||
if (o.text === item.newOption) {
|
||||
this.uniqueOptionText = false
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async addOption(item, question) {
|
||||
this.checkOptionText(item, question)
|
||||
if (!this.uniqueOptionText) {
|
||||
showError(t('forms', 'Two options cannot be the same!'), { duration: 3000 })
|
||||
} else {
|
||||
if (item.newOption !== null & item.newOption !== '' & (/\S/.test(item.newOption))) {
|
||||
const response = await axios.post(generateUrl('/apps/forms/api/v1/option/'), { formId: this.form.id, questionId: question.id, text: item.newOption })
|
||||
const optionId = response.data
|
||||
|
||||
question.options.push({
|
||||
id: optionId,
|
||||
text: item.newOption,
|
||||
})
|
||||
}
|
||||
item.newOption = ''
|
||||
}
|
||||
},
|
||||
|
||||
async deleteOption(question, option, index) {
|
||||
await axios.delete(generateUrl('/apps/forms/api/v1/option/{id}', { id: option.id }))
|
||||
// TODO catch errors
|
||||
question.options.splice(index, 1)
|
||||
},
|
||||
|
||||
checkAllHaveOpt() {
|
||||
this.allHaveOpt = true
|
||||
this.form.questions.forEach(q => {
|
||||
if (q.type !== 'text' && q.type !== 'comment' && q.options.length === 0) {
|
||||
this.allHaveOpt = false
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
debounceWriteForm: debounce(function() {
|
||||
this.writeForm()
|
||||
/**
|
||||
* Title & description save methods
|
||||
*/
|
||||
onTitleChange: debounce(function() {
|
||||
this.saveFormProperty('title')
|
||||
}, 200),
|
||||
onDescChange: debounce(function() {
|
||||
this.saveFormProperty('description')
|
||||
}, 200),
|
||||
|
||||
writeForm() {
|
||||
this.checkAllHaveOpt()
|
||||
if (this.form.form.title.length === 0 | !(/\S/.test(this.form.form.title))) {
|
||||
this.titleEmpty = true
|
||||
showError(t('forms', 'Title must not be empty!'), { duration: 3000 })
|
||||
} else if (!this.allHaveOpt) {
|
||||
showError(t('forms', 'All questions need answers!'), { duration: 3000 })
|
||||
} else if (this.form.form.expires & this.form.form.expirationDate === '') {
|
||||
showError(t('forms', 'Need to pick an expiration date!'), { duration: 3000 })
|
||||
} else {
|
||||
this.writingForm = true
|
||||
this.titleEmpty = false
|
||||
/**
|
||||
* Add a new question to the current form
|
||||
*
|
||||
* @param {string} type the question type, see AnswerTypes
|
||||
*/
|
||||
async addQuestion(type) {
|
||||
const text = t('forms', 'New question')
|
||||
this.isLoadingQuestions = true
|
||||
|
||||
axios.post(OC.generateUrl('apps/forms/write/form'), this.form)
|
||||
.then((response) => {
|
||||
this.form.mode = 'edit'
|
||||
this.form.form.hash = response.data.hash
|
||||
this.form.form.id = response.data.id
|
||||
this.writingForm = false
|
||||
showSuccess(t('forms', '%n successfully saved', 1, this.form.form.title), { duration: 3000 })
|
||||
}, (error) => {
|
||||
this.form.form.hash = ''
|
||||
this.writingForm = false
|
||||
showError(t('forms', 'Error on saving form, see console'))
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.log(error.response)
|
||||
})
|
||||
try {
|
||||
const response = await axios.post(generateUrl('/apps/forms/api/v1/question'), {
|
||||
formId: this.form.id,
|
||||
type,
|
||||
text,
|
||||
})
|
||||
const question = response.data
|
||||
|
||||
// Add newly created question
|
||||
this.form.questions.push(Object.assign({
|
||||
text,
|
||||
type,
|
||||
answers: [],
|
||||
}, question))
|
||||
|
||||
// Focus newly added question
|
||||
this.$nextTick(() => {
|
||||
const lastQuestion = this.$refs.questions[this.$refs.questions.length - 1]
|
||||
lastQuestion.focus()
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showError(t('forms', 'There was an error while adding the new question'))
|
||||
} finally {
|
||||
this.isLoadingQuestions = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a question
|
||||
*
|
||||
* @param {Object} question the question to delete
|
||||
* @param {number} question.id the question id to delete
|
||||
*/
|
||||
async deleteQuestion({ id }) {
|
||||
this.isLoadingQuestions = true
|
||||
|
||||
try {
|
||||
await axios.delete(generateUrl('/apps/forms/api/v1/question/{id}', { id }))
|
||||
const index = this.form.questions.findIndex(search => search.id === id)
|
||||
this.form.questions.splice(index, 1)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showError(t('forms', 'There was an error while removing the question'))
|
||||
} finally {
|
||||
this.isLoadingQuestions = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder questions on dragEnd
|
||||
*/
|
||||
async onQuestionOrderChange() {
|
||||
this.isLoadingQuestions = true
|
||||
const newOrder = this.form.questions.map(question => question.id)
|
||||
|
||||
try {
|
||||
await axios.post(generateUrl('/apps/forms/api/v1/question/reorder'), {
|
||||
formId: this.form.id,
|
||||
newOrder,
|
||||
})
|
||||
} catch (error) {
|
||||
showError(t('forms', 'Error while saving form'))
|
||||
console.error(error)
|
||||
} finally {
|
||||
this.isLoadingQuestions = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Add question methods
|
||||
*/
|
||||
openQuestionMenu() {
|
||||
// TODO: fix the vue components to allow external click triggers without
|
||||
// conflicting with the click outside directive
|
||||
setTimeout(() => {
|
||||
this.questionMenuOpened = true
|
||||
this.$nextTick(() => {
|
||||
this.$refs.questionMenu.focusFirstAction()
|
||||
})
|
||||
}, 10)
|
||||
},
|
||||
|
||||
/**
|
||||
* Topbar methods
|
||||
*/
|
||||
showResults() {
|
||||
this.$router.push({
|
||||
name: 'results',
|
||||
params: {
|
||||
hash: this.form.hash,
|
||||
},
|
||||
})
|
||||
},
|
||||
toggleSidebar() {
|
||||
emit('toggleSidebar')
|
||||
},
|
||||
|
||||
/**
|
||||
* Select the text in the input if it is still set to 'New form'
|
||||
* @param {Event} e the click event
|
||||
*/
|
||||
selectIfUnchanged(e) {
|
||||
if (e.target && e.target.value === t('forms', 'New form')) {
|
||||
e.target.select()
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Auto adjust the description height based on lines number
|
||||
*/
|
||||
autoSizeDescription() {
|
||||
const textarea = this.$refs.description
|
||||
if (textarea) {
|
||||
textarea.style.cssText = 'height:auto; padding:0'
|
||||
textarea.style.cssText = `height: ${textarea.scrollHeight + 20}px`
|
||||
}
|
||||
},
|
||||
},
|
||||
|
@ -311,133 +372,86 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
#app-content {
|
||||
input.hasTimepicker {
|
||||
width: 75px;
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--color-error);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.forms-content {
|
||||
display: flex;
|
||||
padding-top: 45px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.workbench {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-wrap: wrap;
|
||||
overflow-x: hidden;
|
||||
|
||||
> div {
|
||||
min-width: 245px;
|
||||
max-width: 540px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Transitions for inserting and removing list items */
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.list-enter,
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.list-move {
|
||||
transition: transform 0.5s;
|
||||
}
|
||||
/* */
|
||||
|
||||
#form-item-selector-text {
|
||||
> input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.form-table {
|
||||
> li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
line-height: 24px;
|
||||
min-height: 24px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
&:active,
|
||||
&:hover {
|
||||
transition: var(--background-dark) 0.3s ease;
|
||||
background-color: var(--color-background-dark); //$hover-color;
|
||||
|
||||
}
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
font-size: 1.2em;
|
||||
opacity: 0.7;
|
||||
white-space: normal;
|
||||
padding-right: 4px;
|
||||
&.avatar {
|
||||
flex-grow: 0;
|
||||
}
|
||||
}
|
||||
|
||||
> div:nth-last-child(1) {
|
||||
justify-content: center;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
&.button-inline {
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.selectUnit {
|
||||
// Replace with new vue components release
|
||||
#app-content,
|
||||
#app-content-vue {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
> label {
|
||||
padding-right: 4px;
|
||||
flex-direction: column;
|
||||
|
||||
header,
|
||||
section {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
// Title & description header
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 44px;
|
||||
|
||||
#form-title,
|
||||
#form-desc {
|
||||
width: 100%;
|
||||
margin: 10px; // aerate the header
|
||||
padding: 0; // makes alignment and desc height calc easier
|
||||
border: none;
|
||||
}
|
||||
#form-title {
|
||||
font-size: 2em;
|
||||
}
|
||||
#form-desc {
|
||||
// make sure height calculations are correct
|
||||
box-sizing: content-box !important;
|
||||
min-height: 60px;
|
||||
max-height: 200px;
|
||||
padding-left: 2px; // align with title (compensate font size diff)
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-content__button {
|
||||
margin: 5px;
|
||||
> span {
|
||||
margin-right: 5px;
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Questions container
|
||||
section {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 250px;
|
||||
|
||||
.question-toolbar {
|
||||
position: sticky;
|
||||
// Above other menus
|
||||
z-index: 55;
|
||||
top: var(--header-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: flex-end;
|
||||
width: 44px;
|
||||
height: var(--top-bar-height);
|
||||
// make sure this doesn't take any space and appear floating
|
||||
margin-top: -44px;
|
||||
|
||||
.icon-add-white {
|
||||
opacity: 1;
|
||||
border-radius: 50%;
|
||||
// TODO: standardize on components
|
||||
background-color: var(--color-primary-element);
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background-color: var(--color-primary-element-light) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#shiftDates {
|
||||
background-repeat: no-repeat;
|
||||
background-position: 10px center;
|
||||
min-width: 16px;
|
||||
min-height: 16px;
|
||||
padding: 10px;
|
||||
padding-left: 34px;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
<div v-for="sum in stats" :key="sum">
|
||||
{{ sum }}
|
||||
</div>
|
||||
<div id="app-content">
|
||||
<div id="app-content" :class="{'icon-loading': loading}">
|
||||
<transition-group
|
||||
name="list"
|
||||
tag="div"
|
||||
|
@ -48,7 +48,6 @@
|
|||
:answer="answer"
|
||||
@viewResults="viewFormResults(index, form.form, 'results')" />
|
||||
</transition-group>
|
||||
<LoadingOverlay v-if="loading" />
|
||||
<modal-dialog />
|
||||
</div>
|
||||
</AppContent>
|
||||
|
@ -58,7 +57,6 @@
|
|||
import ResultItem from '../components/resultItem'
|
||||
import json2csvParser from 'json2csv'
|
||||
import axios from '@nextcloud/axios'
|
||||
import LoadingOverlay from '../components/_base-LoadingOverlay'
|
||||
import ViewsMixin from '../mixins/ViewsMixin'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
|
||||
|
@ -69,7 +67,6 @@ export default {
|
|||
components: {
|
||||
AppContent,
|
||||
ResultItem,
|
||||
LoadingOverlay,
|
||||
},
|
||||
|
||||
mixins: [ViewsMixin],
|
||||
|
|
|
@ -21,32 +21,34 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<AppSidebar :title="form.form.title">
|
||||
<AppSidebar v-show="opened" :title="form.title" @close="onClose">
|
||||
<div class="configBox ">
|
||||
<label class="title icon-settings">
|
||||
{{ t('forms', 'Form configurations') }}
|
||||
</label>
|
||||
|
||||
<input id="isAnonymous"
|
||||
v-model="form.form.isAnonymous"
|
||||
v-model="form.isAnonymous"
|
||||
|
||||
type="checkbox"
|
||||
class="checkbox">
|
||||
class="checkbox"
|
||||
@change="onAnonChange">
|
||||
<label for="isAnonymous" class="title">
|
||||
{{ t('forms', 'Anonymous form') }}
|
||||
</label>
|
||||
|
||||
<input id="submitOnce"
|
||||
v-model="form.form.submitOnce"
|
||||
:disabled="form.form.access.type === 'public' || form.form.isAnonymous"
|
||||
v-model="form.submitOnce"
|
||||
:disabled="form.access.type === 'public' || form.isAnonymous"
|
||||
type="checkbox"
|
||||
class="checkbox">
|
||||
class="checkbox"
|
||||
@change="onSubmOnceChange">
|
||||
<label for="submitOnce" class="title">
|
||||
<span>{{ t('forms', 'Only allow one submission per user') }}</span>
|
||||
</label>
|
||||
|
||||
<input id="expires"
|
||||
v-model="form.form.expires"
|
||||
v-model="formExpires"
|
||||
|
||||
type="checkbox"
|
||||
class="checkbox">
|
||||
|
@ -54,10 +56,11 @@
|
|||
{{ t('forms', 'Expires') }}
|
||||
</label>
|
||||
|
||||
<DatetimePicker v-show="form.form.expires"
|
||||
<DatetimePicker v-show="formExpires"
|
||||
id="expiresDatetimePicker"
|
||||
v-model="form.form.expiresTimestamp"
|
||||
v-bind="expirationDatePicker" />
|
||||
v-model="form.expires"
|
||||
v-bind="expirationDatePicker"
|
||||
@change="onExpiresChange" />
|
||||
</div>
|
||||
|
||||
<div class="configBox">
|
||||
|
@ -66,37 +69,40 @@
|
|||
</label>
|
||||
|
||||
<input id="registered"
|
||||
v-model="form.form.access.type"
|
||||
v-model="form.access.type"
|
||||
type="radio"
|
||||
value="registered"
|
||||
class="radio">
|
||||
class="radio"
|
||||
@change="onAccessChange">
|
||||
<label for="registered" class="title">
|
||||
<div class="title icon-group" />
|
||||
<span>{{ t('forms', 'Registered users only') }}</span>
|
||||
</label>
|
||||
|
||||
<input id="public"
|
||||
v-model="form.form.access.type"
|
||||
v-model="form.access.type"
|
||||
type="radio"
|
||||
value="public"
|
||||
class="radio">
|
||||
class="radio"
|
||||
@change="onAccessChange">
|
||||
<label for="public" class="title">
|
||||
<div class="title icon-link" />
|
||||
<span>{{ t('forms', 'Public access') }}</span>
|
||||
</label>
|
||||
|
||||
<input id="selected"
|
||||
v-model="form.form.access.type"
|
||||
v-model="form.access.type"
|
||||
type="radio"
|
||||
value="selected"
|
||||
class="radio">
|
||||
class="radio"
|
||||
@change="onAccessChange">
|
||||
<label for="selected" class="title">
|
||||
<div class="title icon-shared" />
|
||||
<span>{{ t('forms', 'Only shared') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ShareDiv v-show="form.form.access.type === 'selected'"
|
||||
<ShareDiv v-show="form.access.type === 'selected'"
|
||||
:active-shares="form.shares"
|
||||
:placeholder="t('forms', 'Name of user or group')"
|
||||
:hide-names="true"
|
||||
|
@ -109,6 +115,7 @@
|
|||
import AppSidebar from '@nextcloud/vue/dist/Components/AppSidebar'
|
||||
import DatetimePicker from '@nextcloud/vue/dist/Components/DatetimePicker'
|
||||
import moment from '@nextcloud/moment'
|
||||
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
|
||||
|
||||
import ShareDiv from '../components/shareDiv'
|
||||
import ViewsMixin from '../mixins/ViewsMixin'
|
||||
|
@ -125,10 +132,12 @@ export default {
|
|||
|
||||
data() {
|
||||
return {
|
||||
opened: true,
|
||||
lang: '',
|
||||
locale: '',
|
||||
longDateFormat: '',
|
||||
dateTimeFormat: '',
|
||||
formExpires: false,
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -149,21 +158,18 @@ export default {
|
|||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
optionDatePicker() {
|
||||
return {
|
||||
editable: false,
|
||||
minuteStep: 1,
|
||||
type: 'datetime',
|
||||
format: moment.localeData().longDateFormat('L') + ' ' + moment.localeData().longDateFormat('LT'),
|
||||
lang: this.lang.split('-')[0],
|
||||
placeholder: t('forms', 'Click to add a date'),
|
||||
timePickerOptions: {
|
||||
start: '00:00',
|
||||
step: '00:30',
|
||||
end: '23:30',
|
||||
},
|
||||
}
|
||||
watch: {
|
||||
formExpires: {
|
||||
handler: function() {
|
||||
if (!this.formExpires) {
|
||||
this.form.expires = 0
|
||||
this.onExpiresChange()
|
||||
} else {
|
||||
this.form.expires = moment().unix() + 3600 // Expires in one hour.
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
@ -182,6 +188,20 @@ export default {
|
|||
moment.locale(this.locale)
|
||||
this.longDateFormat = moment.localeData().longDateFormat('L')
|
||||
this.dateTimeFormat = moment.localeData().longDateFormat('L') + ' ' + moment.localeData().longDateFormat('LT')
|
||||
|
||||
// Compute current formExpires for checkbox
|
||||
if (this.form.expires) {
|
||||
this.formExpires = true
|
||||
} else {
|
||||
this.formExpires = false
|
||||
}
|
||||
|
||||
// Watch for Sidebar toggle
|
||||
subscribe('toggleSidebar', this.onToggle)
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
unsubscribe('toggleSidebar')
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
@ -196,6 +216,32 @@ export default {
|
|||
removeShare(item) {
|
||||
this.form.shares.splice(this.form.shares.indexOf(item), 1)
|
||||
},
|
||||
|
||||
/**
|
||||
* Sidebar state methods
|
||||
*/
|
||||
onClose() {
|
||||
this.opened = false
|
||||
},
|
||||
onToggle() {
|
||||
this.opened = !this.opened
|
||||
},
|
||||
|
||||
/**
|
||||
* Save Form-Properties
|
||||
*/
|
||||
onAnonChange() {
|
||||
this.saveFormProperty('isAnonymous')
|
||||
},
|
||||
onSubmOnceChange() {
|
||||
this.saveFormProperty('submitOnce')
|
||||
},
|
||||
onAccessChange() {
|
||||
this.saveFormProperty('access')
|
||||
},
|
||||
onExpiresChange() {
|
||||
this.saveFormProperty('expires')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
Loading…
Reference in a new issue