Merge branch 'ui-settings' into develop-unstable
commit
83494a6cf1
|
@ -89,9 +89,4 @@ return [
|
|||
'url' => env('EMAIL_TRANSPORT_DEFAULT_URL', null),
|
||||
],
|
||||
],
|
||||
'Cerebrate' => [
|
||||
'open' => [],
|
||||
'dark' => 0,
|
||||
'baseurl' => ''
|
||||
]
|
||||
];
|
||||
|
|
|
@ -87,6 +87,7 @@ try {
|
|||
*/
|
||||
if (file_exists(CONFIG . 'app_local.php')) {
|
||||
Configure::load('app_local', 'default');
|
||||
Configure::load('cerebrate', 'default', true);
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
return [
|
||||
'Cerebrate' => [
|
||||
'open' => [],
|
||||
'app.baseurl' => 'http://localhost:8000/',
|
||||
'app.uuid' => 'cc9b9358-7c4b-4464-9a2c-f0cb089ff974',
|
||||
'ui.dark' => 0,
|
||||
]
|
||||
];
|
|
@ -124,7 +124,7 @@ class AppController extends Controller
|
|||
$this->set('menu', $this->ACL->getMenu());
|
||||
$this->set('ajax', $this->request->is('ajax'));
|
||||
$this->request->getParam('prefix');
|
||||
$this->set('darkMode', !empty(Configure::read('Cerebrate.dark')));
|
||||
$this->set('darkMode', !empty(Configure::read('Cerebrate')['ui.dark']));
|
||||
$this->set('baseurl', Configure::read('App.fullBaseUrl'));
|
||||
}
|
||||
|
||||
|
|
|
@ -811,6 +811,10 @@ class ACLComponent extends Component
|
|||
__('Instance'),
|
||||
'url' => '/instance/home',
|
||||
'children' => [
|
||||
'settings' => [
|
||||
'url' => '/instance/settings',
|
||||
'label' => __('Settings')
|
||||
],
|
||||
'migration' => [
|
||||
'url' => '/instance/migrationIndex',
|
||||
'label' => __('Database migration')
|
||||
|
|
|
@ -7,6 +7,7 @@ use Cake\Utility\Hash;
|
|||
use Cake\Utility\Text;
|
||||
use \Cake\Database\Expression\QueryExpression;
|
||||
use Cake\Event\EventInterface;
|
||||
use Cake\Core\Configure;
|
||||
|
||||
class InstanceController extends AppController
|
||||
{
|
||||
|
@ -101,4 +102,36 @@ class InstanceController extends AppController
|
|||
$this->set('path', ['controller' => 'instance', 'action' => 'rollback']);
|
||||
$this->render('/genericTemplates/confirm');
|
||||
}
|
||||
|
||||
public function settings()
|
||||
{
|
||||
$this->Settings = $this->getTableLocator()->get('Settings');
|
||||
$all = $this->Settings->getSettings(true);
|
||||
$this->set('settingsProvider', $all['settingsProvider']);
|
||||
$this->set('settings', $all['settings']);
|
||||
$this->set('settingsFlattened', $all['settingsFlattened']);
|
||||
$this->set('notices', $all['notices']);
|
||||
}
|
||||
|
||||
public function saveSetting()
|
||||
{
|
||||
if ($this->request->is('post')) {
|
||||
$data = $this->ParamHandler->harvestParams([
|
||||
'name',
|
||||
'value'
|
||||
]);
|
||||
$this->Settings = $this->getTableLocator()->get('Settings');
|
||||
$errors = $this->Settings->saveSetting($data['name'], $data['value']);
|
||||
$message = __('Could not save setting `{0}`', $data['name']);
|
||||
if (empty($errors)) {
|
||||
$message = __('Setting `{0}` saved', $data['name']);
|
||||
$data = $this->Settings->getSetting($data['name']);
|
||||
}
|
||||
$this->CRUD->setResponseForController('saveSetting', empty($errors), $message, $data, $errors);
|
||||
$responsePayload = $this->CRUD->getResponsePayload();
|
||||
if (!empty($responsePayload)) {
|
||||
return $responsePayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,390 @@
|
|||
<?php
|
||||
namespace App\Model\Table;
|
||||
|
||||
use App\Model\Table\AppTable;
|
||||
use Cake\Validation\Validator;
|
||||
|
||||
class SettingsProviderTable extends AppTable
|
||||
{
|
||||
private $settingsConfiguration = [];
|
||||
private $error_critical = '',
|
||||
$error_warning = '',
|
||||
$error_info = '';
|
||||
private $severities = ['info', 'warning', 'critical'];
|
||||
|
||||
public function initialize(array $config): void
|
||||
{
|
||||
parent::initialize($config);
|
||||
$this->settingsConfiguration = $this->generateSettingsConfiguration();
|
||||
$this->setTable(false);
|
||||
$this->error_critical = __('Cerebrate will not operate correctly or will be unsecure until these issues are resolved.');
|
||||
$this->error_warning = __('Some of the features of Cerebrate cannot be utilised until these issues are resolved.');
|
||||
$this->error_info = __('There are some optional tweaks that could be done to improve the looks of your Cerebrate instance.');
|
||||
$this->settingValidator = new SettingValidator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Supports up to 3 levels:
|
||||
* Application -> Network -> Proxy -> Proxy.URL
|
||||
* page -> [group] -> [panel] -> setting
|
||||
* Keys of setting configuration are the actual setting name.
|
||||
* Accepted setting configuration:
|
||||
* name [required]: The human readable name of the setting.
|
||||
* type [required]: The type of the setting.
|
||||
* description [required]: A description of the setting.
|
||||
* Default severity level is `info` if a `default` value is provided otherwise it becomes `critical`
|
||||
* default [optional]: The default value of the setting if not specified in the configuration.
|
||||
* options [optional]: Used to populate the select with options. Keys are values to be saved, values are human readable version of the value.
|
||||
* Required paramter if `type` == `select`.
|
||||
* severity [optional]: Severity level of the setting if the configuration is incorrect.
|
||||
* dependsOn [optional]: If the validation of this setting depends on the validation of the provided setting name
|
||||
* test [optional]: Could be either a string or an anonymous function to be called in order to warn user if setting is invalid.
|
||||
* Could be either: `string`, `boolean`, `integer`, `select`
|
||||
* beforeSave [optional]: Could be either a string or an anonymous function to be called in order to block a setting to be saved.
|
||||
* afterSave [optional]: Could be either a string or an anonymous function to be called allowing to execute a function after the setting is saved.
|
||||
* redacted [optional]: Should the setting value be redacted. FIXME: To implement
|
||||
* cli_only [optional]: Should this setting be modified only via the CLI.
|
||||
*/
|
||||
private function generateSettingsConfiguration()
|
||||
{
|
||||
return [
|
||||
'Application' => [
|
||||
'General' => [
|
||||
'Essentials' => [
|
||||
'_description' => __('Ensentials settings required for the application to run normally.'),
|
||||
'_icon' => 'user-cog',
|
||||
'app.baseurl' => [
|
||||
'name' => __('Base URL'),
|
||||
'type' => 'string',
|
||||
'description' => __('The base url of the application (in the format https://www.mymispinstance.com or https://myserver.com/misp). Several features depend on this setting being correctly set to function.'),
|
||||
'default' => '',
|
||||
'severity' => 'critical',
|
||||
'test' => 'testBaseURL',
|
||||
],
|
||||
'app.uuid' => [
|
||||
'name' => 'UUID',
|
||||
'type' => 'string',
|
||||
'description' => __('The Cerebrate instance UUID. This UUID is used to identify this instance.'),
|
||||
'default' => '',
|
||||
'severity' => 'critical',
|
||||
'test' => 'testUuid',
|
||||
],
|
||||
],
|
||||
'Miscellaneous' => [
|
||||
'sc2.hero' => [
|
||||
'description' => 'The true hero',
|
||||
'default' => 'Sarah Kerrigan',
|
||||
'name' => 'Hero',
|
||||
'options' => [
|
||||
'Jim Raynor' => 'Jim Raynor',
|
||||
'Sarah Kerrigan' => 'Sarah Kerrigan',
|
||||
'Artanis' => 'Artanis',
|
||||
'Zeratul' => 'Zeratul',
|
||||
],
|
||||
'type' => 'select'
|
||||
],
|
||||
'sc2.antagonists' => [
|
||||
'description' => 'The bad guys',
|
||||
'default' => 'Amon',
|
||||
'name' => 'Antagonists',
|
||||
'options' => function($settingsProviders) {
|
||||
return [
|
||||
'Amon' => 'Amon',
|
||||
'Sarah Kerrigan' => 'Sarah Kerrigan',
|
||||
'Narud' => 'Narud',
|
||||
];
|
||||
},
|
||||
'severity' => 'warning',
|
||||
'type' => 'multi-select'
|
||||
],
|
||||
],
|
||||
'floating-setting' => [
|
||||
'description' => 'floaringSetting',
|
||||
// 'default' => 'A default value',
|
||||
'name' => 'Uncategorized Setting',
|
||||
// 'severity' => 'critical',
|
||||
'severity' => 'warning',
|
||||
// 'severity' => 'info',
|
||||
'type' => 'integer'
|
||||
],
|
||||
],
|
||||
'Network' => [
|
||||
'Proxy' => [
|
||||
'proxy.host' => [
|
||||
'name' => __('Host'),
|
||||
'type' => 'string',
|
||||
'description' => __('The hostname of an HTTP proxy for outgoing sync requests. Leave empty to not use a proxy.'),
|
||||
'test' => 'testHostname',
|
||||
],
|
||||
'proxy.port' => [
|
||||
'name' => __('Port'),
|
||||
'type' => 'integer',
|
||||
'description' => __('The TCP port for the HTTP proxy.'),
|
||||
'test' => 'testForRangeXY',
|
||||
],
|
||||
'proxy.user' => [
|
||||
'name' => __('User'),
|
||||
'type' => 'string',
|
||||
'description' => __('The authentication username for the HTTP proxy.'),
|
||||
'default' => 'admin',
|
||||
'dependsOn' => 'proxy.host',
|
||||
],
|
||||
'proxy.password' => [
|
||||
'name' => __('Password'),
|
||||
'type' => 'string',
|
||||
'description' => __('The authentication password for the HTTP proxy.'),
|
||||
'default' => '',
|
||||
'dependsOn' => 'proxy.host',
|
||||
],
|
||||
],
|
||||
],
|
||||
'UI' => [
|
||||
'General' => [
|
||||
'ui.dark' => [
|
||||
'name' => __('Dark theme'),
|
||||
'type' => 'boolean',
|
||||
'description' => __('Enable the dark theme of the application'),
|
||||
'default' => false,
|
||||
'test' => function() {
|
||||
return 'Fake error';
|
||||
},
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'Security' => [
|
||||
'Development' => [
|
||||
'Debugging' => [
|
||||
'security.debug' => [
|
||||
'name' => __('Debug Level'),
|
||||
'type' => 'select',
|
||||
'description' => __('The debug level of the instance'),
|
||||
'default' => 0,
|
||||
'options' => [
|
||||
0 => __('Debug Off'),
|
||||
1 => __('Debug On'),
|
||||
2 => __('Debug On + SQL Dump'),
|
||||
],
|
||||
'test' => function($value, $setting, $validator) {
|
||||
$validator->range('value', [0, 3]);
|
||||
return testValidator($value, $validator);
|
||||
},
|
||||
],
|
||||
],
|
||||
]
|
||||
],
|
||||
'Features' => [
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* getSettingsConfiguration Return the setting configuration and merge existing settings into it if provided
|
||||
*
|
||||
* @param null|array $settings - Settings to be merged in the provided setting configuration
|
||||
* @return array
|
||||
*/
|
||||
public function getSettingsConfiguration($settings = null) {
|
||||
$settingConf = $this->settingsConfiguration;
|
||||
if (!is_null($settings)) {
|
||||
$settingConf = $this->mergeSettingsIntoSettingConfiguration($settingConf, $settings);
|
||||
}
|
||||
return $settingConf;
|
||||
}
|
||||
|
||||
/**
|
||||
* mergeSettingsIntoSettingConfiguration Inject the provided settings into the configuration while performing depencency and validation checks
|
||||
*
|
||||
* @param array $settingConf the setting configuration to have the setting injected into
|
||||
* @param array $settings the settings
|
||||
* @return void
|
||||
*/
|
||||
private function mergeSettingsIntoSettingConfiguration(array $settingConf, array $settings, string $path=''): array
|
||||
{
|
||||
foreach ($settingConf as $key => $value) {
|
||||
if ($this->isSettingMetaKey($key)) {
|
||||
continue;
|
||||
}
|
||||
if ($this->isLeaf($value)) {
|
||||
if (isset($settings[$key])) {
|
||||
$settingConf[$key]['value'] = $settings[$key];
|
||||
}
|
||||
$settingConf[$key] = $this->evaluateLeaf($settingConf[$key], $settingConf);
|
||||
$settingConf[$key]['setting-path'] = $path;
|
||||
$settingConf[$key]['true-name'] = $key;
|
||||
} else {
|
||||
$currentPath = empty($path) ? $key : sprintf('%s.%s', $path, $key);
|
||||
$settingConf[$key] = $this->mergeSettingsIntoSettingConfiguration($value, $settings, $currentPath);
|
||||
}
|
||||
}
|
||||
return $settingConf;
|
||||
}
|
||||
|
||||
public function flattenSettingsConfiguration(array $settingsProvider, $flattenedSettings=[]): array
|
||||
{
|
||||
foreach ($settingsProvider as $key => $value) {
|
||||
if ($this->isSettingMetaKey($key)) {
|
||||
continue;
|
||||
}
|
||||
if ($this->isLeaf($value)) {
|
||||
$flattenedSettings[$key] = $value;
|
||||
} else {
|
||||
$flattenedSettings = $this->flattenSettingsConfiguration($value, $flattenedSettings);
|
||||
}
|
||||
}
|
||||
return $flattenedSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* getNoticesFromSettingsConfiguration Summarize the validation errors
|
||||
*
|
||||
* @param array $settingsProvider the setting configuration having setting value assigned
|
||||
* @return void
|
||||
*/
|
||||
public function getNoticesFromSettingsConfiguration(array $settingsProvider): array
|
||||
{
|
||||
$notices = [];
|
||||
foreach ($settingsProvider as $key => $value) {
|
||||
if ($this->isSettingMetaKey($key)) {
|
||||
continue;
|
||||
}
|
||||
if ($this->isLeaf($value)) {
|
||||
if (!empty($value['error'])) {
|
||||
if (empty($notices[$value['severity']])) {
|
||||
$notices[$value['severity']] = [];
|
||||
}
|
||||
$notices[$value['severity']][] = $value;
|
||||
}
|
||||
} else {
|
||||
$notices = array_merge_recursive($notices, $this->getNoticesFromSettingsConfiguration($value));
|
||||
}
|
||||
}
|
||||
return $notices;
|
||||
}
|
||||
|
||||
private function isLeaf($setting)
|
||||
{
|
||||
return !empty($setting['name']) && !empty($setting['type']);
|
||||
}
|
||||
|
||||
private function evaluateLeaf($setting, $settingSection)
|
||||
{
|
||||
$skipValidation = false;
|
||||
if ($setting['type'] == 'select' || $setting['type'] == 'multi-select') {
|
||||
if (!empty($setting['options']) && is_callable($setting['options'])) {
|
||||
$setting['options'] = $setting['options']($this);
|
||||
}
|
||||
}
|
||||
if (isset($setting['dependsOn'])) {
|
||||
$parentSetting = null;
|
||||
foreach ($settingSection as $settingSectionName => $settingSectionConfig) {
|
||||
if ($settingSectionName == $setting['dependsOn']) {
|
||||
$parentSetting = $settingSectionConfig;
|
||||
}
|
||||
}
|
||||
if (!is_null($parentSetting)) {
|
||||
$parentSetting = $this->evaluateLeaf($parentSetting, $settingSection);
|
||||
$skipValidation = $parentSetting['error'] === true || empty($parentSetting['value']);
|
||||
}
|
||||
}
|
||||
$setting['error'] = false;
|
||||
if (!$skipValidation) {
|
||||
$validationResult = true;
|
||||
if (!isset($setting['value'])) {
|
||||
$validationResult = $this->settingValidator->testEmptyBecomesDefault(null, $setting);
|
||||
} else if (isset($setting['test'])) {
|
||||
$setting['value'] = $setting['value'] ?? '';
|
||||
$validationResult = $this->evaluateFunctionForSetting($setting['test'], $setting);
|
||||
}
|
||||
if ($validationResult !== true) {
|
||||
$setting['severity'] = $setting['severity'] ?? 'warning';
|
||||
if (!in_array($setting['severity'], $this->severities)) {
|
||||
$setting['severity'] = 'warning';
|
||||
}
|
||||
$setting['errorMessage'] = $validationResult;
|
||||
}
|
||||
$setting['error'] = $validationResult !== true ? true : false;
|
||||
}
|
||||
return $setting;
|
||||
}
|
||||
|
||||
/**
|
||||
* evaluateFunctionForSetting - evaluate the provided function. If function could not be evaluated, its result is defaulted to true
|
||||
*
|
||||
* @param mixed $fun
|
||||
* @param array $setting
|
||||
* @return mixed
|
||||
*/
|
||||
public function evaluateFunctionForSetting($fun, $setting)
|
||||
{
|
||||
$functionResult = true;
|
||||
if (is_callable($fun)) { // Validate with anonymous function
|
||||
$functionResult = $fun($setting['value'], $setting, new Validator());
|
||||
} else if (method_exists($this->settingValidator, $fun)) { // Validate with function defined in settingValidator class
|
||||
$functionResult = $this->settingValidator->{$fun}($setting['value'], $setting);
|
||||
} else {
|
||||
$validator = new Validator();
|
||||
if (method_exists($validator, $fun)) { // Validate with cake's validator function
|
||||
$validator->{$fun};
|
||||
$functionResult = $validator->validate($setting['value']);
|
||||
}
|
||||
}
|
||||
return $functionResult;
|
||||
}
|
||||
|
||||
function isSettingMetaKey($key)
|
||||
{
|
||||
return substr($key, 0, 1) == '_';
|
||||
}
|
||||
}
|
||||
|
||||
function testValidator($value, $validator)
|
||||
{
|
||||
$errors = $validator->validate(['value' => $value]);
|
||||
return !empty($errors) ? implode(', ', $errors['value']) : true;
|
||||
}
|
||||
|
||||
class SettingValidator
|
||||
{
|
||||
|
||||
public function testEmptyBecomesDefault($value, &$setting)
|
||||
{
|
||||
if (!empty($value)) {
|
||||
return true;
|
||||
} else if (isset($setting['default'])) {
|
||||
$setting['value'] = $setting['default'];
|
||||
$setting['severity'] = $setting['severity'] ?? 'info';
|
||||
if ($setting['type'] == 'boolean') {
|
||||
return __('Setting is not set, fallback to default value: {0}', empty($setting['default']) ? 'false' : 'true');
|
||||
} else {
|
||||
return __('Setting is not set, fallback to default value: {0}', $setting['default']);
|
||||
}
|
||||
} else {
|
||||
$setting['severity'] = $setting['severity'] ?? 'critical';
|
||||
return __('Cannot be empty. Setting does not have a default value.');
|
||||
}
|
||||
}
|
||||
|
||||
public function testForEmpty($value, &$setting)
|
||||
{
|
||||
return !empty($value) ? true : __('Cannot be empty');
|
||||
}
|
||||
|
||||
public function testBaseURL($value, &$setting)
|
||||
{
|
||||
if (empty($value)) {
|
||||
return __('Cannot be empty');
|
||||
}
|
||||
if (!empty($value) && !preg_match('/^http(s)?:\/\//i', $value)) {
|
||||
return __('Invalid URL, please make sure that the protocol is set.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function testUuid($value, &$setting) {
|
||||
if (empty($value) || !preg_match('/^\{?[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}\}?$/', $value)) {
|
||||
return __('Invalid UUID.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
namespace App\Model\Table;
|
||||
|
||||
use App\Model\Table\AppTable;
|
||||
use Cake\ORM\Table;
|
||||
use Cake\Validation\Validator;
|
||||
use Cake\Core\Configure;
|
||||
use Cake\ORM\TableRegistry;
|
||||
|
||||
class SettingsTable extends AppTable
|
||||
{
|
||||
private static $FILENAME = 'cerebrate';
|
||||
private static $CONFIG_KEY = 'Cerebrate';
|
||||
|
||||
public function initialize(array $config): void
|
||||
{
|
||||
parent::initialize($config);
|
||||
$this->setTable(false);
|
||||
$this->SettingsProvider = TableRegistry::getTableLocator()->get('SettingsProvider');
|
||||
}
|
||||
|
||||
public function getSettings($full=false): array
|
||||
{
|
||||
$settings = $this->readSettings();
|
||||
if (empty($full)) {
|
||||
return $settings;
|
||||
} else {
|
||||
$settingsProvider = $this->SettingsProvider->getSettingsConfiguration($settings);
|
||||
$settingsFlattened = $this->SettingsProvider->flattenSettingsConfiguration($settingsProvider);
|
||||
$notices = $this->SettingsProvider->getNoticesFromSettingsConfiguration($settingsProvider, $settings);
|
||||
return [
|
||||
'settings' => $settings,
|
||||
'settingsProvider' => $settingsProvider,
|
||||
'settingsFlattened' => $settingsFlattened,
|
||||
'notices' => $notices,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
public function getSetting($name=false): array
|
||||
{
|
||||
$settings = $this->readSettings();
|
||||
$settingsProvider = $this->SettingsProvider->getSettingsConfiguration($settings);
|
||||
$settingsFlattened = $this->SettingsProvider->flattenSettingsConfiguration($settingsProvider);
|
||||
return $settingsFlattened[$name] ?? [];
|
||||
}
|
||||
|
||||
public function saveSetting(string $name, string $value): array
|
||||
{
|
||||
$errors = [];
|
||||
$setting = $this->getSetting($name);
|
||||
$value = $this->normaliseValue($value, $setting);
|
||||
if ($setting['type'] == 'select') {
|
||||
if (!in_array($value, array_keys($setting['options']))) {
|
||||
$errors[] = __('Invalid option provided');
|
||||
}
|
||||
}
|
||||
if (empty($errors) && !empty($setting['beforeSave'])) {
|
||||
$setting['value'] = $value ?? '';
|
||||
$beforeSaveResult = $this->SettingsProvider->evaluateFunctionForSetting($setting['beforeSave'], $setting);
|
||||
if ($beforeSaveResult !== true) {
|
||||
$errors[] = $beforeSaveResult;
|
||||
}
|
||||
}
|
||||
if (empty($errors)) {
|
||||
$saveResult = $this->saveSettingOnDisk($name, $value);
|
||||
if ($saveResult) {
|
||||
if (!empty($setting['afterSave'])) {
|
||||
$this->SettingsProvider->evaluateFunctionForSetting($setting['afterSave'], $setting);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
|
||||
private function normaliseValue($value, $setting)
|
||||
{
|
||||
if ($setting['type'] == 'boolean') {
|
||||
return (bool) $value;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function readSettings()
|
||||
{
|
||||
return Configure::read()[$this::$CONFIG_KEY];
|
||||
}
|
||||
|
||||
private function saveSettingOnDisk($name, $value)
|
||||
{
|
||||
$settings = $this->readSettings();
|
||||
$settings[$name] = $value;
|
||||
Configure::write($this::$CONFIG_KEY, $settings);
|
||||
Configure::dump($this::$FILENAME, 'default', [$this::$CONFIG_KEY]);
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -81,6 +81,12 @@ class BootstrapHelper extends Helper
|
|||
return $bsButton->button();
|
||||
}
|
||||
|
||||
public function icon($icon, $options=[])
|
||||
{
|
||||
$bsIcon = new BoostrapIcon($icon, $options);
|
||||
return $bsIcon->icon();
|
||||
}
|
||||
|
||||
public function badge($options)
|
||||
{
|
||||
$bsBadge = new BoostrapBadge($options);
|
||||
|
@ -116,6 +122,17 @@ class BootstrapHelper extends Helper
|
|||
$bsProgressTimeline = new BoostrapProgressTimeline($options, $this);
|
||||
return $bsProgressTimeline->progressTimeline();
|
||||
}
|
||||
|
||||
public function listGroup($options, $data)
|
||||
{
|
||||
$bsListGroup = new BootstrapListGroup($options, $data, $this);
|
||||
return $bsListGroup->listGroup();
|
||||
}
|
||||
|
||||
public function genNode($node, $params=[], $content='')
|
||||
{
|
||||
return BootstrapGeneric::genNode($node, $params, $content);
|
||||
}
|
||||
}
|
||||
|
||||
class BootstrapGeneric
|
||||
|
@ -148,7 +165,7 @@ class BootstrapGeneric
|
|||
}
|
||||
}
|
||||
|
||||
protected static function genNode($node, $params=[], $content="")
|
||||
public static function genNode($node, $params=[], $content="")
|
||||
{
|
||||
return sprintf('<%s %s>%s</%s>', $node, BootstrapGeneric::genHTMLParams($params), $content, $node);
|
||||
}
|
||||
|
@ -245,7 +262,6 @@ class BootstrapTabs extends BootstrapGeneric
|
|||
$this->bsClasses = [
|
||||
'nav' => [],
|
||||
'nav-item' => $this->options['nav-item-class'],
|
||||
|
||||
];
|
||||
|
||||
if (!empty($this->options['justify'])) {
|
||||
|
@ -293,7 +309,9 @@ class BootstrapTabs extends BootstrapGeneric
|
|||
}
|
||||
$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'];
|
||||
if (!empty($this->options['vertical-size']) && $this->options['vertical-size'] != 'auto') {
|
||||
$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'];
|
||||
|
@ -346,17 +364,37 @@ class BootstrapTabs extends BootstrapGeneric
|
|||
|
||||
private function genVerticalTabs()
|
||||
{
|
||||
$html = $this->openNode('div', ['class' => array_merge(['row', ($this->options['card'] ? 'card flex-row' : '')], ["border-{$this->options['header-border-variant']}"])]);
|
||||
$html .= $this->openNode('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->openNode('div', ['class' => array_merge(
|
||||
[
|
||||
'row',
|
||||
($this->options['card'] ? 'card flex-row' : ''),
|
||||
($this->options['vertical-size'] == 'auto' ? 'flex-nowrap' : '')
|
||||
],
|
||||
[
|
||||
"border-{$this->options['header-border-variant']}"
|
||||
]
|
||||
)]);
|
||||
$html .= $this->openNode('div', ['class' => array_merge(
|
||||
[
|
||||
($this->options['vertical-size'] != 'auto' ? '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 .= $this->closeNode('div');
|
||||
$html .= $this->openNode('div', [
|
||||
'class' => array_merge(
|
||||
['col-' . (12 - $this->options['vertical-size']), ($this->options['card'] ? 'card-body2' : '')],
|
||||
$this->options['body-class'] ?? [],
|
||||
["bg-{$this->options['body-variant']}", "text-{$this->options['body-text-variant']}"]
|
||||
)
|
||||
]);
|
||||
$html .= $this->openNode('div', ['class' => array_merge(
|
||||
[
|
||||
($this->options['vertical-size'] != 'auto' ? '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 .= $this->closeNode('div');
|
||||
$html .= $this->closeNode('div');
|
||||
|
@ -859,9 +897,10 @@ class BoostrapButton extends BootstrapGeneric {
|
|||
|
||||
private function genIcon()
|
||||
{
|
||||
return $this->genNode('span', [
|
||||
'class' => ['mr-1', "fa fa-{$this->options['icon']}"],
|
||||
$bsIcon = new BoostrapIcon($this->options['icon'], [
|
||||
'class' => ['mr-1']
|
||||
]);
|
||||
return $bsIcon->icon();
|
||||
}
|
||||
|
||||
private function genContent()
|
||||
|
@ -910,6 +949,40 @@ class BoostrapBadge extends BootstrapGeneric {
|
|||
}
|
||||
}
|
||||
|
||||
class BoostrapIcon extends BootstrapGeneric {
|
||||
private $icon = '';
|
||||
private $defaultOptions = [
|
||||
'class' => [],
|
||||
];
|
||||
|
||||
function __construct($icon, $options=[]) {
|
||||
$this->icon = $icon;
|
||||
$this->processOptions($options);
|
||||
}
|
||||
|
||||
private function processOptions($options)
|
||||
{
|
||||
$this->options = array_merge($this->defaultOptions, $options);
|
||||
$this->checkOptionValidity();
|
||||
}
|
||||
|
||||
public function icon()
|
||||
{
|
||||
return $this->genIcon();
|
||||
}
|
||||
|
||||
private function genIcon()
|
||||
{
|
||||
$html = $this->genNode('span', [
|
||||
'class' => array_merge(
|
||||
is_array($this->options['class']) ? $this->options['class'] : [$this->options['class']],
|
||||
["fa fa-{$this->icon}"]
|
||||
),
|
||||
]);
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
|
||||
class BoostrapModal extends BootstrapGeneric {
|
||||
private $defaultOptions = [
|
||||
'size' => '',
|
||||
|
@ -1408,4 +1481,100 @@ class BoostrapProgressTimeline extends BootstrapGeneric {
|
|||
], $ulIcons . $ulText);
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
|
||||
class BootstrapListGroup extends BootstrapGeneric
|
||||
{
|
||||
private $defaultOptions = [
|
||||
'hover' => false,
|
||||
];
|
||||
|
||||
private $bsClasses = null;
|
||||
|
||||
function __construct($options, $data, $btHelper) {
|
||||
$this->data = $data;
|
||||
$this->processOptions($options);
|
||||
$this->btHelper = $btHelper;
|
||||
}
|
||||
|
||||
private function processOptions($options)
|
||||
{
|
||||
$this->options = array_merge($this->defaultOptions, $options);
|
||||
}
|
||||
|
||||
public function listGroup()
|
||||
{
|
||||
return $this->genListGroup();
|
||||
}
|
||||
|
||||
private function genListGroup()
|
||||
{
|
||||
$html = $this->openNode('div', [
|
||||
'class' => ['list-group',],
|
||||
]);
|
||||
foreach ($this->data as $item) {
|
||||
$html .= $this->genItem($item);
|
||||
}
|
||||
$html .= $this->closeNode('div');
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function genItem($item)
|
||||
{
|
||||
if (!empty($item['heading'])) { // complex layout with heading, badge and body
|
||||
$html = $this->genNode('a', [
|
||||
'class' => ['list-group-item', (!empty($this->options['hover']) ? 'list-group-item-action' : ''),],
|
||||
], implode('', [
|
||||
$this->genHeadingGroup($item),
|
||||
$this->genBody($item),
|
||||
]));
|
||||
} else { // simple layout with just <li>-like elements
|
||||
$html = $this->genNode('a', [
|
||||
'class' => ['list-group-item', 'd-flex', 'align-items-center', 'justify-content-between'],
|
||||
], implode('', [
|
||||
h($item['text']),
|
||||
$this->genBadge($item)
|
||||
]));
|
||||
}
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function genHeadingGroup($item)
|
||||
{
|
||||
$html = $this->genNode('div', [
|
||||
'class' => ['d-flex', 'w-100', 'justify-content-between',],
|
||||
], implode('', [
|
||||
$this->genHeading($item),
|
||||
$this->genBadge($item)
|
||||
]));
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function genHeading($item)
|
||||
{
|
||||
if (empty($item['heading'])) {
|
||||
return '';
|
||||
}
|
||||
return $this->genNode('h5', [
|
||||
'class' => ['mb-1'],
|
||||
], h($item['heading']));
|
||||
}
|
||||
|
||||
private function genBadge($item)
|
||||
{
|
||||
if (empty($item['badge'])) {
|
||||
return '';
|
||||
}
|
||||
return $this->genNode('span', [
|
||||
'class' => ['badge badge-pill', (!empty($item['badge-variant']) ? "badge-{$item['badge-variant']}" : 'badge-primary')],
|
||||
], h($item['badge']));
|
||||
}
|
||||
|
||||
private function genBody($item)
|
||||
{
|
||||
if (!empty($item['bodyHTML'])) {
|
||||
return $item['bodyHTML'];
|
||||
}
|
||||
return !empty($item['body']) ? h($item['body']) : '';
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
echo $this->element('genericElements/Form/genericForm', [
|
||||
'data' => [
|
||||
'description' => __('Authkeys are used for API access. A user can have more than one authkey, so if you would like to use separate keys per tool that queries Cerebrate, add additional keys. Use the comment field to make identifying your keys easier.'),
|
||||
'fields' => [
|
||||
[
|
||||
'field' => 'name',
|
||||
],
|
||||
[
|
||||
'field' => 'value'
|
||||
],
|
||||
],
|
||||
'submit' => [
|
||||
'action' => $this->request->getParam('action')
|
||||
]
|
||||
]
|
||||
]);
|
|
@ -0,0 +1,303 @@
|
|||
<?php
|
||||
|
||||
$variantFromSeverity = [
|
||||
'critical' => 'danger',
|
||||
'warning' => 'warning',
|
||||
'info' => 'info',
|
||||
];
|
||||
$this->set('variantFromSeverity', $variantFromSeverity);
|
||||
$settingTable = genNavcard($settingsProvider, $this);
|
||||
?>
|
||||
|
||||
<script>
|
||||
const variantFromSeverity = <?= json_encode($variantFromSeverity) ?>;
|
||||
const settingsFlattened = <?= json_encode($settingsFlattened) ?>;
|
||||
</script>
|
||||
|
||||
<div class="px-5">
|
||||
<div class="mb-3">
|
||||
<?=
|
||||
$this->element('Settings/search', [
|
||||
]);
|
||||
?>
|
||||
</div>
|
||||
<?= $settingTable; ?>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
function genNavcard($settingsProvider, $appView)
|
||||
{
|
||||
$cardContent = [];
|
||||
$cardNavs = array_keys($settingsProvider);
|
||||
foreach ($settingsProvider as $navName => $sectionSettings) {
|
||||
if (!empty($sectionSettings)) {
|
||||
$cardContent[] = genContentForNav($sectionSettings, $appView);
|
||||
} else {
|
||||
$cardContent[] = __('No Settings available yet');
|
||||
}
|
||||
}
|
||||
array_unshift($cardNavs, __('Settings Diagnostic'));
|
||||
$notice = $appView->element('Settings/notice', [
|
||||
'variantFromSeverity' => $appView->get('variantFromSeverity'),
|
||||
]);
|
||||
array_unshift($cardContent, $notice);
|
||||
$tabsOptions0 = [
|
||||
// 'vertical' => true,
|
||||
// 'vertical-size' => 2,
|
||||
'card' => false,
|
||||
'pills' => false,
|
||||
'justify' => 'center',
|
||||
'nav-class' => ['settings-tabs'],
|
||||
'data' => [
|
||||
'navs' => $cardNavs,
|
||||
'content' => $cardContent
|
||||
]
|
||||
];
|
||||
$table0 = $appView->Bootstrap->tabs($tabsOptions0);
|
||||
return $table0;
|
||||
}
|
||||
|
||||
function genContentForNav($sectionSettings, $appView)
|
||||
{
|
||||
$groupedContent = [];
|
||||
$groupedSetting = [];
|
||||
foreach ($sectionSettings as $sectionName => $subSectionSettings) {
|
||||
if (!empty($subSectionSettings)) {
|
||||
$groupedContent[] = genSection($sectionName, $subSectionSettings, $appView);
|
||||
} else {
|
||||
$groupedContent[] = '';
|
||||
}
|
||||
if (!isLeaf($subSectionSettings)) {
|
||||
$groupedSetting[$sectionName] = array_filter( // only show grouped settings
|
||||
array_keys($subSectionSettings),
|
||||
function ($settingGroupName) use ($subSectionSettings) {
|
||||
return !isLeaf($subSectionSettings[$settingGroupName]) && !empty($subSectionSettings[$settingGroupName]);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
$contentHtml = implode('', $groupedContent);
|
||||
$scrollspyNav = $appView->element('Settings/scrollspyNav', [
|
||||
'groupedSetting' => $groupedSetting
|
||||
]);
|
||||
$mainPanelHeight = 'calc(100vh - 42px - 1rem - 56px - 38px - 1rem)';
|
||||
$container = '<div class="d-flex">';
|
||||
$container .= "<div class=\"\" style=\"flex: 0 0 10em;\">{$scrollspyNav}</div>";
|
||||
$container .= "<div data-spy=\"scroll\" data-target=\"#navbar-scrollspy-setting\" data-offset=\"25\" style=\"height: {$mainPanelHeight}\" class=\"p-3 overflow-auto position-relative flex-grow-1\">{$contentHtml}</div>";
|
||||
$container .= '</div>';
|
||||
return $container;
|
||||
}
|
||||
|
||||
function genSection($sectionName, $subSectionSettings, $appView)
|
||||
{
|
||||
$sectionContent = [];
|
||||
$sectionContent[] = '<div>';
|
||||
if (isLeaf($subSectionSettings)) {
|
||||
$panelHTML = $appView->element('Settings/panel', [
|
||||
'sectionName' => $sectionName,
|
||||
'panelName' => $sectionName,
|
||||
'panelSettings' => $subSectionSettings,
|
||||
]);
|
||||
$sectionContent[] = $panelHTML;
|
||||
} else {
|
||||
if (count($subSectionSettings) > 1) {
|
||||
$sectionContent[] = sprintf('<h2 id="%s">%s</h2>', getResolvableID($sectionName), h($sectionName));
|
||||
}
|
||||
foreach ($subSectionSettings as $panelName => $panelSettings) {
|
||||
if (!empty($panelSettings)) {
|
||||
$panelHTML = $appView->element('Settings/panel', [
|
||||
'sectionName' => $sectionName,
|
||||
'panelName' => $panelName,
|
||||
'panelSettings' => $panelSettings,
|
||||
]);
|
||||
$sectionContent[] = $panelHTML;
|
||||
} else {
|
||||
$sectionContent[] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
$sectionContent[] = '</div>';
|
||||
return implode('', $sectionContent);
|
||||
}
|
||||
|
||||
function isLeaf($setting)
|
||||
{
|
||||
return !empty($setting['name']) && !empty($setting['type']);
|
||||
}
|
||||
|
||||
function getResolvableID($sectionName, $panelName=false)
|
||||
{
|
||||
$id = sprintf('sp-%s', preg_replace('/(\.|\W)/', '_', h($sectionName)));
|
||||
if (!empty($panelName)) {
|
||||
$id .= '-' . preg_replace('/(\.|\W)/', '_', h($panelName));
|
||||
}
|
||||
return $id;
|
||||
}
|
||||
?>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('.depends-on-icon').tooltip({
|
||||
placement: 'right',
|
||||
})
|
||||
$('select.custom-select[multiple]').select2()
|
||||
|
||||
$('.settings-tabs a[data-toggle="tab"]').on('shown.bs.tab', function (event) {
|
||||
$('[data-spy="scroll"]').trigger('scroll.bs.scrollspy')
|
||||
})
|
||||
|
||||
$('.tab-content input, .tab-content select').on('input', function() {
|
||||
if ($(this).attr('type') == 'checkbox') {
|
||||
const $input = $(this)
|
||||
const $inputGroup = $(this).closest('.form-group')
|
||||
const settingName = $(this).data('setting-name')
|
||||
const settingValue = $(this).is(':checked') ? 1 : 0
|
||||
saveSetting($inputGroup[0], $input, settingName, settingValue)
|
||||
} else {
|
||||
handleSettingValueChange($(this))
|
||||
}
|
||||
})
|
||||
|
||||
$('.tab-content .input-group-actions .btn-save-setting').click(function() {
|
||||
const $input = $(this).closest('.input-group').find('input, select')
|
||||
const settingName = $input.data('setting-name')
|
||||
const settingValue = $input.val()
|
||||
saveSetting(this, $input, settingName, settingValue)
|
||||
})
|
||||
$('.tab-content .input-group-actions .btn-reset-setting').click(function() {
|
||||
const $btn = $(this)
|
||||
const $input = $btn.closest('.input-group').find('input, select')
|
||||
let oldValue = settingsFlattened[$input.data('setting-name')].value
|
||||
if ($input.is('select')) {
|
||||
oldValue = oldValue !== undefined ? oldValue : -1
|
||||
} else {
|
||||
oldValue = oldValue !== undefined ? oldValue : ''
|
||||
}
|
||||
$input.val(oldValue)
|
||||
handleSettingValueChange($input)
|
||||
})
|
||||
|
||||
const referencedID = window.location.hash
|
||||
redirectToSetting(referencedID)
|
||||
})
|
||||
|
||||
function saveSetting(statusNode, $input, settingName, settingValue) {
|
||||
const url = '/instance/saveSetting/'
|
||||
const data = {
|
||||
name: settingName,
|
||||
value: settingValue,
|
||||
}
|
||||
const APIOptions = {
|
||||
statusNode: statusNode,
|
||||
}
|
||||
AJAXApi.quickFetchAndPostForm(url, data, APIOptions).then((result) => {
|
||||
settingsFlattened[settingName] = result.data
|
||||
if ($input.attr('type') == 'checkbox') {
|
||||
$input.prop('checked', result.data.value)
|
||||
} else {
|
||||
$input.val(result.data.value)
|
||||
}
|
||||
handleSettingValueChange($input)
|
||||
}).catch((e) => {})
|
||||
}
|
||||
|
||||
function handleSettingValueChange($input) {
|
||||
const oldValue = settingsFlattened[$input.data('setting-name')].value
|
||||
const newValue = ($input.attr('type') == 'checkbox' ? $input.is(':checked') : $input.val())
|
||||
if (newValue == oldValue || (newValue == '' && oldValue == undefined)) {
|
||||
restoreWarnings($input)
|
||||
} else {
|
||||
removeWarnings($input)
|
||||
}
|
||||
}
|
||||
|
||||
function removeWarnings($input) {
|
||||
const $inputGroup = $input.closest('.input-group')
|
||||
const $inputGroupAppend = $inputGroup.find('.input-group-append')
|
||||
const $saveButton = $inputGroup.find('button.btn-save-setting')
|
||||
$input.removeClass(['is-invalid', 'border-warning', 'border-danger', 'border-info', 'warning', 'info'])
|
||||
$inputGroupAppend.removeClass('d-none')
|
||||
if ($input.is('select') && $input.find('option:selected').data('is-empty-option') == 1) {
|
||||
$inputGroupAppend.addClass('d-none') // hide save button if empty selection picked
|
||||
}
|
||||
$inputGroup.parent().find('.invalid-feedback').removeClass('d-block')
|
||||
}
|
||||
|
||||
function restoreWarnings($input) {
|
||||
const $inputGroup = $input.closest('.input-group')
|
||||
const $inputGroupAppend = $inputGroup.find('.input-group-append')
|
||||
const $saveButton = $inputGroup.find('button.btn-save-setting')
|
||||
const setting = settingsFlattened[$input.data('setting-name')]
|
||||
if (setting.error) {
|
||||
borderVariant = setting.severity !== undefined ? variantFromSeverity[setting.severity] : 'warning'
|
||||
$input.addClass(['is-invalid', `border-${borderVariant}`, borderVariant])
|
||||
if (setting.severity == 'warning') {
|
||||
$input.addClass('warning')
|
||||
}
|
||||
$inputGroup.parent().find('.invalid-feedback').addClass('d-block').text(setting.errorMessage)
|
||||
} else {
|
||||
removeWarnings($input)
|
||||
}
|
||||
const $callout = $input.closest('.settings-group')
|
||||
updateCalloutColors($callout)
|
||||
$inputGroupAppend.addClass('d-none')
|
||||
}
|
||||
|
||||
function updateCalloutColors($callout) {
|
||||
if ($callout.length == 0) {
|
||||
return
|
||||
}
|
||||
const $settings = $callout.find('input, select')
|
||||
const settingNames = Array.from($settings).map((i) => {
|
||||
return $(i).data('setting-name')
|
||||
})
|
||||
const severityMapping = {null: 0, info: 1, warning: 2, critical: 3}
|
||||
const severityMappingInverted = Object.assign({}, ...Object.entries(severityMapping).map(([k, v]) => ({[v]: k})))
|
||||
let highestSeverity = severityMapping[null]
|
||||
settingNames.forEach(name => {
|
||||
if (settingsFlattened[name].error) {
|
||||
highestSeverity = severityMapping[settingsFlattened[name].severity] > highestSeverity ? severityMapping[settingsFlattened[name].severity] : highestSeverity
|
||||
}
|
||||
});
|
||||
highestSeverity = severityMappingInverted[highestSeverity]
|
||||
$callout.removeClass(['callout', 'callout-danger', 'callout-warning', 'callout-info'])
|
||||
if (highestSeverity !== null) {
|
||||
$callout.addClass(['callout', `callout-${variantFromSeverity[highestSeverity]}`])
|
||||
}
|
||||
}
|
||||
|
||||
function redirectToSetting(referencedID) {
|
||||
const $settingToFocus = $(referencedID)
|
||||
const pageNavID = $(referencedID).closest('.tab-pane').attr('aria-labelledby')
|
||||
const $navController = $(`#${pageNavID}`)
|
||||
$navController
|
||||
.on('shown.bs.tab.after-redirect', () => {
|
||||
$settingToFocus[0].scrollIntoView()
|
||||
const inputID = $settingToFocus.parent().attr('for')
|
||||
$settingToFocus.closest('.form-group').find(`#${inputID}`).focus()
|
||||
$navController.off('shown.bs.tab.after-redirect')
|
||||
})
|
||||
.tab('show')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.input-group-actions {
|
||||
z-index: 5;
|
||||
}
|
||||
a.btn-reset-setting {
|
||||
left: -1.25em;
|
||||
}
|
||||
.custom-select ~ div > a.btn-reset-setting {
|
||||
left: -2.5em;
|
||||
}
|
||||
.form-control[type="number"] ~ div > a.btn-reset-setting {
|
||||
left: -3em;
|
||||
}
|
||||
select.custom-select[multiple][data-setting-name] ~ span.select2-container{
|
||||
min-width: unset;
|
||||
}
|
||||
span.select2-container--open {
|
||||
min-width: unset;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
if ($setting['type'] == 'string' || $setting['type'] == 'textarea' || empty($setting['type'])) {
|
||||
$input = (function ($settingName, $setting, $appView) {
|
||||
$settingId = str_replace('.', '_', $settingName);
|
||||
return $appView->Bootstrap->genNode(
|
||||
$setting['type'] == 'textarea' ? 'textarea' : 'input',
|
||||
[
|
||||
'class' => [
|
||||
'form-control',
|
||||
'pr-4',
|
||||
(!empty($setting['error']) ? 'is-invalid' : ''),
|
||||
(!empty($setting['error']) ? "border-{$appView->get('variantFromSeverity')[$setting['severity']]}" : ''),
|
||||
(!empty($setting['error']) ? $appView->get('variantFromSeverity')[$setting['severity']] : ''),
|
||||
],
|
||||
($setting['type'] == 'textarea' ? '' : 'type') => ($setting['type'] == 'textarea' ? '' : 'text'),
|
||||
'id' => $settingId,
|
||||
'data-setting-name' => $settingName,
|
||||
'value' => isset($setting['value']) ? $setting['value'] : "",
|
||||
'placeholder' => $setting['default'] ?? '',
|
||||
'aria-describedby' => "{$settingId}Help"
|
||||
]
|
||||
);
|
||||
})($settingName, $setting, $this);
|
||||
|
||||
} elseif ($setting['type'] == 'boolean') {
|
||||
$input = (function ($settingName, $setting, $appView) {
|
||||
$settingId = str_replace('.', '_', $settingName);
|
||||
$switch = $appView->Bootstrap->genNode('input', [
|
||||
'class' => [
|
||||
'custom-control-input',
|
||||
(!empty($setting['error']) ? 'is-invalid' : ''),
|
||||
(!empty($setting['error']) ? $appView->get('variantFromSeverity')[$setting['severity']] : ''),
|
||||
],
|
||||
'type' => 'checkbox',
|
||||
'value' => !empty($setting['value']) ? 1 : 0,
|
||||
(!empty($setting['value']) ? 'checked' : '') => !empty($setting['value']) ? 'checked' : '',
|
||||
'id' => $settingId,
|
||||
'data-setting-name' => $settingName,
|
||||
]);
|
||||
$label = $appView->Bootstrap->genNode('label', [
|
||||
'class' => [
|
||||
'custom-control-label'
|
||||
],
|
||||
'for' => $settingId,
|
||||
], h($setting['description']));
|
||||
$container = $appView->Bootstrap->genNode('div', [
|
||||
'class' => [
|
||||
'custom-control',
|
||||
'custom-switch',
|
||||
],
|
||||
], implode('', [$switch, $label]));
|
||||
return $container;
|
||||
})($settingName, $setting, $this);
|
||||
$description = '';
|
||||
|
||||
} elseif ($setting['type'] == 'integer') {
|
||||
$input = (function ($settingName, $setting, $appView) {
|
||||
$settingId = str_replace('.', '_', $settingName);
|
||||
return $appView->Bootstrap->genNode('input', [
|
||||
'class' => [
|
||||
'form-control',
|
||||
(!empty($setting['error']) ? 'is-invalid' : ''),
|
||||
(!empty($setting['error']) ? "border-{$appView->get('variantFromSeverity')[$setting['severity']]}" : ''),
|
||||
(!empty($setting['error']) ? $appView->get('variantFromSeverity')[$setting['severity']] : ''),
|
||||
],
|
||||
'type' => 'number',
|
||||
'min' => '0',
|
||||
'step' => 1,
|
||||
'id' => $settingId,
|
||||
'data-setting-name' => $settingName,
|
||||
'aria-describedby' => "{$settingId}Help"
|
||||
]);
|
||||
})($settingName, $setting, $this);
|
||||
|
||||
} elseif ($setting['type'] == 'select' || $setting['type'] == 'multi-select') {
|
||||
$input = (function ($settingName, $setting, $appView) {
|
||||
$settingId = str_replace('.', '_', $settingName);
|
||||
$setting['value'] = $setting['value'] ?? '';
|
||||
$options = [];
|
||||
if ($setting['type'] == 'select') {
|
||||
$options[] = $appView->Bootstrap->genNode('option', ['value' => '-1', 'data-is-empty-option' => '1'], __('Select an option'));
|
||||
}
|
||||
foreach ($setting['options'] as $key => $value) {
|
||||
$options[] = $appView->Bootstrap->genNode('option', [
|
||||
'class' => [],
|
||||
'value' => $key,
|
||||
($setting['value'] == $key ? 'selected' : '') => $setting['value'] == $value ? 'selected' : '',
|
||||
], h($value));
|
||||
}
|
||||
$options = implode('', $options);
|
||||
return $appView->Bootstrap->genNode('select', [
|
||||
'class' => [
|
||||
'custom-select',
|
||||
'pr-4',
|
||||
(!empty($setting['error']) ? 'is-invalid' : ''),
|
||||
(!empty($setting['error']) ? "border-{$appView->get('variantFromSeverity')[$setting['severity']]}" : ''),
|
||||
(!empty($setting['error']) ? $appView->get('variantFromSeverity')[$setting['severity']] : ''),
|
||||
],
|
||||
($setting['type'] == 'multi-select' ? 'multiple' : '') => '',
|
||||
'id' => $settingId,
|
||||
'data-setting-name' => $settingName,
|
||||
'placeholder' => $setting['default'] ?? '',
|
||||
'aria-describedby' => "{$settingId}Help"
|
||||
], $options);
|
||||
})($settingName, $setting, $this);
|
||||
}
|
||||
echo $input;
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
$settingId = str_replace('.', '_', $settingName);
|
||||
|
||||
$dependsOnHtml = '';
|
||||
if (!empty($setting['dependsOn'])) {
|
||||
$dependsOnHtml = $this->Bootstrap->genNode('span', [
|
||||
'class' => [
|
||||
'ml-1',
|
||||
'd-inline-block',
|
||||
'depends-on-icon'
|
||||
],
|
||||
'style' => 'min-width: 0.75em;',
|
||||
'title' => __('This setting depends on the validity of: {0}', h($setting['dependsOn'])),
|
||||
], $this->Bootstrap->genNode('sup', [
|
||||
'class' => $this->FontAwesome->getClass('info'),
|
||||
]));
|
||||
}
|
||||
$label = $this->Bootstrap->genNode('label', [
|
||||
'class' => ['font-weight-bolder', 'mb-0'],
|
||||
'for' => $settingId
|
||||
], sprintf('<a id="lb-%s" href="#lb-%s" class="text-reset text-decoration-none">%s</a>', h($settingId), h($settingId), h($setting['name'])) . $dependsOnHtml);
|
||||
|
||||
$description = '';
|
||||
if (!empty($setting['description']) && (empty($setting['type']) || $setting['type'] != 'boolean')) {
|
||||
$description = $this->Bootstrap->genNode('small', [
|
||||
'class' => ['form-text', 'text-muted', 'mt-0'],
|
||||
'id' => "{$settingId}Help"
|
||||
], h($setting['description']));
|
||||
}
|
||||
$textColor = 'text-warning';
|
||||
if (!empty($setting['severity'])) {
|
||||
$textColor = "text-{$this->get('variantFromSeverity')[$setting['severity']]}";
|
||||
}
|
||||
$validationError = $this->Bootstrap->genNode('div', [
|
||||
'class' => ['d-block', 'invalid-feedback', $textColor],
|
||||
], (!empty($setting['error']) ? h($setting['errorMessage']) : ''));
|
||||
|
||||
$input = $this->element('Settings/field', [
|
||||
'setting' => $setting,
|
||||
'settingName' => $settingName,
|
||||
]);
|
||||
|
||||
$inputGroupSave = $this->Bootstrap->genNode('div', [
|
||||
'class' => ['input-group-append', 'd-none', 'position-relative', 'input-group-actions'],
|
||||
], implode('', [
|
||||
$this->Bootstrap->genNode('a', [
|
||||
'class' => ['position-absolute', 'fas fa-times', 'p-abs-center-y', 'text-reset text-decoration-none', 'btn-reset-setting'],
|
||||
'href' => '#',
|
||||
]),
|
||||
$this->Bootstrap->genNode('button', [
|
||||
'class' => ['btn', 'btn-success', 'btn-save-setting'],
|
||||
'type' => 'button',
|
||||
], __('save')),
|
||||
]));
|
||||
$inputGroup = $this->Bootstrap->genNode('div', [
|
||||
'class' => ['input-group'],
|
||||
], implode('', [$input, $inputGroupSave]));
|
||||
|
||||
$container = $this->Bootstrap->genNode('div', [
|
||||
'class' => ['form-group', 'mb-2']
|
||||
], implode('', [$label, $inputGroup, $description, $validationError]));
|
||||
|
||||
echo $container;
|
||||
?>
|
|
@ -0,0 +1,118 @@
|
|||
<?php
|
||||
$mainNoticeHeading = [
|
||||
'critical' => __('Your Cerebrate instance requires immediate attention.'),
|
||||
'warning' => __('Issues found, it is recommended that you resolve them.'),
|
||||
'info' => __('There are some optional settings that are incorrect or not set.'),
|
||||
];
|
||||
$headingPerLevel = [
|
||||
'critical' => __('Critical settings'),
|
||||
'warning' => __('Warning settings'),
|
||||
'info' => __('Info settings'),
|
||||
];
|
||||
$noticeDescriptionPerLevel = [
|
||||
'critical' => __('Cerebrate will not operate correctly or will be unsecure until these issues are resolved.'),
|
||||
'warning' => __('Some of the features of Cerebrate cannot be utilised until these issues are resolved.'),
|
||||
'info' => __('There are some optional tweaks that could be done to improve the looks of your Cerebrate instance.'),
|
||||
];
|
||||
|
||||
$settingNoticeListHeader = [];
|
||||
$settingNoticeList = [];
|
||||
|
||||
$alertVariant = 'info';
|
||||
$skipHeading = false;
|
||||
$alertBody = '';
|
||||
$tableItems = [];
|
||||
foreach (array_keys($mainNoticeHeading) as $level) {
|
||||
if(!empty($notices[$level])) {
|
||||
$variant = $variantFromSeverity[$level];
|
||||
if (!$skipHeading) {
|
||||
$alertBody .= sprintf('<h5 class="alert-heading">%s</h5>', $mainNoticeHeading[$level]);
|
||||
$alertVariant = $variant;
|
||||
$skipHeading = true;
|
||||
}
|
||||
$tableItems[] = [
|
||||
'severity' => $headingPerLevel[$level],
|
||||
'issues' => count($notices[$level]),
|
||||
'badge-variant' => $variant,
|
||||
'description' => $noticeDescriptionPerLevel[$level],
|
||||
];
|
||||
$settingNoticeListHeader[] = [
|
||||
'html' => $this->Bootstrap->badge([
|
||||
'variant' => $variantFromSeverity[$level],
|
||||
'text' => $level
|
||||
])
|
||||
];
|
||||
$settingNoticeList[] = $this->Bootstrap->table([
|
||||
'small' => true,
|
||||
'striped' => false,
|
||||
'hover' => false,
|
||||
'borderless' => true,
|
||||
'bordered' => false,
|
||||
], [
|
||||
'fields' => [
|
||||
['key' => 'name', 'label' => __('Name'), 'formatter' => function($name, $row) {
|
||||
$settingID = preg_replace('/(\.|\W)/', '_', h($row['true-name']));
|
||||
return sprintf('<a style="max-width: 200px; white-space: pre-wrap;" href="#lb-%s" onclick="redirectToSetting(\'#lb-%s\')">%s</a>', $settingID, $settingID, h($name));
|
||||
}],
|
||||
['key' => 'setting-path', 'label' => __('Category'), 'formatter' => function($path, $row) {
|
||||
return '<span class="text-nowrap">' . h(str_replace('.', ' ▸ ', $path)) . '</span>';
|
||||
}],
|
||||
['key' => 'value', 'label' => __('Value'), 'formatter' => function($value, $row) {
|
||||
$formatedValue = '<span class="p-1 rounded mb-0" style="background: #eeeeee55; font-family: monospace;">';
|
||||
if (is_null($value)) {
|
||||
$formatedValue .= '<i class="text-nowrap">' . __('No value') . '</i>';
|
||||
} else if ($value === '') {
|
||||
$formatedValue .= '<i class="text-nowrap">' . __('Empty string') . '</i>';
|
||||
} else if (is_bool($value)) {
|
||||
$formatedValue .= '<i class="text-nowrap">' . ($value ? __('true') : __('false')) . '</i>';
|
||||
} else {
|
||||
$formatedValue .= h($value);
|
||||
}
|
||||
$formatedValue .= '</span>';
|
||||
return $formatedValue;
|
||||
}],
|
||||
['key' => 'description', 'label' => __('Description')]
|
||||
],
|
||||
'items' => $notices[$level],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$alertBody = $this->Bootstrap->table([
|
||||
'small' => true,
|
||||
'striped' => false,
|
||||
'hover' => false,
|
||||
'borderless' => true,
|
||||
'bordered' => false,
|
||||
'tableClass' => 'mb-0'
|
||||
], [
|
||||
'fields' => [
|
||||
['key' => 'severity', 'label' => __('Severity')],
|
||||
['key' => 'issues', 'label' => __('Issues'), 'formatter' => function($count, $row) {
|
||||
return $this->Bootstrap->badge([
|
||||
'variant' => $row['badge-variant'],
|
||||
'text' => $count
|
||||
]);
|
||||
}],
|
||||
['key' => 'description', 'label' => __('Description')]
|
||||
],
|
||||
'items' => $tableItems,
|
||||
]);
|
||||
|
||||
$settingNotice = $this->Bootstrap->alert([
|
||||
'dismissible' => false,
|
||||
'variant' => $alertVariant,
|
||||
'html' => $alertBody
|
||||
]);
|
||||
$settingNotice = sprintf('<div class="mt-3">%s</div>', $settingNotice);
|
||||
echo $settingNotice;
|
||||
|
||||
$tabsOptions = [
|
||||
'card' => true,
|
||||
'pills' => false,
|
||||
'data' => [
|
||||
'navs' => $settingNoticeListHeader,
|
||||
'content' => $settingNoticeList
|
||||
]
|
||||
];
|
||||
echo $this->Bootstrap->tabs($tabsOptions);
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
$panelHTML = '';
|
||||
if (isLeaf($panelSettings)) {
|
||||
$singleSetting = $this->element('Settings/fieldGroup', [
|
||||
'panelName' => $panelName,
|
||||
'panelSettings' => $panelSettings,
|
||||
'settingName' => $panelName,
|
||||
'setting' => $panelSettings,
|
||||
]);
|
||||
$panelHTML = "<div>{$singleSetting}</div>";
|
||||
} else {
|
||||
$panelID = getResolvableID($sectionName, $panelName);
|
||||
$panelHTML .= sprintf('<h4 id="%s"><a class="text-reset text-decoration-none" href="#%s">%s%s</a></h4>',
|
||||
$panelID,
|
||||
$panelID,
|
||||
!empty($panelSettings['_icon']) ? $this->Bootstrap->icon($panelSettings['_icon'], ['class' => 'mr-1']) : '',
|
||||
h($panelName)
|
||||
);
|
||||
if (!empty($panelSettings['_description'])) {
|
||||
$panelHTML .= $this->Bootstrap->genNode('div', [
|
||||
'class' => ['mb-1',],
|
||||
], h($panelSettings['_description']));
|
||||
}
|
||||
$groupIssueSeverity = false;
|
||||
foreach ($panelSettings as $singleSettingName => $singleSetting) {
|
||||
if (substr($singleSettingName, 0, 1) == '_') {
|
||||
continue;
|
||||
}
|
||||
$singleSettingHTML = $this->element('Settings/fieldGroup', [
|
||||
'panelName' => $panelName,
|
||||
'panelSettings' => $panelSettings,
|
||||
'settingName' => $singleSettingName,
|
||||
'setting' => $singleSetting,
|
||||
]);
|
||||
$panelHTML .= sprintf('<div class="ml-3">%s</div>', $singleSettingHTML);
|
||||
if (!empty($singleSetting['error'])) {
|
||||
$settingVariant = $this->get('variantFromSeverity')[$singleSetting['severity']];
|
||||
if ($groupIssueSeverity != 'danger') {
|
||||
if ($groupIssueSeverity != 'warning') {
|
||||
$groupIssueSeverity = $settingVariant;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$panelHTML = $this->Bootstrap->genNode('div', [
|
||||
'class' => [
|
||||
'shadow',
|
||||
'p-2',
|
||||
'mb-4',
|
||||
'rounded',
|
||||
'settings-group',
|
||||
(!empty($groupIssueSeverity) ? "callout callout-${groupIssueSeverity}" : ''),
|
||||
($this->get('darkMode') ? 'bg-dark' : 'bg-light')
|
||||
],
|
||||
], $panelHTML);
|
||||
}
|
||||
echo $panelHTML;
|
|
@ -0,0 +1,63 @@
|
|||
<nav id="navbar-scrollspy-setting" class="navbar">
|
||||
<nav class="nav nav-pills flex-column">
|
||||
<?php foreach ($groupedSetting as $group => $sections): ?>
|
||||
<a class="nav-link main-group text-reset p-1" href="#<?= getResolvableID($group) ?>"><?= h($group) ?></a>
|
||||
<nav class="nav nav-pills sub-group collapse flex-column" data-maingroup="<?= getResolvableID($group) ?>">
|
||||
<?php foreach ($sections as $section): ?>
|
||||
<a class="nav-link nav-link-group text-reset ml-3 my-1 p-1" href="#<?= getResolvableID($group, $section) ?>"><?= h($section) ?></a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('[data-spy="scroll"]').on('activate.bs.scrollspy', function(evt, {relatedTarget}) {
|
||||
const $associatedLink = $(`#navbar-scrollspy-setting nav.nav-pills .nav-link[href="${relatedTarget}"]`)
|
||||
let $associatedNav
|
||||
if ($associatedLink.hasClass('main-group')) {
|
||||
$associatedNav = $associatedLink.next()
|
||||
} else {
|
||||
$associatedNav = $associatedLink.parent()
|
||||
}
|
||||
const $allNavs = $('#navbar-scrollspy-setting nav.nav-pills.sub-group')
|
||||
$allNavs.removeClass('group-active').hide()
|
||||
$associatedNav.addClass('group-active').show()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#navbar-scrollspy-setting nav.nav-pills .nav-link {
|
||||
background-color: unset !important;
|
||||
color: black;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#navbar-scrollspy-setting nav.nav-pills .nav-link:not(.main-group).active {
|
||||
color: #007bff !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#navbar-scrollspy-setting nav.nav-pills .nav-link.main-group:before {
|
||||
margin-right: 0.25em;
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-weight: 900;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
display: inline-block;
|
||||
font-style: normal;
|
||||
font-variant: normal;
|
||||
text-rendering: auto;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#navbar-scrollspy-setting nav.nav-pills .nav-link.main-group.active:before {
|
||||
content: "\f0d7";
|
||||
}
|
||||
|
||||
#navbar-scrollspy-setting nav.nav-pills .nav-link.main-group:before {
|
||||
content: "\f0da";
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,121 @@
|
|||
<select id="search-settings" class="d-block w-100" aria-describedby="<?= __('Search setting input') ?>"><option></option></select>
|
||||
|
||||
<script>
|
||||
let selectData = []
|
||||
for (const settingName in settingsFlattened) {
|
||||
if (Object.hasOwnProperty.call(settingsFlattened, settingName)) {
|
||||
const setting = settingsFlattened[settingName];
|
||||
const selectID = settingName.replaceAll('.', '_')
|
||||
selectData.push({
|
||||
id: selectID,
|
||||
text: setting.name,
|
||||
setting: setting
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
$(document).ready(function() {
|
||||
$("#search-settings").select2({
|
||||
data: selectData,
|
||||
placeholder: '<?= __('Search setting by typing here...') ?>',
|
||||
templateResult: formatSettingSearchResult,
|
||||
templateSelection: formatSettingSearchSelection,
|
||||
matcher: settingMatcher,
|
||||
sorter: settingSorter,
|
||||
})
|
||||
.on('select2:select', function (e) {
|
||||
const selected = e.params.data
|
||||
const settingPath = selected.setting['setting-path']
|
||||
const {tabName, IDtoFocus} = getTabAndSettingIDFromPath(settingPath)
|
||||
showSetting(selected, tabName, IDtoFocus)
|
||||
$("#search-settings").val(null).trigger('change.select2');
|
||||
})
|
||||
})
|
||||
|
||||
function getTabAndSettingIDFromPath(settingPath) {
|
||||
let settingPathTokenized = settingPath.split('.')
|
||||
settingPathTokenized = settingPathTokenized.map((elem) => elem.replaceAll(/(\.|\W)/g, '_'))
|
||||
const tabName = settingPathTokenized[0]
|
||||
const IDtoFocus = 'sp-' + settingPathTokenized.slice(1).join('-')
|
||||
return {tabName: tabName, IDtoFocus: IDtoFocus}
|
||||
}
|
||||
|
||||
function showSetting(selected, tabName, IDtoFocus) {
|
||||
const $navController = $('.settings-tabs').find('a.nav-link').filter(function() {
|
||||
return $(this).text() == tabName
|
||||
})
|
||||
if ($navController.length == 1) {
|
||||
$toFocus = $(`#${IDtoFocus}`).parent()
|
||||
if ($navController.hasClass('active')) {
|
||||
$toFocus[0].scrollIntoView()
|
||||
$toFocus.find(`input#${selected.id}, textarea#${selected.id}`).focus()
|
||||
} else {
|
||||
$navController.on('shown.bs.tab.after-selection', () => {
|
||||
$toFocus[0].scrollIntoView()
|
||||
$toFocus.find(`input#${selected.id}, textarea#${selected.id}`).focus()
|
||||
$navController.off('shown.bs.tab.after-selection')
|
||||
}).tab('show')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function settingMatcher(params, data) {
|
||||
if (params.term == null || params.term.trim() === '') {
|
||||
return data;
|
||||
}
|
||||
if (data.text === undefined || data.setting === undefined) {
|
||||
return null;
|
||||
}
|
||||
let modifiedData = $.extend({}, data, true);
|
||||
const loweredTerms = params.term.trim().toLowerCase().split(' ')
|
||||
let matchNumber = 0
|
||||
for (let i = 0; i < loweredTerms.length; i++) {
|
||||
const loweredTerm = loweredTerms[i];
|
||||
const settingNameMatch = data.setting['true-name'].toLowerCase().indexOf(loweredTerm) > -1 || data.text.toLowerCase().indexOf(loweredTerm) > -1
|
||||
const settingGroupMatch = data.setting['setting-path'].toLowerCase().indexOf(loweredTerm) > -1
|
||||
const settingDescMatch = data.setting.description.toLowerCase().indexOf(loweredTerm) > -1
|
||||
if (settingNameMatch || settingGroupMatch || settingDescMatch) {
|
||||
matchNumber += 1
|
||||
modifiedData.matchPriority = (settingNameMatch ? 10 : 0) + (settingGroupMatch ? 5 : 0) + (settingDescMatch ? 1 : 0)
|
||||
}
|
||||
}
|
||||
if (matchNumber == loweredTerms.length && modifiedData.matchPriority > 0) {
|
||||
return modifiedData;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function settingSorter(data) {
|
||||
let sortedData = data.slice(0)
|
||||
sortedData = sortedData.sort((a, b) => {
|
||||
return a.matchPriority == b.matchPriority ? 0 : (b.matchPriority - a.matchPriority)
|
||||
})
|
||||
return sortedData;
|
||||
}
|
||||
|
||||
function formatSettingSearchResult(state) {
|
||||
if (!state.id) {
|
||||
return state.text;
|
||||
}
|
||||
const $state = $('<div/>').append(
|
||||
$('<div/>').addClass('d-flex justify-content-between')
|
||||
.append(
|
||||
$('<span/>').addClass('font-weight-bold').text(state.text),
|
||||
$('<span/>').addClass('font-weight-light').text(state.setting['setting-path'].replaceAll('.', ' ▸ '))
|
||||
),
|
||||
$('<div/>').addClass('font-italic font-weight-light ml-3').text(state.setting['description'])
|
||||
)
|
||||
return $state
|
||||
}
|
||||
|
||||
function formatSettingSearchSelection(state) {
|
||||
return state.text
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.select2-container {
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
</style>
|
|
@ -41,6 +41,7 @@ $cakeDescription = 'Cerebrate';
|
|||
<?= $this->Html->script('main.js') ?>
|
||||
<?= $this->Html->script('bootstrap-helper.js') ?>
|
||||
<?= $this->Html->script('api-helper.js') ?>
|
||||
<?= $this->Html->script('select2.min.js') ?>
|
||||
<?= $this->Html->script('CodeMirror/codemirror.js') ?>
|
||||
<?= $this->Html->script('CodeMirror/mode/javascript/javascript') ?>
|
||||
<?= $this->Html->script('CodeMirror/addon/hint/show-hint') ?>
|
||||
|
@ -53,6 +54,8 @@ $cakeDescription = 'Cerebrate';
|
|||
<?= $this->Html->css('CodeMirror/codemirror-additional') ?>
|
||||
<?= $this->Html->css('CodeMirror/addon/hint/show-hint') ?>
|
||||
<?= $this->Html->css('CodeMirror/addon/lint/lint') ?>
|
||||
<?= $this->Html->css('select2.min') ?>
|
||||
<?= $this->Html->css('select2-bootstrap4.min') ?>
|
||||
<?= $this->fetch('meta') ?>
|
||||
<?= $this->fetch('css') ?>
|
||||
<?= $this->fetch('script') ?>
|
||||
|
@ -83,5 +86,6 @@ $cakeDescription = 'Cerebrate';
|
|||
</body>
|
||||
<script>
|
||||
const darkMode = (<?= empty($darkMode) ? 'false' : 'true' ?>)
|
||||
$.fn.select2.defaults.set('theme', 'bootstrap4');
|
||||
</script>
|
||||
</html>
|
||||
|
|
|
@ -9,13 +9,13 @@
|
|||
max-width: 25% !important;
|
||||
}
|
||||
.mh-75 {
|
||||
max-width: 75% !important;
|
||||
max-height: 75% !important;
|
||||
}
|
||||
.mh-50 {
|
||||
max-width: 50% !important;
|
||||
max-height: 50% !important;
|
||||
}
|
||||
.mh-25 {
|
||||
max-width: 25% !important;
|
||||
max-height: 25% !important;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
|
@ -107,4 +107,101 @@ div.progress-timeline .progress-line {
|
|||
}
|
||||
div.progress-timeline .progress-line.progress-inactive {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.callout {
|
||||
border: 1px solid #00000000;
|
||||
border-left-color: var(--light);
|
||||
border-left-width: 1px;
|
||||
border-left-width: .25rem;
|
||||
border-radius: .25rem;
|
||||
}
|
||||
.callout-primary {
|
||||
border-left-color: var(--primary);
|
||||
}
|
||||
.callout-info {
|
||||
border-left-color: var(--info);
|
||||
}
|
||||
.callout-success {
|
||||
border-left-color: var(--success);
|
||||
}
|
||||
.callout-warning {
|
||||
border-left-color: var(--warning);
|
||||
}
|
||||
.callout-danger {
|
||||
border-left-color: var(--danger);
|
||||
}
|
||||
.callout-dark {
|
||||
border-left-color: var(--dark);
|
||||
}
|
||||
.callout-light {
|
||||
border-left-color: var(--light);
|
||||
}
|
||||
|
||||
.form-control.is-invalid.warning {
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23ffc107' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ffc107' stroke='none'/%3e%3c/svg%3e")
|
||||
}
|
||||
.form-control.is-invalid.warning:focus {
|
||||
box-shadow: 0 0 0 0.2rem #ffc10740;
|
||||
}
|
||||
|
||||
.form-control.is-invalid.info {
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%2317a2b8' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%2317a2b8' stroke='none'/%3e%3c/svg%3e")
|
||||
}
|
||||
.form-control.is-invalid.info:focus {
|
||||
box-shadow: 0 0 0 0.2rem #17a2b840;
|
||||
}
|
||||
|
||||
.custom-select.is-invalid.warning {
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right 0.75rem center/8px 10px, url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23ffc107' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ffc107' stroke='none'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
|
||||
}
|
||||
.custom-select.is-invalid.info {
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right 0.75rem center/8px 10px, url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%2317a2b8' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%2317a2b8' stroke='none'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
|
||||
}
|
||||
|
||||
.custom-control-input.is-invalid.warning ~ .custom-control-label {
|
||||
color: unset;
|
||||
}
|
||||
.custom-control-input.is-invalid.warning ~ .custom-control-label::before {
|
||||
border-color: #ffc107;
|
||||
}
|
||||
.custom-control-input.is-invalid.warning:checked ~ .custom-control-label::before {
|
||||
background-color: #ffc107;
|
||||
}
|
||||
.custom-control-input.is-invalid.warning:focus:not(:checked) ~ .custom-control-label::before {
|
||||
border-color: #ffc107;
|
||||
}
|
||||
.custom-control-input.is-invalid.warning:focus ~ .custom-control-label::before {
|
||||
box-shadow: 0 0 0 0.2rem #ffc10740;
|
||||
}
|
||||
.custom-control-input.is-invalid.info ~ .custom-control-label {
|
||||
color: unset;
|
||||
}
|
||||
.custom-control-input.is-invalid.info ~ .custom-control-label::before {
|
||||
border-color: #17a2b8;
|
||||
}
|
||||
.custom-control-input.is-invalid.info:checked ~ .custom-control-label::before {
|
||||
background-color: #17a2b8;
|
||||
}
|
||||
.custom-control-input.is-invalid.info:focus:not(:checked) ~ .custom-control-label::before {
|
||||
border-color: #17a2b8;
|
||||
}
|
||||
.custom-control-input.is-invalid.info:focus ~ .custom-control-label::before {
|
||||
box-shadow: 0 0 0 0.2rem #17a2b840;
|
||||
}
|
||||
|
||||
.p-abs-center-y {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.p-abs-center-x {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.p-abs-center-both {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
}
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -68,6 +68,49 @@ class AJAXApi {
|
|||
}
|
||||
}
|
||||
|
||||
provideSuccessFeedback(response, toastOptions, skip=false) {
|
||||
let alteredToastOptions = Object.assign(
|
||||
{
|
||||
'variant': 'success'
|
||||
},
|
||||
AJAXApi.defaultOptions.successToastOptions,
|
||||
toastOptions
|
||||
)
|
||||
alteredToastOptions.body = response.message
|
||||
if (!skip && this.options.provideFeedback) {
|
||||
UI.toast(alteredToastOptions)
|
||||
}
|
||||
}
|
||||
|
||||
provideFailureFeedback(response, toastOptions, skip=false) {
|
||||
let alteredToastOptions = Object.assign(
|
||||
{
|
||||
'variant': 'danger'
|
||||
},
|
||||
AJAXApi.defaultOptions.errorToastOptions,
|
||||
toastOptions
|
||||
)
|
||||
if (response.message && response.errors) {
|
||||
if (Array.isArray(response.errors)) {
|
||||
alteredToastOptions.title = response.message
|
||||
alteredToastOptions.body = response.errors.join(', ')
|
||||
} else if (typeof response.errors === 'string') {
|
||||
alteredToastOptions.title = response.message
|
||||
alteredToastOptions.body = response.errors
|
||||
} else {
|
||||
alteredToastOptions.title = 'There has been a problem with the operation'
|
||||
alteredToastOptions.body = response.message
|
||||
}
|
||||
} else {
|
||||
alteredToastOptions.title = 'There has been a problem with the operation'
|
||||
alteredToastOptions.body = response.message
|
||||
}
|
||||
if (!skip && this.options.provideFeedback) {
|
||||
UI.toast(alteredToastOptions)
|
||||
console.warn(alteredToastOptions.body)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge newOptions configuration into the current object
|
||||
* @param {Object} The options supported by AJAXApi#defaultOptions
|
||||
|
@ -149,6 +192,7 @@ class AJAXApi {
|
|||
/**
|
||||
* @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 {Object} [options={}] - The options supported by AJAXApi#defaultOptions
|
||||
* @return {Promise<Object>} Promise object resolving to the result of the POST operation
|
||||
*/
|
||||
static async quickFetchAndPostForm(url, dataToMerge={}, options={}) {
|
||||
|
@ -174,17 +218,10 @@ class AJAXApi {
|
|||
throw new Error(`Network response was not ok. \`${response.statusText}\``)
|
||||
}
|
||||
const dataHtml = await response.text();
|
||||
this.provideFeedback({
|
||||
variant: 'success',
|
||||
title: 'URL fetched',
|
||||
}, false, skipFeedback);
|
||||
this.provideSuccessFeedback({message: 'URL fetched'}, {}, skipFeedback)
|
||||
toReturn = dataHtml;
|
||||
} catch (error) {
|
||||
this.provideFeedback({
|
||||
variant: 'danger',
|
||||
title: 'There has been a problem with the operation',
|
||||
body: error.message
|
||||
}, true, skipFeedback);
|
||||
this.provideFailureFeedback(error, {}, skipFeedback)
|
||||
toReturn = Promise.reject(error);
|
||||
} finally {
|
||||
if (!skipRequestHooks) {
|
||||
|
@ -211,17 +248,10 @@ class AJAXApi {
|
|||
throw new Error(`Network response was not ok. \`${response.statusText}\``)
|
||||
}
|
||||
const dataJson = await response.json();
|
||||
this.provideFeedback({
|
||||
variant: 'success',
|
||||
title: 'URL fetched',
|
||||
}, false, skipFeedback);
|
||||
this.provideSuccessFeedback({message: 'JSON fetched'}, {}, skipFeedback)
|
||||
toReturn = dataJson;
|
||||
} catch (error) {
|
||||
this.provideFeedback({
|
||||
variant: 'danger',
|
||||
title: 'There has been a problem with the operation',
|
||||
body: error.message
|
||||
}, true, skipFeedback);
|
||||
this.provideFailureFeedback(error, {}, skipFeedback)
|
||||
toReturn = Promise.reject(error);
|
||||
} finally {
|
||||
if (!skipRequestHooks) {
|
||||
|
@ -256,11 +286,7 @@ class AJAXApi {
|
|||
}
|
||||
toReturn = form[0];
|
||||
} catch (error) {
|
||||
this.provideFeedback({
|
||||
variant: 'danger',
|
||||
title: 'There has been a problem with the operation',
|
||||
body: error.message
|
||||
}, true, skipFeedback);
|
||||
this.provideFailureFeedback(error, {}, skipFeedback)
|
||||
toReturn = Promise.reject(error);
|
||||
} finally {
|
||||
if (!skipRequestHooks) {
|
||||
|
@ -301,13 +327,10 @@ class AJAXApi {
|
|||
variant: 'success',
|
||||
body: data.message
|
||||
}, false, skipFeedback);
|
||||
this.provideSuccessFeedback(data, {}, skipFeedback)
|
||||
toReturn = data;
|
||||
} else {
|
||||
this.provideFeedback({
|
||||
variant: 'danger',
|
||||
title: 'There has been a problem with the operation',
|
||||
body: data.message
|
||||
}, true, skipFeedback);
|
||||
this.provideFailureFeedback(data, {}, skipFeedback)
|
||||
toReturn = Promise.reject(data.errors);
|
||||
}
|
||||
} catch (error) {
|
||||
|
@ -326,7 +349,7 @@ class AJAXApi {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form - The form to be posted
|
||||
* @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
|
||||
|
@ -355,17 +378,10 @@ class AJAXApi {
|
|||
try {
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
this.provideFeedback({
|
||||
variant: 'success',
|
||||
body: data.message
|
||||
}, false, skipFeedback);
|
||||
this.provideSuccessFeedback(data, {}, skipFeedback)
|
||||
toReturn = data;
|
||||
} else {
|
||||
this.provideFeedback({
|
||||
variant: 'danger',
|
||||
title: 'There has been a problem with the operation',
|
||||
body: data.message
|
||||
}, true, skipFeedback);
|
||||
this.provideFailureFeedback(data, {}, skipFeedback)
|
||||
feedbackShown = true
|
||||
this.injectFormValidationFeedback(form, data.errors)
|
||||
toReturn = Promise.reject(data.errors);
|
||||
|
|
|
@ -833,7 +833,7 @@ class OverlayFactory {
|
|||
* @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 {boolean} 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
|
||||
|
|
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue