Merge branch 'main' of github.com:cerebrate-project/cerebrate into main

pull/38/head
iglocska 2021-02-09 22:12:23 +01:00
commit aa15ad7781
No known key found for this signature in database
GPG Key ID: BEA224F1FEF113AC
67 changed files with 3161 additions and 435 deletions

View File

@ -0,0 +1,133 @@
<?php
namespace App\Command;
use Cake\Console\Arguments;
use Cake\Console\Command;
use Cake\Console\ConsoleIo;
use Cake\Console\ConsoleOptionParser;
use Cake\Datasource\ConnectionManager;
use Cake\Database\Schema\TableSchema;
use Cake\Utility\Security;
class UpdaterCommand extends Command
{
protected $modelClass = 'Users';
protected $availableUpdates = [
'meta-templates-v2' => 'metaTemplateV2',
];
protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
{
$parser->setDescription('Execute updates.');
$parser->addArgument('updateName', [
'help' => 'The name of the update to execute',
'required' => false,
'choices' => array_keys($this->availableUpdates)
]);
return $parser;
}
public function execute(Arguments $args, ConsoleIo $io)
{
$this->io = $io;
$targetUpdateName = $args->getArgument('updateName');
if (!in_array($targetUpdateName, array_keys($this->availableUpdates))) {
$io->out('Available updates:');
$io->helper('Table')->output($this->listAvailableUpdates());
die(1);
}
$selection = $io->askChoice("Do you wish to apply update `{$targetUpdateName}`?", ['Y', 'N'], 'N');
if ($selection == 'Y') {
$updateFunction = $this->availableUpdates[$targetUpdateName];
$updateResult = $this->{$updateFunction}();
} else {
$io->out('Update canceled');
}
}
private function listAvailableUpdates()
{
$list = [['Update name']];
foreach ($this->availableUpdates as $updateName => $f) {
$list[] = [$updateName];
}
return $list;
}
private function metaTemplateV2()
{
$db = ConnectionManager::get('default');
try {
$db->query("ALTER TABLE `meta_fields` ADD `meta_template_id` int(10) unsigned NOT NULL;");
} catch (\Exception $e) {
$this->io->out('Caught exception: '. $e->getMessage());
}
try {
$db->query("ALTER TABLE `meta_fields` ADD `meta_template_field_id` int(10) unsigned NOT NULL;");
} catch (\Exception $e) {
$this->io->out('Caught exception: '. $e->getMessage());
}
try {
$db->query("ALTER TABLE `meta_templates` ADD `is_default` tinyint(1) NOT NULL DEFAULT 0;");
} catch (\Exception $e) {
$this->io->out('Caught exception: '. $e->getMessage());
}
try {
$db->query("ALTER TABLE `meta_fields` ADD INDEX `meta_template_id` (`meta_template_id`);");
} catch (\Exception $e) {
$this->io->out('Caught exception: '. $e->getMessage());
}
try {
$db->query("ALTER TABLE `meta_fields` ADD INDEX `meta_template_field_id` (`meta_template_field_id`);");
} catch (\Exception $e) {
$this->io->out('Caught exception: '. $e->getMessage());
}
// $schemaMetaFields = new TableSchema('meta_fields');
// $schemaMetaTemplates = new TableSchema('meta_templates');
// $schemaMetaFields->addColumn('meta_template_id', [
// 'type' => 'integer',
// 'length' => 10,
// 'unsigned' => true,
// 'null' => false
// ])
// ->addColumn('meta_template_field_id', [
// 'type' => 'integer',
// 'length' => 10,
// 'unsigned' => true,
// 'null' => false
// ])
// ->addIndex('meta_template_id', [
// 'columns' => ['meta_template_id'],
// 'type' => 'index'
// ])
// ->addIndex('meta_template_field_id', [
// 'columns' => ['meta_template_field_id'],
// 'type' => 'index'
// ]);
// $schemaMetaTemplates->addColumn('is_default', [
// 'type' => 'tinyint',
// 'length' => 1,
// 'null' => false,
// 'default' => 1
// ]);
// $queries = $schemaMetaFields->createSql($db);
// $collection = $db->getSchemaCollection();
// $tableSchema = $collection->describe('meta_fields');
// $tableSchema->addColumn('foobar', [
// 'type' => 'integer',
// 'length' => 10,
// 'unsigned' => true,
// 'null' => false
// ]);
return true;
}
}

View File

@ -18,6 +18,7 @@ class UserCommand extends Command
['', 'Cerebrate users'],
['1', 'List users'],
['2', 'Reset password for a user'],
['3', 'Enable/Disable a user'],
['0', 'Exit']
];
$io->helper('Table')->output($menu);
@ -49,6 +50,23 @@ class UserCommand extends Command
}
}
break;
case '3':
$user = $io->ask(__('Which user do you want to enable/disable?'));
$user = $this->selectUser($user);
if (empty($user)) {
$io->out('Invalid user.');
} else {
$confirm = $io->askChoice(__('Do you want to {0} the user {1}', $user->disabled ? __('enable') : __('disable'), $user->username), ['Y', 'N'], 'N');
if ($confirm) {
$user = $this->toggleDisable($user);
if ($user) {
$io->out(__('User {0}', !$user->disabled ? __('enabled') : __('disabled')));
} else {
$io->out('Could not save the disabled flag.');
}
}
}
break;
case '0':
$exit = true;
break;
@ -93,4 +111,10 @@ class UserCommand extends Command
$user->password = $password;
return $this->Users->save($user);
}
private function toggleDisable($user)
{
$user->disabled = !$user->disabled;
return $this->Users->save($user);
}
}

View File

