chg: [instance:settings] Improved UI interface

Added searches and notices for individual settings
pull/70/head
mokaddem 2021-07-21 11:18:06 +02:00
parent d501969c1d
commit 9f4fbf0410
9 changed files with 204 additions and 70 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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('<h5 class="alert-heading">%s</h5>', $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('<div class="mt-3">%s</div>', $settingNotice);
$this->set('settingNotice', $settingNotice);
$settingTable = genLevel0($settingsProvider, $this);
?>
<div class="px-5">
<div class="">
<?= $settingNotice ?>
</div>
<div class="mb-3">
<input class="form-control" type="text" id="search" placeholder="<?= __('Search settings') ?>" aria-describedby="<?= __('Search setting input') ?>">
<select id="search-settings" class="d-block w-100" aria-describedby="<?= __('Search setting input') ?>"><option></option></select>
</div>
<?= $settingTable; ?>
</div>
@ -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 = '<div class="d-flex">';
$container .= "<div class=\"\" style=\"flex: 0 0 10em;\">{$scrollspyNav}</div>";
$container .= "<div data-spy=\"scroll\" data-target=\"#navbar-scrollspy-setting\" data-offset=\"24\" style=\"height: {$mainPanelHeight}\" class=\"p-3 overflow-auto position-relative flex-grow-1\">{$contentHtml}</div>";
$container .= "<div data-spy=\"scroll\" data-target=\"#navbar-scrollspy-setting\" data-offset=\"25\" style=\"height: {$mainPanelHeight}\" class=\"p-3 overflow-auto position-relative flex-grow-1\">{$contentHtml}</div>";
$container .= '</div>';
return $container;
}
@ -151,12 +159,28 @@ function genLevel3($level2Name, $settingGroupName, $setting, $appView)
} else {
$tmpID = sprintf('sp-%s-%s', h($level2Name), h($settingGroupName));
$settingGroup .= sprintf('<h4 id="%s"><a class="text-reset text-decoration-none" href="#%s">%s</a></h4>', $tmpID, $tmpID, h($settingGroupName));
$groupIssueSeverity = false;
foreach ($setting as $singleSettingName => $singleSetting) {
$tmp = genSingleSetting($singleSettingName, $singleSetting, $appView);
$settingGroup .= sprintf('<div class="ml-3">%s</div>', $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)
?>
<script>
const settingsFlattened = <?= json_encode($settingsFlattened) ?>;
let selectData = []
for (const settingName in settingsFlattened) {
if (Object.hasOwnProperty.call(settingsFlattened, settingName)) {
const setting = settingsFlattened[settingName];
const selectID = settingName.replaceAll('.', '_')
selectData.push({
id: selectID,
text: setting.name,
setting: setting
})
}
}
$(document).ready(function() {
$('[data-spy="scroll"]').on('activate.bs.scrollspy', function(evt, {relatedTarget}) {
const $associatedLink = $(`#navbar-scrollspy-setting nav.nav-pills .nav-link[href="${relatedTarget}"]`)
@ -318,8 +356,62 @@ function isLeaf($setting)
$allNavs.removeClass('group-active').hide()
$associatedNav.addClass('group-active').show()
})
$('.settings-tabs a[data-toggle="tab"]').on('shown.bs.tab', function (event) {
$('[data-spy="scroll"]').trigger('scroll.bs.scrollspy')
})
$("#search-settings").select2({
data: selectData,
placeholder: '<?= __('Search setting by typing here...') ?>',
templateResult: formatSettingSearchResult,
templateSelection: formatSettingSearchSelection,
})
.on('select2:select', function (e) {
const selected = e.params.data
const settingPath = selected.setting['setting-path']
const settingPathTokenized = settingPath.split('.')
const tabName = settingPathTokenized[0]
const IDtoFocus = 'sp-' + settingPathTokenized.slice(1).join('-')
const $navController = $('.settings-tabs').find('a.nav-link').filter(function() {
return $(this).text() == tabName
})
if ($navController.length == 1) {
$toFocus = $(`#${IDtoFocus}`).parent()
if ($navController.hasClass('active')) {
$toFocus[0].scrollIntoView()
$toFocus.find(`input#${selected.id}`).focus()
} else {
$navController.on('shown.bs.tab.after-selection', () => {
$toFocus[0].scrollIntoView()
$toFocus.find(`input#${selected.id}`).focus()
$navController.off('shown.bs.tab.after-selection')
}).tab('show')
}
}
$("#search-settings").val(null).trigger('change.select2');
})
})
function formatSettingSearchResult(state) {
if (!state.id) {
return state.text;
}
const $state = $('<div/>').append(
$('<div/>').addClass('d-flex justify-content-between')
.append(
$('<span/>').addClass('font-weight-bold').text(state.text),
$('<span/>').addClass('font-weight-light').text(state.setting['setting-path'].replaceAll('.', ' ▸ '))
),
$('<div/>').addClass('font-italic font-weight-light ml-3').text(state.setting['description'])
)
return $state
}
function formatSettingSearchSelection(state) {
return state.text
}
</script>
<style>
@ -353,4 +445,9 @@ function isLeaf($setting)
#navbar-scrollspy-setting nav.nav-pills .nav-link.main-group:before {
content: "\f0da";
}
.select2-container {
max-width: 100%;
min-width: 100%;
}
</style>

View File

@ -41,6 +41,7 @@ $cakeDescription = 'Cerebrate';
<?= $this->Html->script('main.js') ?>
<?= $this->Html->script('bootstrap-helper.js') ?>
<?= $this->Html->script('api-helper.js') ?>
<?= $this->Html->script('select2.min.js') ?>
<?= $this->Html->script('CodeMirror/codemirror.js') ?>
<?= $this->Html->script('CodeMirror/mode/javascript/javascript') ?>
<?= $this->Html->script('CodeMirror/addon/hint/show-hint') ?>
@ -53,6 +54,8 @@ $cakeDescription = 'Cerebrate';
<?= $this->Html->css('CodeMirror/codemirror-additional') ?>
<?= $this->Html->css('CodeMirror/addon/hint/show-hint') ?>
<?= $this->Html->css('CodeMirror/addon/lint/lint') ?>
<?= $this->Html->css('select2.min') ?>
<?= $this->Html->css('select2-bootstrap4.min') ?>
<?= $this->fetch('meta') ?>
<?= $this->fetch('css') ?>
<?= $this->fetch('script') ?>
@ -83,5 +86,6 @@ $cakeDescription = 'Cerebrate';
</body>
<script>
const darkMode = (<?= empty($darkMode) ? 'false' : 'true' ?>)
$.fn.select2.defaults.set('theme', 'bootstrap4');
</script>
</html>

File diff suppressed because one or more lines are too long

1
webroot/css/select2.min.css vendored Normal file

File diff suppressed because one or more lines are too long

2
webroot/js/select2.min.js vendored Normal file

File diff suppressed because one or more lines are too long