From 51dd0434cd0f01ab687a92344935a56ca50c3bb1 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Mon, 19 Jul 2021 14:58:54 +0200 Subject: [PATCH 01/35] chg: [helpers:bootstrap] Slight improvements --- src/View/Helper/BootstrapHelper.php | 44 ++++++++++++++++++++++++---- webroot/css/bootstrap-additional.css | 35 ++++++++++++++++++++-- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/View/Helper/BootstrapHelper.php b/src/View/Helper/BootstrapHelper.php index b5502d2..90f2ff4 100644 --- a/src/View/Helper/BootstrapHelper.php +++ b/src/View/Helper/BootstrapHelper.php @@ -110,6 +110,11 @@ class BootstrapHelper extends Helper $bsProgressTimeline = new BoostrapProgressTimeline($options, $this); return $bsProgressTimeline->progressTimeline(); } + + public function genNode($node, $params=[], $content='') + { + return BootstrapGeneric::genNode($node, $params, $content); + } } class BootstrapGeneric @@ -142,7 +147,7 @@ class BootstrapGeneric } } - protected static function genNode($node, $params=[], $content="") + public static function genNode($node, $params=[], $content="") { return sprintf('<%s %s>%s', $node, BootstrapGeneric::genHTMLParams($params), $content, $node); } @@ -238,7 +243,6 @@ class BootstrapTabs extends BootstrapGeneric $this->bsClasses = [ 'nav' => [], 'nav-item' => $this->options['nav-item-class'], - ]; if (!empty($this->options['justify'])) { @@ -286,7 +290,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']; @@ -333,11 +339,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' : '')], ["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'); diff --git a/webroot/css/bootstrap-additional.css b/webroot/css/bootstrap-additional.css index 95e2e80..50fb5a9 100644 --- a/webroot/css/bootstrap-additional.css +++ b/webroot/css/bootstrap-additional.css @@ -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,33 @@ div.progress-timeline .progress-line { } div.progress-timeline .progress-line.progress-inactive { opacity: 0.5; +} + +.bd-callout { + border: 1px solid #eee; + border-left-color: rgb(238, 238, 238); + border-left-width: 1px; + border-left-width: .25rem; + border-radius: .25rem; +} +.bd-callout-primary { + border-left-color: var(--primary); +} +.bd-callout-info { + border-left-color: var(--info); +} +.bd-callout-success { + border-left-color: var(--success); +} +.bd-callout-warning { + border-left-color: var(--warning); +} +.bd-callout-danger { + border-left-color: var(--danger); +} +.bd-callout-dark { + border-left-color: var(--dark); +} +.bd-callout-light { + border-left-color: var(--light); } \ No newline at end of file From dc5d54c30e80bf2a8af4c00a2d321c6ff8ecbaa8 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Mon, 19 Jul 2021 15:00:09 +0200 Subject: [PATCH 02/35] new: [settings] Added setting and settingProvider functionality - WiP --- src/Controller/InstanceController.php | 8 + src/Model/Table/SettingsProviderTable.php | 189 +++++++++++++++ src/Model/Table/SettingsTable.php | 28 +++ templates/Instance/settings.php | 268 ++++++++++++++++++++++ 4 files changed, 493 insertions(+) create mode 100644 src/Model/Table/SettingsProviderTable.php create mode 100644 src/Model/Table/SettingsTable.php create mode 100644 templates/Instance/settings.php diff --git a/src/Controller/InstanceController.php b/src/Controller/InstanceController.php index df141a9..336c413 100644 --- a/src/Controller/InstanceController.php +++ b/src/Controller/InstanceController.php @@ -101,4 +101,12 @@ 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(); + $this->set('settingsProvider', $all['settingsProvider']); + $this->set('settings', $all['settings']); + } } diff --git a/src/Model/Table/SettingsProviderTable.php b/src/Model/Table/SettingsProviderTable.php new file mode 100644 index 0000000..8040482 --- /dev/null +++ b/src/Model/Table/SettingsProviderTable.php @@ -0,0 +1,189 @@ +settingsConfiguration = $this->generateSettingsConfiguration(); + $this->setTable(false); + } + + /** + * 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; + } + + private function mergeSettingsIntoSettingConfiguration($settingConf, $settings) + { + foreach ($settingConf as $key => $value) { + if ($this->isLeaf($value)) { + if (isset($settings[$key])) { + $settingConf[$key]['value'] = $settings[$key]; + } + } else { + $settingConf[$key] = $this->mergeSettingsIntoSettingConfiguration($value, $settings); + } + } + return $settingConf; + } + + private function isLeaf($setting) + { + return !empty($setting['name']) && !empty($setting['type']); + } + + /** + * Support up to 3 level: + * Application -> Network -> Proxy -> Proxy.URL + * + * Leave errorMessage empty to let the validator generate the error message + */ + private function generateSettingsConfiguration() + { + return [ + 'Application' => [ + 'General' => [ + 'Essentials' => [ + 'baseurl' => [ + '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.'), + 'errorMessage' => __('The currently set baseurl does not match the URL through which you have accessed the page. Disregard this if you are accessing the page via an alternate URL (for example via IP address).'), + 'default' => '', + 'name' => __('Base URL'), + 'test' => 'testBaseURL', + 'type' => 'string', + ], + 'uuid' => [ + 'description' => __('The Cerebrate instance UUID. This UUID is used to identify this instance.'), + 'errorMessage' => __('No valid UUID set'), + 'default' => '', + 'name' => 'UUID', + 'test' => 'testUuid', + 'type' => 'string' + ], + ], + 'Miscellaneous' => [ + 'to-del' => [ + 'description' => 'to del', + 'errorMessage' => 'to del', + 'default' => '', + 'name' => 'To DEL', + 'type' => 'string' + ], + 'to-del2' => [ + 'description' => 'to del', + 'errorMessage' => 'to del', + 'default' => '', + 'name' => 'To DEL', + 'type' => 'string' + ], + 'to-del3' => [ + 'description' => 'to del', + 'errorMessage' => 'to del', + 'default' => '', + 'name' => 'To DEL', + 'type' => 'string' + ], + ], + 'floating-setting' => [ + 'description' => 'floaringSetting', + 'errorMessage' => 'floaringSetting', + 'default' => '', + 'name' => 'Uncategorized Setting', + 'type' => 'string' + ], + ], + 'Network' => [ + 'Proxy' => [ + 'host' => [ + 'description' => __('The hostname of an HTTP proxy for outgoing sync requests. Leave empty to not use a proxy.'), + 'default' => '', + 'name' => __('Host'), + 'test' => 'testHostname', + 'type' => 'string', + ], + 'port' => [ + 'description' => __('The TCP port for the HTTP proxy.'), + 'default' => '', + 'name' => __('Port'), + 'test' => 'testForRangeXY', + 'type' => 'integer', + ], + 'user' => [ + 'description' => __('The authentication username for the HTTP proxy.'), + 'default' => '', + 'name' => __('User'), + 'test' => 'testForEmpty', + 'type' => 'string', + ], + 'password' => [ + 'description' => __('The authentication password for the HTTP proxy.'), + 'default' => '', + 'name' => __('Password'), + 'test' => 'testForEmpty', + 'type' => 'string', + ], + ], + 'Proxy2' => [ + 'host' => [ + 'description' => __('The hostname of an HTTP proxy for outgoing sync requests. Leave empty to not use a proxy.'), + 'default' => '', + 'name' => __('Host'), + 'test' => 'testHostname', + 'type' => 'string', + ], + 'port' => [ + 'description' => __('The TCP port for the HTTP proxy.'), + 'default' => '', + 'name' => __('Port'), + 'test' => 'testForRangeXY', + 'type' => 'integer', + ], + 'user' => [ + 'description' => __('The authentication username for the HTTP proxy.'), + 'default' => '', + 'name' => __('User'), + 'test' => 'testForEmpty', + 'type' => 'string', + ], + 'password' => [ + 'description' => __('The authentication password for the HTTP proxy.'), + 'default' => '', + 'name' => __('Password'), + 'test' => 'testForEmpty', + 'type' => 'string', + ], + ], + ], + 'UI' => [ + 'dark' => [ + 'description' => __('Enable the dark theme of the application'), + 'default' => false, + 'name' => __('Dark theme'), + 'type' => 'boolean', + ], + ], + ], + 'Features' => [ + ], + 'Security' => [ + ], + ]; + } +} + diff --git a/src/Model/Table/SettingsTable.php b/src/Model/Table/SettingsTable.php new file mode 100644 index 0000000..beb6a69 --- /dev/null +++ b/src/Model/Table/SettingsTable.php @@ -0,0 +1,28 @@ +setTable(false); + $this->SettingsProvider = TableRegistry::getTableLocator()->get('SettingsProvider'); + } + + public function getSettings(): array + { + $settings = Configure::read()['Cerebrate']; + $settingsProvider = $this->SettingsProvider->getSettingsConfiguration($settings); + return [ + 'settings' => $settings, + 'settingsProvider' => $settingsProvider + ]; + } +} diff --git a/templates/Instance/settings.php b/templates/Instance/settings.php new file mode 100644 index 0000000..309694f --- /dev/null +++ b/templates/Instance/settings.php @@ -0,0 +1,268 @@ + +
+
+ +
+ +
+ + $level1Setting) { + if (!empty($level1Setting)) { + $content0[] = genLevel1($level1Setting, $appView); + } else { + $content0[] = __('No Settings available yet'); + } + } + $tabsOptions0 = [ + // 'vertical' => true, + // 'vertical-size' => 2, + 'card' => false, + 'pills' => false, + 'justify' => 'center', + 'content-class' => ['mt-2'], + 'data' => [ + 'navs' => $level0, + 'content' => $content0 + ] + ]; + $table0 = $appView->Bootstrap->tabs($tabsOptions0); + return $table0; +} + +function genLevel1($level1Setting, $appView) +{ + $content1 = []; + $nav1 = []; + foreach ($level1Setting as $level2Name => $level2Setting) { + if (!empty($level2Setting)) { + $content1[] = genLevel2($level2Name, $level2Setting, $appView); + } else { + $content1[] = ''; + } + $nav1[$level2Name] = array_filter( // only show grouped settings + array_keys($level2Setting), + function ($settingGroupName) use ($level2Setting) { + return !isLeaf($level2Setting[$settingGroupName]); + } + ); + } + $contentHtml = implode('', $content1); + $scrollspyNav = genScrollspyNav($nav1); + $mainPanelHeight = 'calc(100vh - 8px - 42px - 1rem - 56px - 38px - 1rem)'; + $container = '
'; + $container .= "
{$scrollspyNav}
"; + $container .= "
{$contentHtml}
"; + $container .= '
'; + return $container; +} + +function genLevel2($level2Name, $level2Setting, $appView) +{ + foreach ($level2Setting as $level3Name => $level3Setting) { + if (!empty($level3Setting)) { + $level3 = genLevel3($level2Name, $level3Name, $level3Setting, $appView); + $content2[] = sprintf('
%s
', sprintf('sp-%s', h($level2Name)), $level3); + } else { + $content2[] = ''; + } + } + return implode('', $content2); +} + +function genLevel3($level2Name, $settingGroupName, $setting, $appView) +{ + $settingGroup = ''; + if (isLeaf($setting)) { + $tmp = genSingleSetting($settingGroupName, $setting, $appView); + $settingGroup = "
{$tmp}
"; + } else { + $tmpID = sprintf('sp-%s-%s', h($level2Name), h($settingGroupName)); + $settingGroup .= sprintf('

%s

', $tmpID, $tmpID, h($settingGroupName)); + foreach ($setting as $singleSettingName => $singleSetting) { + $tmp = genSingleSetting($singleSettingName, $singleSetting, $appView); + $settingGroup .= sprintf('
%s
', $tmp); + } + $settingGroup = $appView->Bootstrap->genNode('div', [ + 'class' => ['shadow', 'p-2', 'mb-4', 'rounded', ($appView->get('darkMode') ? 'bg-dark' : 'bg-light')], + ], $settingGroup); + } + return $settingGroup; +} + +function genSingleSetting($settingName, $setting, $appView) +{ + $label = $appView->Bootstrap->genNode('label', [ + 'class' => ['font-weight-bolder', 'mb-0'], + 'for' => $settingName + ], h($setting['name'])); + $description = ''; + if (!empty($setting['description'])) { + $description = $appView->Bootstrap->genNode('small', [ + 'class' => ['form-text', 'text-muted', 'mt-0'], + 'id' => "{$settingName}Help" + ], h($setting['description'])); + } + $inputGroup = ''; + if (empty($setting['type'])) { + $setting['type'] = 'string'; + } + if ($setting['type'] == 'string') { + $input = genInputString($settingName, $setting, $appView); + } elseif ($setting['type'] == 'boolean') { + $input = genInputCheckbox($settingName, $setting, $appView); + $description = ''; + } elseif ($setting['type'] == 'integer') { + $input = genInputInteger($settingName, $setting, $appView); + } elseif ($setting['type'] == 'select') { + $input = genInputSelect($settingName, $setting, $appView); + } elseif ($setting['type'] == 'multi-select') { + $input = genInputMultiSelect($settingName, $setting, $appView); + } else { + $input = genInputString($settingName, $setting, $appView); + } + $container = $appView->Bootstrap->genNode('div', [ + 'class' => ['form-group', 'mb-2'] + ], implode('', [$label, $input, $description])); + return $container; +} + +function genInputString($settingName, $setting, $appView) +{ + return $appView->Bootstrap->genNode('input', [ + 'class' => [ + 'form-control' + ], + 'type' => 'text', + 'id' => $settingName, + 'value' => isset($setting['value']) ? $setting['value'] : "", + 'aria-describedby' => "{$settingName}Help" + ]); +} +function genInputCheckbox($settingName, $setting, $appView) +{ + $switch = $appView->Bootstrap->genNode('input', [ + 'class' => [ + 'custom-control-input' + ], + 'type' => 'checkbox', + 'value' => !empty($setting['value']) ? 1 : 0, + 'checked' => !empty($setting['value']) ? 'checked' : '', + 'id' => $settingName, + ]); + $label = $appView->Bootstrap->genNode('label', [ + 'class' => [ + 'custom-control-label' + ], + 'for' => $settingName, + ], h($setting['description'])); + $container = $appView->Bootstrap->genNode('div', [ + 'class' => [ + 'custom-control', + 'custom-switch', + ], + ], implode('', [$switch, $label])); + return $container; +} +function genInputInteger($settingName, $setting, $appView) +{ + return $appView->Bootstrap->genNode('input', [ + 'class' => [ + 'form-control' + ], + 'params' => [ + 'type' => 'integer', + 'id' => $settingName, + 'aria-describedby' => "{$settingName}Help" + ] + ]); +} +function genInputSelect($settingName, $setting, $appView) +{ +} +function genInputMultiSelect($settingName, $setting, $appView) +{ +} + +function genScrollspyNav($nav1) +{ + $nav = ''; + return $nav; +} + +function isLeaf($setting) +{ + return !empty($setting['name']) && !empty($setting['type']); +} + +?> + + + + \ No newline at end of file From f2d5c65fed402a1fee13aab46d839fae460123b8 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Tue, 20 Jul 2021 11:24:37 +0200 Subject: [PATCH 03/35] new: [bootstrapHelper:listGroup] Added list group support --- src/View/Helper/BootstrapHelper.php | 102 ++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/src/View/Helper/BootstrapHelper.php b/src/View/Helper/BootstrapHelper.php index 90f2ff4..05ce6fe 100644 --- a/src/View/Helper/BootstrapHelper.php +++ b/src/View/Helper/BootstrapHelper.php @@ -111,6 +111,12 @@ class BootstrapHelper extends Helper 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); @@ -1276,4 +1282,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
  • -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']) : ''; + } } \ No newline at end of file From d501969c1d69635bc0d10c84c785ba6fc771e376 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Tue, 20 Jul 2021 11:54:55 +0200 Subject: [PATCH 04/35] chg: [instance:settings] Added notice if setting have issues --- src/Controller/InstanceController.php | 3 +- src/Model/Table/SettingsProviderTable.php | 163 +++++++++++++++++++--- src/Model/Table/SettingsTable.php | 18 ++- templates/Instance/settings.php | 98 ++++++++++++- webroot/css/bootstrap-additional.css | 20 +-- 5 files changed, 263 insertions(+), 39 deletions(-) diff --git a/src/Controller/InstanceController.php b/src/Controller/InstanceController.php index 336c413..074e59a 100644 --- a/src/Controller/InstanceController.php +++ b/src/Controller/InstanceController.php @@ -105,8 +105,9 @@ class InstanceController extends AppController public function settings() { $this->Settings = $this->getTableLocator()->get('Settings'); - $all = $this->Settings->getSettings(); + $all = $this->Settings->getSettings(true); $this->set('settingsProvider', $all['settingsProvider']); $this->set('settings', $all['settings']); + $this->set('notices', $all['notices']); } } diff --git a/src/Model/Table/SettingsProviderTable.php b/src/Model/Table/SettingsProviderTable.php index 8040482..ccc3eba 100644 --- a/src/Model/Table/SettingsProviderTable.php +++ b/src/Model/Table/SettingsProviderTable.php @@ -7,12 +7,20 @@ 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(); } /** @@ -28,26 +36,101 @@ class SettingsProviderTable extends AppTable } return $settingConf; } - - private function mergeSettingsIntoSettingConfiguration($settingConf, $settings) + + /** + * 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): array { foreach ($settingConf as $key => $value) { if ($this->isLeaf($value)) { if (isset($settings[$key])) { $settingConf[$key]['value'] = $settings[$key]; } + $settingConf[$key] = $this->evaluateLeaf($settingConf[$key], $settingConf); } else { $settingConf[$key] = $this->mergeSettingsIntoSettingConfiguration($value, $settings); } } return $settingConf; } + + /** + * 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->isLeaf($value)) { + if (!empty($value['error'])) { + if (empty($notices[$value['severity']])) { + $notices[$value['severity']] = []; + } + $notices[$value['severity']][] = $key; + } + } else { + $notices = array_merge($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 (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']); + } + } + if (!$skipValidation) { + if (isset($setting['test'])) { + $error = false; + $setting['value'] = $setting['value'] ?? ''; + if (is_callable($setting['test'])) { // Validate with anonymous function + $error = $setting['test']($setting['value'], $setting); + } else if (method_exists($this->settingValidator, $setting['test'])) { // Validate with function defined in settingValidator class + $error = $this->settingValidator->{$setting['test']}($setting['value'], $setting); + } else { + $validator = new Validator(); + if (method_exists($validator, $setting['test'])) { // Validate with cake's validator function + $validator->{$setting['test']}; + $error = $validator->validate($setting['value']); + } + } + if ($error !== true) { + $setting['severity'] = $setting['severity'] ?? 'warning'; + if (!in_array($setting['severity'], $this->severities)) { + $setting['severity'] = 'warning'; + } + $setting['errorMessage'] = $error; + } + $setting['error'] = $error !== false ? true : false; + } + } + return $setting; + } + /** * Support up to 3 level: * Application -> Network -> Proxy -> Proxy.URL @@ -60,17 +143,17 @@ class SettingsProviderTable extends AppTable 'Application' => [ 'General' => [ 'Essentials' => [ - 'baseurl' => [ + 'app.baseurl' => [ '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.'), - 'errorMessage' => __('The currently set baseurl does not match the URL through which you have accessed the page. Disregard this if you are accessing the page via an alternate URL (for example via IP address).'), + 'severity' => 'critical', 'default' => '', 'name' => __('Base URL'), 'test' => 'testBaseURL', 'type' => 'string', ], - 'uuid' => [ + 'app.uuid' => [ 'description' => __('The Cerebrate instance UUID. This UUID is used to identify this instance.'), - 'errorMessage' => __('No valid UUID set'), + 'severity' => 'critical', 'default' => '', 'name' => 'UUID', 'test' => 'testUuid', @@ -81,8 +164,11 @@ class SettingsProviderTable extends AppTable 'to-del' => [ 'description' => 'to del', 'errorMessage' => 'to del', - 'default' => '', + 'default' => 'A-default-value', 'name' => 'To DEL', + 'test' => function ($value) { + return empty($value) ? __('Oh not! it\'s not valid!') : ''; + }, 'type' => 'string' ], 'to-del2' => [ @@ -110,32 +196,34 @@ class SettingsProviderTable extends AppTable ], 'Network' => [ 'Proxy' => [ - 'host' => [ + 'proxy.host' => [ 'description' => __('The hostname of an HTTP proxy for outgoing sync requests. Leave empty to not use a proxy.'), 'default' => '', 'name' => __('Host'), 'test' => 'testHostname', 'type' => 'string', ], - 'port' => [ + 'proxy.port' => [ 'description' => __('The TCP port for the HTTP proxy.'), 'default' => '', 'name' => __('Port'), 'test' => 'testForRangeXY', 'type' => 'integer', ], - 'user' => [ + 'proxy.user' => [ 'description' => __('The authentication username for the HTTP proxy.'), - 'default' => '', + 'default' => 'admin', 'name' => __('User'), - 'test' => 'testForEmpty', + 'test' => 'testEmptyBecomesDefault', + 'dependsOn' => 'proxy.host', 'type' => 'string', ], - 'password' => [ + 'proxy.password' => [ 'description' => __('The authentication password for the HTTP proxy.'), 'default' => '', 'name' => __('Password'), - 'test' => 'testForEmpty', + 'test' => 'testEmptyBecomesDefault', + 'dependsOn' => 'proxy.host', 'type' => 'string', ], ], @@ -158,20 +246,20 @@ class SettingsProviderTable extends AppTable 'description' => __('The authentication username for the HTTP proxy.'), 'default' => '', 'name' => __('User'), - 'test' => 'testForEmpty', + 'test' => 'testEmptyBecomesDefault', 'type' => 'string', ], 'password' => [ 'description' => __('The authentication password for the HTTP proxy.'), 'default' => '', 'name' => __('Password'), - 'test' => 'testForEmpty', + 'test' => 'testEmptyBecomesDefault', 'type' => 'string', ], ], ], 'UI' => [ - 'dark' => [ + 'app.ui.dark' => [ 'description' => __('Enable the dark theme of the application'), 'default' => false, 'name' => __('Dark theme'), @@ -179,11 +267,48 @@ class SettingsProviderTable extends AppTable ], ], ], - 'Features' => [ - ], 'Security' => [ ], + 'Features' => [ + ], ]; } } +class SettingValidator +{ + + public function testEmptyBecomesDefault($value, $setting) + { + if (!empty($value)) { + return true; + } else if (!empty($setting['default'])) { + return __('Setting is not set, fallback to default value: {0}', $setting['default']); + } else { + return __('Cannot be empty'); + } + } + + 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; + } +} \ No newline at end of file diff --git a/src/Model/Table/SettingsTable.php b/src/Model/Table/SettingsTable.php index beb6a69..ac79cc7 100644 --- a/src/Model/Table/SettingsTable.php +++ b/src/Model/Table/SettingsTable.php @@ -16,13 +16,19 @@ class SettingsTable extends AppTable $this->SettingsProvider = TableRegistry::getTableLocator()->get('SettingsProvider'); } - public function getSettings(): array + public function getSettings($full=false): array { $settings = Configure::read()['Cerebrate']; - $settingsProvider = $this->SettingsProvider->getSettingsConfiguration($settings); - return [ - 'settings' => $settings, - 'settingsProvider' => $settingsProvider - ]; + if (empty($full)) { + return $settings; + } else { + $settingsProvider = $this->SettingsProvider->getSettingsConfiguration($settings); + $notices = $this->SettingsProvider->getNoticesFromSettingsConfiguration($settingsProvider, $settings); + return [ + 'settings' => $settings, + 'settingsProvider' => $settingsProvider, + 'notices' => $notices, + ]; + } } } diff --git a/templates/Instance/settings.php b/templates/Instance/settings.php index 309694f..daea2bf 100644 --- a/templates/Instance/settings.php +++ b/templates/Instance/settings.php @@ -1,9 +1,73 @@ __('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.'), +]; +$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.'), +]; +$headingPerLevel = [ + 'critical' => __('Critical settings'), + 'warning' => __('Warning settings'), + 'info' => __('Info settings'), +]; + $settingTable = genLevel0($settingsProvider, $this); +$alertVariant = 'info'; +$alertBody = ''; +$skipHeading = false; +$tableItems = []; +foreach (array_keys($mainNoticeHeading) as $level) { + if(!empty($notices[$level])) { + $variant = $level == 'critical' ? 'danger' : $level; + if (!$skipHeading) { + $alertBody .= sprintf('
    %s
    ', $mainNoticeHeading[$level]); + $alertVariant = $variant; + $skipHeading = true; + } + $tableItems[] = [ + 'severity' => $headingPerLevel[$level], + 'issues' => count($notices[$level]), + 'badge-variant' => $variant, + 'description' => $noticeDescriptionPerLevel[$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([ + 'variant' => $alertVariant, + 'html' => $alertBody +]); ?>
    +
    + +
    @@ -28,7 +92,7 @@ function genLevel0($settingsProvider, $appView) 'card' => false, 'pills' => false, 'justify' => 'center', - 'content-class' => ['mt-2'], + 'content-class' => [''], 'data' => [ 'navs' => $level0, 'content' => $content0 @@ -100,10 +164,21 @@ function genLevel3($level2Name, $settingGroupName, $setting, $appView) function genSingleSetting($settingName, $setting, $appView) { + $dependsOnHtml = ''; + if (!empty($setting['dependsOn'])) { + $dependsOnHtml = $appView->Bootstrap->genNode('span', [ + ], $appView->Bootstrap->genNode('sup', [ + 'class' => [ + $appView->FontAwesome->getClass('info'), + 'ml-1', + ], + 'title' => __('This setting depends on the validity of: {0}', h($setting['dependsOn'])) + ])); + } $label = $appView->Bootstrap->genNode('label', [ 'class' => ['font-weight-bolder', 'mb-0'], 'for' => $settingName - ], h($setting['name'])); + ], h($setting['name']) . $dependsOnHtml); $description = ''; if (!empty($setting['description'])) { $description = $appView->Bootstrap->genNode('small', [ @@ -111,7 +186,16 @@ function genSingleSetting($settingName, $setting, $appView) 'id' => "{$settingName}Help" ], h($setting['description'])); } - $inputGroup = ''; + $error = ''; + if (!empty($setting['error'])) { + $textColor = ''; + if ($setting['severity'] != 'critical') { + $textColor = "text-{$setting['severity']}"; + } + $error = $appView->Bootstrap->genNode('div', [ + 'class' => ['d-block', 'invalid-feedback', $textColor], + ], h($setting['errorMessage'])); + } if (empty($setting['type'])) { $setting['type'] = 'string'; } @@ -131,19 +215,23 @@ function genSingleSetting($settingName, $setting, $appView) } $container = $appView->Bootstrap->genNode('div', [ 'class' => ['form-group', 'mb-2'] - ], implode('', [$label, $input, $description])); + ], implode('', [$label, $input, $description, $error])); return $container; } function genInputString($settingName, $setting, $appView) { + // debug($setting); return $appView->Bootstrap->genNode('input', [ 'class' => [ - 'form-control' + 'form-control', + (!empty($setting['error']) ? 'is-invalid' : ''), + (!empty($setting['error']) ? ($setting['severity'] != 'critical' ? "border-{$setting['severity']} warning" : '') : ''), ], 'type' => 'text', 'id' => $settingName, 'value' => isset($setting['value']) ? $setting['value'] : "", + 'placeholder' => $setting['default'] ?? '', 'aria-describedby' => "{$settingName}Help" ]); } diff --git a/webroot/css/bootstrap-additional.css b/webroot/css/bootstrap-additional.css index 50fb5a9..c01ce3b 100644 --- a/webroot/css/bootstrap-additional.css +++ b/webroot/css/bootstrap-additional.css @@ -109,31 +109,35 @@ div.progress-timeline .progress-line.progress-inactive { opacity: 0.5; } -.bd-callout { +.callout { border: 1px solid #eee; border-left-color: rgb(238, 238, 238); border-left-width: 1px; border-left-width: .25rem; border-radius: .25rem; } -.bd-callout-primary { +.callout-primary { border-left-color: var(--primary); } -.bd-callout-info { +.callout-info { border-left-color: var(--info); } -.bd-callout-success { +.callout-success { border-left-color: var(--success); } -.bd-callout-warning { +.callout-warning { border-left-color: var(--warning); } -.bd-callout-danger { +.callout-danger { border-left-color: var(--danger); } -.bd-callout-dark { +.callout-dark { border-left-color: var(--dark); } -.bd-callout-light { +.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") } \ No newline at end of file From 9f4fbf0410cac08fd8bf0028376837051b9d6fd1 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Wed, 21 Jul 2021 11:18:06 +0200 Subject: [PATCH 05/35] chg: [instance:settings] Improved UI interface Added searches and notices for individual settings --- src/Controller/Component/ACLComponent.php | 4 + src/Controller/InstanceController.php | 1 + src/Model/Table/SettingsProviderTable.php | 89 +++++++----- src/Model/Table/SettingsTable.php | 2 + templates/Instance/settings.php | 169 +++++++++++++++++----- templates/layout/default.php | 4 + webroot/css/select2-bootstrap4.min.css | 2 + webroot/css/select2.min.css | 1 + webroot/js/select2.min.js | 2 + 9 files changed, 204 insertions(+), 70 deletions(-) create mode 100644 webroot/css/select2-bootstrap4.min.css create mode 100644 webroot/css/select2.min.css create mode 100644 webroot/js/select2.min.js diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php index a1f06ea..fc8966c 100644 --- a/src/Controller/Component/ACLComponent.php +++ b/src/Controller/Component/ACLComponent.php @@ -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') diff --git a/src/Controller/InstanceController.php b/src/Controller/InstanceController.php index 074e59a..11ea1c9 100644 --- a/src/Controller/InstanceController.php +++ b/src/Controller/InstanceController.php @@ -108,6 +108,7 @@ class InstanceController extends AppController $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']); } } diff --git a/src/Model/Table/SettingsProviderTable.php b/src/Model/Table/SettingsProviderTable.php index ccc3eba..acede80 100644 --- a/src/Model/Table/SettingsProviderTable.php +++ b/src/Model/Table/SettingsProviderTable.php @@ -44,20 +44,37 @@ class SettingsProviderTable extends AppTable * @param array $settings the settings * @return void */ - private function mergeSettingsIntoSettingConfiguration(array $settingConf, array $settings): array + private function mergeSettingsIntoSettingConfiguration(array $settingConf, array $settings, string $path=''): array { foreach ($settingConf as $key => $value) { if ($this->isLeaf($value)) { if (isset($settings[$key])) { $settingConf[$key]['value'] = $settings[$key]; } + if (empty($settingConf[$key]['severity'])) { + $settingConf[$key]['severity'] = 'warning'; + } $settingConf[$key] = $this->evaluateLeaf($settingConf[$key], $settingConf); + $settingConf[$key]['setting-path'] = $path; } else { - $settingConf[$key] = $this->mergeSettingsIntoSettingConfiguration($value, $settings); + $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->isLeaf($value)) { + $flattenedSettings[$key] = $value; + } else { + $flattenedSettings = $this->flattenSettingsConfiguration($value, $flattenedSettings); + } + } + return $flattenedSettings; + } /** * getNoticesFromSettingsConfiguration Summarize the validation errors @@ -175,14 +192,14 @@ class SettingsProviderTable extends AppTable 'description' => 'to del', 'errorMessage' => 'to del', 'default' => '', - 'name' => 'To DEL', + 'name' => 'To DEL 2', 'type' => 'string' ], 'to-del3' => [ 'description' => 'to del', 'errorMessage' => 'to del', 'default' => '', - 'name' => 'To DEL', + 'name' => 'To DEL 2', 'type' => 'string' ], ], @@ -227,36 +244,6 @@ class SettingsProviderTable extends AppTable 'type' => 'string', ], ], - 'Proxy2' => [ - 'host' => [ - 'description' => __('The hostname of an HTTP proxy for outgoing sync requests. Leave empty to not use a proxy.'), - 'default' => '', - 'name' => __('Host'), - 'test' => 'testHostname', - 'type' => 'string', - ], - 'port' => [ - 'description' => __('The TCP port for the HTTP proxy.'), - 'default' => '', - 'name' => __('Port'), - 'test' => 'testForRangeXY', - 'type' => 'integer', - ], - 'user' => [ - 'description' => __('The authentication username for the HTTP proxy.'), - 'default' => '', - 'name' => __('User'), - 'test' => 'testEmptyBecomesDefault', - 'type' => 'string', - ], - 'password' => [ - 'description' => __('The authentication password for the HTTP proxy.'), - 'default' => '', - 'name' => __('Password'), - 'test' => 'testEmptyBecomesDefault', - 'type' => 'string', - ], - ], ], 'UI' => [ 'app.ui.dark' => [ @@ -268,6 +255,40 @@ class SettingsProviderTable extends AppTable ], ], 'Security' => [ + 'Network' => [ + 'Proxy Test' => [ + 'proxy-test.host' => [ + 'description' => __('The hostname of an HTTP proxy for outgoing sync requests. Leave empty to not use a proxy.'), + 'default' => '', + 'name' => __('Host'), + 'test' => 'testHostname', + 'type' => 'string', + ], + 'proxy-test.port' => [ + 'description' => __('The TCP port for the HTTP proxy.'), + 'default' => '', + 'name' => __('Port'), + 'test' => 'testForRangeXY', + 'type' => 'integer', + ], + 'proxy-test.user' => [ + 'description' => __('The authentication username for the HTTP proxy.'), + 'default' => '', + 'dependsOn' => 'host', + 'name' => __('User'), + 'test' => 'testEmptyBecomesDefault', + 'type' => 'string', + ], + 'proxy-test.password' => [ + 'description' => __('The authentication password for the HTTP proxy.'), + 'default' => '', + 'dependsOn' => 'host', + 'name' => __('Password'), + 'test' => 'testEmptyBecomesDefault', + 'type' => 'string', + ], + ], + ] ], 'Features' => [ ], diff --git a/src/Model/Table/SettingsTable.php b/src/Model/Table/SettingsTable.php index ac79cc7..7a7d130 100644 --- a/src/Model/Table/SettingsTable.php +++ b/src/Model/Table/SettingsTable.php @@ -23,10 +23,12 @@ class SettingsTable extends AppTable 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, ]; } diff --git a/templates/Instance/settings.php b/templates/Instance/settings.php index daea2bf..07d963b 100644 --- a/templates/Instance/settings.php +++ b/templates/Instance/settings.php @@ -17,15 +17,20 @@ $headingPerLevel = [ 'warning' => __('Warning settings'), 'info' => __('Info settings'), ]; +$variantFromSeverity = [ + 'critical' => 'danger', + 'warning' => 'warning', + 'info' => 'info', +]; +$this->set('variantFromSeverity', $variantFromSeverity); -$settingTable = genLevel0($settingsProvider, $this); $alertVariant = 'info'; $alertBody = ''; $skipHeading = false; $tableItems = []; foreach (array_keys($mainNoticeHeading) as $level) { if(!empty($notices[$level])) { - $variant = $level == 'critical' ? 'danger' : $level; + $variant = $variantFromSeverity[$level]; if (!$skipHeading) { $alertBody .= sprintf('
    %s
    ', $mainNoticeHeading[$level]); $alertVariant = $variant; @@ -60,16 +65,17 @@ $alertBody .= $this->Bootstrap->table([ 'items' => $tableItems, ]); $settingNotice = $this->Bootstrap->alert([ + 'dismissible' => false, 'variant' => $alertVariant, 'html' => $alertBody ]); +$settingNotice = sprintf('
    %s
    ', $settingNotice); +$this->set('settingNotice', $settingNotice); +$settingTable = genLevel0($settingsProvider, $this); ?>
    -
    - -
    - +
    @@ -86,13 +92,15 @@ function genLevel0($settingsProvider, $appView) $content0[] = __('No Settings available yet'); } } + array_unshift($level0, __('Setting Diagnostic')); + array_unshift($content0, $appView->get('settingNotice')); $tabsOptions0 = [ // 'vertical' => true, // 'vertical-size' => 2, 'card' => false, 'pills' => false, 'justify' => 'center', - 'content-class' => [''], + 'nav-class' => ['settings-tabs'], 'data' => [ 'navs' => $level0, 'content' => $content0 @@ -124,7 +132,7 @@ function genLevel1($level1Setting, $appView) $mainPanelHeight = 'calc(100vh - 8px - 42px - 1rem - 56px - 38px - 1rem)'; $container = '
    '; $container .= "
    {$scrollspyNav}
    "; - $container .= "
    {$contentHtml}
    "; + $container .= "
    {$contentHtml}
    "; $container .= '
    '; return $container; } @@ -151,12 +159,28 @@ function genLevel3($level2Name, $settingGroupName, $setting, $appView) } else { $tmpID = sprintf('sp-%s-%s', h($level2Name), h($settingGroupName)); $settingGroup .= sprintf('

    %s

    ', $tmpID, $tmpID, h($settingGroupName)); + $groupIssueSeverity = false; foreach ($setting as $singleSettingName => $singleSetting) { $tmp = genSingleSetting($singleSettingName, $singleSetting, $appView); $settingGroup .= sprintf('
    %s
    ', $tmp); + if (!empty($singleSetting['error'])) { + $settingVariant = $appView->get('variantFromSeverity')[$singleSetting['severity']]; + if ($groupIssueSeverity != 'danger') { + if ($groupIssueSeverity != 'warning') { + $groupIssueSeverity = $settingVariant; + } + } + } } $settingGroup = $appView->Bootstrap->genNode('div', [ - 'class' => ['shadow', 'p-2', 'mb-4', 'rounded', ($appView->get('darkMode') ? 'bg-dark' : 'bg-light')], + 'class' => [ + 'shadow', + 'p-2', + 'mb-4', + 'rounded', + (!empty($groupIssueSeverity) ? "callout callout-${groupIssueSeverity}" : ''), + ($appView->get('darkMode') ? 'bg-dark' : 'bg-light') + ], ], $settingGroup); } return $settingGroup; @@ -175,23 +199,22 @@ function genSingleSetting($settingName, $setting, $appView) 'title' => __('This setting depends on the validity of: {0}', h($setting['dependsOn'])) ])); } + $settingId = str_replace('.', '_', $settingName); $label = $appView->Bootstrap->genNode('label', [ 'class' => ['font-weight-bolder', 'mb-0'], - 'for' => $settingName + 'for' => $settingId ], h($setting['name']) . $dependsOnHtml); $description = ''; if (!empty($setting['description'])) { $description = $appView->Bootstrap->genNode('small', [ 'class' => ['form-text', 'text-muted', 'mt-0'], - 'id' => "{$settingName}Help" + 'id' => "{$settingId}Help" ], h($setting['description'])); } $error = ''; if (!empty($setting['error'])) { $textColor = ''; - if ($setting['severity'] != 'critical') { - $textColor = "text-{$setting['severity']}"; - } + $textColor = "text-{$appView->get('variantFromSeverity')[$setting['severity']]}"; $error = $appView->Bootstrap->genNode('div', [ 'class' => ['d-block', 'invalid-feedback', $textColor], ], h($setting['errorMessage'])); @@ -200,18 +223,18 @@ function genSingleSetting($settingName, $setting, $appView) $setting['type'] = 'string'; } if ($setting['type'] == 'string') { - $input = genInputString($settingName, $setting, $appView); + $input = genInputString($settingId, $setting, $appView); } elseif ($setting['type'] == 'boolean') { - $input = genInputCheckbox($settingName, $setting, $appView); + $input = genInputCheckbox($settingId, $setting, $appView); $description = ''; } elseif ($setting['type'] == 'integer') { - $input = genInputInteger($settingName, $setting, $appView); + $input = genInputInteger($settingId, $setting, $appView); } elseif ($setting['type'] == 'select') { - $input = genInputSelect($settingName, $setting, $appView); + $input = genInputSelect($settingId, $setting, $appView); } elseif ($setting['type'] == 'multi-select') { - $input = genInputMultiSelect($settingName, $setting, $appView); + $input = genInputMultiSelect($settingId, $setting, $appView); } else { - $input = genInputString($settingName, $setting, $appView); + $input = genInputString($settingId, $setting, $appView); } $container = $appView->Bootstrap->genNode('div', [ 'class' => ['form-group', 'mb-2'] @@ -219,23 +242,24 @@ function genSingleSetting($settingName, $setting, $appView) return $container; } -function genInputString($settingName, $setting, $appView) +function genInputString($settingId, $setting, $appView) { - // debug($setting); return $appView->Bootstrap->genNode('input', [ 'class' => [ 'form-control', + "xxx-{$appView->get('variantFromSeverity')[$setting['severity']]} yyy-{$setting['severity']}", (!empty($setting['error']) ? 'is-invalid' : ''), - (!empty($setting['error']) ? ($setting['severity'] != 'critical' ? "border-{$setting['severity']} warning" : '') : ''), + (!empty($setting['error']) ? "border-{$appView->get('variantFromSeverity')[$setting['severity']]}" : ''), + (!empty($setting['error']) && $setting['severity'] == 'warning' ? 'warning' : ''), ], 'type' => 'text', - 'id' => $settingName, + 'id' => $settingId, 'value' => isset($setting['value']) ? $setting['value'] : "", 'placeholder' => $setting['default'] ?? '', - 'aria-describedby' => "{$settingName}Help" + 'aria-describedby' => "{$settingId}Help" ]); } -function genInputCheckbox($settingName, $setting, $appView) +function genInputCheckbox($settingId, $setting, $appView) { $switch = $appView->Bootstrap->genNode('input', [ 'class' => [ @@ -244,13 +268,13 @@ function genInputCheckbox($settingName, $setting, $appView) 'type' => 'checkbox', 'value' => !empty($setting['value']) ? 1 : 0, 'checked' => !empty($setting['value']) ? 'checked' : '', - 'id' => $settingName, + 'id' => $settingId, ]); $label = $appView->Bootstrap->genNode('label', [ 'class' => [ 'custom-control-label' ], - 'for' => $settingName, + 'for' => $settingId, ], h($setting['description'])); $container = $appView->Bootstrap->genNode('div', [ 'class' => [ @@ -260,23 +284,23 @@ function genInputCheckbox($settingName, $setting, $appView) ], implode('', [$switch, $label])); return $container; } -function genInputInteger($settingName, $setting, $appView) +function genInputInteger($settingId, $setting, $appView) { return $appView->Bootstrap->genNode('input', [ 'class' => [ 'form-control' ], - 'params' => [ - 'type' => 'integer', - 'id' => $settingName, - 'aria-describedby' => "{$settingName}Help" - ] + 'type' => 'number', + 'min' => '0', + 'step' => 1, + 'id' => $settingId, + 'aria-describedby' => "{$settingId}Help" ]); } -function genInputSelect($settingName, $setting, $appView) +function genInputSelect($settingId, $setting, $appView) { } -function genInputMultiSelect($settingName, $setting, $appView) +function genInputMultiSelect($settingId, $setting, $appView) { } @@ -304,7 +328,21 @@ function isLeaf($setting) ?> + \ No newline at end of file diff --git a/templates/layout/default.php b/templates/layout/default.php index cc8c564..1e2cbde 100644 --- a/templates/layout/default.php +++ b/templates/layout/default.php @@ -41,6 +41,7 @@ $cakeDescription = 'Cerebrate'; Html->script('main.js') ?> Html->script('bootstrap-helper.js') ?> Html->script('api-helper.js') ?> + Html->script('select2.min.js') ?> Html->script('CodeMirror/codemirror.js') ?> Html->script('CodeMirror/mode/javascript/javascript') ?> Html->script('CodeMirror/addon/hint/show-hint') ?> @@ -53,6 +54,8 @@ $cakeDescription = 'Cerebrate'; Html->css('CodeMirror/codemirror-additional') ?> Html->css('CodeMirror/addon/hint/show-hint') ?> Html->css('CodeMirror/addon/lint/lint') ?> + Html->css('select2.min') ?> + Html->css('select2-bootstrap4.min') ?> fetch('meta') ?> fetch('css') ?> fetch('script') ?> @@ -83,5 +86,6 @@ $cakeDescription = 'Cerebrate'; diff --git a/webroot/css/select2-bootstrap4.min.css b/webroot/css/select2-bootstrap4.min.css new file mode 100644 index 0000000..6d36591 --- /dev/null +++ b/webroot/css/select2-bootstrap4.min.css @@ -0,0 +1,2 @@ +.select2-container{display:block}.select2-container *:focus{outline:0}.input-group .select2-container--bootstrap4{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1}.input-group-prepend ~ .select2-container--bootstrap4 .select2-selection{border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.select2-container--bootstrap4:not(:last-child) .select2-selection{border-top-right-radius:0;border-bottom-right-radius:0}.select2-container--bootstrap4 .select2-selection{width:100%;background-color:#fff;border:1px solid #ced4da;border-radius:.25rem;-webkit-transition:border-color 0.15s ease-in-out,-webkit-box-shadow 0.15s ease-in-out;transition:border-color 0.15s ease-in-out,-webkit-box-shadow 0.15s ease-in-out;transition:border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out;transition:border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,-webkit-box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.select2-container--bootstrap4 .select2-selection{-webkit-transition:none;transition:none}}.select2-container--bootstrap4.select2-container--focus .select2-selection{border-color:#80bdff;-webkit-box-shadow:0 0 0 .2rem rgba(0,123,255,0.25);box-shadow:0 0 0 .2rem rgba(0,123,255,0.25)}.select2-container--bootstrap4.select2-container--focus.select2-container--open .select2-selection{border-bottom:none;border-bottom-right-radius:0;border-bottom-left-radius:0}.select2-container--bootstrap4.select2-container--open.select2-container--above .select2-selection{border-top-left-radius:0;border-top-right-radius:0}.select2-container--bootstrap4.select2-container--open.select2-container--below .select2-selection{border-bottom-right-radius:0;border-bottom-left-radius:0}.select2-container--bootstrap4.select2-container--disabled .select2-selection,.select2-container--bootstrap4.select2-container--disabled.select2-container--focus .select2-selection{cursor:not-allowed;background-color:#e9ecef;border-color:#ced4da;-webkit-box-shadow:none;box-shadow:none}.select2-container--bootstrap4.select2-container--disabled .select2-search__field,.select2-container--bootstrap4.select2-container--disabled.select2-container--focus .select2-search__field{background-color:transparent}select.is-invalid ~ .select2-container--bootstrap4 .select2-selection,form.was-validated select:invalid ~ .select2-container--bootstrap4 .select2-selection{border-color:#dc3545}select.is-valid ~ .select2-container--bootstrap4 .select2-selection,form.was-validated select:valid ~ .select2-container--bootstrap4 .select2-selection{border-color:#28a745}.select2-container--bootstrap4 .select2-search{width:100%}.select2-container--bootstrap4 .select2-dropdown{border-color:#ced4da;border-radius:0}.select2-container--bootstrap4 .select2-dropdown.select2-dropdown--below{border-top:none;border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.select2-container--bootstrap4 .select2-dropdown.select2-dropdown--above{border-top:1px solid #ced4da;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.select2-container--bootstrap4 .select2-dropdown .select2-results__option[aria-selected="true"]{color:#212529;background-color:#f2f2f2}.select2-container--bootstrap4 .select2-results__option--highlighted,.select2-container--bootstrap4 .select2-results__option--highlighted.select2-results__option[aria-selected="true"]{color:#fff;background-color:#007bff}.select2-container--bootstrap4 .select2-results__option[role="group"]{padding:0}.select2-container--bootstrap4 .select2-results__option[role="group"] .select2-results__options--nested .select2-results__option{padding-left:1em}.select2-container--bootstrap4 .select2-results__option{padding:.375rem .75rem}.select2-container--bootstrap4 .select2-results>.select2-results__options{max-height:15em;overflow-y:auto}.select2-container--bootstrap4 .select2-results__group{display:list-item;padding:6px;color:#6c757d}.select2-container--bootstrap4 .select2-selection__clear{float:right;width:.9em;height:.9em;padding-left:.15em;margin-top:.7em;margin-right:.3em;line-height:.75em;color:#f8f9fa;background-color:#c8c8c8;border-radius:100%}.select2-container--bootstrap4 .select2-selection__clear:hover{background-color:#afafaf}.select2-container--bootstrap4 .select2-selection--single{height:calc(1.5em + .75rem + 2px) !important}.select2-container--bootstrap4 .select2-selection--single .select2-selection__placeholder{line-height:calc(1.5em + .75rem);color:#6c757d}.select2-container--bootstrap4 .select2-selection--single .select2-selection__arrow{position:absolute;top:50%;right:3px;width:20px}.select2-container--bootstrap4 .select2-selection--single .select2-selection__arrow b{position:absolute;top:60%;left:50%;width:0;height:0;margin-top:-2px;margin-left:-4px;border-color:#343a40 transparent transparent transparent;border-style:solid;border-width:5px 4px 0}.select2-container--bootstrap4 .select2-selection--single .select2-selection__rendered{padding-left:.75rem;line-height:calc(1.5em + .75rem);color:#495057}.select2-search--dropdown .select2-search__field{padding:.375rem .75rem;border:1px solid #ced4da;border-radius:.25rem}.select2-results__message{color:#6c757d}.select2-container--bootstrap4 .select2-selection--multiple{min-height:calc(1.5em + .75rem + 2px) !important}.select2-container--bootstrap4 .select2-selection--multiple .select2-selection__rendered{-webkit-box-sizing:border-box;box-sizing:border-box;width:100%;padding:0 .375rem;margin:0;list-style:none}.select2-container--bootstrap4 .select2-selection--multiple .select2-selection__choice{float:left;padding:0;padding-right:.75rem;margin-top:calc(.375rem - 2px);margin-right:.375rem;color:#495057;cursor:pointer;border:1px solid #bdc6d0;border-radius:.2rem}.select2-container--bootstrap4 .select2-selection--multiple .select2-search__field{color:#495057}.select2-container--bootstrap4 .select2-selection--multiple .select2-selection__choice+.select2-search{width:0}.select2-container--bootstrap4 .select2-selection--multiple .select2-selection__choice__remove{float:left;padding-right:3px;padding-left:3px;margin-right:1px;margin-left:3px;font-weight:700;color:#bdc6d0}.select2-container--bootstrap4 .select2-selection--multiple .select2-selection__choice__remove:hover{color:#343a40}.select2-container--bootstrap4 .select2-selection--multiple .select2-selection__clear{position:absolute !important;top:0;right:.7em;float:none;margin-right:0}.select2-container--bootstrap4.select2-container--disabled .select2-selection--multiple .select2-selection__choice{padding:0 5px;cursor:not-allowed}.select2-container--bootstrap4.select2-container--disabled .select2-selection--multiple .select2-selection__choice .select2-selection__choice__remove{display:none} + diff --git a/webroot/css/select2.min.css b/webroot/css/select2.min.css new file mode 100644 index 0000000..39a4547 --- /dev/null +++ b/webroot/css/select2.min.css @@ -0,0 +1 @@ +.select2-container{box-sizing:border-box;display:inline-block;margin:0;position:relative;vertical-align:middle}.select2-container .select2-selection--single{box-sizing:border-box;cursor:pointer;display:block;height:28px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--single .select2-selection__rendered{display:block;padding-left:8px;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-selection--single .select2-selection__clear{background-color:transparent;border:none;font-size:1em}.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered{padding-right:8px;padding-left:20px}.select2-container .select2-selection--multiple{box-sizing:border-box;cursor:pointer;display:block;min-height:32px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--multiple .select2-selection__rendered{display:inline;list-style:none;padding:0}.select2-container .select2-selection--multiple .select2-selection__clear{background-color:transparent;border:none;font-size:1em}.select2-container .select2-search--inline .select2-search__field{box-sizing:border-box;border:none;font-size:100%;margin-top:5px;margin-left:5px;padding:0;max-width:100%;resize:none;height:18px;vertical-align:bottom;font-family:sans-serif;overflow:hidden;word-break:keep-all}.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-dropdown{background-color:white;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:block;position:absolute;left:-100000px;width:100%;z-index:1051}.select2-results{display:block}.select2-results__options{list-style:none;margin:0;padding:0}.select2-results__option{padding:6px;user-select:none;-webkit-user-select:none}.select2-results__option--selectable{cursor:pointer}.select2-container--open .select2-dropdown{left:0}.select2-container--open .select2-dropdown--above{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--open .select2-dropdown--below{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-search--dropdown{display:block;padding:4px}.select2-search--dropdown .select2-search__field{padding:4px;width:100%;box-sizing:border-box}.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-search--dropdown.select2-search--hide{display:none}.select2-close-mask{border:0;margin:0;padding:0;display:block;position:fixed;left:0;top:0;min-height:100%;min-width:100%;height:auto;width:auto;opacity:0;z-index:99;background-color:#fff;filter:alpha(opacity=0)}.select2-hidden-accessible{border:0 !important;clip:rect(0 0 0 0) !important;-webkit-clip-path:inset(50%) !important;clip-path:inset(50%) !important;height:1px !important;overflow:hidden !important;padding:0 !important;position:absolute !important;width:1px !important;white-space:nowrap !important}.select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #aaa;border-radius:4px}.select2-container--default .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--default .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;height:26px;margin-right:20px;padding-right:0px}.select2-container--default .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--default .select2-selection--single .select2-selection__arrow{height:26px;position:absolute;top:1px;right:1px;width:20px}.select2-container--default .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow{left:1px;right:auto}.select2-container--default.select2-container--disabled .select2-selection--single{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear{display:none}.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--default .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;padding-bottom:5px;padding-right:5px;position:relative}.select2-container--default .select2-selection--multiple.select2-selection--clearable{padding-right:25px}.select2-container--default .select2-selection--multiple .select2-selection__clear{cursor:pointer;font-weight:bold;height:20px;margin-right:10px;margin-top:5px;position:absolute;right:0;padding:1px}.select2-container--default .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:inline-block;margin-left:5px;margin-top:5px;padding:0;padding-left:20px;position:relative;max-width:100%;overflow:hidden;text-overflow:ellipsis;vertical-align:bottom;white-space:nowrap}.select2-container--default .select2-selection--multiple .select2-selection__choice__display{cursor:default;padding-left:2px;padding-right:5px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove{background-color:transparent;border:none;border-right:1px solid #aaa;border-top-left-radius:4px;border-bottom-left-radius:4px;color:#999;cursor:pointer;font-size:1em;font-weight:bold;padding:0 4px;position:absolute;left:0;top:0}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover,.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:focus{background-color:#f1f1f1;color:#333;outline:none}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__display{padding-left:5px;padding-right:2px}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{border-left:1px solid #aaa;border-right:none;border-top-left-radius:0;border-bottom-left-radius:0;border-top-right-radius:4px;border-bottom-right-radius:4px}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__clear{float:left;margin-left:10px;margin-right:auto}.select2-container--default.select2-container--focus .select2-selection--multiple{border:solid black 1px;outline:0}.select2-container--default.select2-container--disabled .select2-selection--multiple{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection__choice__remove{display:none}.select2-container--default.select2-container--open.select2-container--above .select2-selection--single,.select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple{border-top-left-radius:0;border-top-right-radius:0}.select2-container--default.select2-container--open.select2-container--below .select2-selection--single,.select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--default .select2-search--dropdown .select2-search__field{border:1px solid #aaa}.select2-container--default .select2-search--inline .select2-search__field{background:transparent;border:none;outline:0;box-shadow:none;-webkit-appearance:textfield}.select2-container--default .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--group{padding:0}.select2-container--default .select2-results__option--disabled{color:#999}.select2-container--default .select2-results__option--selected{background-color:#ddd}.select2-container--default .select2-results__option--highlighted.select2-results__option--selectable{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;height:26px;margin-right:20px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0;padding-bottom:5px;padding-right:5px}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;display:inline-block;margin-left:5px;margin-top:5px;padding:0}.select2-container--classic .select2-selection--multiple .select2-selection__choice__display{cursor:default;padding-left:2px;padding-right:5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{background-color:transparent;border:none;border-top-left-radius:4px;border-bottom-left-radius:4px;color:#888;cursor:pointer;font-size:1em;font-weight:bold;padding:0 4px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555;outline:none}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__display{padding-left:5px;padding-right:2px}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{border-top-left-radius:0;border-bottom-left-radius:0;border-top-right-radius:4px;border-bottom-right-radius:4px}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option--group{padding:0}.select2-container--classic .select2-results__option--disabled{color:grey}.select2-container--classic .select2-results__option--highlighted.select2-results__option--selectable{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb} diff --git a/webroot/js/select2.min.js b/webroot/js/select2.min.js new file mode 100644 index 0000000..87c0c56 --- /dev/null +++ b/webroot/js/select2.min.js @@ -0,0 +1,2 @@ +/*! Select2 4.1.0-rc.0 | https://github.com/select2/select2/blob/master/LICENSE.md */ +!function(n){"function"==typeof define&&define.amd?define(["jquery"],n):"object"==typeof module&&module.exports?module.exports=function(e,t){return void 0===t&&(t="undefined"!=typeof window?require("jquery"):require("jquery")(e)),n(t),t}:n(jQuery)}(function(t){var e,n,s,p,r,o,h,f,g,m,y,v,i,a,_,s=(t&&t.fn&&t.fn.select2&&t.fn.select2.amd&&(u=t.fn.select2.amd),u&&u.requirejs||(u?n=u:u={},g={},m={},y={},v={},i=Object.prototype.hasOwnProperty,a=[].slice,_=/\.js$/,h=function(e,t){var n,s,i=c(e),r=i[0],t=t[1];return e=i[1],r&&(n=x(r=l(r,t))),r?e=n&&n.normalize?n.normalize(e,(s=t,function(e){return l(e,s)})):l(e,t):(r=(i=c(e=l(e,t)))[0],e=i[1],r&&(n=x(r))),{f:r?r+"!"+e:e,n:e,pr:r,p:n}},f={require:function(e){return w(e)},exports:function(e){var t=g[e];return void 0!==t?t:g[e]={}},module:function(e){return{id:e,uri:"",exports:g[e],config:(t=e,function(){return y&&y.config&&y.config[t]||{}})};var t}},r=function(e,t,n,s){var i,r,o,a,l,c=[],u=typeof n,d=A(s=s||e);if("undefined"==u||"function"==u){for(t=!t.length&&n.length?["require","exports","module"]:t,a=0;a":">",'"':""","'":"'","/":"/"};return"string"!=typeof e?e:String(e).replace(/[&<>"'\/\\]/g,function(e){return t[e]})},s.__cache={};var n=0;return s.GetUniqueElementId=function(e){var t=e.getAttribute("data-select2-id");return null!=t||(t=e.id?"select2-data-"+e.id:"select2-data-"+(++n).toString()+"-"+s.generateChars(4),e.setAttribute("data-select2-id",t)),t},s.StoreData=function(e,t,n){e=s.GetUniqueElementId(e);s.__cache[e]||(s.__cache[e]={}),s.__cache[e][t]=n},s.GetData=function(e,t){var n=s.GetUniqueElementId(e);return t?s.__cache[n]&&null!=s.__cache[n][t]?s.__cache[n][t]:r(e).data(t):s.__cache[n]},s.RemoveData=function(e){var t=s.GetUniqueElementId(e);null!=s.__cache[t]&&delete s.__cache[t],e.removeAttribute("data-select2-id")},s.copyNonInternalCssClasses=function(e,t){var n=(n=e.getAttribute("class").trim().split(/\s+/)).filter(function(e){return 0===e.indexOf("select2-")}),t=(t=t.getAttribute("class").trim().split(/\s+/)).filter(function(e){return 0!==e.indexOf("select2-")}),t=n.concat(t);e.setAttribute("class",t.join(" "))},s}),u.define("select2/results",["jquery","./utils"],function(d,p){function s(e,t,n){this.$element=e,this.data=n,this.options=t,s.__super__.constructor.call(this)}return p.Extend(s,p.Observable),s.prototype.render=function(){var e=d('
      ');return this.options.get("multiple")&&e.attr("aria-multiselectable","true"),this.$results=e},s.prototype.clear=function(){this.$results.empty()},s.prototype.displayMessage=function(e){var t=this.options.get("escapeMarkup");this.clear(),this.hideLoading();var n=d('
    • '),s=this.options.get("translations").get(e.message);n.append(t(s(e.args))),n[0].className+=" select2-results__message",this.$results.append(n)},s.prototype.hideMessages=function(){this.$results.find(".select2-results__message").remove()},s.prototype.append=function(e){this.hideLoading();var t=[];if(null!=e.results&&0!==e.results.length){e.results=this.sort(e.results);for(var n=0;n",{class:"select2-results__options select2-results__options--nested",role:"none"});i.append(l),o.append(a),o.append(i)}else this.template(e,t);return p.StoreData(t,"data",e),t},s.prototype.bind=function(t,e){var i=this,n=t.id+"-results";this.$results.attr("id",n),t.on("results:all",function(e){i.clear(),i.append(e.data),t.isOpen()&&(i.setClasses(),i.highlightFirstItem())}),t.on("results:append",function(e){i.append(e.data),t.isOpen()&&i.setClasses()}),t.on("query",function(e){i.hideMessages(),i.showLoading(e)}),t.on("select",function(){t.isOpen()&&(i.setClasses(),i.options.get("scrollAfterSelect")&&i.highlightFirstItem())}),t.on("unselect",function(){t.isOpen()&&(i.setClasses(),i.options.get("scrollAfterSelect")&&i.highlightFirstItem())}),t.on("open",function(){i.$results.attr("aria-expanded","true"),i.$results.attr("aria-hidden","false"),i.setClasses(),i.ensureHighlightVisible()}),t.on("close",function(){i.$results.attr("aria-expanded","false"),i.$results.attr("aria-hidden","true"),i.$results.removeAttr("aria-activedescendant")}),t.on("results:toggle",function(){var e=i.getHighlightedResults();0!==e.length&&e.trigger("mouseup")}),t.on("results:select",function(){var e,t=i.getHighlightedResults();0!==t.length&&(e=p.GetData(t[0],"data"),t.hasClass("select2-results__option--selected")?i.trigger("close",{}):i.trigger("select",{data:e}))}),t.on("results:previous",function(){var e,t=i.getHighlightedResults(),n=i.$results.find(".select2-results__option--selectable"),s=n.index(t);s<=0||(e=s-1,0===t.length&&(e=0),(s=n.eq(e)).trigger("mouseenter"),t=i.$results.offset().top,n=s.offset().top,s=i.$results.scrollTop()+(n-t),0===e?i.$results.scrollTop(0):n-t<0&&i.$results.scrollTop(s))}),t.on("results:next",function(){var e,t=i.getHighlightedResults(),n=i.$results.find(".select2-results__option--selectable"),s=n.index(t)+1;s>=n.length||((e=n.eq(s)).trigger("mouseenter"),t=i.$results.offset().top+i.$results.outerHeight(!1),n=e.offset().top+e.outerHeight(!1),e=i.$results.scrollTop()+n-t,0===s?i.$results.scrollTop(0):tthis.$results.outerHeight()||s<0)&&this.$results.scrollTop(n))},s.prototype.template=function(e,t){var n=this.options.get("templateResult"),s=this.options.get("escapeMarkup"),e=n(e,t);null==e?t.style.display="none":"string"==typeof e?t.innerHTML=s(e):d(t).append(e)},s}),u.define("select2/keys",[],function(){return{BACKSPACE:8,TAB:9,ENTER:13,SHIFT:16,CTRL:17,ALT:18,ESC:27,SPACE:32,PAGE_UP:33,PAGE_DOWN:34,END:35,HOME:36,LEFT:37,UP:38,RIGHT:39,DOWN:40,DELETE:46}}),u.define("select2/selection/base",["jquery","../utils","../keys"],function(n,s,i){function r(e,t){this.$element=e,this.options=t,r.__super__.constructor.call(this)}return s.Extend(r,s.Observable),r.prototype.render=function(){var e=n('');return this._tabindex=0,null!=s.GetData(this.$element[0],"old-tabindex")?this._tabindex=s.GetData(this.$element[0],"old-tabindex"):null!=this.$element.attr("tabindex")&&(this._tabindex=this.$element.attr("tabindex")),e.attr("title",this.$element.attr("title")),e.attr("tabindex",this._tabindex),e.attr("aria-disabled","false"),this.$selection=e},r.prototype.bind=function(e,t){var n=this,s=e.id+"-results";this.container=e,this.$selection.on("focus",function(e){n.trigger("focus",e)}),this.$selection.on("blur",function(e){n._handleBlur(e)}),this.$selection.on("keydown",function(e){n.trigger("keypress",e),e.which===i.SPACE&&e.preventDefault()}),e.on("results:focus",function(e){n.$selection.attr("aria-activedescendant",e.data._resultId)}),e.on("selection:update",function(e){n.update(e.data)}),e.on("open",function(){n.$selection.attr("aria-expanded","true"),n.$selection.attr("aria-owns",s),n._attachCloseHandler(e)}),e.on("close",function(){n.$selection.attr("aria-expanded","false"),n.$selection.removeAttr("aria-activedescendant"),n.$selection.removeAttr("aria-owns"),n.$selection.trigger("focus"),n._detachCloseHandler(e)}),e.on("enable",function(){n.$selection.attr("tabindex",n._tabindex),n.$selection.attr("aria-disabled","false")}),e.on("disable",function(){n.$selection.attr("tabindex","-1"),n.$selection.attr("aria-disabled","true")})},r.prototype._handleBlur=function(e){var t=this;window.setTimeout(function(){document.activeElement==t.$selection[0]||n.contains(t.$selection[0],document.activeElement)||t.trigger("blur",e)},1)},r.prototype._attachCloseHandler=function(e){n(document.body).on("mousedown.select2."+e.id,function(e){var t=n(e.target).closest(".select2");n(".select2.select2-container--open").each(function(){this!=t[0]&&s.GetData(this,"element").select2("close")})})},r.prototype._detachCloseHandler=function(e){n(document.body).off("mousedown.select2."+e.id)},r.prototype.position=function(e,t){t.find(".selection").append(e)},r.prototype.destroy=function(){this._detachCloseHandler(this.container)},r.prototype.update=function(e){throw new Error("The `update` method must be defined in child classes.")},r.prototype.isEnabled=function(){return!this.isDisabled()},r.prototype.isDisabled=function(){return this.options.get("disabled")},r}),u.define("select2/selection/single",["jquery","./base","../utils","../keys"],function(e,t,n,s){function i(){i.__super__.constructor.apply(this,arguments)}return n.Extend(i,t),i.prototype.render=function(){var e=i.__super__.render.call(this);return e[0].classList.add("select2-selection--single"),e.html(''),e},i.prototype.bind=function(t,e){var n=this;i.__super__.bind.apply(this,arguments);var s=t.id+"-container";this.$selection.find(".select2-selection__rendered").attr("id",s).attr("role","textbox").attr("aria-readonly","true"),this.$selection.attr("aria-labelledby",s),this.$selection.attr("aria-controls",s),this.$selection.on("mousedown",function(e){1===e.which&&n.trigger("toggle",{originalEvent:e})}),this.$selection.on("focus",function(e){}),this.$selection.on("blur",function(e){}),t.on("focus",function(e){t.isOpen()||n.$selection.trigger("focus")})},i.prototype.clear=function(){var e=this.$selection.find(".select2-selection__rendered");e.empty(),e.removeAttr("title")},i.prototype.display=function(e,t){var n=this.options.get("templateSelection");return this.options.get("escapeMarkup")(n(e,t))},i.prototype.selectionContainer=function(){return e("")},i.prototype.update=function(e){var t,n;0!==e.length?(n=e[0],t=this.$selection.find(".select2-selection__rendered"),e=this.display(n,t),t.empty().append(e),(n=n.title||n.text)?t.attr("title",n):t.removeAttr("title")):this.clear()},i}),u.define("select2/selection/multiple",["jquery","./base","../utils"],function(i,e,c){function r(e,t){r.__super__.constructor.apply(this,arguments)}return c.Extend(r,e),r.prototype.render=function(){var e=r.__super__.render.call(this);return e[0].classList.add("select2-selection--multiple"),e.html('
        '),e},r.prototype.bind=function(e,t){var n=this;r.__super__.bind.apply(this,arguments);var s=e.id+"-container";this.$selection.find(".select2-selection__rendered").attr("id",s),this.$selection.on("click",function(e){n.trigger("toggle",{originalEvent:e})}),this.$selection.on("click",".select2-selection__choice__remove",function(e){var t;n.isDisabled()||(t=i(this).parent(),t=c.GetData(t[0],"data"),n.trigger("unselect",{originalEvent:e,data:t}))}),this.$selection.on("keydown",".select2-selection__choice__remove",function(e){n.isDisabled()||e.stopPropagation()})},r.prototype.clear=function(){var e=this.$selection.find(".select2-selection__rendered");e.empty(),e.removeAttr("title")},r.prototype.display=function(e,t){var n=this.options.get("templateSelection");return this.options.get("escapeMarkup")(n(e,t))},r.prototype.selectionContainer=function(){return i('
      • ')},r.prototype.update=function(e){if(this.clear(),0!==e.length){for(var t=[],n=this.$selection.find(".select2-selection__rendered").attr("id")+"-choice-",s=0;s')).attr("title",s()),e.attr("aria-label",s()),e.attr("aria-describedby",n),a.StoreData(e[0],"data",t),this.$selection.prepend(e),this.$selection[0].classList.add("select2-selection--clearable"))},e}),u.define("select2/selection/search",["jquery","../utils","../keys"],function(s,a,l){function e(e,t,n){e.call(this,t,n)}return e.prototype.render=function(e){var t=this.options.get("translations").get("search"),n=s('');this.$searchContainer=n,this.$search=n.find("textarea"),this.$search.prop("autocomplete",this.options.get("autocomplete")),this.$search.attr("aria-label",t());e=e.call(this);return this._transferTabIndex(),e.append(this.$searchContainer),e},e.prototype.bind=function(e,t,n){var s=this,i=t.id+"-results",r=t.id+"-container";e.call(this,t,n),s.$search.attr("aria-describedby",r),t.on("open",function(){s.$search.attr("aria-controls",i),s.$search.trigger("focus")}),t.on("close",function(){s.$search.val(""),s.resizeSearch(),s.$search.removeAttr("aria-controls"),s.$search.removeAttr("aria-activedescendant"),s.$search.trigger("focus")}),t.on("enable",function(){s.$search.prop("disabled",!1),s._transferTabIndex()}),t.on("disable",function(){s.$search.prop("disabled",!0)}),t.on("focus",function(e){s.$search.trigger("focus")}),t.on("results:focus",function(e){e.data._resultId?s.$search.attr("aria-activedescendant",e.data._resultId):s.$search.removeAttr("aria-activedescendant")}),this.$selection.on("focusin",".select2-search--inline",function(e){s.trigger("focus",e)}),this.$selection.on("focusout",".select2-search--inline",function(e){s._handleBlur(e)}),this.$selection.on("keydown",".select2-search--inline",function(e){var t;e.stopPropagation(),s.trigger("keypress",e),s._keyUpPrevented=e.isDefaultPrevented(),e.which!==l.BACKSPACE||""!==s.$search.val()||0<(t=s.$selection.find(".select2-selection__choice").last()).length&&(t=a.GetData(t[0],"data"),s.searchRemoveChoice(t),e.preventDefault())}),this.$selection.on("click",".select2-search--inline",function(e){s.$search.val()&&e.stopPropagation()});var t=document.documentMode,o=t&&t<=11;this.$selection.on("input.searchcheck",".select2-search--inline",function(e){o?s.$selection.off("input.search input.searchcheck"):s.$selection.off("keyup.search")}),this.$selection.on("keyup.search input.search",".select2-search--inline",function(e){var t;o&&"input"===e.type?s.$selection.off("input.search input.searchcheck"):(t=e.which)!=l.SHIFT&&t!=l.CTRL&&t!=l.ALT&&t!=l.TAB&&s.handleSearch(e)})},e.prototype._transferTabIndex=function(e){this.$search.attr("tabindex",this.$selection.attr("tabindex")),this.$selection.attr("tabindex","-1")},e.prototype.createPlaceholder=function(e,t){this.$search.attr("placeholder",t.text)},e.prototype.update=function(e,t){var n=this.$search[0]==document.activeElement;this.$search.attr("placeholder",""),e.call(this,t),this.resizeSearch(),n&&this.$search.trigger("focus")},e.prototype.handleSearch=function(){var e;this.resizeSearch(),this._keyUpPrevented||(e=this.$search.val(),this.trigger("query",{term:e})),this._keyUpPrevented=!1},e.prototype.searchRemoveChoice=function(e,t){this.trigger("unselect",{data:t}),this.$search.val(t.text),this.handleSearch()},e.prototype.resizeSearch=function(){this.$search.css("width","25px");var e="100%";""===this.$search.attr("placeholder")&&(e=.75*(this.$search.val().length+1)+"em"),this.$search.css("width",e)},e}),u.define("select2/selection/selectionCss",["../utils"],function(n){function e(){}return e.prototype.render=function(e){var t=e.call(this),e=this.options.get("selectionCssClass")||"";return-1!==e.indexOf(":all:")&&(e=e.replace(":all:",""),n.copyNonInternalCssClasses(t[0],this.$element[0])),t.addClass(e),t},e}),u.define("select2/selection/eventRelay",["jquery"],function(o){function e(){}return e.prototype.bind=function(e,t,n){var s=this,i=["open","opening","close","closing","select","selecting","unselect","unselecting","clear","clearing"],r=["opening","closing","selecting","unselecting","clearing"];e.call(this,t,n),t.on("*",function(e,t){var n;-1!==i.indexOf(e)&&(t=t||{},n=o.Event("select2:"+e,{params:t}),s.$element.trigger(n),-1!==r.indexOf(e)&&(t.prevented=n.isDefaultPrevented()))})},e}),u.define("select2/translation",["jquery","require"],function(t,n){function s(e){this.dict=e||{}}return s.prototype.all=function(){return this.dict},s.prototype.get=function(e){return this.dict[e]},s.prototype.extend=function(e){this.dict=t.extend({},e.all(),this.dict)},s._cache={},s.loadPath=function(e){var t;return e in s._cache||(t=n(e),s._cache[e]=t),new s(s._cache[e])},s}),u.define("select2/diacritics",[],function(){return{"Ⓐ":"A","A":"A","À":"A","Á":"A","Â":"A","Ầ":"A","Ấ":"A","Ẫ":"A","Ẩ":"A","Ã":"A","Ā":"A","Ă":"A","Ằ":"A","Ắ":"A","Ẵ":"A","Ẳ":"A","Ȧ":"A","Ǡ":"A","Ä":"A","Ǟ":"A","Ả":"A","Å":"A","Ǻ":"A","Ǎ":"A","Ȁ":"A","Ȃ":"A","Ạ":"A","Ậ":"A","Ặ":"A","Ḁ":"A","Ą":"A","Ⱥ":"A","Ɐ":"A","Ꜳ":"AA","Æ":"AE","Ǽ":"AE","Ǣ":"AE","Ꜵ":"AO","Ꜷ":"AU","Ꜹ":"AV","Ꜻ":"AV","Ꜽ":"AY","Ⓑ":"B","B":"B","Ḃ":"B","Ḅ":"B","Ḇ":"B","Ƀ":"B","Ƃ":"B","Ɓ":"B","Ⓒ":"C","C":"C","Ć":"C","Ĉ":"C","Ċ":"C","Č":"C","Ç":"C","Ḉ":"C","Ƈ":"C","Ȼ":"C","Ꜿ":"C","Ⓓ":"D","D":"D","Ḋ":"D","Ď":"D","Ḍ":"D","Ḑ":"D","Ḓ":"D","Ḏ":"D","Đ":"D","Ƌ":"D","Ɗ":"D","Ɖ":"D","Ꝺ":"D","DZ":"DZ","DŽ":"DZ","Dz":"Dz","Dž":"Dz","Ⓔ":"E","E":"E","È":"E","É":"E","Ê":"E","Ề":"E","Ế":"E","Ễ":"E","Ể":"E","Ẽ":"E","Ē":"E","Ḕ":"E","Ḗ":"E","Ĕ":"E","Ė":"E","Ë":"E","Ẻ":"E","Ě":"E","Ȅ":"E","Ȇ":"E","Ẹ":"E","Ệ":"E","Ȩ":"E","Ḝ":"E","Ę":"E","Ḙ":"E","Ḛ":"E","Ɛ":"E","Ǝ":"E","Ⓕ":"F","F":"F","Ḟ":"F","Ƒ":"F","Ꝼ":"F","Ⓖ":"G","G":"G","Ǵ":"G","Ĝ":"G","Ḡ":"G","Ğ":"G","Ġ":"G","Ǧ":"G","Ģ":"G","Ǥ":"G","Ɠ":"G","Ꞡ":"G","Ᵹ":"G","Ꝿ":"G","Ⓗ":"H","H":"H","Ĥ":"H","Ḣ":"H","Ḧ":"H","Ȟ":"H","Ḥ":"H","Ḩ":"H","Ḫ":"H","Ħ":"H","Ⱨ":"H","Ⱶ":"H","Ɥ":"H","Ⓘ":"I","I":"I","Ì":"I","Í":"I","Î":"I","Ĩ":"I","Ī":"I","Ĭ":"I","İ":"I","Ï":"I","Ḯ":"I","Ỉ":"I","Ǐ":"I","Ȉ":"I","Ȋ":"I","Ị":"I","Į":"I","Ḭ":"I","Ɨ":"I","Ⓙ":"J","J":"J","Ĵ":"J","Ɉ":"J","Ⓚ":"K","K":"K","Ḱ":"K","Ǩ":"K","Ḳ":"K","Ķ":"K","Ḵ":"K","Ƙ":"K","Ⱪ":"K","Ꝁ":"K","Ꝃ":"K","Ꝅ":"K","Ꞣ":"K","Ⓛ":"L","L":"L","Ŀ":"L","Ĺ":"L","Ľ":"L","Ḷ":"L","Ḹ":"L","Ļ":"L","Ḽ":"L","Ḻ":"L","Ł":"L","Ƚ":"L","Ɫ":"L","Ⱡ":"L","Ꝉ":"L","Ꝇ":"L","Ꞁ":"L","LJ":"LJ","Lj":"Lj","Ⓜ":"M","M":"M","Ḿ":"M","Ṁ":"M","Ṃ":"M","Ɱ":"M","Ɯ":"M","Ⓝ":"N","N":"N","Ǹ":"N","Ń":"N","Ñ":"N","Ṅ":"N","Ň":"N","Ṇ":"N","Ņ":"N","Ṋ":"N","Ṉ":"N","Ƞ":"N","Ɲ":"N","Ꞑ":"N","Ꞥ":"N","NJ":"NJ","Nj":"Nj","Ⓞ":"O","O":"O","Ò":"O","Ó":"O","Ô":"O","Ồ":"O","Ố":"O","Ỗ":"O","Ổ":"O","Õ":"O","Ṍ":"O","Ȭ":"O","Ṏ":"O","Ō":"O","Ṑ":"O","Ṓ":"O","Ŏ":"O","Ȯ":"O","Ȱ":"O","Ö":"O","Ȫ":"O","Ỏ":"O","Ő":"O","Ǒ":"O","Ȍ":"O","Ȏ":"O","Ơ":"O","Ờ":"O","Ớ":"O","Ỡ":"O","Ở":"O","Ợ":"O","Ọ":"O","Ộ":"O","Ǫ":"O","Ǭ":"O","Ø":"O","Ǿ":"O","Ɔ":"O","Ɵ":"O","Ꝋ":"O","Ꝍ":"O","Œ":"OE","Ƣ":"OI","Ꝏ":"OO","Ȣ":"OU","Ⓟ":"P","P":"P","Ṕ":"P","Ṗ":"P","Ƥ":"P","Ᵽ":"P","Ꝑ":"P","Ꝓ":"P","Ꝕ":"P","Ⓠ":"Q","Q":"Q","Ꝗ":"Q","Ꝙ":"Q","Ɋ":"Q","Ⓡ":"R","R":"R","Ŕ":"R","Ṙ":"R","Ř":"R","Ȑ":"R","Ȓ":"R","Ṛ":"R","Ṝ":"R","Ŗ":"R","Ṟ":"R","Ɍ":"R","Ɽ":"R","Ꝛ":"R","Ꞧ":"R","Ꞃ":"R","Ⓢ":"S","S":"S","ẞ":"S","Ś":"S","Ṥ":"S","Ŝ":"S","Ṡ":"S","Š":"S","Ṧ":"S","Ṣ":"S","Ṩ":"S","Ș":"S","Ş":"S","Ȿ":"S","Ꞩ":"S","Ꞅ":"S","Ⓣ":"T","T":"T","Ṫ":"T","Ť":"T","Ṭ":"T","Ț":"T","Ţ":"T","Ṱ":"T","Ṯ":"T","Ŧ":"T","Ƭ":"T","Ʈ":"T","Ⱦ":"T","Ꞇ":"T","Ꜩ":"TZ","Ⓤ":"U","U":"U","Ù":"U","Ú":"U","Û":"U","Ũ":"U","Ṹ":"U","Ū":"U","Ṻ":"U","Ŭ":"U","Ü":"U","Ǜ":"U","Ǘ":"U","Ǖ":"U","Ǚ":"U","Ủ":"U","Ů":"U","Ű":"U","Ǔ":"U","Ȕ":"U","Ȗ":"U","Ư":"U","Ừ":"U","Ứ":"U","Ữ":"U","Ử":"U","Ự":"U","Ụ":"U","Ṳ":"U","Ų":"U","Ṷ":"U","Ṵ":"U","Ʉ":"U","Ⓥ":"V","V":"V","Ṽ":"V","Ṿ":"V","Ʋ":"V","Ꝟ":"V","Ʌ":"V","Ꝡ":"VY","Ⓦ":"W","W":"W","Ẁ":"W","Ẃ":"W","Ŵ":"W","Ẇ":"W","Ẅ":"W","Ẉ":"W","Ⱳ":"W","Ⓧ":"X","X":"X","Ẋ":"X","Ẍ":"X","Ⓨ":"Y","Y":"Y","Ỳ":"Y","Ý":"Y","Ŷ":"Y","Ỹ":"Y","Ȳ":"Y","Ẏ":"Y","Ÿ":"Y","Ỷ":"Y","Ỵ":"Y","Ƴ":"Y","Ɏ":"Y","Ỿ":"Y","Ⓩ":"Z","Z":"Z","Ź":"Z","Ẑ":"Z","Ż":"Z","Ž":"Z","Ẓ":"Z","Ẕ":"Z","Ƶ":"Z","Ȥ":"Z","Ɀ":"Z","Ⱬ":"Z","Ꝣ":"Z","ⓐ":"a","a":"a","ẚ":"a","à":"a","á":"a","â":"a","ầ":"a","ấ":"a","ẫ":"a","ẩ":"a","ã":"a","ā":"a","ă":"a","ằ":"a","ắ":"a","ẵ":"a","ẳ":"a","ȧ":"a","ǡ":"a","ä":"a","ǟ":"a","ả":"a","å":"a","ǻ":"a","ǎ":"a","ȁ":"a","ȃ":"a","ạ":"a","ậ":"a","ặ":"a","ḁ":"a","ą":"a","ⱥ":"a","ɐ":"a","ꜳ":"aa","æ":"ae","ǽ":"ae","ǣ":"ae","ꜵ":"ao","ꜷ":"au","ꜹ":"av","ꜻ":"av","ꜽ":"ay","ⓑ":"b","b":"b","ḃ":"b","ḅ":"b","ḇ":"b","ƀ":"b","ƃ":"b","ɓ":"b","ⓒ":"c","c":"c","ć":"c","ĉ":"c","ċ":"c","č":"c","ç":"c","ḉ":"c","ƈ":"c","ȼ":"c","ꜿ":"c","ↄ":"c","ⓓ":"d","d":"d","ḋ":"d","ď":"d","ḍ":"d","ḑ":"d","ḓ":"d","ḏ":"d","đ":"d","ƌ":"d","ɖ":"d","ɗ":"d","ꝺ":"d","dz":"dz","dž":"dz","ⓔ":"e","e":"e","è":"e","é":"e","ê":"e","ề":"e","ế":"e","ễ":"e","ể":"e","ẽ":"e","ē":"e","ḕ":"e","ḗ":"e","ĕ":"e","ė":"e","ë":"e","ẻ":"e","ě":"e","ȅ":"e","ȇ":"e","ẹ":"e","ệ":"e","ȩ":"e","ḝ":"e","ę":"e","ḙ":"e","ḛ":"e","ɇ":"e","ɛ":"e","ǝ":"e","ⓕ":"f","f":"f","ḟ":"f","ƒ":"f","ꝼ":"f","ⓖ":"g","g":"g","ǵ":"g","ĝ":"g","ḡ":"g","ğ":"g","ġ":"g","ǧ":"g","ģ":"g","ǥ":"g","ɠ":"g","ꞡ":"g","ᵹ":"g","ꝿ":"g","ⓗ":"h","h":"h","ĥ":"h","ḣ":"h","ḧ":"h","ȟ":"h","ḥ":"h","ḩ":"h","ḫ":"h","ẖ":"h","ħ":"h","ⱨ":"h","ⱶ":"h","ɥ":"h","ƕ":"hv","ⓘ":"i","i":"i","ì":"i","í":"i","î":"i","ĩ":"i","ī":"i","ĭ":"i","ï":"i","ḯ":"i","ỉ":"i","ǐ":"i","ȉ":"i","ȋ":"i","ị":"i","į":"i","ḭ":"i","ɨ":"i","ı":"i","ⓙ":"j","j":"j","ĵ":"j","ǰ":"j","ɉ":"j","ⓚ":"k","k":"k","ḱ":"k","ǩ":"k","ḳ":"k","ķ":"k","ḵ":"k","ƙ":"k","ⱪ":"k","ꝁ":"k","ꝃ":"k","ꝅ":"k","ꞣ":"k","ⓛ":"l","l":"l","ŀ":"l","ĺ":"l","ľ":"l","ḷ":"l","ḹ":"l","ļ":"l","ḽ":"l","ḻ":"l","ſ":"l","ł":"l","ƚ":"l","ɫ":"l","ⱡ":"l","ꝉ":"l","ꞁ":"l","ꝇ":"l","lj":"lj","ⓜ":"m","m":"m","ḿ":"m","ṁ":"m","ṃ":"m","ɱ":"m","ɯ":"m","ⓝ":"n","n":"n","ǹ":"n","ń":"n","ñ":"n","ṅ":"n","ň":"n","ṇ":"n","ņ":"n","ṋ":"n","ṉ":"n","ƞ":"n","ɲ":"n","ʼn":"n","ꞑ":"n","ꞥ":"n","nj":"nj","ⓞ":"o","o":"o","ò":"o","ó":"o","ô":"o","ồ":"o","ố":"o","ỗ":"o","ổ":"o","õ":"o","ṍ":"o","ȭ":"o","ṏ":"o","ō":"o","ṑ":"o","ṓ":"o","ŏ":"o","ȯ":"o","ȱ":"o","ö":"o","ȫ":"o","ỏ":"o","ő":"o","ǒ":"o","ȍ":"o","ȏ":"o","ơ":"o","ờ":"o","ớ":"o","ỡ":"o","ở":"o","ợ":"o","ọ":"o","ộ":"o","ǫ":"o","ǭ":"o","ø":"o","ǿ":"o","ɔ":"o","ꝋ":"o","ꝍ":"o","ɵ":"o","œ":"oe","ƣ":"oi","ȣ":"ou","ꝏ":"oo","ⓟ":"p","p":"p","ṕ":"p","ṗ":"p","ƥ":"p","ᵽ":"p","ꝑ":"p","ꝓ":"p","ꝕ":"p","ⓠ":"q","q":"q","ɋ":"q","ꝗ":"q","ꝙ":"q","ⓡ":"r","r":"r","ŕ":"r","ṙ":"r","ř":"r","ȑ":"r","ȓ":"r","ṛ":"r","ṝ":"r","ŗ":"r","ṟ":"r","ɍ":"r","ɽ":"r","ꝛ":"r","ꞧ":"r","ꞃ":"r","ⓢ":"s","s":"s","ß":"s","ś":"s","ṥ":"s","ŝ":"s","ṡ":"s","š":"s","ṧ":"s","ṣ":"s","ṩ":"s","ș":"s","ş":"s","ȿ":"s","ꞩ":"s","ꞅ":"s","ẛ":"s","ⓣ":"t","t":"t","ṫ":"t","ẗ":"t","ť":"t","ṭ":"t","ț":"t","ţ":"t","ṱ":"t","ṯ":"t","ŧ":"t","ƭ":"t","ʈ":"t","ⱦ":"t","ꞇ":"t","ꜩ":"tz","ⓤ":"u","u":"u","ù":"u","ú":"u","û":"u","ũ":"u","ṹ":"u","ū":"u","ṻ":"u","ŭ":"u","ü":"u","ǜ":"u","ǘ":"u","ǖ":"u","ǚ":"u","ủ":"u","ů":"u","ű":"u","ǔ":"u","ȕ":"u","ȗ":"u","ư":"u","ừ":"u","ứ":"u","ữ":"u","ử":"u","ự":"u","ụ":"u","ṳ":"u","ų":"u","ṷ":"u","ṵ":"u","ʉ":"u","ⓥ":"v","v":"v","ṽ":"v","ṿ":"v","ʋ":"v","ꝟ":"v","ʌ":"v","ꝡ":"vy","ⓦ":"w","w":"w","ẁ":"w","ẃ":"w","ŵ":"w","ẇ":"w","ẅ":"w","ẘ":"w","ẉ":"w","ⱳ":"w","ⓧ":"x","x":"x","ẋ":"x","ẍ":"x","ⓨ":"y","y":"y","ỳ":"y","ý":"y","ŷ":"y","ỹ":"y","ȳ":"y","ẏ":"y","ÿ":"y","ỷ":"y","ẙ":"y","ỵ":"y","ƴ":"y","ɏ":"y","ỿ":"y","ⓩ":"z","z":"z","ź":"z","ẑ":"z","ż":"z","ž":"z","ẓ":"z","ẕ":"z","ƶ":"z","ȥ":"z","ɀ":"z","ⱬ":"z","ꝣ":"z","Ά":"Α","Έ":"Ε","Ή":"Η","Ί":"Ι","Ϊ":"Ι","Ό":"Ο","Ύ":"Υ","Ϋ":"Υ","Ώ":"Ω","ά":"α","έ":"ε","ή":"η","ί":"ι","ϊ":"ι","ΐ":"ι","ό":"ο","ύ":"υ","ϋ":"υ","ΰ":"υ","ώ":"ω","ς":"σ","’":"'"}}),u.define("select2/data/base",["../utils"],function(n){function s(e,t){s.__super__.constructor.call(this)}return n.Extend(s,n.Observable),s.prototype.current=function(e){throw new Error("The `current` method must be defined in child classes.")},s.prototype.query=function(e,t){throw new Error("The `query` method must be defined in child classes.")},s.prototype.bind=function(e,t){},s.prototype.destroy=function(){},s.prototype.generateResultId=function(e,t){e=e.id+"-result-";return e+=n.generateChars(4),null!=t.id?e+="-"+t.id.toString():e+="-"+n.generateChars(4),e},s}),u.define("select2/data/select",["./base","../utils","jquery"],function(e,a,l){function n(e,t){this.$element=e,this.options=t,n.__super__.constructor.call(this)}return a.Extend(n,e),n.prototype.current=function(e){var t=this;e(Array.prototype.map.call(this.$element[0].querySelectorAll(":checked"),function(e){return t.item(l(e))}))},n.prototype.select=function(i){var e,r=this;if(i.selected=!0,null!=i.element&&"option"===i.element.tagName.toLowerCase())return i.element.selected=!0,void this.$element.trigger("input").trigger("change");this.$element.prop("multiple")?this.current(function(e){var t=[];(i=[i]).push.apply(i,e);for(var n=0;nthis.maximumInputLength?this.trigger("results:message",{message:"inputTooLong",args:{maximum:this.maximumInputLength,input:t.term,params:t}}):e.call(this,t,n)},e}),u.define("select2/data/maximumSelectionLength",[],function(){function e(e,t,n){this.maximumSelectionLength=n.get("maximumSelectionLength"),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var s=this;e.call(this,t,n),t.on("select",function(){s._checkIfMaximumSelected()})},e.prototype.query=function(e,t,n){var s=this;this._checkIfMaximumSelected(function(){e.call(s,t,n)})},e.prototype._checkIfMaximumSelected=function(e,t){var n=this;this.current(function(e){e=null!=e?e.length:0;0=n.maximumSelectionLength?n.trigger("results:message",{message:"maximumSelected",args:{maximum:n.maximumSelectionLength}}):t&&t()})},e}),u.define("select2/dropdown",["jquery","./utils"],function(t,e){function n(e,t){this.$element=e,this.options=t,n.__super__.constructor.call(this)}return e.Extend(n,e.Observable),n.prototype.render=function(){var e=t('');return e.attr("dir",this.options.get("dir")),this.$dropdown=e},n.prototype.bind=function(){},n.prototype.position=function(e,t){},n.prototype.destroy=function(){this.$dropdown.remove()},n}),u.define("select2/dropdown/search",["jquery"],function(r){function e(){}return e.prototype.render=function(e){var t=e.call(this),n=this.options.get("translations").get("search"),e=r('');return this.$searchContainer=e,this.$search=e.find("input"),this.$search.prop("autocomplete",this.options.get("autocomplete")),this.$search.attr("aria-label",n()),t.prepend(e),t},e.prototype.bind=function(e,t,n){var s=this,i=t.id+"-results";e.call(this,t,n),this.$search.on("keydown",function(e){s.trigger("keypress",e),s._keyUpPrevented=e.isDefaultPrevented()}),this.$search.on("input",function(e){r(this).off("keyup")}),this.$search.on("keyup input",function(e){s.handleSearch(e)}),t.on("open",function(){s.$search.attr("tabindex",0),s.$search.attr("aria-controls",i),s.$search.trigger("focus"),window.setTimeout(function(){s.$search.trigger("focus")},0)}),t.on("close",function(){s.$search.attr("tabindex",-1),s.$search.removeAttr("aria-controls"),s.$search.removeAttr("aria-activedescendant"),s.$search.val(""),s.$search.trigger("blur")}),t.on("focus",function(){t.isOpen()||s.$search.trigger("focus")}),t.on("results:all",function(e){null!=e.query.term&&""!==e.query.term||(s.showSearch(e)?s.$searchContainer[0].classList.remove("select2-search--hide"):s.$searchContainer[0].classList.add("select2-search--hide"))}),t.on("results:focus",function(e){e.data._resultId?s.$search.attr("aria-activedescendant",e.data._resultId):s.$search.removeAttr("aria-activedescendant")})},e.prototype.handleSearch=function(e){var t;this._keyUpPrevented||(t=this.$search.val(),this.trigger("query",{term:t})),this._keyUpPrevented=!1},e.prototype.showSearch=function(e,t){return!0},e}),u.define("select2/dropdown/hidePlaceholder",[],function(){function e(e,t,n,s){this.placeholder=this.normalizePlaceholder(n.get("placeholder")),e.call(this,t,n,s)}return e.prototype.append=function(e,t){t.results=this.removePlaceholder(t.results),e.call(this,t)},e.prototype.normalizePlaceholder=function(e,t){return"string"==typeof t&&(t={id:"",text:t}),t},e.prototype.removePlaceholder=function(e,t){for(var n=t.slice(0),s=t.length-1;0<=s;s--){var i=t[s];this.placeholder.id===i.id&&n.splice(s,1)}return n},e}),u.define("select2/dropdown/infiniteScroll",["jquery"],function(n){function e(e,t,n,s){this.lastParams={},e.call(this,t,n,s),this.$loadingMore=this.createLoadingMore(),this.loading=!1}return e.prototype.append=function(e,t){this.$loadingMore.remove(),this.loading=!1,e.call(this,t),this.showLoadingMore(t)&&(this.$results.append(this.$loadingMore),this.loadMoreIfNeeded())},e.prototype.bind=function(e,t,n){var s=this;e.call(this,t,n),t.on("query",function(e){s.lastParams=e,s.loading=!0}),t.on("query:append",function(e){s.lastParams=e,s.loading=!0}),this.$results.on("scroll",this.loadMoreIfNeeded.bind(this))},e.prototype.loadMoreIfNeeded=function(){var e=n.contains(document.documentElement,this.$loadingMore[0]);!this.loading&&e&&(e=this.$results.offset().top+this.$results.outerHeight(!1),this.$loadingMore.offset().top+this.$loadingMore.outerHeight(!1)<=e+50&&this.loadMore())},e.prototype.loadMore=function(){this.loading=!0;var e=n.extend({},{page:1},this.lastParams);e.page++,this.trigger("query:append",e)},e.prototype.showLoadingMore=function(e,t){return t.pagination&&t.pagination.more},e.prototype.createLoadingMore=function(){var e=n('
      • '),t=this.options.get("translations").get("loadingMore");return e.html(t(this.lastParams)),e},e}),u.define("select2/dropdown/attachBody",["jquery","../utils"],function(u,o){function e(e,t,n){this.$dropdownParent=u(n.get("dropdownParent")||document.body),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var s=this;e.call(this,t,n),t.on("open",function(){s._showDropdown(),s._attachPositioningHandler(t),s._bindContainerResultHandlers(t)}),t.on("close",function(){s._hideDropdown(),s._detachPositioningHandler(t)}),this.$dropdownContainer.on("mousedown",function(e){e.stopPropagation()})},e.prototype.destroy=function(e){e.call(this),this.$dropdownContainer.remove()},e.prototype.position=function(e,t,n){t.attr("class",n.attr("class")),t[0].classList.remove("select2"),t[0].classList.add("select2-container--open"),t.css({position:"absolute",top:-999999}),this.$container=n},e.prototype.render=function(e){var t=u(""),e=e.call(this);return t.append(e),this.$dropdownContainer=t},e.prototype._hideDropdown=function(e){this.$dropdownContainer.detach()},e.prototype._bindContainerResultHandlers=function(e,t){var n;this._containerResultsHandlersBound||(n=this,t.on("results:all",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:append",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:message",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("select",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("unselect",function(){n._positionDropdown(),n._resizeDropdown()}),this._containerResultsHandlersBound=!0)},e.prototype._attachPositioningHandler=function(e,t){var n=this,s="scroll.select2."+t.id,i="resize.select2."+t.id,r="orientationchange.select2."+t.id,t=this.$container.parents().filter(o.hasScroll);t.each(function(){o.StoreData(this,"select2-scroll-position",{x:u(this).scrollLeft(),y:u(this).scrollTop()})}),t.on(s,function(e){var t=o.GetData(this,"select2-scroll-position");u(this).scrollTop(t.y)}),u(window).on(s+" "+i+" "+r,function(e){n._positionDropdown(),n._resizeDropdown()})},e.prototype._detachPositioningHandler=function(e,t){var n="scroll.select2."+t.id,s="resize.select2."+t.id,t="orientationchange.select2."+t.id;this.$container.parents().filter(o.hasScroll).off(n),u(window).off(n+" "+s+" "+t)},e.prototype._positionDropdown=function(){var e=u(window),t=this.$dropdown[0].classList.contains("select2-dropdown--above"),n=this.$dropdown[0].classList.contains("select2-dropdown--below"),s=null,i=this.$container.offset();i.bottom=i.top+this.$container.outerHeight(!1);var r={height:this.$container.outerHeight(!1)};r.top=i.top,r.bottom=i.top+r.height;var o=this.$dropdown.outerHeight(!1),a=e.scrollTop(),l=e.scrollTop()+e.height(),c=ai.bottom+o,a={left:i.left,top:r.bottom},l=this.$dropdownParent;"static"===l.css("position")&&(l=l.offsetParent());i={top:0,left:0};(u.contains(document.body,l[0])||l[0].isConnected)&&(i=l.offset()),a.top-=i.top,a.left-=i.left,t||n||(s="below"),e||!c||t?!c&&e&&t&&(s="below"):s="above",("above"==s||t&&"below"!==s)&&(a.top=r.top-i.top-o),null!=s&&(this.$dropdown[0].classList.remove("select2-dropdown--below"),this.$dropdown[0].classList.remove("select2-dropdown--above"),this.$dropdown[0].classList.add("select2-dropdown--"+s),this.$container[0].classList.remove("select2-container--below"),this.$container[0].classList.remove("select2-container--above"),this.$container[0].classList.add("select2-container--"+s)),this.$dropdownContainer.css(a)},e.prototype._resizeDropdown=function(){var e={width:this.$container.outerWidth(!1)+"px"};this.options.get("dropdownAutoWidth")&&(e.minWidth=e.width,e.position="relative",e.width="auto"),this.$dropdown.css(e)},e.prototype._showDropdown=function(e){this.$dropdownContainer.appendTo(this.$dropdownParent),this._positionDropdown(),this._resizeDropdown()},e}),u.define("select2/dropdown/minimumResultsForSearch",[],function(){function e(e,t,n,s){this.minimumResultsForSearch=n.get("minimumResultsForSearch"),this.minimumResultsForSearch<0&&(this.minimumResultsForSearch=1/0),e.call(this,t,n,s)}return e.prototype.showSearch=function(e,t){return!(function e(t){for(var n=0,s=0;s');return e.attr("dir",this.options.get("dir")),this.$container=e,this.$container[0].classList.add("select2-container--"+this.options.get("theme")),r.StoreData(e[0],"element",this.$element),e},o}),u.define("jquery-mousewheel",["jquery"],function(e){return e}),u.define("jquery.select2",["jquery","jquery-mousewheel","./select2/core","./select2/defaults","./select2/utils"],function(i,e,r,t,o){var a;return null==i.fn.select2&&(a=["open","close","destroy"],i.fn.select2=function(t){if("object"==typeof(t=t||{}))return this.each(function(){var e=i.extend(!0,{},t);new r(i(this),e)}),this;if("string"!=typeof t)throw new Error("Invalid arguments for Select2: "+t);var n,s=Array.prototype.slice.call(arguments,1);return this.each(function(){var e=o.GetData(this,"select2");null==e&&window.console&&console.error&&console.error("The select2('"+t+"') method was called on an element that is not using Select2."),n=e[t].apply(e,s)}),-1 Date: Wed, 21 Jul 2021 16:07:30 +0200 Subject: [PATCH 06/35] chg: [instance:settings] UI Improvements and framework to save settings - WiP --- src/Controller/InstanceController.php | 27 +++++ src/Model/Table/SettingsTable.php | 15 +++ templates/Instance/save_setting.php | 17 +++ templates/Instance/settings.php | 146 ++++++++++++++++++++++---- webroot/css/bootstrap-additional.css | 16 +++ webroot/js/api-helper.js | 1 + 6 files changed, 204 insertions(+), 18 deletions(-) create mode 100644 templates/Instance/save_setting.php diff --git a/src/Controller/InstanceController.php b/src/Controller/InstanceController.php index 11ea1c9..10604a2 100644 --- a/src/Controller/InstanceController.php +++ b/src/Controller/InstanceController.php @@ -111,4 +111,31 @@ class InstanceController extends AppController $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']); + // TO DEL + $data['errorMessage'] = 'Test test test'; + $data['error'] = true; + $data['error'] = false; + // TO DEL + } + $this->CRUD->setResponseForController('saveSetting', empty($errors), $message, $data, $errors); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + } } diff --git a/src/Model/Table/SettingsTable.php b/src/Model/Table/SettingsTable.php index 7a7d130..cdc1796 100644 --- a/src/Model/Table/SettingsTable.php +++ b/src/Model/Table/SettingsTable.php @@ -33,4 +33,19 @@ class SettingsTable extends AppTable ]; } } + + public function getSetting($name=false): array + { + $settings = Configure::read()['Cerebrate']; + $settingsProvider = $this->SettingsProvider->getSettingsConfiguration($settings); + $settingsFlattened = $this->SettingsProvider->flattenSettingsConfiguration($settingsProvider); + return $settingsFlattened[$name] ?? []; + } + + public function saveSetting(string $name, string $value): array + { + $errors = []; + // Save setting here! + return $errors; + } } diff --git a/templates/Instance/save_setting.php b/templates/Instance/save_setting.php new file mode 100644 index 0000000..6609749 --- /dev/null +++ b/templates/Instance/save_setting.php @@ -0,0 +1,17 @@ +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') + ] + ] +]); diff --git a/templates/Instance/settings.php b/templates/Instance/settings.php index 07d963b..a40e216 100644 --- a/templates/Instance/settings.php +++ b/templates/Instance/settings.php @@ -92,7 +92,7 @@ function genLevel0($settingsProvider, $appView) $content0[] = __('No Settings available yet'); } } - array_unshift($level0, __('Setting Diagnostic')); + array_unshift($level0, __('Settings Diagnostic')); array_unshift($content0, $appView->get('settingNotice')); $tabsOptions0 = [ // 'vertical' => true, @@ -178,6 +178,7 @@ function genLevel3($level2Name, $settingGroupName, $setting, $appView) 'p-2', 'mb-4', 'rounded', + 'settings-group', (!empty($groupIssueSeverity) ? "callout callout-${groupIssueSeverity}" : ''), ($appView->get('darkMode') ? 'bg-dark' : 'bg-light') ], @@ -211,56 +212,75 @@ function genSingleSetting($settingName, $setting, $appView) 'id' => "{$settingId}Help" ], h($setting['description'])); } - $error = ''; - if (!empty($setting['error'])) { - $textColor = ''; + $textColor = 'text-warning'; + if (!empty($setting['severity'])) { $textColor = "text-{$appView->get('variantFromSeverity')[$setting['severity']]}"; - $error = $appView->Bootstrap->genNode('div', [ - 'class' => ['d-block', 'invalid-feedback', $textColor], - ], h($setting['errorMessage'])); } + $error = $appView->Bootstrap->genNode('div', [ + 'class' => ['d-block', 'invalid-feedback', $textColor], + ], (!empty($setting['error']) ? h($setting['errorMessage']) : '')); if (empty($setting['type'])) { $setting['type'] = 'string'; } if ($setting['type'] == 'string') { - $input = genInputString($settingId, $setting, $appView); + $input = genInputString($settingName, $setting, $appView); } elseif ($setting['type'] == 'boolean') { - $input = genInputCheckbox($settingId, $setting, $appView); + $input = genInputCheckbox($settingName, $setting, $appView); $description = ''; } elseif ($setting['type'] == 'integer') { - $input = genInputInteger($settingId, $setting, $appView); + $input = genInputInteger($settingName, $setting, $appView); } elseif ($setting['type'] == 'select') { - $input = genInputSelect($settingId, $setting, $appView); + $input = genInputSelect($settingName, $setting, $appView); } elseif ($setting['type'] == 'multi-select') { - $input = genInputMultiSelect($settingId, $setting, $appView); + $input = genInputMultiSelect($settingName, $setting, $appView); } else { - $input = genInputString($settingId, $setting, $appView); + $input = genInputString($settingName, $setting, $appView); } + + $inputGroupSave = $appView->Bootstrap->genNode('div', [ + 'class' => ['input-group-append', 'd-none', 'position-relative', 'input-group-actions'], + ], implode('', [ + $appView->Bootstrap->genNode('a', [ + 'class' => ['position-absolute', 'fas fa-times', 'p-abs-center-y', 'text-reset text-decoration-none', 'btn-reset-setting'], + 'href' => '#', + 'style' => 'left: -1.25em; z-index: 5;' + ]), + $appView->Bootstrap->genNode('button', [ + 'class' => ['btn', 'btn-success', 'btn-save-setting'], + 'type' => 'button' + ], __('save')), + ])); + $inputGroup = $appView->Bootstrap->genNode('div', [ + 'class' => ['input-group'], + ], implode('', [$input, $inputGroupSave])); + $container = $appView->Bootstrap->genNode('div', [ 'class' => ['form-group', 'mb-2'] - ], implode('', [$label, $input, $description, $error])); + ], implode('', [$label, $inputGroup, $description, $error])); return $container; } -function genInputString($settingId, $setting, $appView) +function genInputString($settingName, $setting, $appView) { + $settingId = str_replace('.', '_', $settingName); return $appView->Bootstrap->genNode('input', [ 'class' => [ 'form-control', - "xxx-{$appView->get('variantFromSeverity')[$setting['severity']]} yyy-{$setting['severity']}", (!empty($setting['error']) ? 'is-invalid' : ''), (!empty($setting['error']) ? "border-{$appView->get('variantFromSeverity')[$setting['severity']]}" : ''), (!empty($setting['error']) && $setting['severity'] == 'warning' ? 'warning' : ''), ], 'type' => 'text', 'id' => $settingId, + 'data-setting-name' => $settingName, 'value' => isset($setting['value']) ? $setting['value'] : "", 'placeholder' => $setting['default'] ?? '', 'aria-describedby' => "{$settingId}Help" ]); } -function genInputCheckbox($settingId, $setting, $appView) +function genInputCheckbox($settingName, $setting, $appView) { + $settingId = str_replace('.', '_', $settingName); $switch = $appView->Bootstrap->genNode('input', [ 'class' => [ 'custom-control-input' @@ -269,6 +289,7 @@ function genInputCheckbox($settingId, $setting, $appView) 'value' => !empty($setting['value']) ? 1 : 0, 'checked' => !empty($setting['value']) ? 'checked' : '', 'id' => $settingId, + 'data-setting-name' => $settingName, ]); $label = $appView->Bootstrap->genNode('label', [ 'class' => [ @@ -284,8 +305,9 @@ function genInputCheckbox($settingId, $setting, $appView) ], implode('', [$switch, $label])); return $container; } -function genInputInteger($settingId, $setting, $appView) +function genInputInteger($settingName, $setting, $appView) { + $settingId = str_replace('.', '_', $settingName); return $appView->Bootstrap->genNode('input', [ 'class' => [ 'form-control' @@ -294,6 +316,7 @@ function genInputInteger($settingId, $setting, $appView) 'min' => '0', 'step' => 1, 'id' => $settingId, + 'data-setting-name' => $settingName, 'aria-describedby' => "{$settingId}Help" ]); } @@ -330,6 +353,7 @@ function isLeaf($setting) \ No newline at end of file diff --git a/webroot/css/bootstrap-additional.css b/webroot/css/bootstrap-additional.css index 63c730f..1281dc4 100644 --- a/webroot/css/bootstrap-additional.css +++ b/webroot/css/bootstrap-additional.css @@ -141,6 +141,9 @@ div.progress-timeline .progress-line.progress-inactive { .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") } +.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-control-input.is-invalid.warning ~ .custom-control-label { color: unset; From ac464e41298657f257be3ee3e782f142d7f8d8f5 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Fri, 23 Jul 2021 10:32:00 +0200 Subject: [PATCH 10/35] chg: [instance:settings] UI improvements --- src/Model/Table/SettingsProviderTable.php | 64 ++++++++++------------- templates/Instance/settings.php | 2 +- webroot/css/bootstrap-additional.css | 11 ++++ 3 files changed, 41 insertions(+), 36 deletions(-) diff --git a/src/Model/Table/SettingsProviderTable.php b/src/Model/Table/SettingsProviderTable.php index 0644883..a6d5fb4 100644 --- a/src/Model/Table/SettingsProviderTable.php +++ b/src/Model/Table/SettingsProviderTable.php @@ -131,7 +131,7 @@ class SettingsProviderTable extends AppTable $error = false; $setting['value'] = $setting['value'] ?? ''; if (is_callable($setting['test'])) { // Validate with anonymous function - $error = $setting['test']($setting['value'], $setting); + $error = $setting['test']($setting['value'], $setting, new Validator()); } else if (method_exists($this->settingValidator, $setting['test'])) { // Validate with function defined in settingValidator class $error = $this->settingValidator->{$setting['test']}($setting['value'], $setting); } else { @@ -223,8 +223,9 @@ class SettingsProviderTable extends AppTable 'floating-setting' => [ 'description' => 'floaringSetting', 'errorMessage' => 'floaringSetting', - 'default' => '', + 'default' => 'A default value', 'name' => 'Uncategorized Setting', + 'test' => 'testEmptyBecomesDefault', 'type' => 'string' ], ], @@ -277,37 +278,23 @@ class SettingsProviderTable extends AppTable ], ], 'Security' => [ - 'Network' => [ - 'Proxy Test' => [ - 'proxy-test.host' => [ - 'description' => __('The hostname of an HTTP proxy for outgoing sync requests. Leave empty to not use a proxy.'), - 'default' => '', - 'name' => __('Host'), - 'test' => 'testHostname', - 'type' => 'string', - ], - 'proxy-test.port' => [ - 'description' => __('The TCP port for the HTTP proxy.'), - 'default' => '', - 'name' => __('Port'), - 'test' => 'testForRangeXY', - 'type' => 'integer', - ], - 'proxy-test.user' => [ - 'description' => __('The authentication username for the HTTP proxy.'), - 'default' => '', + 'Development' => [ + 'Debugging' => [ + 'Debug' => [ + 'description' => __('The debug level of the instance'), + 'default' => 0, 'dependsOn' => 'host', 'name' => __('User'), - 'test' => 'testEmptyBecomesDefault', - 'type' => 'string', - ], - 'proxy-test.password' => [ - 'description' => __('The authentication password for the HTTP proxy.'), - 'default' => '', - 'dependsOn' => 'host', - 'name' => __('Password'), - 'test' => 'testEmptyBecomesDefault', - 'type' => 'string', + 'test' => function($value, $setting, $validator) { + $validator->range('value', [0, 3]); + return testValidator($value, $validator); + }, + 'type' => 'select', + 'options' => [ + 0 => __('Debug Off'), + 1 => __('Debug On'), + 2 => __('Debug On + SQL Dump'), + ] ], ], ] @@ -318,26 +305,33 @@ class SettingsProviderTable extends AppTable } } +function testValidator($value, $validator) +{ + $errors = $validator->validate(['value' => $value]); + return !empty($errors) ? implode(', ', $errors['value']) : true; +} + class SettingValidator { - public function testEmptyBecomesDefault($value, $setting) + public function testEmptyBecomesDefault($value, &$setting) { if (!empty($value)) { return true; } else if (!empty($setting['default'])) { + $setting['severity'] = 'info'; return __('Setting is not set, fallback to default value: {0}', $setting['default']); } else { return __('Cannot be empty'); } } - public function testForEmpty($value, $setting) + public function testForEmpty($value, &$setting) { return !empty($value) ? true : __('Cannot be empty'); } - public function testBaseURL($value, $setting) + public function testBaseURL($value, &$setting) { if (empty($value)) { return __('Cannot be empty'); @@ -348,7 +342,7 @@ class SettingValidator return true; } - public function testUuid($value, $setting) { + 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.'); } diff --git a/templates/Instance/settings.php b/templates/Instance/settings.php index e45efd4..0dd81c7 100644 --- a/templates/Instance/settings.php +++ b/templates/Instance/settings.php @@ -268,7 +268,7 @@ function genInputString($settingName, $setting, $appView) 'pr-4', (!empty($setting['error']) ? 'is-invalid' : ''), (!empty($setting['error']) ? "border-{$appView->get('variantFromSeverity')[$setting['severity']]}" : ''), - (!empty($setting['error']) && $setting['severity'] == 'warning' ? 'warning' : ''), + (!empty($setting['error']) ? $appView->get('variantFromSeverity')[$setting['severity']] : ''), ], 'type' => 'text', 'id' => $settingId, diff --git a/webroot/css/bootstrap-additional.css b/webroot/css/bootstrap-additional.css index 1281dc4..adac905 100644 --- a/webroot/css/bootstrap-additional.css +++ b/webroot/css/bootstrap-additional.css @@ -141,6 +141,17 @@ div.progress-timeline .progress-line.progress-inactive { .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); } From 22faffb170765423c1ede2bdbb8773ab057d6bb8 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Fri, 23 Jul 2021 12:03:03 +0200 Subject: [PATCH 11/35] fix: [instance:settings] Improved support of select and apply correct UI coloring --- src/Model/Table/SettingsProviderTable.php | 51 +++++++++++------------ templates/Instance/settings.php | 9 ++-- webroot/css/bootstrap-additional.css | 3 ++ 3 files changed, 34 insertions(+), 29 deletions(-) diff --git a/src/Model/Table/SettingsProviderTable.php b/src/Model/Table/SettingsProviderTable.php index a6d5fb4..fe7d739 100644 --- a/src/Model/Table/SettingsProviderTable.php +++ b/src/Model/Table/SettingsProviderTable.php @@ -51,9 +51,6 @@ class SettingsProviderTable extends AppTable if (isset($settings[$key])) { $settingConf[$key]['value'] = $settings[$key]; } - if (empty($settingConf[$key]['severity'])) { - $settingConf[$key]['severity'] = 'warning'; - } $settingConf[$key] = $this->evaluateLeaf($settingConf[$key], $settingConf); $settingConf[$key]['setting-path'] = $path; $settingConf[$key]['true-name'] = $key; @@ -95,7 +92,7 @@ class SettingsProviderTable extends AppTable $notices[$value['severity']][] = $key; } } else { - $notices = array_merge($notices, $this->getNoticesFromSettingsConfiguration($value)); + $notices = array_merge_recursive($notices, $this->getNoticesFromSettingsConfiguration($value)); } } return $notices; @@ -127,29 +124,31 @@ class SettingsProviderTable extends AppTable } } if (!$skipValidation) { - if (isset($setting['test'])) { - $error = false; + $validationResult = false; + if (!isset($setting['value'])) { + $validationResult = $this->settingValidator->testEmptyBecomesDefault(null, $setting); + } else if (isset($setting['test'])) { $setting['value'] = $setting['value'] ?? ''; if (is_callable($setting['test'])) { // Validate with anonymous function - $error = $setting['test']($setting['value'], $setting, new Validator()); + $validationResult = $setting['test']($setting['value'], $setting, new Validator()); } else if (method_exists($this->settingValidator, $setting['test'])) { // Validate with function defined in settingValidator class - $error = $this->settingValidator->{$setting['test']}($setting['value'], $setting); + $validationResult = $this->settingValidator->{$setting['test']}($setting['value'], $setting); } else { $validator = new Validator(); if (method_exists($validator, $setting['test'])) { // Validate with cake's validator function $validator->{$setting['test']}; - $error = $validator->validate($setting['value']); + $validationResult = $validator->validate($setting['value']); } } - if ($error !== true) { - $setting['severity'] = $setting['severity'] ?? 'warning'; - if (!in_array($setting['severity'], $this->severities)) { - $setting['severity'] = 'warning'; - } - $setting['errorMessage'] = $error; - } - $setting['error'] = $error !== false ? true : false; } + 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; } @@ -176,9 +175,9 @@ class SettingsProviderTable extends AppTable ], 'app.uuid' => [ 'description' => __('The Cerebrate instance UUID. This UUID is used to identify this instance.'), - 'severity' => 'critical', 'default' => '', 'name' => 'UUID', + 'severity' => 'critical', 'test' => 'testUuid', 'type' => 'string' ], @@ -196,19 +195,20 @@ class SettingsProviderTable extends AppTable ], 'sc2.hero' => [ 'description' => 'The true hero', - 'default' => '', + 'default' => 'Sarah Kerrigan', + 'name' => 'Hero', 'options' => [ 'Jim Raynor' => 'Jim Raynor', 'Sarah Kerrigan' => 'Sarah Kerrigan', 'Artanis' => 'Artanis', 'Zeratul' => 'Zeratul', ], - 'name' => 'Hero', 'type' => 'select' ], 'sc2.antagonist' => [ 'description' => 'The real bad guy', - 'default' => '', + 'default' => 'Amon', + 'name' => 'Antagonist', 'options' => function($settingsProviders) { return [ 'Amon' => 'Amon', @@ -249,7 +249,6 @@ class SettingsProviderTable extends AppTable 'description' => __('The authentication username for the HTTP proxy.'), 'default' => 'admin', 'name' => __('User'), - 'test' => 'testEmptyBecomesDefault', 'dependsOn' => 'proxy.host', 'type' => 'string', ], @@ -257,7 +256,6 @@ class SettingsProviderTable extends AppTable 'description' => __('The authentication password for the HTTP proxy.'), 'default' => '', 'name' => __('Password'), - 'test' => 'testEmptyBecomesDefault', 'dependsOn' => 'proxy.host', 'type' => 'string', ], @@ -284,7 +282,7 @@ class SettingsProviderTable extends AppTable 'description' => __('The debug level of the instance'), 'default' => 0, 'dependsOn' => 'host', - 'name' => __('User'), + 'name' => __('Debug Level'), 'test' => function($value, $setting, $validator) { $validator->range('value', [0, 3]); return testValidator($value, $validator); @@ -319,10 +317,11 @@ class SettingValidator if (!empty($value)) { return true; } else if (!empty($setting['default'])) { - $setting['severity'] = 'info'; + $setting['severity'] = $setting['severity'] ?? 'info'; return __('Setting is not set, fallback to default value: {0}', $setting['default']); } else { - return __('Cannot be empty'); + $setting['severity'] = $setting['severity'] ?? 'critical'; + return __('Cannot be empty. Setting does not have a default value.'); } } diff --git a/templates/Instance/settings.php b/templates/Instance/settings.php index 0dd81c7..6a7757c 100644 --- a/templates/Instance/settings.php +++ b/templates/Instance/settings.php @@ -312,7 +312,10 @@ function genInputInteger($settingName, $setting, $appView) $settingId = str_replace('.', '_', $settingName); return $appView->Bootstrap->genNode('input', [ 'class' => [ - 'form-control' + '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', @@ -343,7 +346,7 @@ function genInputSelect($settingName, $setting, $appView) 'pr-4', (!empty($setting['error']) ? 'is-invalid' : ''), (!empty($setting['error']) ? "border-{$appView->get('variantFromSeverity')[$setting['severity']]}" : ''), - (!empty($setting['error']) && $setting['severity'] == 'warning' ? 'warning' : ''), + (!empty($setting['error']) ? $appView->get('variantFromSeverity')[$setting['severity']] : ''), ], 'type' => 'text', 'id' => $settingId, @@ -543,7 +546,7 @@ function isLeaf($setting) if ($callout.length == 0) { return } - const $settings = $callout.find('input') + const $settings = $callout.find('input, select') const settingNames = Array.from($settings).map((i) => { return $(i).data('setting-name') }) diff --git a/webroot/css/bootstrap-additional.css b/webroot/css/bootstrap-additional.css index adac905..0fe5f21 100644 --- a/webroot/css/bootstrap-additional.css +++ b/webroot/css/bootstrap-additional.css @@ -155,6 +155,9 @@ div.progress-timeline .progress-line.progress-inactive { .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; From cf793b674225795bcd431c907b4a06f27256ac9c Mon Sep 17 00:00:00 2001 From: mokaddem Date: Fri, 23 Jul 2021 14:51:48 +0200 Subject: [PATCH 12/35] chg: [instance:settings] UI improvements --- src/Model/Table/SettingsProviderTable.php | 13 ++++++------- templates/Instance/settings.php | 8 +++++++- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/Model/Table/SettingsProviderTable.php b/src/Model/Table/SettingsProviderTable.php index fe7d739..e7c381b 100644 --- a/src/Model/Table/SettingsProviderTable.php +++ b/src/Model/Table/SettingsProviderTable.php @@ -216,31 +216,30 @@ class SettingsProviderTable extends AppTable 'Narud' => 'Narud', ]; }, - 'name' => 'Antagonist', + 'severity' => 'warning', 'type' => 'select' ], ], 'floating-setting' => [ 'description' => 'floaringSetting', - 'errorMessage' => 'floaringSetting', - 'default' => 'A default value', + // 'default' => 'A default value', 'name' => 'Uncategorized Setting', - 'test' => 'testEmptyBecomesDefault', - 'type' => 'string' + // 'severity' => 'critical', + 'severity' => 'warning', + // 'severity' => 'info', + 'type' => 'integer' ], ], 'Network' => [ 'Proxy' => [ 'proxy.host' => [ 'description' => __('The hostname of an HTTP proxy for outgoing sync requests. Leave empty to not use a proxy.'), - 'default' => '', 'name' => __('Host'), 'test' => 'testHostname', 'type' => 'string', ], 'proxy.port' => [ 'description' => __('The TCP port for the HTTP proxy.'), - 'default' => '', 'name' => __('Port'), 'test' => 'testForRangeXY', 'type' => 'integer', diff --git a/templates/Instance/settings.php b/templates/Instance/settings.php index 6a7757c..23f2b31 100644 --- a/templates/Instance/settings.php +++ b/templates/Instance/settings.php @@ -474,6 +474,8 @@ function isLeaf($setting) 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) @@ -503,7 +505,7 @@ function isLeaf($setting) 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) { + if (newValue == oldValue || (newValue == '' && oldValue == undefined)) { restoreWarnings($input) } else { removeWarnings($input) @@ -664,4 +666,8 @@ function isLeaf($setting) .custom-select ~ div > a.btn-reset-setting { left: -2.5em; } + + .form-control ~ div > a.btn-reset-setting { + left: -3em; + } \ No newline at end of file From ef86e77e415d6cb6e6e9f1d15e1785a161a2f39c Mon Sep 17 00:00:00 2001 From: mokaddem Date: Mon, 26 Jul 2021 11:16:52 +0200 Subject: [PATCH 13/35] chg: [instance:settings] UI refactoring --- src/Model/Table/SettingsProviderTable.php | 3 +- templates/Instance/settings.php | 534 +++----------------- templates/element/Settings/field.php | 108 ++++ templates/element/Settings/fieldGroup.php | 64 +++ templates/element/Settings/notice.php | 66 +++ templates/element/Settings/panel.php | 44 ++ templates/element/Settings/scrollspyNav.php | 63 +++ templates/element/Settings/search.php | 109 ++++ 8 files changed, 513 insertions(+), 478 deletions(-) create mode 100644 templates/element/Settings/field.php create mode 100644 templates/element/Settings/fieldGroup.php create mode 100644 templates/element/Settings/notice.php create mode 100644 templates/element/Settings/panel.php create mode 100644 templates/element/Settings/scrollspyNav.php create mode 100644 templates/element/Settings/search.php diff --git a/src/Model/Table/SettingsProviderTable.php b/src/Model/Table/SettingsProviderTable.php index e7c381b..c32100f 100644 --- a/src/Model/Table/SettingsProviderTable.php +++ b/src/Model/Table/SettingsProviderTable.php @@ -277,10 +277,9 @@ class SettingsProviderTable extends AppTable 'Security' => [ 'Development' => [ 'Debugging' => [ - 'Debug' => [ + 'app.security.debug' => [ 'description' => __('The debug level of the instance'), 'default' => 0, - 'dependsOn' => 'host', 'name' => __('Debug Level'), 'test' => function($value, $setting, $validator) { $validator->range('value', [0, 3]); diff --git a/templates/Instance/settings.php b/templates/Instance/settings.php index 23f2b31..a01b9bd 100644 --- a/templates/Instance/settings.php +++ b/templates/Instance/settings.php @@ -1,99 +1,45 @@ __('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.'), -]; -$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.'), -]; -$headingPerLevel = [ - 'critical' => __('Critical settings'), - 'warning' => __('Warning settings'), - 'info' => __('Info settings'), -]; $variantFromSeverity = [ 'critical' => 'danger', 'warning' => 'warning', 'info' => 'info', ]; $this->set('variantFromSeverity', $variantFromSeverity); - -$alertVariant = 'info'; -$alertBody = ''; -$skipHeading = false; -$tableItems = []; -foreach (array_keys($mainNoticeHeading) as $level) { - if(!empty($notices[$level])) { - $variant = $variantFromSeverity[$level]; - if (!$skipHeading) { - $alertBody .= sprintf('
        %s
        ', $mainNoticeHeading[$level]); - $alertVariant = $variant; - $skipHeading = true; - } - $tableItems[] = [ - 'severity' => $headingPerLevel[$level], - 'issues' => count($notices[$level]), - 'badge-variant' => $variant, - 'description' => $noticeDescriptionPerLevel[$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('
        %s
        ', $settingNotice); -$this->set('settingNotice', $settingNotice); -$settingTable = genLevel0($settingsProvider, $this); +$settingTable = genNavcard($settingsProvider, $this); ?> + + +
        - + element('Settings/search', [ + ]); + ?>
        $level1Setting) { - if (!empty($level1Setting)) { - $content0[] = genLevel1($level1Setting, $appView); + $cardContent = []; + $cardNavs = array_keys($settingsProvider); + foreach ($settingsProvider as $navName => $sectionSettings) { + if (!empty($sectionSettings)) { + $cardContent[] = genContentForNav($sectionSettings, $appView); } else { - $content0[] = __('No Settings available yet'); + $cardContent[] = __('No Settings available yet'); } } - array_unshift($level0, __('Settings Diagnostic')); - array_unshift($content0, $appView->get('settingNotice')); + array_unshift($cardNavs, __('Settings Diagnostic')); + $notice = $appView->element('Settings/notice', [ + 'variantFromSeverity' => $appView->get('variantFromSeverity'), + ]); + array_unshift($cardContent, $notice); $tabsOptions0 = [ // 'vertical' => true, // 'vertical-size' => 2, @@ -102,33 +48,35 @@ function genLevel0($settingsProvider, $appView) 'justify' => 'center', 'nav-class' => ['settings-tabs'], 'data' => [ - 'navs' => $level0, - 'content' => $content0 + 'navs' => $cardNavs, + 'content' => $cardContent ] ]; $table0 = $appView->Bootstrap->tabs($tabsOptions0); return $table0; } -function genLevel1($level1Setting, $appView) +function genContentForNav($sectionSettings, $appView) { - $content1 = []; - $nav1 = []; - foreach ($level1Setting as $level2Name => $level2Setting) { - if (!empty($level2Setting)) { - $content1[] = genLevel2($level2Name, $level2Setting, $appView); + $groupedContent = []; + $groupedSetting = []; + foreach ($sectionSettings as $sectionName => $subSectionSettings) { + if (!empty($subSectionSettings)) { + $groupedContent[] = genSection($sectionName, $subSectionSettings, $appView); } else { - $content1[] = ''; + $groupedContent[] = ''; } - $nav1[$level2Name] = array_filter( // only show grouped settings - array_keys($level2Setting), - function ($settingGroupName) use ($level2Setting) { - return !isLeaf($level2Setting[$settingGroupName]) && !empty($level2Setting[$settingGroupName]); + $groupedSetting[$sectionName] = array_filter( // only show grouped settings + array_keys($subSectionSettings), + function ($settingGroupName) use ($subSectionSettings) { + return !isLeaf($subSectionSettings[$settingGroupName]) && !empty($subSectionSettings[$settingGroupName]); } ); } - $contentHtml = implode('', $content1); - $scrollspyNav = genScrollspyNav($nav1); + $contentHtml = implode('', $groupedContent); + $scrollspyNav = $appView->element('Settings/scrollspyNav', [ + 'groupedSetting' => $groupedSetting + ]); $mainPanelHeight = 'calc(100vh - 42px - 1rem - 56px - 38px - 1rem)'; $container = '
        '; $container .= "
        {$scrollspyNav}
        "; @@ -137,319 +85,42 @@ function genLevel1($level1Setting, $appView) return $container; } -function genLevel2($level2Name, $level2Setting, $appView) +function genSection($sectionName, $subSectionSettings, $appView) { - foreach ($level2Setting as $level3Name => $level3Setting) { - if (!empty($level3Setting)) { - $level3 = genLevel3($level2Name, $level3Name, $level3Setting, $appView); - $content2[] = sprintf('
        %s
        ', sprintf('sp-%s', h($level2Name)), $level3); + $sectionContent = []; + $sectionContent[] = sprintf('
        ', sprintf('sp-%s', h($sectionName))); + foreach ($subSectionSettings as $panelName => $panelSettings) { + if (!empty($panelSettings)) { + $panelHTML = $appView->element('Settings/panel', [ + 'sectionName' => $sectionName, + 'panelName' => $panelName, + 'panelSettings' => $panelSettings, + ]); + $sectionContent[] = $panelHTML; } else { - $content2[] = ''; + $sectionContent[] = ''; } } - return implode('', $content2); -} - -function genLevel3($level2Name, $settingGroupName, $setting, $appView) -{ - $settingGroup = ''; - if (isLeaf($setting)) { - $tmp = genSingleSetting($settingGroupName, $setting, $appView); - $settingGroup = "
        {$tmp}
        "; - } else { - $tmpID = sprintf('sp-%s-%s', h($level2Name), h($settingGroupName)); - $settingGroup .= sprintf('

        %s

        ', $tmpID, $tmpID, h($settingGroupName)); - $groupIssueSeverity = false; - foreach ($setting as $singleSettingName => $singleSetting) { - $tmp = genSingleSetting($singleSettingName, $singleSetting, $appView); - $settingGroup .= sprintf('
        %s
        ', $tmp); - if (!empty($singleSetting['error'])) { - $settingVariant = $appView->get('variantFromSeverity')[$singleSetting['severity']]; - if ($groupIssueSeverity != 'danger') { - if ($groupIssueSeverity != 'warning') { - $groupIssueSeverity = $settingVariant; - } - } - } - } - $settingGroup = $appView->Bootstrap->genNode('div', [ - 'class' => [ - 'shadow', - 'p-2', - 'mb-4', - 'rounded', - 'settings-group', - (!empty($groupIssueSeverity) ? "callout callout-${groupIssueSeverity}" : ''), - ($appView->get('darkMode') ? 'bg-dark' : 'bg-light') - ], - ], $settingGroup); - } - return $settingGroup; -} - -function genSingleSetting($settingName, $setting, $appView) -{ - $dependsOnHtml = ''; - if (!empty($setting['dependsOn'])) { - $dependsOnHtml = $appView->Bootstrap->genNode('span', [ - ], $appView->Bootstrap->genNode('sup', [ - 'class' => [ - $appView->FontAwesome->getClass('info'), - 'ml-1', - ], - 'title' => __('This setting depends on the validity of: {0}', h($setting['dependsOn'])) - ])); - } - $settingId = str_replace('.', '_', $settingName); - $label = $appView->Bootstrap->genNode('label', [ - 'class' => ['font-weight-bolder', 'mb-0'], - 'for' => $settingId - ], h($setting['name']) . $dependsOnHtml); - $description = ''; - if (!empty($setting['description'])) { - $description = $appView->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-{$appView->get('variantFromSeverity')[$setting['severity']]}"; - } - $error = $appView->Bootstrap->genNode('div', [ - 'class' => ['d-block', 'invalid-feedback', $textColor], - ], (!empty($setting['error']) ? h($setting['errorMessage']) : '')); - if (empty($setting['type'])) { - $setting['type'] = 'string'; - } - if ($setting['type'] == 'string') { - $input = genInputString($settingName, $setting, $appView); - } elseif ($setting['type'] == 'boolean') { - $input = genInputCheckbox($settingName, $setting, $appView); - $description = ''; - } elseif ($setting['type'] == 'integer') { - $input = genInputInteger($settingName, $setting, $appView); - } elseif ($setting['type'] == 'select') { - $input = genInputSelect($settingName, $setting, $appView); - } elseif ($setting['type'] == 'multi-select') { - $input = genInputMultiSelect($settingName, $setting, $appView); - } else { - $input = genInputString($settingName, $setting, $appView); - } - - $inputGroupSave = $appView->Bootstrap->genNode('div', [ - 'class' => ['input-group-append', 'd-none', 'position-relative', 'input-group-actions'], - ], implode('', [ - $appView->Bootstrap->genNode('a', [ - 'class' => ['position-absolute', 'fas fa-times', 'p-abs-center-y', 'text-reset text-decoration-none', 'btn-reset-setting'], - 'href' => '#', - ]), - $appView->Bootstrap->genNode('button', [ - 'class' => ['btn', 'btn-success', 'btn-save-setting'], - 'type' => 'button', - ], __('save')), - ])); - $inputGroup = $appView->Bootstrap->genNode('div', [ - 'class' => ['input-group'], - ], implode('', [$input, $inputGroupSave])); - - $container = $appView->Bootstrap->genNode('div', [ - 'class' => ['form-group', 'mb-2'] - ], implode('', [$label, $inputGroup, $description, $error])); - return $container; -} - -function genInputString($settingName, $setting, $appView) -{ - $settingId = str_replace('.', '_', $settingName); - return $appView->Bootstrap->genNode('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']] : ''), - ], - 'type' => 'text', - 'id' => $settingId, - 'data-setting-name' => $settingName, - 'value' => isset($setting['value']) ? $setting['value'] : "", - 'placeholder' => $setting['default'] ?? '', - 'aria-describedby' => "{$settingId}Help" - ]); -} -function genInputCheckbox($settingName, $setting, $appView) -{ - $settingId = str_replace('.', '_', $settingName); - $switch = $appView->Bootstrap->genNode('input', [ - 'class' => [ - 'custom-control-input', - (!empty($setting['error']) ? 'is-invalid' : ''), - (!empty($setting['error']) && $setting['severity'] == 'warning' ? 'warning' : ''), - ], - '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; -} -function genInputInteger($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" - ]); -} -function genInputSelect($settingName, $setting, $appView) -{ - $settingId = str_replace('.', '_', $settingName); - $setting['value'] = $setting['value'] ?? ''; - $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'] == $value ? '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']] : ''), - ], - 'type' => 'text', - 'id' => $settingId, - 'data-setting-name' => $settingName, - 'placeholder' => $setting['default'] ?? '', - 'aria-describedby' => "{$settingId}Help" - ], $options); -} -function genInputMultiSelect($settingName, $setting, $appView) -{ -} - -function genScrollspyNav($nav1) -{ - $nav = ''; - return $nav; + $sectionContent[] = '
        '; + return implode('', $sectionContent); } function isLeaf($setting) { return !empty($setting['name']) && !empty($setting['type']); } - ?> - \ No newline at end of file diff --git a/templates/element/Settings/field.php b/templates/element/Settings/field.php new file mode 100644 index 0000000..39b03c7 --- /dev/null +++ b/templates/element/Settings/field.php @@ -0,0 +1,108 @@ +Bootstrap->genNode('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']] : ''), + ], + 'type' => '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']) && $setting['severity'] == 'warning' ? 'warning' : ''), + ], + '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') { + $input = (function ($settingName, $setting, $appView) { + $settingId = str_replace('.', '_', $settingName); + $setting['value'] = $setting['value'] ?? ''; + $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'] == $value ? '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']] : ''), + ], + 'type' => 'text', + 'id' => $settingId, + 'data-setting-name' => $settingName, + 'placeholder' => $setting['default'] ?? '', + 'aria-describedby' => "{$settingId}Help" + ], $options); + })($settingName, $setting, $this); + + } elseif ($setting['type'] == 'multi-select') { + $input = (function ($settingName, $setting, $appView) { + return ''; + })($settingName, $setting, $this); + } + echo $input; diff --git a/templates/element/Settings/fieldGroup.php b/templates/element/Settings/fieldGroup.php new file mode 100644 index 0000000..5a24921 --- /dev/null +++ b/templates/element/Settings/fieldGroup.php @@ -0,0 +1,64 @@ +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 + ], 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; +?> \ No newline at end of file diff --git a/templates/element/Settings/notice.php b/templates/element/Settings/notice.php new file mode 100644 index 0000000..7a8a2e5 --- /dev/null +++ b/templates/element/Settings/notice.php @@ -0,0 +1,66 @@ + __('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.'), +]; + +$alertVariant = 'info'; +$skipHeading = false; +$alertBody = ''; +$tableItems = []; +foreach (array_keys($mainNoticeHeading) as $level) { + if(!empty($notices[$level])) { + $variant = $variantFromSeverity[$level]; + if (!$skipHeading) { + $alertBody .= sprintf('
        %s
        ', $mainNoticeHeading[$level]); + $alertVariant = $variant; + $skipHeading = true; + } + $tableItems[] = [ + 'severity' => $headingPerLevel[$level], + 'issues' => count($notices[$level]), + 'badge-variant' => $variant, + 'description' => $noticeDescriptionPerLevel[$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('
        %s
        ', $settingNotice); +echo $settingNotice; \ No newline at end of file diff --git a/templates/element/Settings/panel.php b/templates/element/Settings/panel.php new file mode 100644 index 0000000..b273fde --- /dev/null +++ b/templates/element/Settings/panel.php @@ -0,0 +1,44 @@ +element('Settings/fieldGroup', [ + 'panelName' => $panelName, + 'panelSettings' => $panelSettings, + 'settingName' => $panelName, + 'setting' => $panelSettings, + ]); + $panelHTML = "
        {$singleSetting}
        "; +} else { + $panelID = sprintf('sp-%s-%s', h($sectionName), h($panelName)); + $panelHTML .= sprintf('

        %s

        ', $panelID, $panelID, h($panelName)); + $groupIssueSeverity = false; + foreach ($panelSettings as $singleSettingName => $singleSetting) { + $singleSettingHTML = $this->element('Settings/fieldGroup', [ + 'panelName' => $panelName, + 'panelSettings' => $panelSettings, + 'settingName' => $singleSettingName, + 'setting' => $singleSetting, + ]); + $panelHTML .= sprintf('
        %s
        ', $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; \ No newline at end of file diff --git a/templates/element/Settings/scrollspyNav.php b/templates/element/Settings/scrollspyNav.php new file mode 100644 index 0000000..b8bce10 --- /dev/null +++ b/templates/element/Settings/scrollspyNav.php @@ -0,0 +1,63 @@ + + + + + \ No newline at end of file diff --git a/templates/element/Settings/search.php b/templates/element/Settings/search.php new file mode 100644 index 0000000..c19b8c2 --- /dev/null +++ b/templates/element/Settings/search.php @@ -0,0 +1,109 @@ + + + + + \ No newline at end of file From 34fc7ad0279a79ab70a03fd008267dd53363acd9 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Tue, 27 Jul 2021 10:38:43 +0200 Subject: [PATCH 14/35] chg: [helpers:api-helpers] Slight rework on notifications --- webroot/js/api-helper.js | 91 +++++++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 38 deletions(-) diff --git a/webroot/js/api-helper.js b/webroot/js/api-helper.js index aa01843..9062c23 100644 --- a/webroot/js/api-helper.js +++ b/webroot/js/api-helper.js @@ -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 @@ -175,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) { @@ -212,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) { @@ -257,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) { @@ -302,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) { @@ -327,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 @@ -356,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); From 7fc2c595d7c3a85caeaccb540e9806d427630296 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Tue, 27 Jul 2021 10:40:58 +0200 Subject: [PATCH 15/35] chg: [instance:settings] Integrated actually save of settings --- src/Controller/InstanceController.php | 6 +-- src/Model/Table/SettingsProviderTable.php | 53 ++++++++++++++++------- src/Model/Table/SettingsTable.php | 38 ++++++++++++++-- templates/Instance/settings.php | 6 +-- 4 files changed, 77 insertions(+), 26 deletions(-) diff --git a/src/Controller/InstanceController.php b/src/Controller/InstanceController.php index 10604a2..c5a9556 100644 --- a/src/Controller/InstanceController.php +++ b/src/Controller/InstanceController.php @@ -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 { @@ -125,11 +126,6 @@ class InstanceController extends AppController if (empty($errors)) { $message = __('Setting `{0}` saved', $data['name']); $data = $this->Settings->getSetting($data['name']); - // TO DEL - $data['errorMessage'] = 'Test test test'; - $data['error'] = true; - $data['error'] = false; - // TO DEL } $this->CRUD->setResponseForController('saveSetting', empty($errors), $message, $data, $errors); $responsePayload = $this->CRUD->getResponsePayload(); diff --git a/src/Model/Table/SettingsProviderTable.php b/src/Model/Table/SettingsProviderTable.php index c32100f..8c97f77 100644 --- a/src/Model/Table/SettingsProviderTable.php +++ b/src/Model/Table/SettingsProviderTable.php @@ -124,22 +124,12 @@ class SettingsProviderTable extends AppTable } } if (!$skipValidation) { - $validationResult = false; + $validationResult = true; if (!isset($setting['value'])) { $validationResult = $this->settingValidator->testEmptyBecomesDefault(null, $setting); } else if (isset($setting['test'])) { $setting['value'] = $setting['value'] ?? ''; - if (is_callable($setting['test'])) { // Validate with anonymous function - $validationResult = $setting['test']($setting['value'], $setting, new Validator()); - } else if (method_exists($this->settingValidator, $setting['test'])) { // Validate with function defined in settingValidator class - $validationResult = $this->settingValidator->{$setting['test']}($setting['value'], $setting); - } else { - $validator = new Validator(); - if (method_exists($validator, $setting['test'])) { // Validate with cake's validator function - $validator->{$setting['test']}; - $validationResult = $validator->validate($setting['value']); - } - } + $validationResult = $this->evaluateFunctionForSetting($setting['test'], $setting); } if ($validationResult !== true) { $setting['severity'] = $setting['severity'] ?? 'warning'; @@ -152,12 +142,37 @@ class SettingsProviderTable extends AppTable } 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; + } /** * Support up to 3 level: * Application -> Network -> Proxy -> Proxy.URL * - * Leave errorMessage empty to let the validator generate the error message + * - Leave errorMessage empty to let the validator generate the error message + * - Default severity level is `info` if a `default` value is provided otherwise it becomes `critical` */ private function generateSettingsConfiguration() { @@ -188,8 +203,16 @@ class SettingsProviderTable extends AppTable 'errorMessage' => 'to del', 'default' => 'A-default-value', 'name' => 'To DEL', - 'test' => function ($value) { - return empty($value) ? __('Oh not! it\'s not valid!') : ''; + 'test' => function($value) { + return empty($value) ? __('Oh not! it\'s not valid!') : true; + }, + 'beforeSave' => function($value, $setting) { + if ($value != 'foo') { + return 'value must be `foo`!'; + } + return true; + }, + 'afterSave' => function($value, $setting) { }, 'type' => 'string' ], diff --git a/src/Model/Table/SettingsTable.php b/src/Model/Table/SettingsTable.php index cdc1796..0fe4784 100644 --- a/src/Model/Table/SettingsTable.php +++ b/src/Model/Table/SettingsTable.php @@ -9,6 +9,9 @@ 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); @@ -18,7 +21,7 @@ class SettingsTable extends AppTable public function getSettings($full=false): array { - $settings = Configure::read()['Cerebrate']; + $settings = $this->readSettings(); if (empty($full)) { return $settings; } else { @@ -36,7 +39,7 @@ class SettingsTable extends AppTable public function getSetting($name=false): array { - $settings = Configure::read()['Cerebrate']; + $settings = $this->readSettings(); $settingsProvider = $this->SettingsProvider->getSettingsConfiguration($settings); $settingsFlattened = $this->SettingsProvider->flattenSettingsConfiguration($settingsProvider); return $settingsFlattened[$name] ?? []; @@ -45,7 +48,36 @@ class SettingsTable extends AppTable public function saveSetting(string $name, string $value): array { $errors = []; - // Save setting here! + $setting = $this->getSetting($name); + if (!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 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; + } } diff --git a/templates/Instance/settings.php b/templates/Instance/settings.php index a01b9bd..100d6a8 100644 --- a/templates/Instance/settings.php +++ b/templates/Instance/settings.php @@ -170,7 +170,7 @@ function isLeaf($setting) $input.val(result.data.value) } handleSettingValueChange($input) - }) + }).catch((e) => {}) } function handleSettingValueChange($input) { @@ -187,7 +187,7 @@ function isLeaf($setting) 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']) + $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 @@ -202,7 +202,7 @@ function isLeaf($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}`]) + $input.addClass(['is-invalid', `border-${borderVariant}`, borderVariant]) if (setting.severity == 'warning') { $input.addClass('warning') } From 6a89e65a37a8a0ac8aef3e47c2f16e5a96b29338 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Tue, 27 Jul 2021 10:58:34 +0200 Subject: [PATCH 16/35] fix: [instance:settings] Normalise value before saving --- src/Model/Table/SettingsTable.php | 9 +++++++++ templates/Instance/settings.php | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Model/Table/SettingsTable.php b/src/Model/Table/SettingsTable.php index 0fe4784..a3b5b87 100644 --- a/src/Model/Table/SettingsTable.php +++ b/src/Model/Table/SettingsTable.php @@ -49,6 +49,7 @@ class SettingsTable extends AppTable { $errors = []; $setting = $this->getSetting($name); + $value = $this->normaliseValue($value, $setting); if (!empty($setting['beforeSave'])) { $setting['value'] = $value ?? ''; $beforeSaveResult = $this->SettingsProvider->evaluateFunctionForSetting($setting['beforeSave'], $setting); @@ -67,6 +68,14 @@ class SettingsTable extends AppTable 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]; diff --git a/templates/Instance/settings.php b/templates/Instance/settings.php index 100d6a8..e8c9246 100644 --- a/templates/Instance/settings.php +++ b/templates/Instance/settings.php @@ -126,7 +126,7 @@ function isLeaf($setting) const $input = $(this) const $inputGroup = $(this).closest('.form-group') const settingName = $(this).data('setting-name') - const settingValue = $(this).is(':checked') + const settingValue = $(this).is(':checked') ? 1 : 0 saveSetting($inputGroup[0], $input, settingName, settingValue) } else { handleSettingValueChange($(this)) From 99522056fee1c71ac0a83bec9d2691f74e619e0e Mon Sep 17 00:00:00 2001 From: mokaddem Date: Tue, 27 Jul 2021 13:39:56 +0200 Subject: [PATCH 17/35] chg: [instance:settings] Moved setting provider function at the top --- src/Model/Table/SettingsProviderTable.php | 291 +++++++++++----------- 1 file changed, 146 insertions(+), 145 deletions(-) diff --git a/src/Model/Table/SettingsProviderTable.php b/src/Model/Table/SettingsProviderTable.php index 8c97f77..cf2252f 100644 --- a/src/Model/Table/SettingsProviderTable.php +++ b/src/Model/Table/SettingsProviderTable.php @@ -22,154 +22,11 @@ class SettingsProviderTable extends AppTable $this->error_info = __('There are some optional tweaks that could be done to improve the looks of your Cerebrate instance.'); $this->settingValidator = new SettingValidator(); } - - /** - * 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->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->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->isLeaf($value)) { - if (!empty($value['error'])) { - if (empty($notices[$value['severity']])) { - $notices[$value['severity']] = []; - } - $notices[$value['severity']][] = $key; - } - } 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') { - 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']); - } - } - 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; - } /** - * Support up to 3 level: + * Supports up to 3 levels: * Application -> Network -> Proxy -> Proxy.URL + * page -> [group] -> [panel] -> setting * * - Leave errorMessage empty to let the validator generate the error message * - Default severity level is `info` if a `default` value is provided otherwise it becomes `critical` @@ -322,6 +179,150 @@ class SettingsProviderTable extends AppTable ], ]; } + + /** + * 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->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->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->isLeaf($value)) { + if (!empty($value['error'])) { + if (empty($notices[$value['severity']])) { + $notices[$value['severity']] = []; + } + $notices[$value['severity']][] = $key; + } + } 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') { + 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']); + } + } + 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 testValidator($value, $validator) From b75cdac42b27804bb43cd93667844af3818df764 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Tue, 27 Jul 2021 13:40:24 +0200 Subject: [PATCH 18/35] chg: [instance:settings] group and panel level are optionals --- templates/Instance/settings.php | 43 +++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/templates/Instance/settings.php b/templates/Instance/settings.php index e8c9246..f020b1f 100644 --- a/templates/Instance/settings.php +++ b/templates/Instance/settings.php @@ -66,12 +66,14 @@ function genContentForNav($sectionSettings, $appView) } else { $groupedContent[] = ''; } - $groupedSetting[$sectionName] = array_filter( // only show grouped settings - array_keys($subSectionSettings), - function ($settingGroupName) use ($subSectionSettings) { - return !isLeaf($subSectionSettings[$settingGroupName]) && !empty($subSectionSettings[$settingGroupName]); - } - ); + 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', [ @@ -89,16 +91,25 @@ function genSection($sectionName, $subSectionSettings, $appView) { $sectionContent = []; $sectionContent[] = sprintf('
        ', sprintf('sp-%s', 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[] = ''; + if (isLeaf($subSectionSettings)) { + $panelHTML = $appView->element('Settings/panel', [ + 'sectionName' => $sectionName, + 'panelName' => $sectionName, + 'panelSettings' => $subSectionSettings, + ]); + $sectionContent[] = $panelHTML; + } else { + foreach ($subSectionSettings as $panelName => $panelSettings) { + if (!empty($panelSettings)) { + $panelHTML = $appView->element('Settings/panel', [ + 'sectionName' => $sectionName, + 'panelName' => $panelName, + 'panelSettings' => $panelSettings, + ]); + $sectionContent[] = $panelHTML; + } else { + $sectionContent[] = ''; + } } } $sectionContent[] = '
        '; From a82c8fe62bc1d839b7f5f01f8dd5aba0ef48dedc Mon Sep 17 00:00:00 2001 From: mokaddem Date: Fri, 30 Jul 2021 08:16:04 +0200 Subject: [PATCH 19/35] fix: [settings] Fixed missing error when evaluating parent settings --- src/Model/Table/SettingsTable.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Model/Table/SettingsTable.php b/src/Model/Table/SettingsTable.php index a3b5b87..0235cdc 100644 --- a/src/Model/Table/SettingsTable.php +++ b/src/Model/Table/SettingsTable.php @@ -50,7 +50,12 @@ class SettingsTable extends AppTable $errors = []; $setting = $this->getSetting($name); $value = $this->normaliseValue($value, $setting); - if (!empty($setting['beforeSave'])) { + 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) { From 64646aa1188ca698d9e353ee9d953c54cfc129ac Mon Sep 17 00:00:00 2001 From: mokaddem Date: Fri, 30 Jul 2021 08:17:00 +0200 Subject: [PATCH 20/35] fix: [settings:fields] Added support of textarea and fixed variant from severity --- templates/element/Settings/field.php | 37 +++++++++++++++------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/templates/element/Settings/field.php b/templates/element/Settings/field.php index 39b03c7..34e781a 100644 --- a/templates/element/Settings/field.php +++ b/templates/element/Settings/field.php @@ -1,22 +1,25 @@ Bootstrap->genNode('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']] : ''), - ], - 'type' => 'text', - 'id' => $settingId, - 'data-setting-name' => $settingName, - 'value' => isset($setting['value']) ? $setting['value'] : "", - 'placeholder' => $setting['default'] ?? '', - 'aria-describedby' => "{$settingId}Help" - ]); + 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') { @@ -26,7 +29,7 @@ 'class' => [ 'custom-control-input', (!empty($setting['error']) ? 'is-invalid' : ''), - (!empty($setting['error']) && $setting['severity'] == 'warning' ? 'warning' : ''), + (!empty($setting['error']) ? $appView->get('variantFromSeverity')[$setting['severity']] : ''), ], 'type' => 'checkbox', 'value' => !empty($setting['value']) ? 1 : 0, From 3588841df84842e985a6aafe4f6ce40f3eb82549 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Fri, 30 Jul 2021 08:17:51 +0200 Subject: [PATCH 21/35] chg: [setting:fields] Improved variant support for switch checkbox --- webroot/css/bootstrap-additional.css | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/webroot/css/bootstrap-additional.css b/webroot/css/bootstrap-additional.css index 0fe5f21..fd47cfa 100644 --- a/webroot/css/bootstrap-additional.css +++ b/webroot/css/bootstrap-additional.css @@ -110,8 +110,8 @@ div.progress-timeline .progress-line.progress-inactive { } .callout { - border: 1px solid #eee; - border-left-color: rgb(238, 238, 238); + border: 1px solid #00000000; + border-left-color: var(--light); border-left-width: 1px; border-left-width: .25rem; border-radius: .25rem; @@ -174,6 +174,21 @@ div.progress-timeline .progress-line.progress-inactive { .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%; From 57ab7c6ed80097b7ac7a9d8df7b4018f5db2da42 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Fri, 30 Jul 2021 08:18:30 +0200 Subject: [PATCH 22/35] fix: [settings] Fixed scrollspy resolving missing some entries --- templates/Instance/settings.php | 15 +++++++++++++-- templates/element/Settings/panel.php | 2 +- templates/element/Settings/scrollspyNav.php | 6 +++--- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/templates/Instance/settings.php b/templates/Instance/settings.php index f020b1f..6cdfc90 100644 --- a/templates/Instance/settings.php +++ b/templates/Instance/settings.php @@ -1,4 +1,5 @@ 'danger', 'warning' => 'warning', @@ -82,7 +83,7 @@ function genContentForNav($sectionSettings, $appView) $mainPanelHeight = 'calc(100vh - 42px - 1rem - 56px - 38px - 1rem)'; $container = '
        '; $container .= "
        {$scrollspyNav}
        "; - $container .= "
        {$contentHtml}
        "; + $container .= "
        {$contentHtml}
        "; $container .= '
        '; return $container; } @@ -90,7 +91,8 @@ function genContentForNav($sectionSettings, $appView) function genSection($sectionName, $subSectionSettings, $appView) { $sectionContent = []; - $sectionContent[] = sprintf('
        ', sprintf('sp-%s', h($sectionName))); + $sectionContent[] = '
        '; + $sectionContent[] = sprintf('

        %s

        ', getResolvableID($sectionName), h($sectionName)); if (isLeaf($subSectionSettings)) { $panelHTML = $appView->element('Settings/panel', [ 'sectionName' => $sectionName, @@ -120,6 +122,15 @@ function isLeaf($setting) { return !empty($setting['name']) && !empty($setting['type']); } + +function getResolvableID($sectionName, $panelName=false) +{ + $id = sprintf('sp-%s', h($sectionName)); + if (!empty($panelName)) { + $id .= preg_replace('/(\.|\s)/', '_', h($panelName)); + } + return $id; +} ?> \ No newline at end of file diff --git a/templates/element/Settings/field.php b/templates/element/Settings/field.php index 34e781a..4d6c2df 100644 --- a/templates/element/Settings/field.php +++ b/templates/element/Settings/field.php @@ -72,18 +72,19 @@ ]); })($settingName, $setting, $this); - } elseif ($setting['type'] == 'select') { + } elseif ($setting['type'] == 'select' || $setting['type'] == 'multi-select') { $input = (function ($settingName, $setting, $appView) { $settingId = str_replace('.', '_', $settingName); $setting['value'] = $setting['value'] ?? ''; - $options = [ - $appView->Bootstrap->genNode('option', ['value' => '-1', 'data-is-empty-option' => '1'], __('Select an option')) - ]; + $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'] == $value ? 'selected' : '') => $setting['value'] == $value ? 'selected' : '', + ($setting['value'] == $key ? 'selected' : '') => $setting['value'] == $value ? 'selected' : '', ], h($value)); } $options = implode('', $options); @@ -95,17 +96,12 @@ (!empty($setting['error']) ? "border-{$appView->get('variantFromSeverity')[$setting['severity']]}" : ''), (!empty($setting['error']) ? $appView->get('variantFromSeverity')[$setting['severity']] : ''), ], - 'type' => 'text', + ($setting['type'] == 'multi-select' ? 'multiple' : '') => '', 'id' => $settingId, 'data-setting-name' => $settingName, 'placeholder' => $setting['default'] ?? '', 'aria-describedby' => "{$settingId}Help" ], $options); })($settingName, $setting, $this); - - } elseif ($setting['type'] == 'multi-select') { - $input = (function ($settingName, $setting, $appView) { - return ''; - })($settingName, $setting, $this); } echo $input; From 82dab54b719bcfadf62945ca74dec2cb8c30ec49 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Fri, 30 Jul 2021 14:58:50 +0200 Subject: [PATCH 31/35] fix: [setting] Support of dot and spaces when redirecting to the setting --- templates/element/Settings/notice.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/element/Settings/notice.php b/templates/element/Settings/notice.php index 5282837..9e79877 100644 --- a/templates/element/Settings/notice.php +++ b/templates/element/Settings/notice.php @@ -51,8 +51,8 @@ foreach (array_keys($mainNoticeHeading) as $level) { ], [ 'fields' => [ ['key' => 'name', 'label' => __('Name'), 'formatter' => function($name, $row) { - $settingID = $row['true-name']; - return sprintf('%s', h($settingID), h($settingID), h($name)); + $settingID = preg_replace('/(\.|\W)/', '_', h($row['true-name'])); + return sprintf('%s', $settingID, $settingID, h($name)); }], ['key' => 'setting-path', 'label' => __('Category'), 'formatter' => function($path, $row) { return '' . h(str_replace('.', ' ▸ ', $path)) . ''; From fcde68be3febf72bf4d46b628c2900667319b835 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Fri, 30 Jul 2021 15:12:09 +0200 Subject: [PATCH 32/35] fix: [settingProvider] Fixed various UI bug --- src/Model/Table/SettingsProviderTable.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Model/Table/SettingsProviderTable.php b/src/Model/Table/SettingsProviderTable.php index 9dcafc8..659174b 100644 --- a/src/Model/Table/SettingsProviderTable.php +++ b/src/Model/Table/SettingsProviderTable.php @@ -246,7 +246,7 @@ class SettingsProviderTable extends AppTable if (empty($notices[$value['severity']])) { $notices[$value['severity']] = []; } - $notices[$value['severity']][] = $key; + $notices[$value['severity']][] = $value; } } else { $notices = array_merge_recursive($notices, $this->getNoticesFromSettingsConfiguration($value)); @@ -263,7 +263,7 @@ class SettingsProviderTable extends AppTable private function evaluateLeaf($setting, $settingSection) { $skipValidation = false; - if ($setting['type'] == 'select') { + if ($setting['type'] == 'select' || $setting['type'] == 'multi-select') { if (!empty($setting['options']) && is_callable($setting['options'])) { $setting['options'] = $setting['options']($this); } @@ -339,9 +339,14 @@ class SettingValidator { if (!empty($value)) { return true; - } else if (!empty($setting['default'])) { + } else if (isset($setting['default'])) { + $setting['value'] = $setting['default']; $setting['severity'] = $setting['severity'] ?? 'info'; - return __('Setting is not set, fallback to default value: {0}', $setting['default']); + 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.'); From 14c70a092f66df03e75f6981625f023643912c49 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Fri, 30 Jul 2021 15:12:54 +0200 Subject: [PATCH 33/35] new: Decoupled Cerebrate settings from application settings And included an example of setting provider --- config/app_local.example.php | 5 -- config/bootstrap.php | 1 + config/cerebrate.php | 10 +++ src/Controller/AppController.php | 2 +- src/Model/Table/SettingsProviderTable.php | 96 +++++++++++------------ 5 files changed, 58 insertions(+), 56 deletions(-) create mode 100644 config/cerebrate.php diff --git a/config/app_local.example.php b/config/app_local.example.php index 03aeef1..637128c 100644 --- a/config/app_local.example.php +++ b/config/app_local.example.php @@ -89,9 +89,4 @@ return [ 'url' => env('EMAIL_TRANSPORT_DEFAULT_URL', null), ], ], - 'Cerebrate' => [ - 'open' => [], - 'dark' => 0, - 'baseurl' => '' - ] ]; diff --git a/config/bootstrap.php b/config/bootstrap.php index 615df3d..ed41346 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -87,6 +87,7 @@ try { */ if (file_exists(CONFIG . 'app_local.php')) { Configure::load('app_local', 'default'); + Configure::load('cerebrate', 'default', true); } /* diff --git a/config/cerebrate.php b/config/cerebrate.php new file mode 100644 index 0000000..dce6e11 --- /dev/null +++ b/config/cerebrate.php @@ -0,0 +1,10 @@ + [ + 'open' => [], + 'app.baseurl' => 'http://localhost:8000/', + 'app.uuid' => 'cc9b9358-7c4b-4464-9a2c-f0cb089ff974', + 'ui.dark' => 0, + ] +]; + diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index 5d49631..498b0ae 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -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')); } diff --git a/src/Model/Table/SettingsProviderTable.php b/src/Model/Table/SettingsProviderTable.php index 659174b..a8cc779 100644 --- a/src/Model/Table/SettingsProviderTable.php +++ b/src/Model/Table/SettingsProviderTable.php @@ -27,9 +27,23 @@ class SettingsProviderTable extends AppTable * Supports up to 3 levels: * Application -> Network -> Proxy -> Proxy.URL * page -> [group] -> [panel] -> setting - * - * - Leave errorMessage empty to let the validator generate the error message - * - Default severity level is `info` if a `default` value is provided otherwise it becomes `critical` + * 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() { @@ -38,41 +52,23 @@ class SettingsProviderTable extends AppTable 'General' => [ 'Essentials' => [ 'app.baseurl' => [ - '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.'), - 'severity' => 'critical', - 'default' => '', 'name' => __('Base URL'), - 'test' => 'testBaseURL', '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' => '', - 'name' => 'UUID', 'severity' => 'critical', 'test' => 'testUuid', - 'type' => 'string' ], ], 'Miscellaneous' => [ - 'to-del' => [ - 'description' => 'to del', - 'errorMessage' => 'to del', - 'default' => 'A-default-value', - 'name' => 'To DEL', - 'test' => function($value) { - return empty($value) ? __('Oh not! it\'s not valid!') : true; - }, - 'beforeSave' => function($value, $setting) { - if ($value != 'foo') { - return 'value must be `foo`!'; - } - return true; - }, - 'afterSave' => function($value, $setting) { - }, - 'type' => 'string' - ], 'sc2.hero' => [ 'description' => 'The true hero', 'default' => 'Sarah Kerrigan', @@ -85,10 +81,10 @@ class SettingsProviderTable extends AppTable ], 'type' => 'select' ], - 'sc2.antagonist' => [ - 'description' => 'The real bad guy', + 'sc2.antagonists' => [ + 'description' => 'The bad guys', 'default' => 'Amon', - 'name' => 'Antagonist', + 'name' => 'Antagonists', 'options' => function($settingsProviders) { return [ 'Amon' => 'Amon', @@ -97,7 +93,7 @@ class SettingsProviderTable extends AppTable ]; }, 'severity' => 'warning', - 'type' => 'select' + 'type' => 'multi-select' ], ], 'floating-setting' => [ @@ -113,43 +109,43 @@ class SettingsProviderTable extends AppTable 'Network' => [ 'Proxy' => [ 'proxy.host' => [ - 'description' => __('The hostname of an HTTP proxy for outgoing sync requests. Leave empty to not use a proxy.'), 'name' => __('Host'), - 'test' => 'testHostname', 'type' => 'string', + 'description' => __('The hostname of an HTTP proxy for outgoing sync requests. Leave empty to not use a proxy.'), + 'test' => 'testHostname', ], 'proxy.port' => [ - 'description' => __('The TCP port for the HTTP proxy.'), 'name' => __('Port'), - 'test' => 'testForRangeXY', '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', - 'name' => __('User'), 'dependsOn' => 'proxy.host', - 'type' => 'string', ], 'proxy.password' => [ + 'name' => __('Password'), + 'type' => 'string', 'description' => __('The authentication password for the HTTP proxy.'), 'default' => '', - 'name' => __('Password'), 'dependsOn' => 'proxy.host', - 'type' => 'string', ], ], ], 'UI' => [ 'General' => [ - 'app.ui.dark' => [ + 'ui.dark' => [ + 'name' => __('Dark theme'), + 'type' => 'boolean', 'description' => __('Enable the dark theme of the application'), 'default' => false, - 'name' => __('Dark theme'), 'test' => function() { return 'Fake error'; }, - 'type' => 'boolean', ], ], ], @@ -157,20 +153,20 @@ class SettingsProviderTable extends AppTable 'Security' => [ 'Development' => [ 'Debugging' => [ - 'app.security.debug' => [ + 'security.debug' => [ + 'name' => __('Debug Level'), + 'type' => 'select', 'description' => __('The debug level of the instance'), 'default' => 0, - 'name' => __('Debug Level'), - 'test' => function($value, $setting, $validator) { - $validator->range('value', [0, 3]); - return testValidator($value, $validator); - }, - 'type' => 'select', '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); + }, ], ], ] From 9662e15afe55df90ecc1667427fe753e74105ba5 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Fri, 30 Jul 2021 15:21:31 +0200 Subject: [PATCH 34/35] chg: [setting:cerebrate] Remove useless line --- config/cerebrate.php | 1 - 1 file changed, 1 deletion(-) diff --git a/config/cerebrate.php b/config/cerebrate.php index dce6e11..bfb2cf9 100644 --- a/config/cerebrate.php +++ b/config/cerebrate.php @@ -7,4 +7,3 @@ return [ 'ui.dark' => 0, ] ]; - From feeda3b32bad47446b95edb7560bc84d86b4248a Mon Sep 17 00:00:00 2001 From: mokaddem Date: Fri, 3 Sep 2021 10:48:18 +0200 Subject: [PATCH 35/35] chg: [settings] Possibility to add icons and description in setting panels --- src/Model/Table/SettingsProviderTable.php | 16 ++++++++ src/View/Helper/BootstrapHelper.php | 45 ++++++++++++++++++++++- templates/element/Settings/panel.php | 15 +++++++- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/Model/Table/SettingsProviderTable.php b/src/Model/Table/SettingsProviderTable.php index a8cc779..d70b2d2 100644 --- a/src/Model/Table/SettingsProviderTable.php +++ b/src/Model/Table/SettingsProviderTable.php @@ -51,6 +51,8 @@ class SettingsProviderTable extends AppTable 'Application' => [ 'General' => [ 'Essentials' => [ + '_description' => __('Ensentials settings required for the application to run normally.'), + '_icon' => 'user-cog', 'app.baseurl' => [ 'name' => __('Base URL'), 'type' => 'string', @@ -200,6 +202,9 @@ class SettingsProviderTable extends AppTable 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]; @@ -218,6 +223,9 @@ class SettingsProviderTable extends AppTable 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 { @@ -237,6 +245,9 @@ class SettingsProviderTable extends AppTable { $notices = []; foreach ($settingsProvider as $key => $value) { + if ($this->isSettingMetaKey($key)) { + continue; + } if ($this->isLeaf($value)) { if (!empty($value['error'])) { if (empty($notices[$value['severity']])) { @@ -320,6 +331,11 @@ class SettingsProviderTable extends AppTable } return $functionResult; } + + function isSettingMetaKey($key) + { + return substr($key, 0, 1) == '_'; + } } function testValidator($value, $validator) diff --git a/src/View/Helper/BootstrapHelper.php b/src/View/Helper/BootstrapHelper.php index 05ce6fe..e763a13 100644 --- a/src/View/Helper/BootstrapHelper.php +++ b/src/View/Helper/BootstrapHelper.php @@ -75,6 +75,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); @@ -733,9 +739,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() @@ -784,6 +791,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' => '', diff --git a/templates/element/Settings/panel.php b/templates/element/Settings/panel.php index a556df1..c49d996 100644 --- a/templates/element/Settings/panel.php +++ b/templates/element/Settings/panel.php @@ -10,9 +10,22 @@ if (isLeaf($panelSettings)) { $panelHTML = "
        {$singleSetting}
        "; } else { $panelID = getResolvableID($sectionName, $panelName); - $panelHTML .= sprintf('

        %s

        ', $panelID, $panelID, h($panelName)); + $panelHTML .= sprintf('

        %s%s

        ', + $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,