@ -36,10 +36,8 @@ class AlignmentsController extends AppController
throw new NotFoundException(__('Invalid alignment.'));
}
$individual = $this->Alignments->get($id);
if ($this->ParamHandler->isRest()) {
if ($this->ParamHandler->isRest() || $this->ParamHandler->isAjax()) {
return $this->RestResponse->viewData($individual, 'json');
} else {
}
$this->set('metaGroup', 'ContactDB');
$this->set('alignment', $individual);
@ -50,12 +48,11 @@ class AlignmentsController extends AppController
if (empty($id)) {
throw new NotFoundException(__('Invalid alignment.'));
}
$individual = $this->Alignments->get($id);
$alignment = $this->Alignments->get($id);
if ($this->request->is('post') || $this->request->is('delete')) {
if ($this->Alignments->delete($individual)) {
$message = __('Individual deleted.');
if ($this->ParamHandler->isRest()) {
$individual = $this->Alignments->get($id);
if ($this->Alignments->delete($alignment)) {
$message = __('Alignments deleted.');
if ($this->ParamHandler->isRest() || $this->ParamHandler->isAjax()) {
return $this->RestResponse->saveSuccessResponse('Alignments', 'delete', $id, 'json', $message);
} else {
$this->Flash->success($message);
@ -65,8 +62,8 @@ class AlignmentsController extends AppController
}
$this->set('metaGroup', 'ContactDB');
$this->set('scope', 'alignments');
$this->set('id', $individual['id']);
$this->set('alignment', $individual);
$this->set('id', $alignment['id']);
$this->set('alignment', $alignment);
$this->viewBuilder()->setLayout('ajax');
$this->render('/genericTemplates/delete');
}
@ -86,18 +83,20 @@ class AlignmentsController extends AppController
} else {
$alignment['organisation_id'] = $source_id;
}
if ($this->Alignments->save($alignment)) {
$alignment = $this->Alignments->save($alignment);
if ($alignment) {
$message = __('Alignment added.');
if ($this->ParamHandler->isRest()) {
$alignment = $this->Alignments->get($this->Alignments->id);
return $this->RestResponse->viewData($alignment, 'json');
} else if($this->ParamHandler->isAjax()) {
return $this->RestResponse->ajaxSuccessResponse('Alignment', 'add', $alignment, $message);
} else {
$this->Flash->success($message);
$this->redirect($this->referer());
}
} else {
$message = __('Alignment could not be added.');
if ($this->ParamHandler->isRest()) {
if ($this->ParamHandler->isRest() || $this->ParamHandler->isAjax()) {
return $this->RestResponse->saveFailResponse('Individuals', 'addAlignment', false, $message);
} else {
$this->Flash->error($message);

View File

@ -17,13 +17,14 @@ class AuthKeysController extends AppController
public function index()
{
$this->CRUD->index([
'filters' => ['users.username', 'authkey', 'comment', 'users.id'],
'filters' => ['Users.username', 'authkey', 'comment', 'Users.id'],
'quickFilters' => ['authkey', 'comment'],
'contain' => ['Users'],
'exclude_fields' => ['authkey']
]);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set('metaGroup', $this->isAdmin ? 'Administration' : 'Cerebrate');
}
@ -31,8 +32,9 @@ class AuthKeysController extends AppController
public function delete($id)
{
$this->CRUD->delete($id);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set('metaGroup', $this->isAdmin ? 'Administration' : 'Cerebrate');
}
@ -43,8 +45,11 @@ class AuthKeysController extends AppController
$this->CRUD->add([
'displayOnSuccess' => 'authkey_display'
]);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload([
'displayOnSuccess' => 'authkey_display'
]);
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->loadModel('Users');
$dropdownData = [

View File

@ -12,12 +12,18 @@ class BroodsController extends AppController
public function index()
{
$this->CRUD->index([
'filters' => ['name', 'uuid', 'url', 'description', 'Organisations.id', 'trusted', 'pull', 'authkey'],
'quickFilters' => ['name', 'uuid', 'description'],
'filters' => ['Broods.name', 'Broods.uuid', 'Broods.url', 'Broods.description', 'Organisations.id', 'Broods.trusted', 'pull', 'authkey'],
'quickFilters' => [['Broods.name' => true], 'Broods.uuid', ['Broods.description' => true]],
'contextFilters' => [
'fields' => [
'pull',
]
],
'contain' => ['Organisations']
]);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set('metaGroup', 'Sync');
}
@ -25,8 +31,9 @@ class BroodsController extends AppController
public function add()
{
$this->CRUD->add();
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set('metaGroup', 'Sync');
$this->loadModel('Organisations');
@ -41,8 +48,9 @@ class BroodsController extends AppController
public function view($id)
{
$this->CRUD->view($id, ['contain' => ['Organisations']]);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set('metaGroup', 'Sync');
}
@ -50,18 +58,27 @@ class BroodsController extends AppController
public function edit($id)
{
$this->CRUD->edit($id);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set('metaGroup', 'Sync');
$this->loadModel('Organisations');
$dropdownData = [
'organisation' => $this->Organisations->find('list', [
'sort' => ['name' => 'asc']
])
];
$this->set(compact('dropdownData'));
$this->render('add');
}
public function delete($id)
{
$this->CRUD->delete($id);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set('metaGroup', 'Sync');
}

View File

@ -6,6 +6,7 @@ use Cake\Controller\Component;
use Cake\Error\Debugger;
use Cake\Utility\Hash;
use Cake\Utility\Inflector;
use Cake\View\ViewBuilder;
class CRUDComponent extends Component
{
@ -16,7 +17,7 @@ class CRUDComponent extends Component
$this->Table = $config['table'];
$this->request = $config['request'];
$this->TableAlias = $this->Table->getAlias();
$this->ObjectAlias = \Cake\Utility\Inflector::singularize($this->TableAlias);
$this->ObjectAlias = Inflector::singularize($this->TableAlias);
$this->MetaFields = $config['MetaFields'];
$this->MetaTemplates = $config['MetaTemplates'];
}
@ -42,28 +43,43 @@ class CRUDComponent extends Component
} else {
$this->Controller->loadComponent('Paginator');
$data = $this->Controller->Paginator->paginate($query);
if (!empty($options['contextFilters'])) {
$this->setFilteringContext($options['contextFilters'], $params);
}
$this->Controller->set('data', $data);
}
}
/**
* getResponsePayload Returns the adaquate response payload based on the request context
*
* @return false or Array
*/
public function getResponsePayload()
{
if ($this->Controller->ParamHandler->isRest()) {
return $this->Controller->restResponsePayload;
} else if ($this->Controller->ParamHandler->isAjax() && $this->request->is(['post', 'put'])) {
return $this->Controller->ajaxResponsePayload;
}
return false;
}
private function getMetaTemplates()
{
$metaFields = [];
$metaTemplates = [];
if (!empty($this->Table->metaFields)) {
$metaQuery = $this->MetaTemplates->find();
$metaQuery->where([
'scope' => $this->Table->metaFields,
'enabled' => 1
]);
$metaQuery
->order(['is_default' => 'DESC'])
->where([
'scope' => $this->Table->metaFields,
'enabled' => 1
]);
$metaQuery->contain(['MetaTemplateFields']);
$metaTemplates = $metaQuery->all();
foreach ($metaTemplates as $metaTemplate) {
foreach ($metaTemplate->meta_template_fields as $field) {
$metaFields[$field['field']] = $field;
}
}
}
$this->Controller->set('metaFields', $metaFields);
$this->Controller->set('metaTemplates', $metaTemplates);
return true;
}
@ -86,21 +102,23 @@ class CRUDComponent extends Component
$patchEntityParams['fields'] = $params['fields'];
}
$data = $this->Table->patchEntity($data, $input, $patchEntityParams);
if ($this->Table->save($data)) {
$savedData = $this->Table->save($data);
if ($savedData !== false) {
$message = __('{0} added.', $this->ObjectAlias);
if (!empty($input['metaFields'])) {
$this->saveMetaFields($data->id, $input);
}
if ($this->Controller->ParamHandler->isRest()) {
$this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json');
$this->Controller->restResponsePayload = $this->RestResponse->viewData($savedData, 'json');
} else if ($this->Controller->ParamHandler->isAjax()) {
if (!empty($params['displayOnSuccess'])) {
$displayOnSuccess = $this->renderViewInVariable($params['displayOnSuccess'], ['entity' => $data]);
$this->Controller->ajaxResponsePayload = $this->Controller->RestResponse->ajaxSuccessResponse($this->ObjectAlias, 'add', $savedData, $message, ['displayOnSuccess' => $displayOnSuccess]);
} else {
$this->Controller->ajaxResponsePayload = $this->Controller->RestResponse->ajaxSuccessResponse($this->ObjectAlias, 'add', $savedData, $message);
}
} else {
$this->Controller->Flash->success($message);
if (!empty($params['displayOnSuccess'])) {
$this->Controller->set('entity', $data);
$this->Controller->set('referer', $this->Controller->referer());
$this->Controller->render($params['displayOnSuccess']);
return;
}
if (empty($params['redirect'])) {
$this->Controller->redirect(['action' => 'view', $data->id]);
} else {
@ -108,6 +126,7 @@ class CRUDComponent extends Component
}
}
} else {
$this->Controller->isFailResponse = true;
$validationMessage = $this->prepareValidationError($data);
$message = __(
'{0} could not be added.{1}',
@ -115,7 +134,8 @@ class CRUDComponent extends Component
empty($validationMessage) ? '' : ' ' . __('Reason:{0}', $validationMessage)
);
if ($this->Controller->ParamHandler->isRest()) {
} else if ($this->Controller->ParamHandler->isAjax()) {
$this->Controller->ajaxResponsePayload = $this->Controller->RestResponse->ajaxFailResponse($this->ObjectAlias, 'add', $data, $message, $validationMessage);
} else {
$this->Controller->Flash->error($message);
}
@ -141,7 +161,7 @@ class CRUDComponent extends Component
private function saveMetaFields($id, $input)
{
$this->Table->saveMetaFields($id, $input);
$this->Table->saveMetaFields($id, $input, $this->Table);
}
private function __massageInput($params)
@ -153,9 +173,10 @@ class CRUDComponent extends Component
}
}
if (!empty($params['removeEmpty'])) {
foreach ($params['removeEmpty'] as $removeEmptyField)
if (isset($input[$removeEmptyField])) {
unset($input[$removeEmptyField]);
foreach ($params['removeEmpty'] as $removeEmptyField) {
if (empty($input[$removeEmptyField])) {
unset($input[$removeEmptyField]);
}
}
}
return $input;
@ -181,14 +202,17 @@ class CRUDComponent extends Component
$patchEntityParams['fields'] = $params['fields'];
}
$data = $this->Table->patchEntity($data, $input, $patchEntityParams);
if ($this->Table->save($data)) {
$message = __('{0} updated.', $this->ObjectAlias);
$savedData = $this->Table->save($data);
if ($savedData !== false) {
$message = __('{0} `{1}` updated.', $this->ObjectAlias, $savedData->{$this->Table->getDisplayField()});
if (!empty($input['metaFields'])) {
$this->MetaFields->deleteAll(['scope' => $this->Table->metaFields, 'parent_id' => $data->id]);
$this->saveMetaFields($data->id, $input);
$this->MetaFields->deleteAll(['scope' => $this->Table->metaFields, 'parent_id' => $savedData->id]);
$this->saveMetaFields($savedData->id, $input);
}
if ($this->Controller->ParamHandler->isRest()) {
$this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json');
$this->Controller->restResponsePayload = $this->RestResponse->viewData($savedData, 'json');
} else if ($this->Controller->ParamHandler->isAjax()) {
$this->Controller->ajaxResponsePayload = $this->Controller->RestResponse->ajaxSuccessResponse($this->ObjectAlias, 'edit', $savedData, $message);
} else {
$this->Controller->Flash->success($message);
if (empty($params['redirect'])) {
@ -200,12 +224,12 @@ class CRUDComponent extends Component
} else {
$validationMessage = $this->prepareValidationError($data);
$message = __(
'{0} could not be modified.{1}',
$this->ObjectAlias,
empty($validationMessage) ? '' : ' ' . __('Reason:{0}', $validationMessage)
__('{0} could not be modified.'),
$this->ObjectAlias
);
if ($this->Controller->ParamHandler->isRest()) {
} else if ($this->Controller->ParamHandler->isAjax()) {
$this->Controller->ajaxResponsePayload = $this->Controller->RestResponse->ajaxFailResponse($this->ObjectAlias, 'edit', $data, $message, $data->getErrors());
} else {
$this->Controller->Flash->error($message);
}
@ -214,17 +238,37 @@ class CRUDComponent extends Component
$this->Controller->set('entity', $data);
}
public function attachMetaData($id, $data)
{
if (empty($this->Table->metaFields)) {
return $data;
}
$query = $this->MetaFields->MetaTemplates->find();
$metaFields = $this->Table->metaFields;
$query->contain('MetaTemplateFields', function ($q) use ($id, $metaFields) {
return $q->innerJoinWith('MetaFields')
->where(['MetaFields.scope' => $metaFields, 'MetaFields.parent_id' => $id]);
});
$query->innerJoinWith('MetaTemplateFields', function ($q) {
return $q->contain('MetaFields')->innerJoinWith('MetaFields');
});
$query->group(['MetaTemplates.id'])->order(['MetaTemplates.is_default' => 'DESC']);
$metaTemplates = $query->all();
$data['metaTemplates'] = $metaTemplates;
return $data;
}
public function getMetaFields($id, $data)
{
if (empty($this->Table->metaFields)) {
return $data;
}
$query = $this->MetaFields->find();
$query->where(['scope' => $this->Table->metaFields, 'parent_id' => $id]);
$query->where(['MetaFields.scope' => $this->Table->metaFields, 'MetaFields.parent_id' => $id]);
$metaFields = $query->all();
$data['metaFields'] = [];
foreach($metaFields as $metaField) {
$data['metaFields'][$metaField->field] = $metaField->value;
$data['metaFields'][$metaField->meta_template_id][$metaField->field] = $metaField->value;
}
return $data;
}
@ -236,7 +280,7 @@ class CRUDComponent extends Component
}
$data = $this->Table->get($id, $params);
$data = $this->getMetaFields($id, $data);
$data = $this->attachMetaData($id, $data);
if ($this->Controller->ParamHandler->isRest()) {
$this->Controller->restResponsePayload = $this->Controller->RestResponse->viewData($data, 'json');
}
@ -253,8 +297,9 @@ class CRUDComponent extends Component
if ($this->Table->delete($data)) {
$message = __('{0} deleted.', $this->ObjectAlias);
if ($this->Controller->ParamHandler->isRest()) {
$data = $this->Table->get($id);
$this->Controller->restResponsePayload = $this->RestResponse->saveSuccessResponse($this->TableAlias, 'delete', $id, 'json', $message);
$this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json');
} else if ($this->Controller->ParamHandler->isAjax()) {
$this->Controller->ajaxResponsePayload = $this->Controller->RestResponse->ajaxSuccessResponse($this->ObjectAlias, 'delete', $data, $message);
} else {
$this->Controller->Flash->success($message);
$this->Controller->redirect($this->Controller->referer());
@ -295,11 +340,22 @@ class CRUDComponent extends Component
protected function setQuickFilters(array $params, \Cake\ORM\Query $query, array $quickFilterFields): \Cake\ORM\Query
{
$queryConditions = [];
$this->Controller->set('quickFilter', empty($quickFilterFields) ? [] : $quickFilterFields);
if (!empty($params['quickFilter']) && !empty($quickFilterFields)) {
$this->Controller->set('quickFilterValue', $params['quickFilter']);
foreach ($quickFilterFields as $filterField) {
$queryConditions[$filterField] = $params['quickFilter'];
$likeCondition = false;
if (is_array($filterField)) {
$likeCondition = reset($filterField);
$filterFieldName = array_key_first($filterField);
$queryConditions[$filterFieldName . ' LIKE'] = '%' . $params['quickFilter'] .'%';
} else {
$queryConditions[$filterField] = $params['quickFilter'];
}
}
$query->where(['OR' => $queryConditions]);
} else {
$this->Controller->set('quickFilterValue', '');
}
return $query;
}
@ -313,10 +369,14 @@ class CRUDComponent extends Component
if ($filter === 'quickFilter') {
continue;
}
if (strlen(trim($filterValue, '%')) === strlen($filterValue)) {
$query->where([$filter => $filterValue]);
if (is_array($filterValue)) {
$query->where([($filter . ' IN') => $filterValue]);
} else {
$query->like([$filter => $filterValue]);
if (strlen(trim($filterValue, '%')) === strlen($filterValue)) {
$query->where([$filter => $filterValue]);
} else {
$query->like([$filter => $filterValue]);
}
}
}
}
@ -335,6 +395,95 @@ class CRUDComponent extends Component
return $query;
}
protected function setFilteringContext($contextFilters, $params)
{
$filteringContexts = [];
if (!isset($contextFilters['allow_all']) || $contextFilters['allow_all']) {
$filteringContexts[] = ['label' => __('All')];
}
if (!empty($contextFilters['fields'])) {
foreach ($contextFilters['fields'] as $field) {
$contextsFromField = $this->getFilteringContextFromField($field);
foreach ($contextsFromField as $contextFromField) {
if (is_bool($contextFromField)) {
$contextFromFieldText = sprintf('%s: %s', $field, $contextFromField ? 'true' : 'false');
} else {
$contextFromFieldText = $contextFromField;
}
$filteringContexts[] = [
'label' => Inflector::humanize($contextFromFieldText),
'filterCondition' => [
$field => $contextFromField
]
];
}
}
}
if (!empty($contextFilters['custom'])) {
$filteringContexts = array_merge($filteringContexts, $contextFilters['custom']);
}
$this->Controller->set('filteringContexts', $filteringContexts);
}
public function toggle(int $id, string $fieldName = 'enabled', array $params = []): void
{
if (empty($id)) {
throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias));
}
$data = $this->Table->get($id, $params);
if ($this->request->is(['post', 'put'])) {
if (isset($params['force_state'])) {
$data->{$fieldName} = $params['force_state'];
} else {
$data->{$fieldName} = !$data->{$fieldName};
}
$savedData = $this->Table->save($data);
if ($savedData !== false) {
$message = __('{0} field {1}. (ID: {2} {3})',
$fieldName,
$data->{$fieldName} ? __('enabled') : __('disabled'),
Inflector::humanize($this->ObjectAlias),
$data->id
);
if ($this->Controller->ParamHandler->isRest()) {
$this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json');
} else if ($this->Controller->ParamHandler->isAjax()) {
$this->Controller->ajaxResponsePayload = $this->Controller->RestResponse->ajaxSuccessResponse($this->ObjectAlias, 'toggle', $savedData, $message);
} else {
$this->Controller->Flash->success($message);
if (empty($params['redirect'])) {
$this->Controller->redirect(['action' => 'view', $id]);
} else {
$this->Controller->redirect($params['redirect']);
}
}
} else {
$validationMessage = $this->prepareValidationError($data);
$message = __(
'{0} could not be modified.{1}',
$this->ObjectAlias,
empty($validationMessage) ? '' : ' ' . __('Reason:{0}', $validationMessage)
);
if ($this->Controller->ParamHandler->isRest()) {
} else if ($this->Controller->ParamHandler->isAjax()) {
$this->Controller->ajaxResponsePayload = $this->Controller->RestResponse->ajaxFailResponse($this->ObjectAlias, 'toggle', $message, $validationMessage);
} else {
$this->Controller->Flash->error($message);
if (empty($params['redirect'])) {
$this->Controller->redirect(['action' => 'view', $id]);
} else {
$this->Controller->redirect($params['redirect']);
}
}
}
}
$this->Controller->set('entity', $data);
$this->Controller->set('fieldName', $fieldName);
$this->Controller->viewBuilder()->setLayout('ajax');
$this->Controller->render('/genericTemplates/toggle');
}
public function toggleEnabled(int $id, array $path, string $fieldName = 'enabled'): bool
{
if (empty($id)) {
@ -356,4 +505,46 @@ class CRUDComponent extends Component
}
}
}
private function getFilteringContextFromField($field)
{
$exploded = explode('.', $field);
if (count($exploded) > 1) {
$model = $exploded[0];
$subField = $exploded[1];
$association = $this->Table->associations()->get($model);
$associationType = $association->type();
if ($associationType == 'oneToMany') {
$fieldToExtract = $subField;
$associatedTable = $association->getTarget();
$query = $associatedTable->find()->rightJoin(
[$this->Table->getAlias() => $this->Table->getTable()],
[sprintf('%s.id = %s.%s', $this->Table->getAlias(), $associatedTable->getAlias(), $association->getForeignKey())]
)
->where([
["${field} IS NOT" => NULL]
]);
} else if ($associationType == 'manyToOne') {
$fieldToExtract = sprintf('%s.%s', Inflector::singularize(strtolower($model)), $subField);
$query = $this->Table->find()->contain($model);
} else {
throw new Exception("Association ${associationType} not supported in CRUD Component");
}
} else {
$fieldToExtract = $field;
$query = $this->Table->find();
}
return $query->select([$field])
->distinct()
->extract($fieldToExtract)
->toList();
}
private function renderViewInVariable($templateRelativeName, $data)
{
$builder = new ViewBuilder();
$builder->disableAutoLayout()->setTemplate("{$this->TableAlias}/{$templateRelativeName}");
$view = $builder->build($data);
return $view->render();
}
}

View File

@ -61,4 +61,13 @@ class ParamHandlerComponent extends Component
{
return (json_decode($data) != null) ? true : false;
}
public function isAjax()
{
if ($this->isAjax !== null) {
return $this->isAjax;
}
$this->isAjax = $this->request->is('ajax');
return $this->isAjax;
}
}

View File

@ -4,6 +4,7 @@ namespace App\Controller\Component;
use Cake\Controller\Component;
use Cake\Core\Configure;
use Cake\Utility\Inflector;
class RestResponseComponent extends Component
{
@ -419,6 +420,33 @@ class RestResponseComponent extends Component
return $this->__sendResponse($response, 200, $format);
}
public function ajaxSuccessResponse($ObjectAlias, $action, $entity, $message, $additionalData=[])
{
$action = $this->__dissectAdminRouting($action);
$response = [
'success' => true,
'message' => $message,
'data' => $entity->toArray(),
'url' => $this->__generateURL($action, $ObjectAlias, $entity->id)
];
if (!empty($additionalData)) {
$response['additionalData'] = $additionalData;
}
return $this->viewData($response);
}
public function ajaxFailResponse($ObjectAlias, $action, $entity, $message, $errors = [])
{
$action = $this->__dissectAdminRouting($action);
$response = [
'success' => false,
'message' => $message,
'errors' => $errors,
'url' => $this->__generateURL($action, $ObjectAlias, $entity->id)
];
return $this->viewData($response);
}
private function __sendResponse($response, $code, $format = false, $raw = false, $download = false, $headers = array())
{
if (strtolower($format) === 'application/xml' || strtolower($format) === 'xml') {

View File

@ -19,6 +19,11 @@ class EncryptionKeysController extends AppController
$this->CRUD->index([
'quickFilters' => ['encryption_key'],
'filters' => ['owner_type', 'organisation_id', 'individual_id', 'encryption_key'],
'contextFilters' => [
'fields' => [
'type'
]
],
'contain' => ['Individuals', 'Organisations']
]);
if ($this->ParamHandler->isRest()) {

View File

@ -15,12 +15,18 @@ class IndividualsController extends AppController
public function index()
{
$this->CRUD->index([
'filters' => ['uuid', 'email', 'first_name', 'last_name', 'position', 'Organisations.id'],
'filters' => ['uuid', 'email', 'first_name', 'last_name', 'position', 'Organisations.id', 'Alignments.type'],
'quickFilters' => ['uuid', 'email', 'first_name', 'last_name', 'position'],
'contextFilters' => [
'fields' => [
'Alignments.type'
]
],
'contain' => ['Alignments' => 'Organisations']
]);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set('alignmentScope', 'individuals');
$this->set('metaGroup', 'ContactDB');
@ -29,8 +35,9 @@ class IndividualsController extends AppController
public function add()
{
$this->CRUD->add();
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set('metaGroup', 'ContactDB');
}
@ -38,8 +45,9 @@ class IndividualsController extends AppController
public function view($id)
{
$this->CRUD->view($id, ['contain' => ['Alignments' => 'Organisations']]);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set('metaGroup', 'ContactDB');
}
@ -47,8 +55,9 @@ class IndividualsController extends AppController
public function edit($id)
{
$this->CRUD->edit($id);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set('metaGroup', 'ContactDB');
$this->render('add');
@ -57,8 +66,9 @@ class IndividualsController extends AppController
public function delete($id)
{
$this->CRUD->delete($id);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set('metaGroup', 'ContactDB');
}

View File

@ -9,6 +9,7 @@ use \Cake\Database\Expression\QueryExpression;
class MetaTemplatesController extends AppController
{
public function update()
{
if ($this->request->is('post')) {
@ -33,13 +34,27 @@ class MetaTemplatesController extends AppController
public function index()
{
$this->CRUD->index([
'filters' => ['name', 'uuid', 'scope'],
'filters' => ['name', 'uuid', 'scope', 'namespace'],
'quickFilters' => ['name', 'uuid', 'scope'],
'contextFilters' => [
'fields' => ['scope'],
'custom' => [
[
'label' => __('Contact DB'),
'filterCondition' => ['scope' => ['individual', 'organisation']]
],
[
'label' => __('Namespace CNW'),
'filterCondition' => ['namespace' => 'cnw']
],
]
],
'contain' => ['MetaTemplateFields']
]);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
}
$this->set('defaultTemplatePerScope', $this->MetaTemplates->getDefaultTemplatePerScope());
$this->set('alignmentScope', 'individuals');
$this->set('metaGroup', 'Administration');
}
@ -55,25 +70,19 @@ class MetaTemplatesController extends AppController
$this->set('metaGroup', 'Administration');
}
public function toggle($id)
public function toggle($id, $fieldName = 'enabled')
{
$template = $this->MetaTemplates->getTemplate($id);
$template['enabled'] = $template['enabled'] ? 0 : 1;
$result = $this->MetaTemplates->save($template);
if ($template['enabled']) {
$message = $result ? __('Template enabled.') : __('Could not enable template');
if ($this->request->is('POST') && $fieldName == 'is_default') {
$template = $this->MetaTemplates->get($id);
$this->MetaTemplates->removeDefaultFlag($template->scope);
$this->CRUD->toggle($id, $fieldName, ['force_state' => !$template->is_default]);
} else {
$message = $result ? __('Template disabled.') : __('Could not disable template');
$this->CRUD->toggle($id, $fieldName);
}
if ($this->ParamHandler->isRest()) {
if ($result) {
return $this->RestResponse->saveSuccessResponse('MetaTemplates', 'toggle', $id, 'json', $message);
} else {
return $this->RestResponse->saveFailResponse('MetaTemplates', 'toggle', $id, 'json', $message);
}
} else {
if ($this->Flash->{$result ? 'success' : 'error'}($message));
$this->redirect($this->referer());
return $this->restResponsePayload;
} else if($this->ParamHandler->isAjax() && $this->request->is(['post', 'put'])) {
return $this->ajaxResponsePayload;
}
}
}

View File

@ -19,8 +19,9 @@ class OrganisationsController extends AppController
'quickFilters' => ['name', 'uuid', 'nationality', 'sector', 'type', 'url'],
'contain' => ['Alignments' => 'Individuals']
]);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set('alignmentScope', 'individuals');
$this->set('metaGroup', 'ContactDB');
@ -29,8 +30,9 @@ class OrganisationsController extends AppController
public function add()
{
$this->CRUD->add();
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set('metaGroup', 'ContactDB');
}
@ -38,8 +40,9 @@ class OrganisationsController extends AppController
public function view($id)
{
$this->CRUD->view($id, ['contain' => ['Alignments' => 'Individuals']]);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set('metaGroup', 'ContactDB');
}
@ -47,8 +50,9 @@ class OrganisationsController extends AppController
public function edit($id)
{
$this->CRUD->edit($id);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set('metaGroup', 'ContactDB');
$this->render('add');
@ -57,8 +61,9 @@ class OrganisationsController extends AppController
public function delete($id)
{
$this->CRUD->delete($id);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set('metaGroup', 'ContactDB');
}

View File

@ -2,6 +2,7 @@
namespace App\Controller;
use App\Controller\AppController;
use Cake\Utility\Inflector;
use Cake\Utility\Hash;
use Cake\Utility\Text;
use \Cake\Database\Expression\QueryExpression;
@ -31,8 +32,9 @@ class SharingGroupsController extends AppController
$dropdownData = [
'organisation' => $this->getAvailableOrgForSg($this->ACL->getUser())
];
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set(compact('dropdownData'));
$this->set('metaGroup', 'Trust Circles');
@ -52,8 +54,9 @@ class SharingGroupsController extends AppController
public function edit($id = false)
{
$this->CRUD->edit($id);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$dropdownData = [
'organisation' => $this->getAvailableOrgForSg($this->ACL->getUser())
@ -66,8 +69,9 @@ class SharingGroupsController extends AppController
public function delete($id)
{
$this->CRUD->delete($id);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set('metaGroup', 'Trust Circles');
}
@ -110,11 +114,14 @@ class SharingGroupsController extends AppController
} else {
$message = __('Organisation(s) could not be added to the sharing group.');
}
if ($this->ParamHandler->isRest()) {
if ($this->ParamHandler->isRest() || $this->ParamHandler->isAjax()) {
if ($result) {
$this->RestResponse->saveSuccessResponse('SharingGroups', 'addOrg', $id, 'json', $message);
$savedData = $this->SharingGroups->get($id, [
'contain' => 'SharingGroupOrgs'
]);
return $this->RestResponse->ajaxSuccessResponse(Inflector::singularize($this->SharingGroups->getAlias()), 'addOrg', $savedData, $message);
} else {
$this->RestResponse->saveFailResponse('SharingGroups', 'addOrg', $id, $message, 'json');
return $this->RestResponse->ajaxFailResponse(Inflector::singularize($this->SharingGroups->getAlias()), 'addOrg', $sharingGroup, $message);;
}
} else {
if ($result) {
@ -128,9 +135,45 @@ class SharingGroupsController extends AppController
$this->set(compact('dropdownData'));
}
public function removeOrg($id)
public function removeOrg($id, $org_id)
{
$sharingGroup = $this->SharingGroups->get($id, [
'contain' => 'SharingGroupOrgs'
]);
if ($this->request->is('post')) {
$org = $this->SharingGroups->SharingGroupOrgs->get($org_id);
$result = (bool)$this->SharingGroups->SharingGroupOrgs->unlink($sharingGroup, [$org]);
if ($result) {
$message = __('Organisation(s) removed from the sharing group.');
} else {
$message = __('Organisation(s) could not be removed to the sharing group.');
}
if ($this->ParamHandler->isRest() || $this->ParamHandler->isAjax()) {
if ($result) {
$savedData = $this->SharingGroups->get($id, [
'contain' => 'SharingGroupOrgs'
]);
return $this->RestResponse->ajaxSuccessResponse(Inflector::singularize($this->SharingGroups->getAlias()), 'removeOrg', $savedData, $message);
} else {
return $this->RestResponse->ajaxFailResponse(Inflector::singularize($this->SharingGroups->getAlias()), 'removeOrg', $sharingGroup, $message);
;
}
} else {
if ($result) {
$this->Flash->success($message);
} else {
$this->Flash->error($message);
}
$this->redirect(['action' => 'view', $id]);
}
}
$this->set('scope', 'sharing_groups');
$this->set('id', $org_id);
$this->set('sharingGroup', $sharingGroup);
$this->set('deletionText', __('Are you sure you want to remove Organisation #{0} from Sharing group #{1}?', $org_id, $sharingGroup['id']));
$this->set('postLinkParameters', ['action' => 'removeOrg', $id, $org_id]);
$this->viewBuilder()->setLayout('ajax');
$this->render('/genericTemplates/delete');
}
public function listOrgs($id)

View File

@ -23,8 +23,9 @@ class UsersController extends AppController
public function add()
{
$this->CRUD->add();
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$dropdownData = [
'role' => $this->Users->Roles->find('list', [
@ -74,8 +75,9 @@ class UsersController extends AppController
$params['fields'][] = 'role_id';
}
$this->CRUD->edit($id, $params);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$dropdownData = [
'role' => $this->Users->Roles->find('list', [
@ -90,11 +92,21 @@ class UsersController extends AppController
$this->render('add');
}
public function toggle($id, $fieldName = 'disabled')
{
$this->CRUD->toggle($id, $fieldName);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
public function delete($id)
{
$this->CRUD->delete($id);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set('metaGroup', $this->isAdmin ? 'Administration' : 'Cerebrate');
}

View File

@ -8,7 +8,7 @@ use Authentication\PasswordHasher\DefaultPasswordHasher;
class User extends AppModel
{
protected $_hidden = ['password'];
protected $_hidden = ['password', 'confirm_password'];
protected function _setPassword(string $password) : ?string
{
if (strlen($password) > 0) {

View File

@ -17,18 +17,31 @@ class AppTable extends Table
public function saveMetaFields($id, $input)
{
$this->MetaFields = TableRegistry::getTableLocator()->get('MetaFields');
foreach ($input['metaFields'] as $metaField => $values) {
if (!is_array($values)) {
$values = [$values];
$this->MetaTemplates = TableRegistry::getTableLocator()->get('MetaTemplates');
foreach ($input['metaFields'] as $templateID => $metaFields) {
$metaTemplates = $this->MetaTemplates->find()->where([
'id' => $templateID,
'enabled' => 1
])->contain(['MetaTemplateFields'])->first();
$fieldNameToId = [];
foreach ($metaTemplates->meta_template_fields as $i => $metaTemplateField) {
$fieldNameToId[$metaTemplateField->field] = $metaTemplateField->id;
}
foreach ($values as $value) {
if ($value !== '') {
$temp = $this->MetaFields->newEmptyEntity();
$temp->field = $metaField;
$temp->value = $value;
$temp->scope = $this->metaFields;
$temp->parent_id = $id;
$this->MetaFields->save($temp);
foreach ($metaFields as $metaField => $values) {
if (!is_array($values)) {
$values = [$values];
}
foreach ($values as $value) {
if ($value !== '') {
$temp = $this->MetaFields->newEmptyEntity();
$temp->field = $metaField;
$temp->value = $value;
$temp->scope = $this->metaFields;
$temp->parent_id = $id;
$temp->meta_template_id = $templateID;
$temp->meta_template_field_id = $fieldNameToId[$metaField];
$res = $this->MetaFields->save($temp);
}
}
}
}

View File

@ -44,6 +44,10 @@ class BroodsTable extends AppTable
'error' => __('Authentication failure'),
'reason' => __('Invalid user credentials.')
],
404 => [
'error' => __('Not found'),
'reason' => __('Incorrect URL or proxy error')
],
405 => [
'error' => __('Insufficient privileges'),
'reason' => __('The remote user account doesn\'t have the required privileges to synchronise.')

View File

@ -13,6 +13,8 @@ class MetaFieldsTable extends AppTable
parent::initialize($config);
$this->addBehavior('UUID');
$this->setDisplayField('field');
$this->belongsTo('MetaTemplates');
$this->belongsTo('MetaTemplateFields');
}
public function validationDefault(Validator $validator): Validator
@ -22,7 +24,9 @@ class MetaFieldsTable extends AppTable
->notEmptyString('field')
->notEmptyString('uuid')
->notEmptyString('value')
->requirePresence(['scope', 'field', 'value', 'uuid'], 'create');
->notEmptyString('meta_template_id')
->notEmptyString('meta_template_field_id')
->requirePresence(['scope', 'field', 'value', 'uuid', 'meta_template_id', 'meta_template_field_id'], 'create');
return $validator;
}
}

View File

@ -14,6 +14,7 @@ class MetaTemplateFieldsTable extends AppTable
$this->BelongsTo(
'MetaTemplates'
);
$this->hasMany('MetaFields');
$this->setDisplayField('field');
}

View File

@ -68,6 +68,28 @@ class MetaTemplatesTable extends AppTable
return $template;
}
public function getDefaultTemplatePerScope(String $scope = '')
{
$query = $this->find('list', [
'keyField' => 'scope',
'valueField' => function ($template) {
return $template;
}
])->where(['is_default' => true]);
if (!empty($scope)) {
$query->where(['scope' => $scope]);
}
return $query->all()->toArray();
}
public function removeDefaultFlag(String $scope)
{
$this->updateAll(
['is_default' => false],
['scope' => $scope]
);
}
public function loadMetaFile(String $filePath)
{
if (file_exists($filePath)) {

View File

@ -56,7 +56,9 @@ class UsersTable extends AppTable
},
'message' => __('Password confirmation missing or not matching the password.')
]
]);
])
->requirePresence(['username'], 'create')
->notEmptyString('username', 'Please fill this field');
return $validator;
}

View File

@ -0,0 +1,313 @@
<?php
/**
* Bootstrap Tabs helper
* Options:
* [style]
* - fill: Should the navigation items occupy all available space
* - justify: Should the navigation items be justified (accept: ['center', 'end'])
* - pills: Should the navigation items be pills
* - vertical: Should the navigation bar be placed on the left side of the content
* - vertical-size: Specify how many boostrap's `cols` should be used for the navigation (only used when `vertical` is true)
* - card: Should the navigation be placed in a bootstrap card
* - header-variant: The bootstrap variant to be used for the card header
* - body-variant: The bootstrap variant to be used for the card body
* - nav-class: additional class to add to the nav container
* - content-class: additional class to add to the content container
* [data]
* - data: contains the data for the tabs and content
* {
* 'navs': [{nav-item}, {nav-item}, ...],
* 'content': [{nav-content}, {nav-content}, ...]
* }
*
* # Usage:
* echo $this->Bootstrap->Tabs([
* 'pills' => true,
* 'card' => true,
* 'data' => [
* 'navs' => [
* 'tab1',
* ['text' => 'tab2', 'active' => true],
* ['html' => '<b>tab3</b>', 'disabled' => true],
* ],
* 'content' => [
* 'body1',
* '<i>body2</i>',
* '~body3~'
* ]
* ]
* ]);
*/
namespace App\View\Helper;
use Cake\View\Helper;
use Cake\Utility\Security;
use InvalidArgumentException;
class BootstrapHelper extends Helper
{
public function tabs($options)
{
$bsTabs = new BootstrapTabs($options);
return $bsTabs->tabs();
}
}
class BootstrapTabs extends Helper
{
private $defaultOptions = [
'fill' => false,
'justify' => false,
'pills' => false,
'vertical' => false,
'vertical-size' => 3,
'card' => false,
'header-variant' => 'light',
'body-variant' => '',
'nav-class' => [],
'nav-item-class' => [],
'content-class' => [],
'data' => [
'navs' => [],
'content' => [],
],
];
private $allowedOptionValues = [
'justify' => [false, 'center', 'end'],
'body-variant' => ['primary', 'success', 'danger', 'warning', 'info', 'light', 'dark', 'white', 'transparent', ''],
'header-variant' => ['primary', 'success', 'danger', 'warning', 'info', 'light', 'dark', 'white', 'transparent'],
];
private $options = null;
private $bsClasses = null;
function __construct($options) {
$this->processOptions($options);
}
public function tabs()
{
return $this->genTabs();
}
private function processOptions($options)
{
$this->options = array_merge($this->defaultOptions, $options);
$this->data = $this->options['data'];
$this->checkOptionValidity();
$this->bsClasses = [
'nav' => [],
'nav-item' => $this->options['nav-item-class'],
];
if (!empty($this->options['justify'])) {
$this->bsClasses['nav'][] = 'justify-content-' . $this->options['justify'];
}
if ($this->options['vertical'] && !isset($options['pills']) && !isset($options['card'])) {
$this->options['pills'] = true;
$this->options['card'] = true;
}
if ($this->options['pills']) {
$this->bsClasses['nav'][] = 'nav-pills';
if ($this->options['vertical']) {
$this->bsClasses['nav'][] = 'flex-column';
}
if ($this->options['card']) {
$this->bsClasses['nav'][] = 'card-header-pills';
}
} else {
$this->bsClasses['nav'][] = 'nav-tabs';
if ($this->options['card']) {
$this->bsClasses['nav'][] = 'card-header-tabs';
}
}
if ($this->options['fill']) {
$this->bsClasses['nav'][] = 'nav-fill';
}
if ($this->options['justify']) {
$this->bsClasses['nav'][] = 'nav-justify';
}
$activeTab = 0;
foreach ($this->data['navs'] as $i => $nav) {
if (!is_array($nav)) {
$this->data['navs'][$i] = ['text' => $nav];
}
if (!isset($this->data['navs'][$i]['id'])) {
$this->data['navs'][$i]['id'] = 't-' . Security::randomString(8);
}
if (!empty($nav['active'])) {
$activeTab = $i;
}
}
$this->data['navs'][$activeTab]['active'] = true;
$this->options['vertical-size'] = $this->options['vertical-size'] < 0 || $this->options['vertical-size'] > 11 ? 3 : $this->options['vertical-size'];
$this->options['header-text-variant'] = $this->options['header-variant'] == 'light' ? 'body' : 'white';
$this->options['header-border-variant'] = $this->options['header-variant'] == 'light' ? '' : $this->options['header-variant'];
$this->options['body-text-variant'] = $this->options['body-variant'] == '' ? 'body' : 'white';
if (!is_array($this->options['nav-class'])) {
$this->options['nav-class'] = [$this->options['nav-class']];
}
if (!is_array($this->options['content-class'])) {
$this->options['content-class'] = [$this->options['content-class']];
}
}
private function checkOptionValidity()
{
foreach ($this->allowedOptionValues as $option => $values) {
if (!isset($this->options[$option])) {
throw new InvalidArgumentException(__('Option `{0}` should have a value', $option));
}
if (!in_array($this->options[$option], $values)) {
throw new InvalidArgumentException(__('Option `{0}` is not a valid option for `{1}`. Accepted values: {2}', json_encode($this->options[$option]), $option, json_encode($values)));
}
}
if (empty($this->data['navs'])) {
throw new InvalidArgumentException(__('No navigation data provided'));
}
}
private function genTabs()
{
$html = '';
if ($this->options['vertical']) {
$html .= $this->genVerticalTabs();
} else {
$html .= $this->genHorizontalTabs();
}
return $html;
}
private function genHorizontalTabs()
{
$html = '';
if ($this->options['card']) {
$html .= $this->genNode('div', ['class' => array_merge(['card'], ["border-{$this->options['header-border-variant']}"])]);
$html .= $this->genNode('div', ['class' => array_merge(['card-header'], ["bg-{$this->options['header-variant']}", "text-{$this->options['header-text-variant']}"])]);
}
$html .= $this->genNav();
if ($this->options['card']) {
$html .= '</div>';
$html .= $this->genNode('div', ['class' => array_merge(['card-body'], ["bg-{$this->options['body-variant']}", "text-{$this->options['body-text-variant']}"])]);
}
$html .= $this->genContent();
if ($this->options['card']) {
$html .= '</div>';
$html .= '</div>';
}
return $html;
}
private function genVerticalTabs()
{
$html = $this->genNode('div', ['class' => array_merge(['row', ($this->options['card'] ? 'card flex-row' : '')], ["border-{$this->options['header-border-variant']}"])]);
$html .= $this->genNode('div', ['class' => array_merge(['col-' . $this->options['vertical-size'], ($this->options['card'] ? 'card-header border-right' : '')], ["bg-{$this->options['header-variant']}", "text-{$this->options['header-text-variant']}", "border-{$this->options['header-border-variant']}"])]);
$html .= $this->genNav();
$html .= '</div>';
$html .= $this->genNode('div', ['class' => array_merge(['col-' . (12 - $this->options['vertical-size']), ($this->options['card'] ? 'card-body2' : '')], ["bg-{$this->options['body-variant']}", "text-{$this->options['body-text-variant']}"])]);
$html .= $this->genContent();
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function genNav()
{
$html = $this->genNode('ul', [
'class' => array_merge(['nav'], $this->bsClasses['nav'], $this->options['nav-class']),
'role' => 'tablist',
]);
foreach ($this->data['navs'] as $navItem) {
$html .= $this->genNavItem($navItem);
}
$html .= '</ul>';
return $html;
}
private function genNavItem($navItem)
{
$html = $this->genNode('li', [
'class' => array_merge(['nav-item'], $this->bsClasses['nav-item'], $this->options['nav-item-class']),
'role' => 'presentation',
]);
$html .= $this->genNode('a', [
'class' => array_merge(
['nav-link'],
[!empty($navItem['active']) ? 'active' : ''],
[!empty($navItem['disabled']) ? 'disabled' : '']
),
'data-toggle' => $this->options['pills'] ? 'pill' : 'tab',
'id' => $navItem['id'] . '-tab',
'href' => '#' . $navItem['id'],
'aria-controls' => $navItem['id'],
'aria-selected' => !empty($navItem['active']),
'role' => 'tab',
]);
if (!empty($navItem['html'])) {
$html .= $navItem['html'];
} else {
$html .= h($navItem['text']);
}
$html .= '</a></li>';
return $html;
}
private function genContent()
{
$html = $this->genNode('div', [
'class' => array_merge(['tab-content'], $this->options['content-class']),
]);
foreach ($this->data['content'] as $i => $content) {
$navItem = $this->data['navs'][$i];
$html .= $this->genContentItem($navItem, $content);
}
$html .= '</div>';
return $html;
}
private function genContentItem($navItem, $content)
{
$html = $this->genNode('div', [
'class' => array_merge(['tab-pane', 'fade'], [!empty($navItem['active']) ? 'show active' : '']),
'role' => 'tabpanel',
'id' => $navItem['id'],
'aria-labelledby' => $navItem['id'] . '-tab'
]);
$html .= $content;
$html .= '</div>';
return $html;
}
private function genNode($node, $params)
{
return sprintf('<%s %s>', $node, $this->genHTMLParams($params));
}
private function genHTMLParams($params)
{
$html = '';
foreach ($params as $k => $v) {
$html .= $this->genHTMLParam($k, $v) . ' ';
}
return $html;
}
private function genHTMLParam($paramName, $values)
{
if (!is_array($values)) {
$values = [$values];
}
return sprintf('%s="%s"', $paramName, implode(' ', $values));
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace App\View\Helper;
use Cake\View\Helper;
use Cake\Utility\Hash;
class DataFromPathHelper extends Helper
{
private $defaultOptions = [
'sanitize' => true, // Should the variables to be injected into the string be sanitized. (ignored with the function)
'highlight' => false, // Should the extracted data be highlighted
];
/**
* buildStringFromDataPath Inject data into a string at the correct place
*
* @param String $str The string that will have its arguments replaced by their value
* @param mixed $data The data from which the value of datapath arguement will be taken
* @param array $strArgs The arguments to be injected into the string.
* - Each argument can be of mixed type:
* - String: A cakephp's Hash datapath used to extract the data
* - array: can contain a key of either
* - `datapath`: A cakephp's Hash datapath used to extract the data
* - `raw`: A raw string to be injecte as-is
* - `function`: A function to be executed with its $strArgs being passed
* @param array $options Allows to configure the behavior of the function
* @return String The string with its arguments replaced by their value
*/
public function buildStringFromDataPath(String $str, $data=[], array $strArgs=[], array $options=[])
{
$options = array_merge($this->defaultOptions, $options);
if (!empty($strArgs)) {
$extractedVars = [];
foreach ($strArgs as $i => $strArg) {
$varValue = '';
if (is_array($strArg)) {
$varValue = '';
if (!empty($strArg['datapath'])) {
$varValue = Hash::get($data, $strArg['datapath']);
} else if (!empty($strArg['raw'])) {
$varValue = $strArg['raw'];
} else if (!empty($strArg['function'])) {
$varValue = $strArg['function']($data, $strArg);
}
} else {
$varValue = Hash::get($data, $strArg);
}
if (empty($strArg['function'])) {
$varValue = $options['sanitize'] ? h($varValue) : $varValue;
}
$extractedVars[] = $varValue;
}
foreach ($extractedVars as $i => $value) {
$value = $options['highlight'] ? "<span class=\"font-weight-light\">${value}</span>" : $value;
$str = str_replace(
"{{{$i}}}",
$value,
$str
);
}
}
return $str;
}
/**
* buildStringsInArray Apply buildStringFromDataPath for all strings in the provided array
*
* @param array $stringArray The array containing the strings that will have their arguments replaced by their value
* @param mixed $data The data from which the value of datapath arguement will be taken
* @param array $stringArrayArgs The arguments to be injected into each strings.
* - Each argument can be of mixed type:
* - String: A cakephp's Hash datapath used to extract the data
* - array: can contain a key of either
* - `datapath`: A cakephp's Hash datapath used to extract the data
* - `raw`: A raw string to be injecte as-is
* - `function`: A function to be executed with its $strArgs being passed
* @param array $options Allows to configure the behavior of the function
* @return array The array containing the strings with their arguments replaced by their value
*/
public function buildStringsInArray(array $stringArray, $data=[], array $stringArrayArgs, array $options=[])
{
foreach ($stringArrayArgs as $stringName => $argsPath) {
$theString = Hash::get($stringArray, $stringName);
if (!is_null($theString)) {
$argsPath = !is_array($argsPath) ? [$argsPath] : $argsPath;
if (!empty($argsPath['function'])) {
$newString = $argsPath['function']($data, $argsPath);
} else {
$newString = $this->buildStringFromDataPath($theString, $data, $argsPath, $options);
}
$stringArray = Hash::insert($stringArray, $stringName, $newString);
}
}
return $stringArray;
}
}

View File

@ -11,4 +11,9 @@ class HashHelper extends Helper
{
return Hash::extract($target, $extraction_string);
}
public function get($target, $extraction_string)
{
return Hash::get($target, $extraction_string);
}
}

View File

@ -1,5 +1,13 @@
<h4><?= __('Authkey created'); ?></h4>
<p><?= __('Please make sure that you note down the authkey below, this is the only time the authkey is shown in plain text, so make sure you save it. If you lose the key, simply remove the entry and generate a new one.'); ?></p>
<p><?=__('Cerebrate will use the first and the last 4 digit for identification purposes.')?></p>
<p><?= sprintf('%s: <span class="text-weight-bold">%s</span>', __('Authkey'), h($entity->authkey_raw)) ?></p>
<a href="<?= $referer ?>" class="btn btn-primary"><?= __('I have noted down my key, take me back now') ?></a>
<?php
echo $this->element('genericElements/genericModal', [
'title' => __('Authkey created'),
'body' => sprintf(
'<p>%s</p><p>%s</p><p>%s</p>',
__('Please make sure that you note down the authkey below, this is the only time the authkey is shown in plain text, so make sure you save it. If you lose the key, simply remove the entry and generate a new one.'),
__('Cerebrate will use the first and the last 4 digit for identification purposes.'),
sprintf('%s: <span class="font-weight-bold">%s</span>', __('Authkey'), h($entity->authkey_raw))
),
'actionButton' => sprintf('<button" class="btn btn-primary" data-dismiss="modal">%s</button>', __('I have noted down my key, take me back now')),
'noCancel' => true,
'staticBackdrop' => true,
]);

View File

@ -3,7 +3,6 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'data' => $data,
'top_bar' => [
'pull' => 'right',
'children' => [
[
'type' => 'simple',
@ -11,7 +10,6 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'type' => 'simple',
'text' => __('Add authentication key'),
'class' => 'btn btn-primary',
'popover_url' => '/authKeys/add'
]
]
@ -65,8 +63,8 @@ echo $this->element('genericElements/IndexTable/index_table', [
'pull' => 'right',
'actions' => [
[
'onclick' => 'populateAndLoadModal(\'/authKeys/delete/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'open_modal' => '/authKeys/delete/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'trash'
]
]

View File

@ -3,7 +3,6 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'data' => $data,
'top_bar' => [
'pull' => 'right',
'children' => [
[
'type' => 'simple',
@ -11,11 +10,14 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'type' => 'simple',
'text' => __('Add brood'),
'class' => 'btn btn-primary',
'popover_url' => '/broods/add'
]
]
],
[
'type' => 'context_filters',
'context_filters' => $filteringContexts
],
[
'type' => 'search',
'button' => __('Filter'),
@ -67,15 +69,15 @@ echo $this->element('genericElements/IndexTable/index_table', [
'icon' => 'eye'
],
[
'onclick' => 'populateAndLoadModal(\'/broods/edit/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'open_modal' => '/broods/edit/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'edit'
],
[
'onclick' => 'populateAndLoadModal(\'/broods/delete/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'open_modal' => '/broods/delete/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'trash'
]
],
]
]
]);

View File

@ -6,7 +6,6 @@ echo $this->element('genericElements/IndexTable/index_table', [
},
'data' => $data,
'top_bar' => [
'pull' => 'right',
'children' => [
[
'type' => 'simple',
@ -19,6 +18,10 @@ echo $this->element('genericElements/IndexTable/index_table', [
]
]
],
[
'type' => 'context_filters',
'context_filters' => $filteringContexts
],
[
'type' => 'search',
'button' => __('Filter'),
@ -61,20 +64,20 @@ echo $this->element('genericElements/IndexTable/index_table', [
'pull' => 'right',
'actions' => [
[
'onclick' => 'populateAndLoadModal(\'/encryptionKeys/view/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'url' => '/encryptionKeys/view',
'url_params_data_paths' => ['id'],
'icon' => 'eye'
],
[
'onclick' => 'populateAndLoadModal(\'/encryptionKeys/edit/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'open_modal' => '/encryptionKeys/edit/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'edit'
],
[
'onclick' => 'populateAndLoadModal(\'/encryptionKeys/delete/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'open_modal' => '/encryptionKeys/delete/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'trash'
]
],
]
]
]);

View File

@ -22,7 +22,7 @@
'field' => 'position'
)
),
'metaFields' => empty($metaFields) ? [] : $metaFields,
'metaTemplates' => empty($metaTemplates) ? [] : $metaTemplates,
'submit' => array(
'action' => $this->request->getParam('action')
)

View File

@ -3,7 +3,6 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'data' => $data,
'top_bar' => [
'pull' => 'right',
'children' => [
[
'type' => 'simple',
@ -11,11 +10,14 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'type' => 'simple',
'text' => __('Add individual'),
'class' => 'btn btn-primary',
'popover_url' => '/individuals/add'
]
]
],
[
'type' => 'context_filters',
'context_filters' => $filteringContexts
],
[
'type' => 'search',
'button' => __('Filter'),
@ -69,15 +71,15 @@ echo $this->element('genericElements/IndexTable/index_table', [
'icon' => 'eye'
],
[
'onclick' => 'populateAndLoadModal(\'/individuals/edit/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'open_modal' => '/individuals/edit/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'edit'
],
[
'onclick' => 'populateAndLoadModal(\'/individuals/delete/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'open_modal' => '/individuals/delete/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'trash'
]
],
]
]
]);

View File

@ -3,7 +3,6 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'data' => $data,
'top_bar' => [
'pull' => 'right',
'children' => [
[
'type' => 'search',

View File

@ -3,8 +3,11 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'data' => $data,
'top_bar' => [
'pull' => 'right',
'children' => [
[
'type' => 'context_filters',
'context_filters' => $filteringContexts
],
[
'type' => 'search',
'button' => __('Filter'),
@ -24,7 +27,92 @@ echo $this->element('genericElements/IndexTable/index_table', [
'name' => 'Enabled',
'sort' => 'enabled',
'data_path' => 'enabled',
'element' => 'boolean'
'element' => 'toggle',
'url' => '/metaTemplates/toggle/{{0}}',
'url_params_vars' => ['id'],
'toggle_data' => [
'editRequirement' => [
'function' => function($row, $options) {
return true;
},
],
'skip_full_reload' => true
]
],
[
'name' => 'Default',
'sort' => 'is_default',
'data_path' => 'is_default',
'element' => 'toggle',
'url' => '/metaTemplates/toggle/{{0}}/{{1}}',
'url_params_vars' => [['datapath' => 'id'], ['raw' => 'is_default']],
'toggle_data' => [
'editRequirement' => [
'function' => function($row, $options) {
return true;
}
],
'confirm' => [
'enable' => [
'titleHtml' => __('Make {{0}} the default template?'),
'bodyHtml' => $this->Html->nestedList([
__('Only one template per scope can be set as the default template'),
'{{0}}',
]),
'type' => '{{0}}',
'confirmText' => __('Yes, set as default'),
'arguments' => [
'titleHtml' => ['name'],
'bodyHtml' => [
[
'function' => function($row, $data) {
$conflictingTemplate = getConflictingTemplate($row, $data);
if (!empty($conflictingTemplate)) {
return sprintf(
"<span class=\"text-danger font-weight-bolder\">%s</span> %s.<br />
<ul><li><span class=\"font-weight-bolder\">%s</span> %s <span class=\"font-weight-bolder\">%s</span></li></ul>",
__('Conflict with:'),
$this->Html->link(
h($conflictingTemplate->name),
'/metaTemplates/view/' . h($conflictingTemplate->id),
['target' => '_blank']
),
__('By proceeding'),
h($conflictingTemplate->name),
__('will not be the default anymore')
);
}
return __('Current scope: {0}', h($row->scope));
},
'data' => [
'defaultTemplatePerScope' => $defaultTemplatePerScope
]
]
],
'type' => [
'function' => function($row, $data) {
$conflictingTemplate = getConflictingTemplate($row, $data);
if (!empty($conflictingTemplate)) {
return 'confirm-danger';
}
return 'confirm-warning';
},
'data' => [
'defaultTemplatePerScope' => $defaultTemplatePerScope
]
]
]
],
'disable' => [
'titleHtml' => __('Remove {{0}} as the default template?'),
'type' => 'confirm-warning',
'confirmText' => __('Yes, do not set as default'),
'arguments' => [
'titleHtml' => ['name'],
]
]
]
]
],
[
'name' => __('Scope'),
@ -56,30 +144,17 @@ echo $this->element('genericElements/IndexTable/index_table', [
'url_params_data_paths' => ['id'],
'icon' => 'eye'
],
[
'url' => '/metaTemplates/toggle',
'url_params_data_paths' => ['id'],
'title' => __('Enable template'),
'icon' => 'plus',
'complex_requirement' => [
'function' => function($row, $options) {
return !(bool)$row['enabled'];
}
]
],
[
'url' => '/metaTemplates/toggle',
'url_params_data_paths' => ['id'],
'title' => __('DIsable template'),
'icon' => 'minus',
'complex_requirement' => [
'function' => function($row, $options) {
return (bool)$row['enabled'];
}
]
]
]
]
]);
function getConflictingTemplate($row, $data) {
if (!empty($data['data']['defaultTemplatePerScope'][$row->scope])) {
$conflictingTemplate = $data['data']['defaultTemplatePerScope'][$row->scope];
if (!empty($conflictingTemplate)) {
return $conflictingTemplate;
}
}
return [];
}
?>

View File

@ -20,6 +20,16 @@ echo $this->element(
'key' => __('Description'),
'path' => 'description'
],
[
'key' => __('Enabled'),
'path' => 'enabled',
'type' => 'boolean'
],
[
'key' => __('is_default'),
'path' => 'is_default',
'type' => 'boolean'
],
[
'key' => __('Version'),
'path' => 'version'

View File

@ -56,16 +56,6 @@ echo $this->element('genericElements/IndexTable/index_table', [
'url' => '/individuals/view',
'url_params_data_paths' => ['id'],
'icon' => 'eye'
],
[
'onclick' => 'populateAndLoadModal(\'/individuals/edit/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'icon' => 'edit'
],
[
'onclick' => 'populateAndLoadModal(\'/individuals/delete/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'icon' => 'trash'
]
]
]

View File

@ -77,16 +77,6 @@ echo $this->element('genericElements/IndexTable/index_table', [
'url' => '/organisations/view',
'url_params_data_paths' => ['id'],
'icon' => 'eye'
],
[
'onclick' => 'populateAndLoadModal(\'/organisations/edit/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'icon' => 'edit'
],
[
'onclick' => 'populateAndLoadModal(\'/organisations/delete/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'icon' => 'trash'
]
]
]

View File

@ -29,7 +29,7 @@
'field' => 'type'
)
),
'metaFields' => empty($metaFields) ? [] : $metaFields,
'metaTemplates' => empty($metaTemplates) ? [] : $metaTemplates,
'submit' => array(
'action' => $this->request->getParam('action')
)

View File

@ -3,7 +3,6 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'data' => $data,
'top_bar' => [
'pull' => 'right',
'children' => [
[
'type' => 'simple',
@ -79,15 +78,15 @@ echo $this->element('genericElements/IndexTable/index_table', [
'icon' => 'eye'
],
[
'onclick' => 'populateAndLoadModal(\'/organisations/edit/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'open_modal' => '/organisations/edit/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'edit'
],
[
'onclick' => 'populateAndLoadModal(\'/organisations/delete/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'open_modal' => '/organisations/delete/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'trash'
]
],
]
]
]);

View File

@ -44,7 +44,7 @@ echo $this->element(
'scope' => 'organisations'
]
],
'metaFields' => empty($metaFields) ? [] : $metaFields,
'metaTemplates' => empty($metaFields) ? [] : $metaFields,
'children' => []
]
);

View File

@ -3,7 +3,6 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'data' => $data,
'top_bar' => [
'pull' => 'right',
'children' => [
[
'type' => 'simple',
@ -65,15 +64,15 @@ echo $this->element('genericElements/IndexTable/index_table', [
'icon' => 'eye'
],
[
'onclick' => 'populateAndLoadModal(\'/roles/edit/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'icon' => 'edit',
'open_modal' => '/roles/edit/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'edit'
],
[
'onclick' => 'populateAndLoadModal(\'/roles/delete/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'open_modal' => '/roles/delete/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'trash'
]
],
]
]
]);

View File

@ -3,7 +3,6 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'data' => $data,
'top_bar' => [
'pull' => 'right',
'children' => [
[
'type' => 'simple',
@ -11,7 +10,6 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'type' => 'simple',
'text' => __('Add sharing group'),
'class' => 'btn btn-primary',
'popover_url' => '/SharingGroups/add'
]
]
@ -61,15 +59,15 @@ echo $this->element('genericElements/IndexTable/index_table', [
'icon' => 'eye'
],
[
'onclick' => 'populateAndLoadModal(\'/sharingGroups/edit/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'open_modal' => '/sharingGroups/edit/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'edit'
],
[
'onclick' => 'populateAndLoadModal(\'/sharingGroups/delete/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'open_modal' => '/sharingGroups/delete/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'trash'
]
],
]
]
]);

View File

@ -4,7 +4,6 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data' => $sharing_group_orgs,
'skip_pagination' => 1,
'top_bar' => [
'pull' => 'right',
'children' => [
[
'type' => 'simple',
@ -12,8 +11,8 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'type' => 'simple',
'text' => __('Add member'),
'class' => 'btn btn-primary',
'popover_url' => '/sharingGroups/addOrg/' . h($sharing_group_id)
'popover_url' => '/sharingGroups/addOrg/' . h($sharing_group_id),
'reload_url' => '/sharingGroups/listOrgs/' . h($sharing_group_id)
]
]
],
@ -53,10 +52,11 @@ echo $this->element('genericElements/IndexTable/index_table', [
'icon' => 'eye'
],
[
'onclick' => 'populateAndLoadModal(\'/sharingGroups/removeOrg/' . h($sharing_group_id) . '/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'open_modal' => '/sharingGroups/removeOrg/' . h($sharing_group_id) . '/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'reload_url' => '/sharingGroups/listOrgs/' . h($sharing_group_id),
'icon' => 'trash'
]
],
]
]
]);

View File

@ -3,7 +3,6 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'data' => $data,
'top_bar' => [
'pull' => 'right',
'children' => [
[
'type' => 'simple',
@ -31,6 +30,22 @@ echo $this->element('genericElements/IndexTable/index_table', [
'sort' => 'id',
'data_path' => 'id',
],
[
'name' => __('Disabled'),
'sort' => 'disabled',
'data_path' => 'disabled',
'element' => 'toggle',
'url' => '/users/toggle/{{0}}',
'url_params_vars' => ['id'],
'toggle_data' => [
'editRequirement' => [
'function' => function($row, $options) {
return true;
},
],
'skip_full_reload' => true
]
],
[
'name' => __('Username'),
'sort' => 'username',
@ -71,15 +86,15 @@ echo $this->element('genericElements/IndexTable/index_table', [
'icon' => 'eye'
],
[
'onclick' => 'populateAndLoadModal(\'/users/edit/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'open_modal' => '/users/edit/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'edit'
],
[
'onclick' => 'populateAndLoadModal(\'/users/delete/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'open_modal' => '/users/delete/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'trash'
]
],
]
]
]);

View File

@ -12,6 +12,7 @@
- use these to define dynamic form fields, or anything that will feed into the regular fields via JS population
* - submit: The submit button itself. By default it will simply submit to the form as defined via the 'model' field
*/
$this->Form->setConfig('errorClass', 'is-invalid');
$modelForForm = empty($data['model']) ?
h(\Cake\Utility\Inflector::singularize(\Cake\Utility\Inflector::classify($this->request->getParam('controller')))) :
h($data['model']);
@ -35,11 +36,14 @@
'select' => '<select name="{{name}}" {{attrs}}>{{content}}</select>',
'checkbox' => '<input type="checkbox" name="{{name}}" value="{{value}}"{{attrs}}>',
'checkboxFormGroup' => '{{label}}',
'formGroup' => '<div class="col-sm-2 col-form-label" {{attrs}}>{{label}}</div><div class="col-sm-10">{{input}}</div>',
'formGroup' => '<div class="col-sm-2 col-form-label" {{attrs}}>{{label}}</div><div class="col-sm-10">{{input}}{{error}}</div>',
'nestingLabel' => '{{hidden}}<div class="col-sm-2 col-form-label">{{text}}</div><div class="col-sm-10">{{input}}</div>',
'option' => '<option value="{{value}}"{{attrs}}>{{text}}</option>',
'optgroup' => '<optgroup label="{{label}}"{{attrs}}>{{content}}</optgroup>',
'select' => '<select name="{{name}}"{{attrs}}>{{content}}</select>'
'select' => '<select name="{{name}}"{{attrs}}>{{content}}</select>',
'error' => '<div class="error-message invalid-feedback d-block">{{content}}</div>',
'errorList' => '<ul>{{content}}</ul>',
'errorItem' => '<li>{{text}}</li>',
];
if (!empty($data['fields'])) {
foreach ($data['fields'] as $fieldData) {
@ -49,6 +53,7 @@
}
}
// we reset the template each iteration as individual fields might override the defaults.
$this->Form->setConfig($default_template);
$this->Form->setTemplates($default_template);
if (isset($fieldData['requirements']) && !$fieldData['requirements']) {
continue;
@ -62,18 +67,13 @@
);
}
}
$metaFieldString = '';
if (!empty($data['metaFields'])) {
foreach ($data['metaFields'] as $metaField) {
$metaField['label'] = \Cake\Utility\Inflector::humanize($metaField['field']);
$metaField['field'] = 'metaFields.' . $metaField['field'];
$metaFieldString .= $this->element(
'genericElements/Form/fieldScaffold', [
'fieldData' => $metaField->toArray(),
'form' => $this->Form
]
);
}
if (!empty($data['metaTemplates']) && $data['metaTemplates']->count() > 0) {
$metaTemplateString = $this->element(
'genericElements/Form/metaTemplateScaffold', [
'metaTemplatesData' => $data['metaTemplates'],
'form' => $this->Form,
]
);
}
$submitButtonData = ['model' => $modelForForm, 'formRandomValue' => $formRandomValue];
if (!empty($data['submit'])) {
@ -104,9 +104,9 @@
$ajaxFlashMessage,
$formCreate,
$fieldsString,
empty($metaFieldString) ? '' : $this->element(
empty($metaTemplateString) ? '' : $this->element(
'genericElements/accordion_scaffold', [
'body' => $metaFieldString,
'body' => $metaTemplateString,
'title' => 'Meta fields'
]
),
@ -127,10 +127,11 @@
$data['description']
),
$fieldsString,
empty($metaFieldString) ? '' : $this->element(
empty($metaTemplateString) ? '' : $this->element(
'genericElements/accordion_scaffold', [
'body' => $metaFieldString,
'title' => 'Meta fields'
'body' => $metaTemplateString,
'title' => 'Meta fields',
'class' => 'mb-2'
]
),
$this->element('genericElements/Form/submitButton', $submitButtonData),

View File

@ -0,0 +1,32 @@
<?php
use Cake\Utility\Inflector;
$tabData = [];
foreach($metaTemplatesData as $i => $metaTemplate) {
if ($metaTemplate->is_default) {
$tabData['navs'][$i] = [
'html' => $this->element('/genericElements/MetaTemplates/metaTemplateNav', ['metaTemplate' => $metaTemplate])
];
} else {
$tabData['navs'][$i] = [
'text' => $metaTemplate->name
];
}
$fieldsHtml = '';
foreach ($metaTemplate->meta_template_fields as $metaField) {
$metaField->label = Inflector::humanize($metaField->field);
$metaField->field = sprintf('%s.%s.%s', 'metaFields', $metaField->meta_template_id, $metaField->field);
$fieldsHtml .= $this->element(
'genericElements/Form/fieldScaffold', [
'fieldData' => $metaField->toArray(),
'form' => $this->Form
]
);
}
$tabData['content'][$i] = $fieldsHtml;
}
echo $this->Bootstrap->Tabs([
'pills' => true,
'data' => $tabData,
'nav-class' => ['pb-1']
]);

View File

@ -3,8 +3,8 @@
echo sprintf(
'%s',
sprintf(
'<button id="submitButton" class="btn btn-primary" onClick="%s" autofocus>%s</button>',
"$('#form-" . h($formRandomValue) . "').submit()",
'<button id="submitButton" class="btn btn-primary" data-form-id="%s" autofocus>%s</button>',
'#form-' . h($formRandomValue),
__('Submit')
)
);

View File

@ -80,6 +80,14 @@
$action['onclick']
);
} else if (!empty($action['open_modal']) && !empty($action['modal_params_data_path'])) {
$modal_url = str_replace(
'[onclick_params_data_path]',
h(Cake\Utility\Hash::extract($row, $action['modal_params_data_path'])[0]),
$action['open_modal']
);
$reload_url = !empty($action['reload_url']) ? $action['reload_url'] : $this->Url->build(['action' => 'index']);
$action['onclick'] = sprintf('UI.openModalFromURL(\'%s\', \'%s\', \'%s\')', $modal_url, $reload_url, $tableRandomValue);
}
echo sprintf(
'<a href="%s%s" title="%s" aria-label="%s" %s %s class="link-unstyled"><i class="%s"></i></a> ',

View File

@ -14,10 +14,10 @@ if ($field['scope'] === 'individuals') {
h($alignment['organisation']['name'])
),
!$canRemove ? '' : sprintf(
"populateAndLoadModal(%s);",
"UI.openModalFromURL(%s);",
sprintf(
"'/alignments/delete/%s'",
$alignment['id']
h($alignment['id'])
)
)
);
@ -34,10 +34,10 @@ if ($field['scope'] === 'individuals') {
h($alignment['individual']['email'])
),
!$canRemove ? '' : sprintf(
"populateAndLoadModal(%s);",
"UI.openModalFromURL(%s);",
sprintf(
"'/alignments/delete/%s'",
$alignment['id']
h($alignment['id'])
)
)
);

View File

@ -4,56 +4,96 @@
* On click, issues a GET to a given endpoint, retrieving a form with the
* value flipped, which is immediately POSTed.
* to fetch it.
* Options:
* - url: The URL on which to perform the POST
* - url_params_vars: Variables to be injected into the URL using the DataFromPath helper
* - toggle_data.skip_full_reload: If true, the index will not be reloaded and the checkbox will be flipped on success
* - toggle_data.editRequirement.function: A function to be called to assess if the checkbox can be toggled
* - toggle_data.editRequirement.options: Option that will be passed to the function
* - toggle_data.editRequirement.options.datapath: If provided, entries will have their datapath values converted into their extracted value
* - toggle_data.confirm.[enable/disable].title:
* - toggle_data.confirm.[enable/disable].titleHtml:
* - toggle_data.confirm.[enable/disable].body:
* - toggle_data.confirm.[enable/disable].bodyHtml:
* - toggle_data.confirm.[enable/disable].type:
*
*/
$data = $this->Hash->extract($row, $field['data_path']);
$data = $this->Hash->get($row, $field['data_path']);
$seed = rand();
$checkboxId = 'GenericToggle-' . $seed;
$tempboxId = 'TempBox-' . $seed;
$requirementMet = false;
if (isset($field['toggle_data']['editRequirement'])) {
if (isset($field['toggle_data']['editRequirement']['options']['datapath'])) {
foreach ($field['toggle_data']['editRequirement']['options']['datapath'] as $name => $path) {
$field['toggle_data']['editRequirement']['options']['datapath'][$name] = empty($this->Hash->extract($row, $path)[0]) ? null : $this->Hash->extract($row, $path)[0];
}
}
$options = isset($field['toggle_data']['editRequirement']['options']) ? $field['toggle_data']['editRequirement']['options'] : array();
$requirementMet = $field['toggle_data']['editRequirement']['function']($row, $options);
}
echo sprintf(
'<input type="checkbox" id="%s" %s><span id="%s" class="hidden">',
'<input type="checkbox" id="%s" class="change-cursor" %s %s><span id="%s" class="d-none"></span>',
$checkboxId,
empty($data[0]) ? '' : 'checked',
empty($data) ? '' : 'checked',
$requirementMet ? '' : 'disabled="disabled"',
$tempboxId
);
// inject variables into the strings
if (!empty($field['toggle_data']['confirm'])) {
$field['toggle_data']['confirm']['enable']['arguments'] = isset($field['toggle_data']['confirm']['enable']['arguments']) ? $field['toggle_data']['confirm']['enable']['arguments'] : [];
$field['toggle_data']['confirm']['disable']['arguments'] = isset($field['toggle_data']['confirm']['disable']['arguments']) ? $field['toggle_data']['confirm']['disable']['arguments'] : [];
$stringArrayEnable = $field['toggle_data']['confirm']['enable'];
unset($stringArrayEnable['arguments']);
$stringArrayDisable = $field['toggle_data']['confirm']['disable'];
unset($stringArrayDisable['arguments']);
$confirmOptions = [
'enable' => $this->DataFromPath->buildStringsInArray($stringArrayEnable, $row, $field['toggle_data']['confirm']['enable']['arguments'], ['highlight' => true]),
'disable' => $this->DataFromPath->buildStringsInArray($stringArrayDisable, $row, $field['toggle_data']['confirm']['disable']['arguments'], ['highlight' => true]),
];
}
$url = $this->DataFromPath->buildStringFromDataPath($field['url'], $row, $field['url_params_vars']);
?>
<?php if ($requirementMet): ?>
<script type="text/javascript">
$(document).ready(function() {
var url = baseurl + "<?= h($field['url']) ?>";
<?php
if (!empty($field['url_params_data_paths'][0])) {
$id = $this->Hash->extract($row, $field['url_params_data_paths'][0]);
echo 'url = url + "/' . h($id[0]) . '";';
}
?>
$('#<?= $checkboxId ?>').on('click', function() {
$.ajax({
type:"get",
url: url,
error:function() {
showMessage('fail', '<?= __('Could not retrieve current state.') ?>.');
},
success: function (data, textStatus) {
$('#<?= $tempboxId ?>').html(data);
// Make @mokaddem aka Graphman happy
var $form = $('#<?= $tempboxId ?>').find('form');
$.ajax({
data: $form.serialize(),
cache: false,
type:"post",
url: $form.attr('action'),
success:function(data, textStatus) {
showMessage('success', '<?= __('Field updated.') ?>.');
},
error:function() {
showMessage('fail', '<?= __('Could not update field.') ?>.');
},
complete:function() {
$('#<?= $tempboxId ?>').empty();
}
});
(function() {
const url = "<?= h($url) ?>"
const confirmationOptions = <?= isset($confirmOptions) ? json_encode($confirmOptions) : 'false' ?>;
$('#<?= $checkboxId ?>').click(function(evt) {
evt.preventDefault()
if(confirmationOptions !== false) {
const correctOptions = $('#<?= $checkboxId ?>').prop('checked') ? confirmationOptions['enable'] : confirmationOptions['disable'] // Adjust modal option based on checkbox state
const modalOptions = {
...correctOptions,
APIConfirm: (tmpApi) => {
return submitForm(tmpApi, url)
},
}
});
});
});
UI.modal(modalOptions)
} else {
const tmpApi = new AJAXApi({
statusNode: $('#<?= $checkboxId ?>')[0]
})
submitForm(tmpApi, url)
}
})
function submitForm(api, url) {
const reloadUrl = '<?= isset($field['toggle_data']['reload_url']) ? $field['toggle_data']['reload_url'] : $this->Url->build(['action' => 'index']) ?>'
return api.fetchAndPostForm(url, {})
.then(() => {
<?php if (!empty($field['toggle_data']['skip_full_reload'])): ?>
const isChecked = $('#<?= $checkboxId ?>').prop('checked')
$('#<?= $checkboxId ?>').prop('checked', !$('#<?= $checkboxId ?>').prop('checked'))
<?php else: ?>
UI.reload(reloadUrl, $('#table-container-<?= $tableRandomValue ?>'), $('#table-container-<?= $tableRandomValue ?> table.table'))
<?php endif; ?>
})
}
}())
</script>
<?php endif; ?>

View File

@ -2,31 +2,39 @@
if (!isset($data['requirement']) || $data['requirement']) {
if (!empty($data['popover_url'])) {
$onClick = sprintf(
'onClick="populateAndLoadModal(%s)"',
sprintf("'%s'", h($data['popover_url']))
'onClick="openModalForButton(this, \'%s\', \'%s\')"',
h($data['popover_url']),
h(!empty($data['reload_url']) ? $data['reload_url'] : '')
);
}
if (empty($onClick) && (!empty($data['onClick']) || empty($data['url']))) {
$onClickParams = array();
if (!empty($data['onClickParams'])) {
foreach ($data['onClickParams'] as $param) {
if ($param === 'this') {
$onClickParams[] = h($param);
} else {
$onClickParams[] = '\'' . h($param) . '\'';
if (empty($onClick)) {
if (!empty($data['onClick']) || empty($data['url'])) {
$onClickParams = array();
if (!empty($data['onClickParams'])) {
foreach ($data['onClickParams'] as $param) {
if ($param === 'this') {
$onClickParams[] = h($param);
} else {
$onClickParams[] = '\'' . h($param) . '\'';
}
}
}
$onClickParams = implode(',', $onClickParams);
$onClick = sprintf(
'onClick = "%s%s"',
(empty($data['url'])) ? 'event.preventDefault();' : '',
(!empty($data['onClick']) ? sprintf(
'%s(%s)',
h($data['onClick']),
$onClickParams
) : '')
);
} else if(!empty($data['url'])) {
$onClick = sprintf(
'onClick = "%s"',
sprintf('window.location=\'%s\'', $data['url'])
);
}
$onClickParams = implode(',', $onClickParams);
$onClick = sprintf(
'onClick = "%s%s"',
(empty($data['url'])) ? 'event.preventDefault();' : '',
(!empty($data['onClick']) ? sprintf(
'%s(%s)',
h($data['onClick']),
$onClickParams
) : '')
);
}
$dataFields = array();
if (!empty($data['data'])) {
@ -40,9 +48,9 @@
}
$dataFields = implode(' ', $dataFields);
echo sprintf(
'<button class="btn btn-small %s %s" %s href="%s" %s %s %s %s %s>%s%s%s</button>',
'<button class="btn %s %s" %s href="%s" %s %s %s %s %s>%s%s%s</button>',
empty($data['class']) ? '' : h($data['class']),
empty($data['active']) ? 'btn-inverse' : 'btn-primary', // Change the default class for highlighted/active toggles here
empty($data['isFilter']) ? 'btn-primary' : (empty($data['active']) ? 'btn-light' : 'btn-secondary'), // Change the default class for highlighted/active toggles here
empty($data['id']) ? '' : 'id="' . h($data['id']) . '"',
empty($data['url']) ? '#' : $baseurl . h($data['url']), // prevent default is passed if the url is not set
empty($onClick) ? '' : $onClick, // pass $data['onClick'] for the function name to call and $data['onClickParams'] for the parameter list
@ -53,10 +61,22 @@
empty($data['fa-icon']) ? '' : sprintf(
'<i class="%s fa-%s"></i> ',
empty($data['fa-source']) ? 'fas' : h($data['fa-source']),
$data['fa-icon']
), // this has to be sanitised beforehand!
h($data['fa-icon'])
),
empty($data['html']) ? '' : $data['html'], // this has to be sanitised beforehand!
empty($data['text']) ? '' : h($data['text'])
);
}
?>
<script>
function openModalForButton(clicked, url, reloadUrl='') {
const loadingOverlay = new OverlayFactory(clicked);
const fallbackReloadUrl = '<?= $this->Url->build(['action' => 'index']); ?>'
reloadUrl = reloadUrl != '' ? reloadUrl : fallbackReloadUrl
loadingOverlay.show()
UI.openModalFromURL(url, reloadUrl, '<?= $tableRandomValue ?>').finally(() => {
loadingOverlay.hide()
})
}
</script>

View File

@ -0,0 +1,61 @@
<?php
$contextArray = [];
foreach ($data['context_filters'] as $filteringContext) {
$filteringContext['filterCondition'] = empty($filteringContext['filterCondition']) ? [] : $filteringContext['filterCondition'];
$urlParams = [
'controller' => $this->request->getParam('controller'),
'action' => 'index',
'?' => $filteringContext['filterCondition']
];
$currentQuery = $this->request->getQuery();
unset($currentQuery['page'], $currentQuery['limit'], $currentQuery['sort']);
if (!empty($filteringContext['filterCondition'])) { // PHP replaces `.` by `_` when fetching the request parameter
$currentFilteringContextKey = array_key_first($filteringContext['filterCondition']);
$currentFilteringContext = [
str_replace('.', '_', $currentFilteringContextKey) => $filteringContext['filterCondition'][$currentFilteringContextKey]
];
} else {
$currentFilteringContext = $filteringContext['filterCondition'];
}
$contextArray[] = [
'active' => $currentQuery == $currentFilteringContext,
'isFilter' => true,
'onClick' => 'changeIndexContext',
'onClickParams' => [
'this',
$this->Url->build($urlParams),
"#table-container-${tableRandomValue}",
"#table-container-${tableRandomValue} table.table",
],
'text' => $filteringContext['label'],
'class' => 'btn-sm'
];
}
$dataGroup = [
'type' => 'simple',
'children' => $contextArray,
];
if (isset($data['requirement'])) {
$dataGroup['requirement'] = $data['requirement'];
}
echo '<div class="d-flex align-items-end topbar-contextual-filter">';
echo $this->element('/genericElements/ListTopBar/group_simple', [
'data' => $dataGroup,
'tableRandomValue' => $tableRandomValue
]);
echo '</div>';
?>
<script>
function changeIndexContext(clicked, url, container, statusNode) {
UI.reload(url, container, statusNode, [{
node: clicked,
config: {
spinnerVariant: 'dark',
spinnerType: 'grow',
spinnerSmall: true
}
}])
}
</script>

View File

@ -29,10 +29,10 @@
empty($data['placeholder']) ? '' : h($data['placeholder']),
empty($data['id']) ? 'quickFilterField' : h($data['id']),
empty($data['searchKey']) ? 'searchall' : h($data['searchKey']),
empty($data['value']) ? '' : h($data['value'])
empty($data['value']) ? (!empty($quickFilterValue) ? h($quickFilterValue) : '') : h($data['value'])
);
echo sprintf(
'<div class="input-group" data-table-random-value="%s">%s%s</div>',
'<div class="input-group" data-table-random-value="%s" style="margin-left: auto;">%s%s</div>',
h($tableRandomValue),
$input,
$button
@ -44,6 +44,7 @@
var controller = '<?= $this->request->getParam('controller') ?>';
var action = '<?= $this->request->getParam('action') ?>';
var additionalUrlParams = '';
var quickFilter = <?= json_encode(!empty($quickFilter) ? $quickFilter : []) ?>;
<?php
if (!empty($data['additionalUrlParams'])) {
echo sprintf(
@ -53,15 +54,65 @@
}
?>
var randomValue = '<?= h($tableRandomValue) ?>';
$('#quickFilterButton-' + randomValue).click(function() {
var url = '/' + controller + '/' + action + additionalUrlParams + '?quickFilter=' + encodeURIComponent($('#quickFilterField-<?= h($tableRandomValue) ?>').val());
executePagination(randomValue, url);
$(`#quickFilterField-${randomValue}`).popover({
title: '<?= __('Searcheable fields') ?>',
content: function() { return buildPopoverQuickFilterBody(quickFilter) },
html: true,
sanitize: false,
trigger: 'manual',
})
$(`#quickFilterButton-${randomValue}`).click((e) => {
doFilter($(e.target))
});
$('#quickFilterField').on('keypress', function (e) {
if(e.which === 13) {
var url = '/' + controller + '/' + action + additionalUrlParams + '?quickFilter=' + encodeURIComponent($('#quickFilterField-<?= h($tableRandomValue) ?>').val());
executePagination(randomValue, url);
$(`#quickFilterField-${randomValue}`).on('keypress', (e) => {
if (e.which === 13) {
const $button = $(`#quickFilterButton-${randomValue}`)
doFilter($button)
}
}).on('focus', (e) => {
$(`#quickFilterField-${randomValue}`).popover('show')
}).on('focusout', (e) => {
$(`#quickFilterField-${randomValue}`).popover('hide')
});
function doFilter($button) {
$(`#quickFilterField-${randomValue}`).popover('hide')
const encodedFilters = encodeURIComponent($(`#quickFilterField-${randomValue}`).val())
const url = `/${controller}/${action}${additionalUrlParams}?quickFilter=${encodedFilters}`
UI.reload(url, $(`#table-container-${randomValue}`), $(`#table-container-${randomValue} table.table`), [{
node: $button,
config: {}
}])
}
function buildPopoverQuickFilterBody(quickFilter) {
let tableData = []
quickFilter.forEach(field => {
let fieldName, searchContain
if (typeof field === 'object') {
fieldName = Object.keys(field)[0];
searchContain = field[fieldName]
} else {
fieldName = field
searchContain = false
}
$searchType = $('<span/>')
.text(searchContain ? '<?= __('Contain') ?>' : '<?= __('Exact match') ?>')
.attr('title', searchContain ? '<?= __('The search value will be used as a substring') ?>' : '<?= __('The search value must strictly match') ?>')
tableData.push([fieldName, $searchType])
});
tableData.sort((a, b) => a[0] < b[0] ? -1 : 1)
$table = HtmlHelper.table(
['<?= __('Field name') ?>', '<?= __('Search type') ?>'],
tableData,
{
small: true,
tableClass: ['mb-0'],
caption: '<?= __('All these fields will be searched simultaneously') ?>'
}
)
return $table[0].outerHTML
}
});
</script>

View File

@ -2,10 +2,10 @@
if (!isset($data['requirement']) || $data['requirement']) {
$elements = '';
foreach ($data['children'] as $element) {
$elements .= $this->element('/genericElements/ListTopBar/element_' . (empty($element['type']) ? 'simple' : h($element['type'])), array('data' => $element));
$elements .= $this->element('/genericElements/ListTopBar/element_' . (empty($element['type']) ? 'simple' : h($element['type'])), array('data' => $element, 'tableRandomValue' => $tableRandomValue));
}
echo sprintf(
'<div %s class="btn-group mr-2" role="group" aria-label="button-group">%s</div>',
'<div %s class="btn-group btn-group-sm mr-2" role="group" aria-label="button-group">%s</div>',
(!empty($data['id'])) ? 'id="' . h($data['id']) . '"' : '',
$elements
);

View File

@ -1,10 +1,12 @@
<?php
$groups = '';
$hasGroupSearch = false;
foreach ($data['children'] as $group) {
$groups .= $this->element('/genericElements/ListTopBar/group_' . (empty($group['type']) ? 'simple' : h($group['type'])), array('data' => $group, 'tableRandomValue' => $tableRandomValue));
$hasGroupSearch = $hasGroupSearch || (!empty($group['type']) && $group['type'] == 'search');
}
$tempClass = "btn-toolbar";
if (count($data['children']) > 1) {
if (count($data['children']) > 1 && !$hasGroupSearch) {
$tempClass .= ' justify-content-between';
} else if (!empty($data['pull'])) {
$tempClass .= ' float-' . h($data['pull']);

View File

@ -0,0 +1,4 @@
<span>
<?= h($metaTemplate->name) ?>
<i class="<?= $this->FontAwesome->getClass('star')?> small align-text-top" title="<?= __('Default Meta template') ?>"></i>
</span>

View File

@ -20,7 +20,7 @@ if ($field['scope'] === 'individuals') {
h($alignment['organisation']['name'])
),
sprintf(
"populateAndLoadModal(%s);",
"UI.openModalFromURL(%s);",
sprintf(
"'/alignments/delete/%s'",
$alignment['id']
@ -40,7 +40,7 @@ if ($field['scope'] === 'individuals') {
h($alignment['individual']['email'])
),
sprintf(
"populateAndLoadModal(%s);",
"UI.openModalFromURL(%s);",
sprintf(
"'/alignments/delete/%s'",
$alignment['id']
@ -50,10 +50,10 @@ if ($field['scope'] === 'individuals') {
}
}
echo sprintf(
'<div class="alignments-list">%s</div><div class="alignments-add-container"><button class="alignments-add-button btn btn-secondary btn-sm" onclick="%s">%s</button></div>',
'<div class="alignments-list">%s</div><div class="alignments-add-container"><button class="alignments-add-button btn btn-primary btn-sm" onclick="%s">%s</button></div>',
$alignments,
sprintf(
"populateAndLoadModal('/alignments/add/%s/%s');",
"UI.openModalFromURL('/alignments/add/%s/%s');",
h($field['scope']),
h($extracted['id'])
),

View File

@ -9,24 +9,26 @@
* elements passed as to be displayed in the <ul> element.
* format:
* [
'key' => '' // key to be displayed
* 'key' => '' // key to be displayed
* 'path' => '' // path for the value to be parsed
* 'type' => '' // generic assumed if not filled, uses SingleViews/Fields/* elements
* ]
* ],
* 'children' => [
* // Additional elements attached to the currently viewed object. index views will be appended via ajax calls below.
[
* [
* 'title' => '',
* 'url' => '', //cakephp compatible url, can be actual url or array for the constructor
* 'collapsed' => 0|1 // defaults to 0, whether to display it by default or not
* 'loadOn' => 'ready|expand' // load the data directly or only when expanded from a collapsed state
*
* ],
* ]
* ],
* 'skip_meta_templates' => false // should the meta templates not be displayed
* ]);
*
*/
$tableRandomValue = Cake\Utility\Security::randomString(8);
$listElements = '';
if (!empty($fields)) {
foreach ($fields as $field) {
@ -34,7 +36,10 @@
$field['type'] = 'generic';
}
$listElements .= sprintf(
'<tr class="row"><td class="col-sm-2 font-weight-bold">%s</td><td class="col-sm-10">%s</td></tr>',
"<tr class=\"row\">
<td class=\"col-sm-2 font-weight-bold\">%s</td>
<td class=\"col-sm-10\">%s</td>
</tr>",
h($field['key']),
$this->element(
'/genericElements/SingleViews/Fields/' . $field['type'] . 'Field',
@ -43,20 +48,50 @@
);
}
}
if (!empty($data['metaFields'])) {
foreach ($data['metaFields'] as $metaField => $value) {
$listElements .= sprintf(
'<tr class="row"><td class="col-sm-2 font-weight-bold">%s</td><td class="col-sm-10">%s</td></tr>',
h($metaField),
$this->element(
'/genericElements/SingleViews/Fields/genericField',
[
'field' => [
'raw' => $value
]
]
)
);
$metaTemplateTabs = '';
if (!empty($data['metaTemplates']) && (empty($skip_meta_templates))) {
$tabData = [
'navs' => [],
'content' => []
];
foreach($data['metaTemplates'] as $metaTemplate) {
if (!empty($metaTemplate->meta_template_fields)) {
if ($metaTemplate->is_default) {
$tabData['navs'][] = [
'html' => $this->element('/genericElements/MetaTemplates/metaTemplateNav', ['metaTemplate' => $metaTemplate])
];
} else {
$tabData['navs'][] = [
'text' => $metaTemplate->name
];
}
$fieldsHtml = '<table class="table table-striped">';
foreach ($metaTemplate->meta_template_fields as $metaTemplateField) {
$metaField = $metaTemplateField->meta_fields[0];
$fieldsHtml .= sprintf(
'<tr class="row"><td class="col-sm-2 font-weight-bold">%s</td><td class="col-sm-10">%s</td></tr>',
h($metaField->field),
$this->element(
'/genericElements/SingleViews/Fields/genericField',
[
'data' => $metaField->value,
'field' => [
'raw' => $metaField->value
]
]
)
);
}
$fieldsHtml .= '</table>';
$tabData['content'][] = $fieldsHtml;
}
}
if (!empty($tabData['navs'])) {
$metaTemplateTabs = $this->Bootstrap->Tabs([
'pills' => true,
'card' => true,
'data' => $tabData
]);
}
}
$ajaxLists = '';
@ -75,11 +110,22 @@
__('{0} view', \Cake\Utility\Inflector::singularize(\Cake\Utility\Inflector::humanize($this->request->getParam('controller')))) :
$title;
echo sprintf(
'<div><h2>%s</h2>%s%s<div class="px-3"><table class="table table-striped col-sm-8">%s</table></div><div id="accordion">%s</div></div>',
"<div id=\"single-view-table-container-%s\">
<h2>%s</h2>
%s%s
<div class=\"px-3\">
<table id=\"single-view-table-%s\" class=\"table table-striped col-sm-8\">%s</table>
</div>
<div id=\"metaTemplates\" class=\"col-lg-8 px-0\">%s</div>
<div id=\"accordion\">%s</div>
</div>",
$tableRandomValue,
h($title),
empty($description) ? '' : sprintf('<p>%s</p>', h($description)),
empty($description_html) ? '' : sprintf('<p>%s</p>', $description_html),
$tableRandomValue,
$listElements,
$metaTemplateTabs,
$ajaxLists
);
?>

View File

@ -1,7 +1,7 @@
<?php
$randomId = Cake\Utility\Security::randomString(8);
?>
<div id="accordion">
<div id="accordion" class="<?= !empty($class) ? $class : '' ?>">
<div class="card">
<div class="card-header" id="heading-<?= $randomId ?>">
<h5 class="mb0"><a href="#" class="btn btn-link" data-toggle="collapse" data-target="#view-child-<?= $randomId ?>" aria-expanded="true" aria-controls="collapseOne"><?= h($title) ?></a></h5>

View File

@ -1,4 +1,4 @@
<div class="modal-dialog <?= empty($class) ? '' : h($class) ?>" role="document">
<div class="modal-dialog <?= empty($class) ? '' : h($class) ?>" <?= !empty($staticBackdrop) ? 'data-backdrop="static"' : ''?> role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><?= h($title) ?></h5>
@ -10,8 +10,10 @@
<?= $body ?>
</div>
<div class="modal-footer">
<?php if (empty($noCancel)): ?>
<button type="button" class="btn btn-secondary cancel-button" data-dismiss="modal"><?= __('Cancel') ?></button>
<?php endif; ?>
<?= $actionButton ?>
<button type="button" class="btn btn-secondary cancel-button" data-dismiss="modal"><?= __('Cancel') ?></button>
</div>
</div>
</div>

View File

@ -35,7 +35,7 @@ if (isset($menu[$metaGroup])) {
}
$active = ($scope === $this->request->getParam('controller') && $action === $this->request->getParam('action'));
if (!empty($data['popup'])) {
$link_template = '<a href="#" onClick="populateAndLoadModal(\'%s\')" class="list-group-item list-group-item-action %s %s pl-3 border-0 %s">%s</a>';
$link_template = '<a href="#" onClick="UI.openModalFromURL(\'%s\')" class="list-group-item list-group-item-action %s %s pl-3 border-0 %s">%s</a>';
} else {
$link_template = '<a href="%s" class="list-group-item list-group-item-action %s %s pl-3 border-0 %s">%s</a>';
}

View File

@ -7,13 +7,17 @@
</button>
</div>
<div class="modal-body">
<p><?= __('Are you sure you want to delete {0} #{1}?', h(Cake\Utility\Inflector::singularize($this->request->getParam('controller'))), h($id)) ?></p>
<?php if (empty($deletionText)): ?>
<p><?= __('Are you sure you want to delete {0} #{1}?', h(Cake\Utility\Inflector::singularize($this->request->getParam('controller'))), h($id)) ?></p>
<?php else: ?>
<p><?= h($deletionText) ?></p>
<?php endif; ?>
</div>
<div class="modal-footer">
<?= $this->Form->postLink(
'Delete',
['action' => 'delete', $id],
['class' => 'btn btn-primary button-execute']
(empty($postLinkParameters) ? ['action' => 'delete', $id] : $postLinkParameters),
['class' => 'btn btn-primary button-execute', 'id' => 'submitButton']
)
?>
<button type="button" class="btn btn-secondary cancel-button" data-dismiss="modal"><?= __('Cancel') ?></button>

View File

@ -0,0 +1 @@
<?= $this->Form->postLink(__('Toggle'), ['action' => 'toggle', $entity->id, $fieldName], ['confirm' => __('Are you sure you want to toggle {0} of {1}?', $fieldName. $entity->id)]) ?>

View File

@ -39,9 +39,12 @@ $cakeDescription = 'Cerebrate';
<?= $this->Html->script('popper.min.js') ?>
<?= $this->Html->script('bootstrap.bundle.js') ?>
<?= $this->Html->script('main.js') ?>
<?= $this->Html->script('bootstrap-helper.js') ?>
<?= $this->Html->script('api-helper.js') ?>
<?= $this->fetch('meta') ?>
<?= $this->fetch('css') ?>
<?= $this->fetch('script') ?>
<?= $this->Html->css('bootstrap-additional.css') ?>
<?= $this->Html->meta('favicon.ico', '/img/favicon.ico', ['type' => 'icon']); ?>
</head>
<body>
@ -63,5 +66,10 @@ $cakeDescription = 'Cerebrate';
</div>
</main>
<div id="mainModal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="mediumModalLabel" aria-hidden="true"></div>
<div id="mainToastContainer" style="position: absolute; top: 15px; right: 15px; z-index: 1080"></div>
<div id="mainModalContainer"></div>
</body>
<script>
const darkMode = (<?= empty($darkMode) ? 'false' : 'true' ?>)
</script>
</html>

View File

@ -0,0 +1,68 @@
/* Toast */
.toast {
min-width: 250px;
}
.toast-primary {
color: #004085;
background-color: #cce5ff;
border-color: #b8daff;
}
.toast-primary strong {
color: #002752;
}
.toast-secondary {
color: #383d41;
background-color: #e2e3e5;
border-color: #d6d8db;
}
.toast-secondary strong {
color: #202326;
}
.toast-success {
color: #155724 !important;
background-color: #d4edda !important;
border-color: #c3e6cb !important;
}
.toast-success strong {
color: #0b2e13;
}
.toast-info {
color: #0c5460;
background-color: #d1ecf1;
border-color: #bee5eb;
}
.toast-info strong {
color: #062c33;
}
.toast-warning {
color: #856404;
background-color: #fff3cd;
border-color: #ffeeba;
}
.toast-warning strong {
color: #533f03;
}
.toast-danger {
color: #721c24;
background-color: #f8d7da;
border-color: #f5c6cb;
}
.toast-danger strong {
color: #491217;
}
.toast-light {
color: #818182;
background-color: #fefefe;
border-color: #fdfdfe;
}
.toast-light strong {
color: #686868;
}
.toast-dark {
color: #1b1e21;
background-color: #d6d8d9;
border-color: #c6c8ca;
}
.toast-dark strong {
color: #040505;
}

View File

@ -114,3 +114,29 @@
color: inherit;
text-decoration: inherit;
}
.btn-group > .btn:last-of-type:not(.dropdown-toggle), .btn-group > .btn-group:not(:last-of-type) > .btn {
border-top-right-radius: 0.2rem;
}
.btn-group > div > .btn:not(:last-child):not(.dropdown-toggle), .btn-group > .btn-group:not(:last-child) > div > .btn {
border-top-right-radius: 0;
border-top-left-radius: 0;
}
.topbar-contextual-filter button.btn:not(:first-child) {
border-left: 1px solid #77777733;
}
input[type="checkbox"].change-cursor {
cursor: pointer;
}
input[type="checkbox"]:disabled.change-cursor {
cursor: not-allowed;
}
.topbar-contextual-filter button.btn {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}

392
webroot/js/api-helper.js Normal file
View File

@ -0,0 +1,392 @@
/** AJAXApi class providing helpers to perform AJAX request */
class AJAXApi {
static genericRequestHeaders = {
'X-Requested-With': 'XMLHttpRequest'
};
static genericRequestConfigGET = {
headers: new Headers(Object.assign({}, AJAXApi.genericRequestHeaders))
}
static genericRequestConfigPOST = {
headers: new Headers(Object.assign({}, AJAXApi.genericRequestHeaders)),
redirect: 'manual',
method: 'POST',
}
static genericRequestConfigGETJSON = {
headers: new Headers(Object.assign({}, AJAXApi.genericRequestHeaders, {Accept: 'application/json'}))
}
/**
* @namespace
* @property {boolean} provideFeedback - Should a toast be used to provide feedback upon request fulfillment
* @property {(jQuery|string)} statusNode - The node on which the loading overlay should be placed (OverlayFactory.node)
* @property {Object} statusNodeOverlayConfig - The configuration (OverlayFactory.options) of the overlay applied on the status node
* @property {Object} errorToastOptions - The options supported by Toaster#defaultOptions
* @property {Object} successToastOptions - The options supported by Toaster#defaultOptions
*/
static defaultOptions = {
provideFeedback: true,
statusNode: false,
statusNodeOverlayConfig: {},
errorToastOptions: {
delay: 10000
},
successToastOptions: {
},
}
options = {}
loadingOverlay = false
/**
* Instantiate an AJAXApi object.
* @param {Object} options - The options supported by AJAXApi#defaultOptions
*/
constructor(options) {
this.mergeOptions(AJAXApi.defaultOptions)
this.mergeOptions(options)
}
/**
* Based on the current configuration, provide feedback to the user via toast, console or do not
* @param {Object} toastOptions - The options supported by Toaster#defaultOptions
* @param {boolean} isError - If true and toast feedback is disable, write the feedback in the console
* @param {boolean} skip - If true, skip the feedback regardless of the configuration
*/
provideFeedback(toastOptions, isError=false, skip=false) {
const alteredToastOptions = Object.assign(
{},
isError ? AJAXApi.defaultOptions.errorToastOptions : AJAXApi.defaultOptions.successToastOptions,
toastOptions
)
if (!skip) {
if (this.options.provideFeedback) {
UI.toast(alteredToastOptions)
} else {
if (isError) {
console.error(alteredToastOptions.body)
}
}
}
}
/**
* Merge newOptions configuration into the current object
* @param {Object} The options supported by AJAXApi#defaultOptions
*/
mergeOptions(newOptions) {
this.options = Object.assign({}, this.options, newOptions)
}
/**
*
* @param {FormData} formData - The data of a form
* @param {Object} dataToMerge - Data to be merge into formData
* @return {FormData} The form data merged with the additional dataToMerge data
*/
static mergeFormData(formData, dataToMerge) {
for (const [fieldName, value] of Object.entries(dataToMerge)) {
formData.set(fieldName, value)
}
return formData
}
/**
* @param {string} url - The URL to fetch
* @param {Object} [options={}] - The options supported by AJAXApi#defaultOptions
* @return {Promise<string>} Promise object resolving to the fetched HTML
*/
static async quickFetchURL(url, options={}) {
const constAlteredOptions = Object.assign({}, {provideFeedback: false}, options)
const tmpApi = new AJAXApi(constAlteredOptions)
return tmpApi.fetchURL(url, constAlteredOptions.skipRequestHooks)
}
/**
* @param {string} url - The URL to fetch
* @param {Object} [options={}] - The options supported by AJAXApi#defaultOptions
* @return {Promise<Object>} Promise object resolving to the fetched HTML
*/
static async quickFetchJSON(url, options={}) {
const constAlteredOptions = Object.assign({}, {provideFeedback: false}, options)
const tmpApi = new AJAXApi(constAlteredOptions)
return tmpApi.fetchJSON(url, constAlteredOptions.skipRequestHooks)
}
/**
* @param {string} url - The URL to fetch
* @param {Object} [options={}] - The options supported by AJAXApi#defaultOptions
* @return {Promise<HTMLFormElement>} Promise object resolving to the fetched form
*/
static async quickFetchForm(url, options={}) {
const constAlteredOptions = Object.assign({}, {provideFeedback: false}, options)
const tmpApi = new AJAXApi(constAlteredOptions)
return tmpApi.fetchForm(url, constAlteredOptions.skipRequestHooks)
}
/**
* @param {HTMLFormElement} form - The form to be posted
* @param {Object} [dataToMerge={}] - Additional data to be integrated or modified in the form
* @param {Object} [options={}] - The options supported by AJAXApi#defaultOptions
* @return {Promise<Object>} Promise object resolving to the result of the POST operation
*/
static async quickPostForm(form, dataToMerge={}, options={}) {
const constAlteredOptions = Object.assign({}, {}, options)
const tmpApi = new AJAXApi(constAlteredOptions)
return tmpApi.postForm(form, dataToMerge, constAlteredOptions.skipRequestHooks)
}
/**
* @param {string} url - The URL from which to fetch the form
* @param {Object} [dataToMerge={}] - Additional data to be integrated or modified in the form
* @return {Promise<Object>} Promise object resolving to the result of the POST operation
*/
static async quickFetchAndPostForm(url, dataToMerge={}, options={}) {
const constAlteredOptions = Object.assign({}, {}, options)
const tmpApi = new AJAXApi(constAlteredOptions)
return tmpApi.fetchAndPostForm(url, dataToMerge, constAlteredOptions.skipRequestHooks)
}
/**
* @param {string} url - The URL to fetch
* @param {boolean} [skipRequestHooks=false] - If true, default request hooks will be skipped
* @param {boolean} [skipFeedback=false] - Pass this value to the AJAXApi.provideFeedback function
* @return {Promise<string>} Promise object resolving to the fetched HTML
*/
async fetchURL(url, skipRequestHooks=false, skipFeedback=false) {
if (!skipRequestHooks) {
this.beforeRequest()
}
let toReturn
try {
const response = await fetch(url, AJAXApi.genericRequestConfigGET);
if (!response.ok) {
throw new Error(`Network response was not ok. \`${response.statusText}\``)
}
const dataHtml = await response.text();
this.provideFeedback({
variant: 'success',
title: 'URL fetched',
}, false, skipFeedback);
toReturn = dataHtml;
} catch (error) {
this.provideFeedback({
variant: 'danger',
title: 'There has been a problem with the operation',
body: error.message
}, true, skipFeedback);
toReturn = Promise.reject(error);
} finally {
if (!skipRequestHooks) {
this.afterRequest()
}
}
return toReturn
}
/**
* @param {string} url - The URL to fetch
* @param {boolean} [skipRequestHooks=false] - If true, default request hooks will be skipped
* @param {boolean} [skipFeedback=false] - Pass this value to the AJAXApi.provideFeedback function
* @return {Promise<string>} Promise object resolving to the fetched JSON
*/
async fetchJSON(url, skipRequestHooks=false, skipFeedback=false) {
if (!skipRequestHooks) {
this.beforeRequest()
}
let toReturn
try {
const response = await fetch(url, AJAXApi.genericRequestConfigGETJSON);
if (!response.ok) {
throw new Error(`Network response was not ok. \`${response.statusText}\``)
}
const dataJson = await response.json();
this.provideFeedback({
variant: 'success',
title: 'URL fetched',
}, false, skipFeedback);
toReturn = dataJson;
} catch (error) {
this.provideFeedback({
variant: 'danger',
title: 'There has been a problem with the operation',
body: error.message
}, true, skipFeedback);
toReturn = Promise.reject(error);
} finally {
if (!skipRequestHooks) {
this.afterRequest()
}
}
return toReturn
}
/**
* @param {string} url - The URL to fetch
* @param {boolean} [skipRequestHooks=false] - If true, default request hooks will be skipped
* @param {boolean} [skipFeedback=false] - Pass this value to the AJAXApi.provideFeedback function
* @return {Promise<HTMLFormElement>} Promise object resolving to the fetched HTML
*/
async fetchForm(url, skipRequestHooks=false, skipFeedback=false) {
if (!skipRequestHooks) {
this.beforeRequest()
}
let toReturn
try {
const response = await fetch(url, AJAXApi.genericRequestConfigGET);
if (!response.ok) {
throw new Error(`Network response was not ok. \`${response.statusText}\``)
}
const formHtml = await response.text();
let tmpNode = document.createElement("div");
tmpNode.innerHTML = formHtml;
let form = tmpNode.getElementsByTagName('form');
if (form.length == 0) {
throw new Error('The server did not return a form element')
}
toReturn = form[0];
} catch (error) {
this.provideFeedback({
variant: 'danger',
title: 'There has been a problem with the operation',
body: error.message
}, true, skipFeedback);
toReturn = Promise.reject(error);
} finally {
if (!skipRequestHooks) {
this.afterRequest()
}
}
return toReturn
}
/**
* @param {HTMLFormElement} form - The form to be posted
* @param {Object} [dataToMerge={}] - Additional data to be integrated or modified in the form
* @param {boolean} [skipRequestHooks=false] - If true, default request hooks will be skipped
* @param {boolean} [skipFeedback=false] - Pass this value to the AJAXApi.provideFeedback function
* @return {Promise<Object>} Promise object resolving to the result of the POST operation
*/
async postForm(form, dataToMerge={}, skipRequestHooks=false, skipFeedback=false) {
if (!skipRequestHooks) {
this.beforeRequest()
}
let toReturn
let feedbackShown = false
try {
try {
let formData = new FormData(form)
formData = AJAXApi.mergeFormData(formData, dataToMerge)
let requestConfig = AJAXApi.genericRequestConfigPOST
let options = {
...requestConfig,
body: formData,
};
const response = await fetch(form.action, options);
if (!response.ok) {
throw new Error(`Network response was not ok. \`${response.statusText}\``)
}
const clonedResponse = response.clone()
try {
const data = await response.json()
if (data.success) {
this.provideFeedback({
variant: 'success',
body: data.message
}, false, skipFeedback);
toReturn = data;
} else {
this.provideFeedback({
variant: 'danger',
title: 'There has been a problem with the operation',
body: data.message
}, true, skipFeedback);
feedbackShown = true
this.injectFormValidationFeedback(form, data.errors)
toReturn = Promise.reject(data.errors);
}
} catch (error) {
this.provideFeedback({
variant: 'danger',
title: 'There has been a problem with the operation',
body: error.message
}, true, feedbackShown);
toReturn = Promise.reject(error);
}
} catch (error) {
this.provideFeedback({
variant: 'danger',
title: 'There has been a problem with the operation',
body: error.message
}, true, feedbackShown);
toReturn = Promise.reject(error);
}
} catch (error) {
toReturn = Promise.reject(error);
} finally {
if (!skipRequestHooks) {
this.afterRequest()
}
}
return toReturn
}
/**
* @param {string} url - The URL from which to fetch the form
* @param {Object} [dataToMerge={}] - Additional data to be integrated or modified in the form
* @param {boolean} [skipRequestHooks=false] - If true, default request hooks will be skipped
* @return {Promise<Object>} Promise object resolving to the result of the POST operation
*/
async fetchAndPostForm(url, dataToMerge={}, skipRequestHooks=false) {
if (!skipRequestHooks) {
this.beforeRequest()
}
let toReturn
try {
const form = await this.fetchForm(url, true, true);
toReturn = await this.postForm(form, dataToMerge, true, true)
} catch (error) {
toReturn = Promise.reject(error);
} finally {
if (!skipRequestHooks) {
this.afterRequest()
}
}
return toReturn
}
/**
* @param {HTMLFormElement} form - The form form which the POST operation is coming from
* @param {Object} [validationErrors={}] - Validation errors reported by the server
*/
injectFormValidationFeedback(form, validationErrors) {
const formHelper = new FormValidationHelper(form)
formHelper.injectValidationErrors(validationErrors)
}
/** Based on the configuration, show the loading overlay */
beforeRequest() {
if (this.options.statusNode !== false) {
this.toggleLoading(true)
}
}
/** Based on the configuration, hide the loading overlay */
afterRequest() {
if (this.options.statusNode !== false) {
this.toggleLoading(false)
}
}
/** Show or hide the loading overlay */
toggleLoading(loading) {
if (this.loadingOverlay === false) {
this.loadingOverlay = new OverlayFactory(this.options.statusNode, this.options.statusNodeOverlayConfig);
}
if (loading) {
this.loadingOverlay.show()
} else {
this.loadingOverlay.hide()
}
}
}

View File

@ -0,0 +1,918 @@
/** Class containing common UI functionalities */
class UIFactory {
/**
* Create and display a toast
* @param {Object} options - The options to be passed to the Toaster class
* @return {Object} The Toaster object
*/
toast(options) {
const theToast = new Toaster(options);
theToast.makeToast()
theToast.show()
return theToast
}
/**
* Create and display a modal
* @param {Object} options - The options to be passed to the ModalFactory class
* @return {Object} The ModalFactory object
*/
modal(options) {
const theModal = new ModalFactory(options);
theModal.makeModal()
theModal.show()
return theModal
}
/**
* Create and display a modal where the modal's content is fetched from the provided URL.
* @param {string} url - The URL from which the modal's content should be fetched
* @param {ModalFactory~POSTSuccessCallback} POSTSuccessCallback - The callback that handles successful form submission
* @param {ModalFactory~POSTFailCallback} POSTFailCallback - The callback that handles form submissions errors and validation errors.
* @return {Promise<Object>} Promise object resolving to the ModalFactory object
*/
modalFromURL(url, POSTSuccessCallback, POSTFailCallback) {
return AJAXApi.quickFetchURL(url).then((modalHTML) => {
const theModal = new ModalFactory({
rawHtml: modalHTML,
POSTSuccessCallback: POSTSuccessCallback !== undefined ? POSTSuccessCallback : () => {},
POSTFailCallback: POSTFailCallback !== undefined ? POSTFailCallback : (errorMessage) => {},
});
theModal.makeModal(modalHTML)
theModal.show()
theModal.$modal.data('modalObject', theModal)
return theModal
})
}
/**
* Creates and displays a modal where the modal's content is fetched from the provided URL. Reloads the table after a successful operation and handles displayOnSuccess option
* @param {string} url - The URL from which the modal's content should be fetched
* @param {string} tableId - The table ID which should be reloaded on success
* @return {Promise<Object>} Promise object resolving to the ModalFactory object
*/
openModalFromURL(url, reloadUrl=false, tableId=false) {
return UI.modalFromURL(url, (data) => {
let reloaded = false
if (reloadUrl === false || tableId === false) { // Try to get information from the DOM
let $elligibleTable = $('table.table')
let currentModel = location.pathname.split('/')[1]
if ($elligibleTable.length == 1 && currentModel.length > 0) {
let $container = $elligibleTable.closest('div[id^="table-container-"]')
if ($container.length == 1) {
UI.reload(`/${currentModel}/index`, $container, $elligibleTable)
reloaded = true
} else {
$container = $elligibleTable.closest('div[id^="single-view-table-container-"]')
if ($container.length == 1) {
UI.reload(location.pathname, $container, $elligibleTable)
reloaded = true
}
}
}
} else {
UI.reload(reloadUrl, $(`#table-container-${tableId}`), $(`#table-container-${tableId} table.table`))
reloaded = true
}
if (data.additionalData !== undefined && data.additionalData.displayOnSuccess !== undefined) {
UI.modal({
rawHtml: data.additionalData.displayOnSuccess
})
} else {
if (!reloaded) {
location.reload()
}
}
})
}
/**
* Fetch HTML from the provided URL and override the $container's content. $statusNode allows to specify another HTML node to display the loading
* @param {string} url - The URL from which the $container's content should be fetched
* @param {(jQuery|string)} $container - The container that should hold the data fetched
* @param {(jQuery|string)} [$statusNode=null] - A reference to a HTML node on which the loading animation should be displayed. If not provided, $container will be used
* @param {array} [additionalStatusNodes=[]] - A list of other node on which to apply overlay. Must contain the node and possibly the overlay configuration
* @return {Promise<jQuery>} Promise object resolving to the $container object after its content has been replaced
*/
reload(url, $container, $statusNode=null, additionalStatusNodes=[]) {
$container = $($container)
$statusNode = $($statusNode)
if (!$statusNode) {
$statusNode = $container
}
const otherStatusNodes = []
additionalStatusNodes.forEach(otherStatusNode => {
const loadingOverlay = new OverlayFactory(otherStatusNode.node, otherStatusNode.config)
loadingOverlay.show()
otherStatusNodes.push(loadingOverlay)
})
return AJAXApi.quickFetchURL(url, {
statusNode: $statusNode[0],
}).then((theHTML) => {
$container.replaceWith(theHTML)
return $container
}).finally(() => {
otherStatusNodes.forEach(overlay => {
overlay.hide()
})
})
}
/**
* Place an overlay onto a node and remove it whenever the promise resolves
* @param {(jQuery|string)} node - The node on which the overlay should be placed
* @param {Promise} promise - A promise to be fulfilled
* @param {Object} [overlayOptions={} - The options to be passed to the overlay class
* @return {Promise} Result of the passed promised
*/
overlayUntilResolve(node, promise, overlayOptions={}) {
const $node = $(node)
const loadingOverlay = new OverlayFactory($node[0], overlayOptions);
loadingOverlay.show()
promise.finally(() => {
loadingOverlay.hide()
})
return promise
}
}
/** Class representing a Toast */
class Toaster {
/**
* Create a Toast.
* @param {Object} options - The options supported by Toaster#defaultOptions
*/
constructor(options) {
this.options = Object.assign({}, Toaster.defaultOptions, options)
this.bsToastOptions = {
autohide: this.options.autohide,
delay: this.options.delay,
}
}
/**
* @namespace
* @property {number} id - The ID to be used for the toast's container
* @property {string} title - The title's content of the toast
* @property {string} muted - The muted's content of the toast
* @property {string} body - The body's content of the toast
* @property {string=('primary'|'secondary'|'success'|'danger'|'warning'|'info'|'light'|'dark'|'white'|'transparent')} variant - The variant of the toast
* @property {boolean} autohide - If the toast show be hidden after some time defined by the delay
* @property {number} delay - The number of milliseconds the toast should stay visible before being hidden
* @property {string} titleHtml - The raw HTML title's content of the toast
* @property {string} mutedHtml - The raw HTML muted's content of the toast
* @property {string} bodyHtml - The raw HTML body's content of the toast
* @property {boolean} closeButton - If the toast's title should include a close button
*/
static defaultOptions = {
id: false,
title: false,
muted: false,
body: false,
variant: 'default',
autohide: true,
delay: 5000,
titleHtml: false,
mutedHtml: false,
bodyHtml: false,
closeButton: true,
}
/** Create the HTML of the toast and inject it into the DOM */
makeToast() {
if (this.isValid()) {
this.$toast = Toaster.buildToast(this.options)
$('#mainToastContainer').append(this.$toast)
}
}
/** Display the toast to the user and remove it from the DOM once it get hidden */
show() {
if (this.isValid()) {
var that = this
this.$toast.toast(this.bsToastOptions)
.toast('show')
.on('hidden.bs.toast', function () {
that.removeToast()
})
}
}
/** Remove the toast from the DOM */
removeToast() {
this.$toast.remove();
}
/**
* Check wheter a toast is valid
* @return {boolean} Return true if the toast contains at least data to be rendered
*/
isValid() {
return this.options.title !== false || this.options.titleHtml !== false ||
this.options.muted !== false || this.options.mutedHtml !== false ||
this.options.body !== false || this.options.bodyHtml !== false
}
/**
* Build the toast HTML
* @param {Object} options - The options supported by Toaster#defaultOptions to build the toast
* @return {jQuery} The toast jQuery object
*/
static buildToast(options) {
var $toast = $('<div class="toast" role="alert" aria-live="assertive" aria-atomic="true"/>')
if (options.id !== false) {
$toast.attr('id', options.id)
}
$toast.addClass('toast-' + options.variant)
if (options.title !== false || options.titleHtml !== false || options.muted !== false || options.mutedHtml !== false) {
var $toastHeader = $('<div class="toast-header"/>')
$toastHeader.addClass('toast-' + options.variant)
if (options.title !== false || options.titleHtml !== false) {
var $toastHeaderText
if (options.titleHtml !== false) {
$toastHeaderText = $('<div class="mr-auto"/>').html(options.titleHtml);
} else {
$toastHeaderText = $('<strong class="mr-auto"/>').text(options.title)
}
$toastHeader.append($toastHeaderText)
}
if (options.muted !== false || options.mutedHtml !== false) {
var $toastHeaderMuted
if (options.mutedHtml !== false) {
$toastHeaderMuted = $('<div/>').html(options.mutedHtml)
} else {
$toastHeaderMuted = $('<small class="text-muted"/>').text(options.muted)
}
$toastHeader.append($toastHeaderMuted)
}
if (options.closeButton) {
var $closeButton = $('<button type="button" class="ml-2 mb-1 close" data-dismiss="toast" aria-label="Close"><span aria-hidden="true">&times;</span></button>')
$toastHeader.append($closeButton)
}
$toast.append($toastHeader)
}
if (options.body !== false || options.bodyHtml !== false) {
var $toastBody
if (options.bodyHtml !== false) {
$toastBody = $('<div class="toast-body"/>').html(options.mutedHtml)
} else {
$toastBody = $('<div class="toast-body"/>').text(options.body)
}
$toast.append($toastBody)
}
return $toast
}
}
/** Class representing a Modal */
class ModalFactory {
/**
* Create a Modal.
* @param {Object} options - The options supported by ModalFactory#defaultOptions
*/
constructor(options) {
this.options = Object.assign({}, ModalFactory.defaultOptions, options)
if (this.options.rawHtml && options.POSTSuccessCallback !== undefined) {
this.attachSubmitButtonListener = true
}
if (options.type === undefined && options.cancel !== undefined) {
this.options.type = 'confirm'
}
this.bsModalOptions = {
show: true
}
if (this.options.backdropStatic) {
this.bsModalOptions['backdrop'] = 'static'
}
}
/**
* @callback ModalFactory~closeModalFunction
*/
/**
* @callback ModalFactory~confirm
* @param {ModalFactory~closeModalFunction} closeFunction - A function that will close the modal if called
* @param {Object} modalFactory - The instance of the ModalFactory
* @param {Object} evt - The event that triggered the confirm operation
*/
/**
* @callback ModalFactory~cancel
* @param {ModalFactory~closeModalFunction} closeFunction - A function that will close the modal if called
* @param {Object} modalFactory - The instance of the ModalFactory
* @param {Object} evt - The event that triggered the confirm operation
*/
/**
* @callback ModalFactory~APIConfirm
* @param {AJAXApi} ajaxApi - An instance of the AJAXApi with the AJAXApi.statusNode linked to the modal confirm button
*/
/**
* @callback ModalFactory~APIError
* @param {ModalFactory~closeModalFunction} closeFunction - A function that will close the modal if called
* @param {Object} modalFactory - The instance of the ModalFactory
* @param {Object} evt - The event that triggered the confirm operation
*/
/**
* @callback ModalFactory~shownCallback
* @param {Object} modalFactory - The instance of the ModalFactory
*/
/**
* @callback ModalFactory~hiddenCallback
* @param {Object} modalFactory - The instance of the ModalFactory
*/
/**
* @callback ModalFactory~POSTSuccessCallback
* @param {Object} data - The data received from the successful POST operation
*/
/**
* @callback ModalFactory~POSTFailCallback
* @param {string} errorMessage
*/
/**
* @namespace
* @property {number} id - The ID to be used for the modal's container
* @property {string=('sm'|'lg'|'xl'|'')} size - The size of the modal
* @property {boolean} centered - Should the modal be vertically centered
* @property {boolean} scrollable - Should the modal be scrollable
* @property {boolean} backdropStatic - When set, the modal will not close when clicking outside it.
* @property {string} title - The title's content of the modal
* @property {string} titleHtml - The raw HTML title's content of the modal
* @property {string} body - The body's content of the modal
* @property {string} bodyHtml - The raw HTML body's content of the modal
* @property {string} rawHtml - The raw HTML of the whole modal. If provided, will override any other content
* @property {string=('primary'|'secondary'|'success'|'danger'|'warning'|'info'|'light'|'dark'|'white'|'transparent')} variant - The variant of the modal
* @property {string} modalClass - Classes to be added to the modal's container
* @property {string} headerClass - Classes to be added to the modal's header
* @property {string} bodyClass - Classes to be added to the modal's body
* @property {string} footerClass - Classes to be added to the modal's footer
* @property {string=('ok-only','confirm','confirm-success','confirm-warning','confirm-danger')} type - Pre-configured template covering most use cases
* @property {string} confirmText - The text to be placed in the confirm button
* @property {string} cancelText - The text to be placed in the cancel button
* @property {boolean} closeManually - If true, the modal will be closed automatically whenever a footer's button is pressed
* @property {boolean} closeOnSuccess - If true, the modal will be closed if the $FILL_ME operation is successful
* @property {ModalFactory~confirm} confirm - The callback that should be called if the user confirm the modal
* @property {ModalFactory~cancel} cancel - The callback that should be called if the user cancel the modal
* @property {ModalFactory~APIConfirm} APIConfirm - The callback that should be called if the user confirm the modal. Behaves like the confirm option but provides an AJAXApi object that can be used to issue requests
* @property {ModalFactory~APIError} APIError - The callback called if the APIConfirm callback fails.
* @property {ModalFactory~shownCallback} shownCallback - The callback that should be called whenever the modal is shown
* @property {ModalFactory~hiddenCallback} hiddenCallback - The callback that should be called whenever the modal is hiddenAPIConfirm
* @property {ModalFactory~POSTSuccessCallback} POSTSuccessCallback - The callback that should be called if the POST operation has been a success
* @property {ModalFactory~POSTFailCallback} POSTFailCallback - The callback that should be called if the POST operation has been a failure (Either the request failed or the form validation did not pass)
*/
static defaultOptions = {
id: false,
size: 'md',
centered: false,
scrollable: false,
backdropStatic: false,
title: '',
titleHtml: false,
body: false,
bodyHtml: false,
rawHtml: false,
variant: '',
modalClass: '',
headerClass: '',
bodyClass: '',
footerClass: '',
type: 'ok-only',
confirmText: 'Confirm',
cancelText: 'Cancel',
closeManually: false,
closeOnSuccess: true,
confirm: function() {},
cancel: function() {},
APIConfirm: null,
APIError: function() {},
shownCallback: function() {},
hiddenCallback: function() {},
POSTSuccessCallback: function() {},
POSTFailCallback: function() {},
}
static availableType = [
'ok-only',
'confirm',
'confirm-success',
'confirm-warning',
'confirm-danger',
]
static closeButtonHtml = '<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>'
/** Create the HTML of the modal and inject it into the DOM */
makeModal() {
if (this.isValid()) {
this.$modal = this.buildModal()
$('#mainModalContainer').append(this.$modal)
} else {
console.log('Modal not valid')
}
}
/** Display the modal and remove it form the DOM once it gets hidden */
show() {
if (this.isValid()) {
var that = this
this.$modal.modal(this.bsModalOptions)
.on('hidden.bs.modal', function () {
that.removeModal()
that.options.hiddenCallback(that)
})
.on('shown.bs.modal', function () {
that.options.shownCallback(that)
if (that.attachSubmitButtonListener) {
that.findSubmitButtonAndAddListener()
}
})
} else {
console.log('Modal not valid')
}
}
/** Hide the modal using the bootstrap modal's hide command */
hide() {
this.$modal.modal('hide')
}
/** Remove the modal from the DOM */
removeModal() {
this.$modal.remove();
}
/**
* Check wheter a modal is valid
* @return {boolean} Return true if the modal contains at least data to be rendered
*/
isValid() {
return this.options.title !== false || this.options.titleHtml !== false ||
this.options.body !== false || this.options.bodyHtml !== false ||
this.options.rawHtml !== false
}
/**
* Build the modal HTML
* @return {jQuery} The modal jQuery object
*/
buildModal() {
const $modal = $('<div class="modal fade" tabindex="-1" aria-hidden="true"/>')
if (this.options.id !== false) {
$modal.attr('id', this.options.id)
$modal.attr('aria-labelledby', this.options.id)
}
if (this.options.modalClass) {
$modal.addClass(this.options.modalClass)
}
let $modalDialog
if (this.options.rawHtml) {
$modalDialog = $(this.options.rawHtml)
if ($modalDialog.data('backdrop') == 'static') {
this.bsModalOptions['backdrop'] = 'static'
}
} else {
$modalDialog = $('<div class="modal-dialog"/>')
const $modalContent = $('<div class="modal-content"/>')
if (this.options.title !== false || this.options.titleHtml !== false) {
const $modalHeader = $('<div class="modal-header"/>')
if (this.options.headerClass) {
$modalHeader.addClass(this.options.headerClass)
}
let $modalHeaderText
if (this.options.titleHtml !== false) {
$modalHeaderText = $('<div/>').html(this.options.titleHtml);
} else {
$modalHeaderText = $('<h5 class="modal-title"/>').text(this.options.title)
}
$modalHeader.append($modalHeaderText, ModalFactory.getCloseButton())
$modalContent.append($modalHeader)
}
if (this.options.body !== false || this.options.bodyHtml !== false) {
const $modalBody = $('<div class="modal-body"/>')
if (this.options.bodyClass) {
$modalBody.addClass(this.options.bodyClass)
}
let $modalBodyText
if (this.options.bodyHtml !== false) {
$modalBodyText = $('<div/>').html(this.options.bodyHtml);
} else {
$modalBodyText = $('<div/>').text(this.options.body)
}
$modalBody.append($modalBodyText)
$modalContent.append($modalBody)
}
const $modalFooter = $('<div class="modal-footer"/>')
if (this.options.footerClass) {
$modalFooter.addClass(this.options.footerClass)
}
$modalFooter.append(this.getFooterBasedOnType())
$modalContent.append($modalFooter)
$modalDialog.append($modalContent)
}
$modal.append($modalDialog)
return $modal
}
/** Returns the correct footer data based on the provided type */
getFooterBasedOnType() {
if (this.options.type == 'ok-only') {
return this.getFooterOkOnly()
} else if (this.options.type.includes('confirm')) {
return this.getFooterConfirm()
} else {
return this.getFooterOkOnly()
}
}
/** Generate the ok-only footer type */
getFooterOkOnly() {
return [
$('<button type="button" class="btn btn-primary">OK</button>')
.attr('data-dismiss', 'modal'),
]
}
/** Generate the confirm-* footer type */
getFooterConfirm() {
let variant = this.options.type.split('-')[1]
variant = variant !== undefined ? variant : 'primary'
const $buttonCancel = $('<button type="button" class="btn btn-secondary" data-dismiss="modal"></button>')
.text(this.options.cancelText)
.click(
(evt) => {
this.options.cancel(() => { this.hide() }, this, evt)
}
)
.attr('data-dismiss', (this.options.closeManually || !this.options.closeOnSuccess) ? '' : 'modal')
const $buttonConfirm = $('<button type="button" class="btn"></button>')
.addClass('btn-' + variant)
.text(this.options.confirmText)
.click(this.getConfirmationHandlerFunction())
.attr('data-dismiss', (this.options.closeManually || this.options.closeOnSuccess) ? '' : 'modal')
return [$buttonCancel, $buttonConfirm]
}
/** Return a close button */
static getCloseButton() {
return $(ModalFactory.closeButtonHtml)
}
/** Generate the function that will be called when the user confirm the modal */
getConfirmationHandlerFunction() {
return (evt) => {
let confirmFunction = this.options.confirm
if (this.options.APIConfirm) {
const tmpApi = new AJAXApi({
statusNode: evt.target
})
confirmFunction = () => { return this.options.APIConfirm(tmpApi) }
}
let confirmResult = confirmFunction(() => { this.hide() }, this, evt)
if (confirmResult === undefined) {
this.hide()
} else {
confirmResult.then((data) => {
if (this.options.closeOnSuccess) {
this.hide()
}
})
.catch((err) => {
this.options.APIError(() => { this.hide() }, this, evt)
})
}
}
}
/** Attach the submission click listener for modals that have been generated by raw HTML */
findSubmitButtonAndAddListener(clearOnclick=true) {
const $submitButton = this.$modal.find('.modal-footer #submitButton')
const formID = $submitButton.data('form-id')
let $form
if (formID) {
$form = $(formID)
} else {
$form = this.$modal.find('form')
}
if (clearOnclick) {
$submitButton[0].removeAttribute('onclick')
}
this.options.APIConfirm = (tmpApi) => {
return tmpApi.postForm($form[0])
.then((data) => {
if (data.success) {
this.options.POSTSuccessCallback(data)
} else { // Validation error
this.injectFormValidationFeedback(form, data.errors)
return Promise.reject('Validation error');
}
})
.catch((errorMessage) => {
this.options.POSTFailCallback(errorMessage)
return Promise.reject(errorMessage);
})
}
$submitButton.click(this.getConfirmationHandlerFunction())
}
}
/** Class representing an loading overlay */
class OverlayFactory {
/**
* Create a loading overlay
* @param {(jQuery|string|HTMLButtonElement)} node - The node on which the overlay should be placed
* @param {Object} options - The options supported by OverlayFactory#defaultOptions
*/
constructor(node, options={}) {
this.node = node
this.$node = $(this.node)
if (darkMode) {
this.options = Object.assign({}, OverlayFactory.defaultOptionsDarkTheme, options)
} else {
this.options = Object.assign({}, OverlayFactory.defaultOptions, options)
}
this.options.auto = options.auto ? this.options.auto : !(options.variant || options.spinnerVariant)
if (this.options.auto) {
this.adjustOptionsBasedOnNode()
}
}
/**
* @namespace
* @property {string} text - A small text indicating the reason of the overlay
* @property {string=('primary'|'secondary'|'success'|'danger'|'warning'|'info'|'light'|'dark'|'white'|'transparent')} variant - The variant of the overlay
* @property {number} opacity - The opacity of the overlay
* @property {boolean} rounded - If the overlay should be rounded
* @property {number} auto - Whether overlay and spinner options should be adapted automatically based on the node
* @property {string=('primary'|'secondary'|'success'|'danger'|'warning'|'info'|'light'|'dark'|'white'|'transparent')} spinnerVariant - The variant of the spinner
* @property {boolean} spinnerSmall - If the spinner inside the overlay should be small
* @property {string=('border'|'grow')} spinnerSmall - If the spinner inside the overlay should be small
*/
static defaultOptionsDarkTheme = {
text: '',
variant: 'light',
opacity: 0.25,
blur: '2px',
rounded: false,
auto: true,
spinnerVariant: '',
spinnerSmall: false,
spinnerType: 'border',
fallbackBoostrapVariant: 'light'
}
static defaultOptions = {
text: '',
variant: 'light',
opacity: 0.85,
blur: '2px',
rounded: false,
auto: true,
spinnerVariant: '',
spinnerSmall: false,
spinnerType: 'border',
fallbackBoostrapVariant: ''
}
static overlayWrapper = '<div aria-busy="true" class="position-relative"/>'
static overlayContainer = '<div class="position-absolute text-nowrap" style="inset: 0px; z-index: 10;"/>'
static overlayBg = '<div class="position-absolute" style="inset: 0px;"/>'
static overlaySpinner = '<div class="position-absolute" style="top: 50%; left: 50%; transform: translateX(-50%) translateY(-50%);"><span aria-hidden="true" class=""><!----></span></div></div>'
static overlayText = '<span class="ml-1 align-text-top"></span>'
shown = false
originalNodeIndex = 0
/** Create the HTML of the overlay */
buildOverlay() {
this.$overlayWrapper = $(OverlayFactory.overlayWrapper)
this.$overlayContainer = $(OverlayFactory.overlayContainer)
this.$overlayBg = $(OverlayFactory.overlayBg)
.addClass([`bg-${this.options.variant}`, (this.options.rounded ? 'rounded' : '')])
.css('opacity', this.options.opacity)
this.$overlaySpinner = $(OverlayFactory.overlaySpinner)
this.$overlaySpinner.children().addClass(`spinner-${this.options.spinnerType}`)
if (this.options.spinnerSmall) {
this.$overlaySpinner.children().addClass(`spinner-${this.options.spinnerType}-sm`)
}
if (this.options.spinnerVariant.length > 0) {
this.$overlaySpinner.children().addClass(`text-${this.options.spinnerVariant}`)
}
if (this.options.text.length > 0) {
this.$overlayText = $(OverlayFactory.overlayText);
this.$overlayText.addClass(`text-${this.options.spinnerVariant}`)
.text(this.options.text)
this.$overlaySpinner.append(this.$overlayText)
}
}
/** Create the overlay, attach it to the DOM and display it */
show() {
this.buildOverlay()
this.mountOverlay()
this.shown = true
}
/** Hide the overlay and remove it from the DOM */
hide() {
if (this.shown) {
this.unmountOverlay()
}
this.shown = false
}
/** Attach the overlay to the DOM */
mountOverlay() {
this.originalNodeIndex = this.$node.index()
this.$overlayBg.appendTo(this.$overlayContainer)
this.$overlaySpinner.appendTo(this.$overlayContainer)
this.appendToIndex(this.$overlayWrapper, this.$node.parent(), this.originalNodeIndex)
this.$overlayContainer.appendTo(this.$overlayWrapper)
this.$node.prependTo(this.$overlayWrapper)
}
/** Remove the overlay from the DOM */
unmountOverlay() {
this.appendToIndex(this.$node, this.$overlayWrapper.parent(), this.originalNodeIndex)
this.$overlayWrapper.remove()
this.originalNodeIndex = 0
}
/** Append a node to the provided DOM index */
appendToIndex($node, $targetContainer, index) {
const $target = $targetContainer.children().eq(index);
$node.insertBefore($target);
}
/** Adjust instance's options based on the provided node */
adjustOptionsBasedOnNode() {
if (this.$node.width() < 50 || this.$node.height() < 50) {
this.options.spinnerSmall = true
}
if (this.$node.is('input[type="checkbox"]') || this.$node.css('border-radius') !== '0px') {
this.options.rounded = true
}
let classes = this.$node.attr('class')
if (classes !== undefined) {
classes = classes.split(' ')
const detectedVariant = OverlayFactory.detectedBootstrapVariant(classes, this.options.fallbackBoostrapVariant)
this.options.spinnerVariant = detectedVariant
}
}
/**
* Detect the bootstrap variant from a list of classes
* @param {Array} classes - A list of classes containg a bootstrap variant
*/
static detectedBootstrapVariant(classes, fallback=OverlayFactory.defaultOptions.fallbackBoostrapVariant) {
const re = /^[a-zA-Z]+-(?<variant>primary|success|danger|warning|info|light|dark|white|transparent)$/;
let result
for (let i=0; i<classes.length; i++) {
let theClass = classes[i]
if ((result = re.exec(theClass)) !== null) {
if (result.groups !== undefined && result.groups.variant !== undefined) {
return result.groups.variant
}
}
}
return fallback
}
}
/** Class representing a FormValidationHelper */
class FormValidationHelper {
/**
* Create a FormValidationHelper.
* @param {Object} options - The options supported by FormValidationHelper#defaultOptions
*/
constructor(form, options={}) {
this.form = form
this.options = Object.assign({}, Toaster.defaultOptions, options)
}
/**
* @namespace
*/
static defaultOptions = {
}
/**
* Create node containing validation information from validationError. If no field can be associated to the error, it will be placed on top
* @param {Object} validationErrors - The validation errors to be displayed. Keys are the fieldName that had errors, values are the error text
*/
injectValidationErrors(validationErrors) {
this.cleanValidationErrors()
for (const [fieldName, errors] of Object.entries(validationErrors)) {
this.injectValidationErrorInForm(fieldName, errors)
}
}
injectValidationErrorInForm(fieldName, errors) {
const inputField = Array.from(this.form).find(node => { return node.name == fieldName })
if (inputField !== undefined) {
const $messageNode = this.buildValidationMessageNode(errors)
const $inputField = $(inputField)
$inputField.addClass('is-invalid')
$messageNode.insertAfter($inputField)
} else {
const $messageNode = this.buildValidationMessageNode(errors, true)
const $flashContainer = $(this.form).parent().find('#flashContainer')
$messageNode.insertAfter($flashContainer)
}
}
buildValidationMessageNode(errors, isAlert=false) {
const $messageNode = $('<div></div>')
if (isAlert) {
$messageNode.addClass('alert alert-danger').attr('role', 'alert')
} else {
$messageNode.addClass('invalid-feedback')
}
const hasMultipleErrors = Object.keys(errors).length > 1
for (const [ruleName, error] of Object.entries(errors)) {
if (hasMultipleErrors) {
$messageNode.append($('<li></li>').text(error))
} else {
$messageNode.text(error)
}
}
return $messageNode
}
cleanValidationErrors() {
$(this.form).find('textarea, input, select').removeClass('is-invalid')
$(this.form).find('.invalid-feedback').remove()
$(this.form).parent().find('.alert').remove()
}
}
class HtmlHelper {
static table(head=[], body=[], options={}) {
const $table = $('<table/>')
const $thead = $('<thead/>')
const $tbody = $('<tbody/>')
$table.addClass('table')
if (options.striped) {
$table.addClass('table-striped')
}
if (options.bordered) {
$table.addClass('table-bordered')
}
if (options.borderless) {
$table.addClass('table-borderless')
}
if (options.hoverable) {
$table.addClass('table-hover')
}
if (options.small) {
$table.addClass('table-sm')
}
if (options.variant) {
$table.addClass(`table-${options.variant}`)
}
if (options.tableClass) {
$table.addClass(options.tableClass)
}
const $caption = $('<caption/>')
if (options.caption) {
if (options.caption instanceof jQuery) {
$caption = options.caption
} else {
$caption.text(options.caption)
}
}
const $theadRow = $('<tr/>')
head.forEach(head => {
if (head instanceof jQuery) {
$theadRow.append($('<td/>').append(head))
} else {
$theadRow.append($('<th/>').text(head))
}
})
$thead.append($theadRow)
body.forEach(row => {
const $bodyRow = $('<tr/>')
row.forEach(item => {
if (item instanceof jQuery) {
$bodyRow.append($('<td/>').append(item))
} else {
$bodyRow.append($('<td/>').text(item))
}
})
$tbody.append($bodyRow)
})
$table.append($caption, $thead, $tbody)
if (options.responsive) {
options.responsiveBreakpoint = options.responsiveBreakpoint !== undefined ? options.responsiveBreakpoint : ''
$table = $('<div/>').addClass(options.responsiveBreakpoint !== undefined ? `table-responsive-${options.responsiveBreakpoint}` : 'table-responsive').append($table)
}
return $table
}
}

View File

@ -1,25 +1,5 @@
function populateAndLoadModal(url) {
$.ajax({
dataType:"html",
cache: false,
success:function (data, textStatus) {
$("#mainModal").html(data);
$("#mainModal").modal('show');
},
url:url,
});
}
function executePagination(randomValue, url) {
var target = '#table-container-' + randomValue
$.ajax({
dataType:"html",
cache: false,
success:function (data, textStatus) {
$(target).html(data);
},
url:url,
});
UI.reload(url, $(`#table-container-${randomValue}`), $(`#table-container-${randomValue} table.table`))
}
function executeStateDependencyChecks(dependenceSourceSelector) {
@ -39,33 +19,56 @@ function executeStateDependencyChecks(dependenceSourceSelector) {
}
function testConnection(id) {
$.ajax({
url: '/broods/testConnection/' + id,
type: 'GET',
beforeSend: function () {
$("#connection_test_" + id).html('Running test...');
},
error: function(){
$("#connection_test_" + id).html('<span class="red bold">Internal error</span>');
},
success: function(result) {
var html = '';
if (result['error']) {
html += '<strong>Status</strong>: <span class="text-danger">OK</span> (' + $("<span>").text(result['ping']).html() + ' ms)<br />';
html += '<strong>Status</strong>: <span class="text-danger">Error: ' + result['error'] + '</span>';
html += '<strong>Reason</strong>: <span class="text-danger">' + result['reason'] + '</span>';
} else {
html += '<strong>Status</strong>: <span class="text-success">OK</span> (' + $("<span>").text(result['ping']).html() + ' ms)<br />';
html += '<strong>Remote</strong>: ' + $("<span>").text(result['response']['application']).html() + ' v' + $("<span>").text(result['response']['version']).html() + '<br />';
html += '<strong>User</strong>: ' + $("<span>").text(result['response']['user']).html() + ' (' + $("<span>").text(result['response']['role']['name']).html() + ')' + '<br />';
var canSync = result['response']['role']['perm_admin'] || result['response']['role']['perm_sync'];
if (canSync) {
html += '<strong>Sync permission</strong>: <span class="text-success">Yes</span><br />';
} else {
html += '<strong>Sync permission</strong>: <span class="text-danger">No</span><br />';
}
}
$("#connection_test_" + id).html(html);
}
$container = $(`#connection_test_${id}`)
UI.overlayUntilResolve(
$container[0],
AJAXApi.quickFetchJSON(`/broods/testConnection/${id}`),
{text: 'Running test'}
).then(result => {
const $testResult = attachTestConnectionResultHtml(result, $container)
$(`#connection_test_${id}`).append($testResult)
})
.catch((error) => {
const $testResult = attachTestConnectionResultHtml(error.message, $container)
$(`#connection_test_${id}`).append($testResult)
})
}
function attachTestConnectionResultHtml(result, $container) {
function getKVHtml(key, value, valueClasses=[], extraValue='') {
return $('<div/>').append(
$('<strong/>').text(key + ': '),
$('<span/>').addClass(valueClasses).text(value),
$('<span/>').text(extraValue.length > 0 ? ` (${extraValue})` : '')
)
}
$container.find('div.tester-result').remove()
$testResultDiv = $('<div class="tester-result"></div>');
if (typeof result !== 'object') {
$testResultDiv.append(getKVHtml('Internal error', result, ['text-danger font-weight-bold']))
} else {
if (result['error']) {
$testResultDiv.append(
getKVHtml('Status', 'OK', ['text-danger'], `${result['ping']} ms`),
getKVHtml('Status', `Error: ${result['error']}`, ['text-danger']),
getKVHtml('Reason', result['reason'], ['text-danger'])
)
} else {
const canSync = result['response']['role']['perm_admin'] || result['response']['role']['perm_sync'];
$testResultDiv.append(
getKVHtml('Status', 'OK', ['text-success'], `${result['ping']} ms`),
getKVHtml('Remote', `${result['response']['application']} v${result['response']['version']}`),
getKVHtml('User', result['response']['user'], [], result['response']['role']['name']),
getKVHtml('Sync permission', (canSync ? 'Yes' : 'No'), [(canSync ? 'text-success' : 'text-danger')]),
)
}
}
return $testResultDiv
}
var UI
$(document).ready(() => {
if (typeof UIFactory !== "undefined") {
UI = new UIFactory()
}
})