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..bfb2cf9 --- /dev/null +++ b/config/cerebrate.php @@ -0,0 +1,9 @@ + [ + '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/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 df141a9..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 { @@ -101,4 +102,36 @@ class InstanceController extends AppController $this->set('path', ['controller' => 'instance', 'action' => 'rollback']); $this->render('/genericTemplates/confirm'); } + + public function settings() + { + $this->Settings = $this->getTableLocator()->get('Settings'); + $all = $this->Settings->getSettings(true); + $this->set('settingsProvider', $all['settingsProvider']); + $this->set('settings', $all['settings']); + $this->set('settingsFlattened', $all['settingsFlattened']); + $this->set('notices', $all['notices']); + } + + public function saveSetting() + { + if ($this->request->is('post')) { + $data = $this->ParamHandler->harvestParams([ + 'name', + 'value' + ]); + $this->Settings = $this->getTableLocator()->get('Settings'); + $errors = $this->Settings->saveSetting($data['name'], $data['value']); + $message = __('Could not save setting `{0}`', $data['name']); + if (empty($errors)) { + $message = __('Setting `{0}` saved', $data['name']); + $data = $this->Settings->getSetting($data['name']); + } + $this->CRUD->setResponseForController('saveSetting', empty($errors), $message, $data, $errors); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + } } diff --git a/src/Model/Table/SettingsProviderTable.php b/src/Model/Table/SettingsProviderTable.php new file mode 100644 index 0000000..d70b2d2 --- /dev/null +++ b/src/Model/Table/SettingsProviderTable.php @@ -0,0 +1,390 @@ +settingsConfiguration = $this->generateSettingsConfiguration(); + $this->setTable(false); + $this->error_critical = __('Cerebrate will not operate correctly or will be unsecure until these issues are resolved.'); + $this->error_warning = __('Some of the features of Cerebrate cannot be utilised until these issues are resolved.'); + $this->error_info = __('There are some optional tweaks that could be done to improve the looks of your Cerebrate instance.'); + $this->settingValidator = new SettingValidator(); + } + + /** + * Supports up to 3 levels: + * Application -> Network -> Proxy -> Proxy.URL + * page -> [group] -> [panel] -> setting + * Keys of setting configuration are the actual setting name. + * Accepted setting configuration: + * name [required]: The human readable name of the setting. + * type [required]: The type of the setting. + * description [required]: A description of the setting. + * Default severity level is `info` if a `default` value is provided otherwise it becomes `critical` + * default [optional]: The default value of the setting if not specified in the configuration. + * options [optional]: Used to populate the select with options. Keys are values to be saved, values are human readable version of the value. + * Required paramter if `type` == `select`. + * severity [optional]: Severity level of the setting if the configuration is incorrect. + * dependsOn [optional]: If the validation of this setting depends on the validation of the provided setting name + * test [optional]: Could be either a string or an anonymous function to be called in order to warn user if setting is invalid. + * Could be either: `string`, `boolean`, `integer`, `select` + * beforeSave [optional]: Could be either a string or an anonymous function to be called in order to block a setting to be saved. + * afterSave [optional]: Could be either a string or an anonymous function to be called allowing to execute a function after the setting is saved. + * redacted [optional]: Should the setting value be redacted. FIXME: To implement + * cli_only [optional]: Should this setting be modified only via the CLI. + */ + private function generateSettingsConfiguration() + { + return [ + 'Application' => [ + 'General' => [ + 'Essentials' => [ + '_description' => __('Ensentials settings required for the application to run normally.'), + '_icon' => 'user-cog', + 'app.baseurl' => [ + 'name' => __('Base URL'), + 'type' => 'string', + 'description' => __('The base url of the application (in the format https://www.mymispinstance.com or https://myserver.com/misp). Several features depend on this setting being correctly set to function.'), + 'default' => '', + 'severity' => 'critical', + 'test' => 'testBaseURL', + ], + 'app.uuid' => [ + 'name' => 'UUID', + 'type' => 'string', + 'description' => __('The Cerebrate instance UUID. This UUID is used to identify this instance.'), + 'default' => '', + 'severity' => 'critical', + 'test' => 'testUuid', + ], + ], + 'Miscellaneous' => [ + 'sc2.hero' => [ + 'description' => 'The true hero', + 'default' => 'Sarah Kerrigan', + 'name' => 'Hero', + 'options' => [ + 'Jim Raynor' => 'Jim Raynor', + 'Sarah Kerrigan' => 'Sarah Kerrigan', + 'Artanis' => 'Artanis', + 'Zeratul' => 'Zeratul', + ], + 'type' => 'select' + ], + 'sc2.antagonists' => [ + 'description' => 'The bad guys', + 'default' => 'Amon', + 'name' => 'Antagonists', + 'options' => function($settingsProviders) { + return [ + 'Amon' => 'Amon', + 'Sarah Kerrigan' => 'Sarah Kerrigan', + 'Narud' => 'Narud', + ]; + }, + 'severity' => 'warning', + 'type' => 'multi-select' + ], + ], + 'floating-setting' => [ + 'description' => 'floaringSetting', + // 'default' => 'A default value', + 'name' => 'Uncategorized Setting', + // 'severity' => 'critical', + 'severity' => 'warning', + // 'severity' => 'info', + 'type' => 'integer' + ], + ], + 'Network' => [ + 'Proxy' => [ + 'proxy.host' => [ + 'name' => __('Host'), + 'type' => 'string', + 'description' => __('The hostname of an HTTP proxy for outgoing sync requests. Leave empty to not use a proxy.'), + 'test' => 'testHostname', + ], + 'proxy.port' => [ + 'name' => __('Port'), + 'type' => 'integer', + 'description' => __('The TCP port for the HTTP proxy.'), + 'test' => 'testForRangeXY', + ], + 'proxy.user' => [ + 'name' => __('User'), + 'type' => 'string', + 'description' => __('The authentication username for the HTTP proxy.'), + 'default' => 'admin', + 'dependsOn' => 'proxy.host', + ], + 'proxy.password' => [ + 'name' => __('Password'), + 'type' => 'string', + 'description' => __('The authentication password for the HTTP proxy.'), + 'default' => '', + 'dependsOn' => 'proxy.host', + ], + ], + ], + 'UI' => [ + 'General' => [ + 'ui.dark' => [ + 'name' => __('Dark theme'), + 'type' => 'boolean', + 'description' => __('Enable the dark theme of the application'), + 'default' => false, + 'test' => function() { + return 'Fake error'; + }, + ], + ], + ], + ], + 'Security' => [ + 'Development' => [ + 'Debugging' => [ + 'security.debug' => [ + 'name' => __('Debug Level'), + 'type' => 'select', + 'description' => __('The debug level of the instance'), + 'default' => 0, + 'options' => [ + 0 => __('Debug Off'), + 1 => __('Debug On'), + 2 => __('Debug On + SQL Dump'), + ], + 'test' => function($value, $setting, $validator) { + $validator->range('value', [0, 3]); + return testValidator($value, $validator); + }, + ], + ], + ] + ], + 'Features' => [ + ], + ]; + } + + /** + * getSettingsConfiguration Return the setting configuration and merge existing settings into it if provided + * + * @param null|array $settings - Settings to be merged in the provided setting configuration + * @return array + */ + public function getSettingsConfiguration($settings = null) { + $settingConf = $this->settingsConfiguration; + if (!is_null($settings)) { + $settingConf = $this->mergeSettingsIntoSettingConfiguration($settingConf, $settings); + } + return $settingConf; + } + + /** + * mergeSettingsIntoSettingConfiguration Inject the provided settings into the configuration while performing depencency and validation checks + * + * @param array $settingConf the setting configuration to have the setting injected into + * @param array $settings the settings + * @return void + */ + private function mergeSettingsIntoSettingConfiguration(array $settingConf, array $settings, string $path=''): array + { + foreach ($settingConf as $key => $value) { + if ($this->isSettingMetaKey($key)) { + continue; + } + if ($this->isLeaf($value)) { + if (isset($settings[$key])) { + $settingConf[$key]['value'] = $settings[$key]; + } + $settingConf[$key] = $this->evaluateLeaf($settingConf[$key], $settingConf); + $settingConf[$key]['setting-path'] = $path; + $settingConf[$key]['true-name'] = $key; + } else { + $currentPath = empty($path) ? $key : sprintf('%s.%s', $path, $key); + $settingConf[$key] = $this->mergeSettingsIntoSettingConfiguration($value, $settings, $currentPath); + } + } + return $settingConf; + } + + public function flattenSettingsConfiguration(array $settingsProvider, $flattenedSettings=[]): array + { + foreach ($settingsProvider as $key => $value) { + if ($this->isSettingMetaKey($key)) { + continue; + } + if ($this->isLeaf($value)) { + $flattenedSettings[$key] = $value; + } else { + $flattenedSettings = $this->flattenSettingsConfiguration($value, $flattenedSettings); + } + } + return $flattenedSettings; + } + + /** + * getNoticesFromSettingsConfiguration Summarize the validation errors + * + * @param array $settingsProvider the setting configuration having setting value assigned + * @return void + */ + public function getNoticesFromSettingsConfiguration(array $settingsProvider): array + { + $notices = []; + foreach ($settingsProvider as $key => $value) { + if ($this->isSettingMetaKey($key)) { + continue; + } + if ($this->isLeaf($value)) { + if (!empty($value['error'])) { + if (empty($notices[$value['severity']])) { + $notices[$value['severity']] = []; + } + $notices[$value['severity']][] = $value; + } + } else { + $notices = array_merge_recursive($notices, $this->getNoticesFromSettingsConfiguration($value)); + } + } + return $notices; + } + + private function isLeaf($setting) + { + return !empty($setting['name']) && !empty($setting['type']); + } + + private function evaluateLeaf($setting, $settingSection) + { + $skipValidation = false; + if ($setting['type'] == 'select' || $setting['type'] == 'multi-select') { + if (!empty($setting['options']) && is_callable($setting['options'])) { + $setting['options'] = $setting['options']($this); + } + } + if (isset($setting['dependsOn'])) { + $parentSetting = null; + foreach ($settingSection as $settingSectionName => $settingSectionConfig) { + if ($settingSectionName == $setting['dependsOn']) { + $parentSetting = $settingSectionConfig; + } + } + if (!is_null($parentSetting)) { + $parentSetting = $this->evaluateLeaf($parentSetting, $settingSection); + $skipValidation = $parentSetting['error'] === true || empty($parentSetting['value']); + } + } + $setting['error'] = false; + if (!$skipValidation) { + $validationResult = true; + if (!isset($setting['value'])) { + $validationResult = $this->settingValidator->testEmptyBecomesDefault(null, $setting); + } else if (isset($setting['test'])) { + $setting['value'] = $setting['value'] ?? ''; + $validationResult = $this->evaluateFunctionForSetting($setting['test'], $setting); + } + if ($validationResult !== true) { + $setting['severity'] = $setting['severity'] ?? 'warning'; + if (!in_array($setting['severity'], $this->severities)) { + $setting['severity'] = 'warning'; + } + $setting['errorMessage'] = $validationResult; + } + $setting['error'] = $validationResult !== true ? true : false; + } + return $setting; + } + + /** + * evaluateFunctionForSetting - evaluate the provided function. If function could not be evaluated, its result is defaulted to true + * + * @param mixed $fun + * @param array $setting + * @return mixed + */ + public function evaluateFunctionForSetting($fun, $setting) + { + $functionResult = true; + if (is_callable($fun)) { // Validate with anonymous function + $functionResult = $fun($setting['value'], $setting, new Validator()); + } else if (method_exists($this->settingValidator, $fun)) { // Validate with function defined in settingValidator class + $functionResult = $this->settingValidator->{$fun}($setting['value'], $setting); + } else { + $validator = new Validator(); + if (method_exists($validator, $fun)) { // Validate with cake's validator function + $validator->{$fun}; + $functionResult = $validator->validate($setting['value']); + } + } + return $functionResult; + } + + function isSettingMetaKey($key) + { + return substr($key, 0, 1) == '_'; + } +} + +function testValidator($value, $validator) +{ + $errors = $validator->validate(['value' => $value]); + return !empty($errors) ? implode(', ', $errors['value']) : true; +} + +class SettingValidator +{ + + public function testEmptyBecomesDefault($value, &$setting) + { + if (!empty($value)) { + return true; + } else if (isset($setting['default'])) { + $setting['value'] = $setting['default']; + $setting['severity'] = $setting['severity'] ?? 'info'; + if ($setting['type'] == 'boolean') { + return __('Setting is not set, fallback to default value: {0}', empty($setting['default']) ? 'false' : 'true'); + } else { + return __('Setting is not set, fallback to default value: {0}', $setting['default']); + } + } else { + $setting['severity'] = $setting['severity'] ?? 'critical'; + return __('Cannot be empty. Setting does not have a default value.'); + } + } + + public function testForEmpty($value, &$setting) + { + return !empty($value) ? true : __('Cannot be empty'); + } + + public function testBaseURL($value, &$setting) + { + if (empty($value)) { + return __('Cannot be empty'); + } + if (!empty($value) && !preg_match('/^http(s)?:\/\//i', $value)) { + return __('Invalid URL, please make sure that the protocol is set.'); + } + return true; + } + + public function testUuid($value, &$setting) { + if (empty($value) || !preg_match('/^\{?[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}\}?$/', $value)) { + return __('Invalid UUID.'); + } + return true; + } +} \ No newline at end of file diff --git a/src/Model/Table/SettingsTable.php b/src/Model/Table/SettingsTable.php new file mode 100644 index 0000000..0235cdc --- /dev/null +++ b/src/Model/Table/SettingsTable.php @@ -0,0 +1,97 @@ +setTable(false); + $this->SettingsProvider = TableRegistry::getTableLocator()->get('SettingsProvider'); + } + + public function getSettings($full=false): array + { + $settings = $this->readSettings(); + if (empty($full)) { + return $settings; + } else { + $settingsProvider = $this->SettingsProvider->getSettingsConfiguration($settings); + $settingsFlattened = $this->SettingsProvider->flattenSettingsConfiguration($settingsProvider); + $notices = $this->SettingsProvider->getNoticesFromSettingsConfiguration($settingsProvider, $settings); + return [ + 'settings' => $settings, + 'settingsProvider' => $settingsProvider, + 'settingsFlattened' => $settingsFlattened, + 'notices' => $notices, + ]; + } + } + + public function getSetting($name=false): array + { + $settings = $this->readSettings(); + $settingsProvider = $this->SettingsProvider->getSettingsConfiguration($settings); + $settingsFlattened = $this->SettingsProvider->flattenSettingsConfiguration($settingsProvider); + return $settingsFlattened[$name] ?? []; + } + + public function saveSetting(string $name, string $value): array + { + $errors = []; + $setting = $this->getSetting($name); + $value = $this->normaliseValue($value, $setting); + if ($setting['type'] == 'select') { + if (!in_array($value, array_keys($setting['options']))) { + $errors[] = __('Invalid option provided'); + } + } + if (empty($errors) && !empty($setting['beforeSave'])) { + $setting['value'] = $value ?? ''; + $beforeSaveResult = $this->SettingsProvider->evaluateFunctionForSetting($setting['beforeSave'], $setting); + if ($beforeSaveResult !== true) { + $errors[] = $beforeSaveResult; + } + } + if (empty($errors)) { + $saveResult = $this->saveSettingOnDisk($name, $value); + if ($saveResult) { + if (!empty($setting['afterSave'])) { + $this->SettingsProvider->evaluateFunctionForSetting($setting['afterSave'], $setting); + } + } + } + return $errors; + } + + private function normaliseValue($value, $setting) + { + if ($setting['type'] == 'boolean') { + return (bool) $value; + } + return $value; + } + + private function readSettings() + { + return Configure::read()[$this::$CONFIG_KEY]; + } + + private function saveSettingOnDisk($name, $value) + { + $settings = $this->readSettings(); + $settings[$name] = $value; + Configure::write($this::$CONFIG_KEY, $settings); + Configure::dump($this::$FILENAME, 'default', [$this::$CONFIG_KEY]); + return true; + } +} diff --git a/src/View/Helper/BootstrapHelper.php b/src/View/Helper/BootstrapHelper.php index 136ef03..532f2fd 100644 --- a/src/View/Helper/BootstrapHelper.php +++ b/src/View/Helper/BootstrapHelper.php @@ -81,6 +81,12 @@ class BootstrapHelper extends Helper return $bsButton->button(); } + public function icon($icon, $options=[]) + { + $bsIcon = new BoostrapIcon($icon, $options); + return $bsIcon->icon(); + } + public function badge($options) { $bsBadge = new BoostrapBadge($options); @@ -116,6 +122,17 @@ class BootstrapHelper extends Helper $bsProgressTimeline = new BoostrapProgressTimeline($options, $this); return $bsProgressTimeline->progressTimeline(); } + + public function listGroup($options, $data) + { + $bsListGroup = new BootstrapListGroup($options, $data, $this); + return $bsListGroup->listGroup(); + } + + public function genNode($node, $params=[], $content='') + { + return BootstrapGeneric::genNode($node, $params, $content); + } } class BootstrapGeneric @@ -148,7 +165,7 @@ class BootstrapGeneric } } - protected static function genNode($node, $params=[], $content="") + public static function genNode($node, $params=[], $content="") { return sprintf('<%s %s>%s%s>', $node, BootstrapGeneric::genHTMLParams($params), $content, $node); } @@ -245,7 +262,6 @@ class BootstrapTabs extends BootstrapGeneric $this->bsClasses = [ 'nav' => [], 'nav-item' => $this->options['nav-item-class'], - ]; if (!empty($this->options['justify'])) { @@ -293,7 +309,9 @@ class BootstrapTabs extends BootstrapGeneric } $this->data['navs'][$activeTab]['active'] = true; - $this->options['vertical-size'] = $this->options['vertical-size'] < 0 || $this->options['vertical-size'] > 11 ? 3 : $this->options['vertical-size']; + if (!empty($this->options['vertical-size']) && $this->options['vertical-size'] != 'auto') { + $this->options['vertical-size'] = $this->options['vertical-size'] < 0 || $this->options['vertical-size'] > 11 ? 3 : $this->options['vertical-size']; + } $this->options['header-text-variant'] = $this->options['header-variant'] == 'light' ? 'body' : 'white'; $this->options['header-border-variant'] = $this->options['header-variant'] == 'light' ? '' : $this->options['header-variant']; @@ -346,17 +364,37 @@ class BootstrapTabs extends BootstrapGeneric private function genVerticalTabs() { - $html = $this->openNode('div', ['class' => array_merge(['row', ($this->options['card'] ? 'card flex-row' : '')], ["border-{$this->options['header-border-variant']}"])]); - $html .= $this->openNode('div', ['class' => array_merge(['col-' . $this->options['vertical-size'], ($this->options['card'] ? 'card-header border-right' : '')], ["bg-{$this->options['header-variant']}", "text-{$this->options['header-text-variant']}", "border-{$this->options['header-border-variant']}"])]); + $html = $this->openNode('div', ['class' => array_merge( + [ + 'row', + ($this->options['card'] ? 'card flex-row' : ''), + ($this->options['vertical-size'] == 'auto' ? 'flex-nowrap' : '') + ], + [ + "border-{$this->options['header-border-variant']}" + ] + )]); + $html .= $this->openNode('div', ['class' => array_merge( + [ + ($this->options['vertical-size'] != 'auto' ? 'col-' . $this->options['vertical-size'] : ''), + ($this->options['card'] ? 'card-header border-right' : '') + ], + [ + "bg-{$this->options['header-variant']}", + "text-{$this->options['header-text-variant']}", + "border-{$this->options['header-border-variant']}" + ])]); $html .= $this->genNav(); $html .= $this->closeNode('div'); - $html .= $this->openNode('div', [ - 'class' => array_merge( - ['col-' . (12 - $this->options['vertical-size']), ($this->options['card'] ? 'card-body2' : '')], - $this->options['body-class'] ?? [], - ["bg-{$this->options['body-variant']}", "text-{$this->options['body-text-variant']}"] - ) - ]); + $html .= $this->openNode('div', ['class' => array_merge( + [ + ($this->options['vertical-size'] != 'auto' ? 'col-' . (12 - $this->options['vertical-size']) : ''), + ($this->options['card'] ? 'card-body2' : '') + ], + [ + "bg-{$this->options['body-variant']}", + "text-{$this->options['body-text-variant']}" + ])]); $html .= $this->genContent(); $html .= $this->closeNode('div'); $html .= $this->closeNode('div'); @@ -859,9 +897,10 @@ class BoostrapButton extends BootstrapGeneric { private function genIcon() { - return $this->genNode('span', [ - 'class' => ['mr-1', "fa fa-{$this->options['icon']}"], + $bsIcon = new BoostrapIcon($this->options['icon'], [ + 'class' => ['mr-1'] ]); + return $bsIcon->icon(); } private function genContent() @@ -910,6 +949,40 @@ class BoostrapBadge extends BootstrapGeneric { } } +class BoostrapIcon extends BootstrapGeneric { + private $icon = ''; + private $defaultOptions = [ + 'class' => [], + ]; + + function __construct($icon, $options=[]) { + $this->icon = $icon; + $this->processOptions($options); + } + + private function processOptions($options) + { + $this->options = array_merge($this->defaultOptions, $options); + $this->checkOptionValidity(); + } + + public function icon() + { + return $this->genIcon(); + } + + private function genIcon() + { + $html = $this->genNode('span', [ + 'class' => array_merge( + is_array($this->options['class']) ? $this->options['class'] : [$this->options['class']], + ["fa fa-{$this->icon}"] + ), + ]); + return $html; + } +} + class BoostrapModal extends BootstrapGeneric { private $defaultOptions = [ 'size' => '', @@ -1408,4 +1481,100 @@ class BoostrapProgressTimeline extends BootstrapGeneric { ], $ulIcons . $ulText); return $html; } +} + +class BootstrapListGroup extends BootstrapGeneric +{ + private $defaultOptions = [ + 'hover' => false, + ]; + + private $bsClasses = null; + + function __construct($options, $data, $btHelper) { + $this->data = $data; + $this->processOptions($options); + $this->btHelper = $btHelper; + } + + private function processOptions($options) + { + $this->options = array_merge($this->defaultOptions, $options); + } + + public function listGroup() + { + return $this->genListGroup(); + } + + private function genListGroup() + { + $html = $this->openNode('div', [ + 'class' => ['list-group',], + ]); + foreach ($this->data as $item) { + $html .= $this->genItem($item); + } + $html .= $this->closeNode('div'); + return $html; + } + + private function genItem($item) + { + if (!empty($item['heading'])) { // complex layout with heading, badge and body + $html = $this->genNode('a', [ + 'class' => ['list-group-item', (!empty($this->options['hover']) ? 'list-group-item-action' : ''),], + ], implode('', [ + $this->genHeadingGroup($item), + $this->genBody($item), + ])); + } else { // simple layout with just