Merge pull request #256 from nextcloud/enh/design-fixes

Enh/design fixes
This commit is contained in:
Jan-Christoph Borchardt 2020-04-28 00:21:06 +02:00 committed by GitHub
commit 2764e92da9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1878 additions and 1355 deletions

View file

@ -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

View file

@ -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'],
]

View file

@ -2,8 +2,11 @@ module.exports = {
plugins: ['@babel/plugin-syntax-dynamic-import'],
presets: [
[
'@babel/preset-env'
]
]
'@babel/preset-env',
{
corejs: 3,
useBuiltIns: 'entry',
},
],
],
}

View file

@ -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';

View file

@ -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
View 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
View 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
View 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
View 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
View 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

View file

@ -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);
}

View file

@ -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');
}

View file

@ -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()
];

View file

@ -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');

View file

@ -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
View file

@ -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",

View file

@ -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"

View file

@ -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)

View file

@ -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)

View file

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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
View 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()
}
})
},
},
}

View file

@ -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)
}
},
},
}

View file

@ -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'),
},
}

View file

@ -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 },
},
],
})

View 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

View file

@ -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

View file

@ -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>

View file

@ -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],

View file

@ -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>