From 2bbbe69d6d6ed2d3df6bca07a62a082f5de04b01 Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Fri, 24 Mar 2023 09:13:03 +0100 Subject: [PATCH] chg: [ui] Migrated UI from Cerebrate and adapted to MISP logo usage --- .../templates/User/Registration.php | 6 +- plugins/Tags/src/View/Helper/TagHelper.php | 24 +- src/Application.php | 5 +- src/Controller/Component/CRUDComponent.php | 233 +- src/Utility/Utils.php | 32 + .../BootstrapElements/BootstrapAccordion.php | 155 + .../BootstrapElements/BootstrapAlert.php | 83 + .../BootstrapElements/BootstrapBadge.php | 88 + .../BootstrapElements/BootstrapButton.php | 143 + .../BootstrapElements/BootstrapCard.php | 135 + .../BootstrapElements/BootstrapCollapse.php | 125 + .../BootstrapDropdownMenu.php | 214 ++ .../BootstrapElements/BootstrapIcon.php | 63 + .../BootstrapElements/BootstrapListGroup.php | 119 + .../BootstrapElements/BootstrapListTable.php | 223 ++ .../BootstrapElements/BootstrapModal.php | 349 +++ .../BootstrapNotificationBubble.php | 106 + .../BootstrapElements/BootstrapProgress.php | 88 + .../BootstrapProgressTimeline.php | 153 + .../BootstrapElements/BootstrapSwitch.php | 81 + .../BootstrapElements/BootstrapTable.php | 243 ++ .../BootstrapElements/BootstrapTabs.php | 303 ++ .../BootstrapElements/BootstrapToast.php | 74 + src/View/Helper/BootstrapHelper.php | 2216 +++----------- src/View/Helper/FormFieldMassageHelper.php | 9 + src/View/Helper/SocialProviderHelper.php | 10 +- templates/Users/login.php | 8 +- templates/element/Settings/field.php | 10 +- templates/element/Settings/fieldGroup.php | 14 +- templates/element/Settings/notice.php | 14 +- templates/element/Settings/panel.php | 4 +- .../element/UserSettings/saved-bookmarks.php | 12 +- .../Form/Fields/dropdownField.php | 23 +- .../genericElements/Form/Fields/uuidField.php | 76 +- .../genericElements/Form/fieldScaffold.php | 100 +- .../element/genericElements/Form/formInfo.php | 53 +- .../Form/formLayouts/formDefault.php | 4 +- .../Form/formLayouts/formRaw.php | 4 +- .../genericElements/Form/genericForm.php | 11 +- .../genericElements/Form/metaTemplateForm.php | 6 +- .../index_statistic_field_amount.php | 4 +- .../Statistics/index_statistic_timestamp.php | 8 +- .../IndexTable/index_table.php | 108 +- .../genericElements/IndexTable/pagination.php | 2 +- .../ListTopBar/group_multi_select_actions.php | 4 +- .../ListTopBar/group_search.php | 6 +- .../ListTopBar/group_table_action.php | 59 +- .../group_table_action/hiddenColumns.php | 2 +- .../group_table_action/hiddenMetaColumns.php | 2 +- .../SingleViews/Fields/tagsField.php | 4 +- .../SingleViews/metafields_panel.php | 20 +- templates/element/layouts/action-bar.php | 32 +- templates/element/layouts/header.php | 6 +- .../layouts/header/header-notifications.php | 1 + .../element/layouts/sidebar/bookmark-add.php | 4 +- .../layouts/sidebar/bookmark-entry.php | 2 +- templates/element/widgets/highlight-panel.php | 2 +- templates/genericTemplates/confirm.php | 66 +- templates/genericTemplates/delete.php | 7 +- templates/genericTemplates/filters.php | 84 +- templates/genericTemplates/toggle.php | 14 +- templates/layout/default.php | 2 +- templates/layout/login.php | 2 +- webroot/css/layout.css | 72 +- webroot/css/login.css | 24 - webroot/css/select2-bootstrap5-vars.css | 516 ++++ webroot/img/icon-composition/bubble.svg | 95 + webroot/img/icon-composition/sheet-all.svg | 2582 +--------------- webroot/img/icon-composition/z.svg | 2659 ----------------- webroot/js/api-helper.js | 5 +- webroot/js/bootstrap-helper.js | 323 +- webroot/js/main.js | 27 +- webroot/js/utils.js | 18 + 73 files changed, 4898 insertions(+), 7483 deletions(-) create mode 100644 src/Utility/Utils.php create mode 100644 src/View/Helper/BootstrapElements/BootstrapAccordion.php create mode 100644 src/View/Helper/BootstrapElements/BootstrapAlert.php create mode 100644 src/View/Helper/BootstrapElements/BootstrapBadge.php create mode 100644 src/View/Helper/BootstrapElements/BootstrapButton.php create mode 100644 src/View/Helper/BootstrapElements/BootstrapCard.php create mode 100644 src/View/Helper/BootstrapElements/BootstrapCollapse.php create mode 100644 src/View/Helper/BootstrapElements/BootstrapDropdownMenu.php create mode 100644 src/View/Helper/BootstrapElements/BootstrapIcon.php create mode 100644 src/View/Helper/BootstrapElements/BootstrapListGroup.php create mode 100644 src/View/Helper/BootstrapElements/BootstrapListTable.php create mode 100644 src/View/Helper/BootstrapElements/BootstrapModal.php create mode 100644 src/View/Helper/BootstrapElements/BootstrapNotificationBubble.php create mode 100644 src/View/Helper/BootstrapElements/BootstrapProgress.php create mode 100644 src/View/Helper/BootstrapElements/BootstrapProgressTimeline.php create mode 100644 src/View/Helper/BootstrapElements/BootstrapSwitch.php create mode 100644 src/View/Helper/BootstrapElements/BootstrapTable.php create mode 100644 src/View/Helper/BootstrapElements/BootstrapTabs.php create mode 100644 src/View/Helper/BootstrapElements/BootstrapToast.php create mode 100644 webroot/css/select2-bootstrap5-vars.css create mode 100644 webroot/img/icon-composition/bubble.svg delete mode 100644 webroot/img/icon-composition/z.svg diff --git a/libraries/default/InboxProcessors/templates/User/Registration.php b/libraries/default/InboxProcessors/templates/User/Registration.php index 5e5f0ab81..92b77951b 100644 --- a/libraries/default/InboxProcessors/templates/User/Registration.php +++ b/libraries/default/InboxProcessors/templates/User/Registration.php @@ -74,8 +74,10 @@ echo $this->Bootstrap->modal([ '
%s
', $combinedForm ), - 'confirmText' => __('Create user'), - 'confirmFunction' => 'submitRegistration' + 'confirmButton' => [ + 'text' => __('Create user'), + 'onclick' => 'submitRegistration', + ], ]); ?> diff --git a/plugins/Tags/src/View/Helper/TagHelper.php b/plugins/Tags/src/View/Helper/TagHelper.php index 71b48d78f..405c51312 100644 --- a/plugins/Tags/src/View/Helper/TagHelper.php +++ b/plugins/Tags/src/View/Helper/TagHelper.php @@ -60,9 +60,7 @@ class TagHelper extends Helper 'icon' => 'plus', 'variant' => 'secondary', 'class' => ['badge'], - 'params' => [ - 'onclick' => 'createTagPicker(this)', - ] + 'onclick' => 'createTagPicker(this)', ]); } else { $html .= ''; @@ -111,22 +109,20 @@ class TagHelper extends Helper 'class' => ['ms-1', 'border-0', "text-${textColour}"], 'variant' => 'text', 'title' => __('Delete tag'), - 'params' => [ - 'onclick' => sprintf('deleteTag(\'%s\', \'%s\', this)', - $this->Url->build([ - 'controller' => $this->getView()->getName(), - 'action' => 'untag', - $this->getView()->get('entity')['id'] - ]), - h($tag['name']) - ), - ], + 'onclick' => sprintf('deleteTag(\'%s\', \'%s\', this)', + $this->Url->build([ + 'controller' => $this->getView()->getName(), + 'action' => 'untag', + $this->getView()->get('entity')['id'] + ]), + h($tag['name']) + ), ]); } else { $deleteButton = ''; } - $html = $this->Bootstrap->genNode('span', [ + $html = $this->Bootstrap->node('span', [ 'class' => [ 'tag', 'badge', diff --git a/src/Application.php b/src/Application.php index ed478df55..8da8635d3 100644 --- a/src/Application.php +++ b/src/Application.php @@ -120,8 +120,9 @@ class Application extends BaseApplication implements AuthenticationServiceProvid ])); \SocialConnect\JWX\JWT::$screw = Configure::check('keycloak.screw') ? Configure::read('keycloak.screw') : 0; } - $middlewareQueue->add(new AuthenticationMiddleware($this)) - ->add(new BodyParserMiddleware()); + $middlewareQueue + ->add(new BodyParserMiddleware()) + ->add(new AuthenticationMiddleware($this)); return $middlewareQueue; } diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 1808f42dd..d56609c7c 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -9,6 +9,8 @@ use Cake\Utility\Inflector; use Cake\Utility\Text; use Cake\View\ViewBuilder; use Cake\ORM\TableRegistry; +use Cake\ORM\Query; +use Cake\Database\Expression\QueryExpression; use Cake\Routing\Router; use Cake\Http\Exception\MethodNotAllowedException; use Cake\Http\Exception\NotFoundException; @@ -32,6 +34,10 @@ class CRUDComponent extends Component public function index(array $options): void { + $embedInModal = !empty($this->request->getQuery('embedInModal', false)); + $excludeStats = !empty($this->request->getQuery('excludeStats', false)); + $skipTableToolbar = !empty($this->request->getQuery('skipTableToolbar', false)); + if (!empty($options['quickFilters'])) { if (empty($options['filters'])) { $options['filters'] = []; @@ -45,9 +51,12 @@ class CRUDComponent extends Component $options['filters'][] = 'filteringTags'; } - $optionFilters = empty($options['filters']) ? [] : $options['filters']; + $optionFilters = []; + $optionFilters += empty($options['filters']) ? [] : $options['filters']; foreach ($optionFilters as $i => $filter) { $optionFilters[] = "{$filter} !="; + $optionFilters[] = "{$filter} >="; + $optionFilters[] = "{$filter} <="; } $params = $this->Controller->ParamHandler->harvestParams($optionFilters); $params = $this->fakeContextFilter($options, $params); @@ -70,13 +79,22 @@ class CRUDComponent extends Component $query->select($options['fields']); } if (!empty($options['order'])) { - $query->order($options['order']); - } - if ($this->Controller->ParamHandler->isRest()) { - if ($this->metaFieldsSupported()) { - $query = $this->includeRequestedMetaFields($query); + $orderFields = array_keys($options['order']); + if ($this->_validOrderFields($orderFields)) { + $query->order($options['order']); + $this->Controller->paginate['order'] = $options['order']; } - $data = $query->all(); + } + if ($this->metaFieldsSupported() && !$this->Controller->ParamHandler->isRest()) { + $query = $this->includeRequestedMetaFields($query); + } + if (!$this->Controller->ParamHandler->isRest()) { + $this->setRequestedEntryAmount(); + } + $data = $this->Controller->paginate($query, $this->Controller->paginate ?? []); + $totalCount = $this->Controller->getRequest()->getAttribute('paging')[$this->TableAlias]['count']; + if ($this->Controller->ParamHandler->isRest()) { + $data = $this->Controller->paginate($query, $this->Controller->paginate ?? []); if (isset($options['hidden'])) { $data->each(function($value, $key) use ($options) { $hidden = is_array($options['hidden']) ? $options['hidden'] : [$options['hidden']]; @@ -107,12 +125,11 @@ class CRUDComponent extends Component return $this->attachMetaTemplatesIfNeeded($value, $metaTemplates); }); } - $this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json'); + $this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json', false, false, false, [ + 'X-Total-Count' => $totalCount, + ]); } else { - if ($this->metaFieldsSupported()) { - $query = $this->includeRequestedMetaFields($query); - } - $data = $this->Controller->paginate($query, $this->Controller->paginate ?? []); + $this->Controller->setResponse($this->Controller->getResponse()->withHeader('X-Total-Count', $totalCount)); if (isset($options['afterFind'])) { $function = $options['afterFind']; if (is_callable($function)) { @@ -138,8 +155,11 @@ class CRUDComponent extends Component $data[$i] = $this->attachMetaTemplatesIfNeeded($row, $metaTemplates); } $this->Controller->set('meta_templates', $metaTemplates); + $this->Controller->set('meta_templates_enabled', array_filter($metaTemplates, function($template) { + return $template['enabled']; + })); } - if (true) { // check if stats are requested + if (empty($excludeStats)) { // check if stats are requested $modelStatistics = []; if ($this->Table->hasBehavior('Timestamp')) { $modelStatistics = $this->Table->getActivityStatisticsForModel( @@ -180,6 +200,8 @@ class CRUDComponent extends Component } $this->Controller->set('model', $this->Table); $this->Controller->set('data', $data); + $this->Controller->set('embedInModal', $embedInModal); + $this->Controller->set('skipTableToolbar', $skipTableToolbar); } } @@ -195,21 +217,50 @@ class CRUDComponent extends Component $metaTemplates = $this->getMetaTemplates()->toArray(); $this->Controller->set('metaFieldsEnabled', true); $this->Controller->set('metaTemplates', $metaTemplates); + $typeHandlers = $this->Table->getBehavior('MetaFields')->getTypeHandlers(); + $typeHandlersOperators = []; + foreach ($typeHandlers as $type => $handler) { + $typeHandlersOperators[$type] = $handler::OPERATORS; + } + $this->Controller->set('typeHandlersOperators', $typeHandlersOperators); } else { $this->Controller->set('metaFieldsEnabled', false); } - $filters = !empty($this->Controller->filterFields) ? $this->Controller->filterFields : []; - $typeHandlers = $this->Table->getBehavior('MetaFields')->getTypeHandlers(); - $typeHandlersOperators = []; - foreach ($typeHandlers as $type => $handler) { - $typeHandlersOperators[$type] = $handler::OPERATORS; + $filtersConfigRaw= !empty($this->Controller->filterFields) ? $this->Controller->filterFields : []; + $filtersConfig = []; + foreach ($filtersConfigRaw as $fieldConfig) { + if (is_array($fieldConfig)) { + $filtersConfig[$fieldConfig['name']] = $fieldConfig; + } else { + $filtersConfig[$fieldConfig] = ['name' => $fieldConfig]; + } } - $this->Controller->set('typeHandlersOperators', $typeHandlersOperators); - $this->Controller->set('filters', $filters); + $filtersName = $this->getFilterFieldsName(); + $typeMap = $this->Table->getSchema()->typeMap(); + $associatedtypeMap = !empty($filtersName) ? $this->_getAssociatedTypeMap() : []; + $typeMap = array_merge( + $this->Table->getSchema()->typeMap(), + $associatedtypeMap + ); + $typeMap = array_filter($typeMap, function ($field) use ($filtersName) { + return in_array($field, $filtersName); + }, ARRAY_FILTER_USE_KEY); + $this->Controller->set('typeMap', $typeMap); + $this->Controller->set('filters', $filtersName); + $this->Controller->set('filtersConfig', $filtersConfig); $this->Controller->viewBuilder()->setLayout('ajax'); $this->Controller->render('/genericTemplates/filters'); } + public function getFilterFieldsName(): array + { + $filters = !empty($this->Controller->filterFields) ? $this->Controller->filterFields : []; + $filters = array_map(function($item) { + return is_array($item) ? $item['name'] : $item; + }, $filters); + return $filters; + } + /** * getResponsePayload Returns the adaquate response payload based on the request context * @@ -258,6 +309,9 @@ class CRUDComponent extends Component if ($this->metaFieldsSupported()) { $metaTemplates = $this->getMetaTemplates(); $data = $this->attachMetaTemplatesIfNeeded($data, $metaTemplates->toArray()); + if (isset($params['afterFind'])) { + $data = $params['afterFind']($data, $params); + } } if ($this->request->is('post')) { $patchEntityParams = [ @@ -418,6 +472,7 @@ class CRUDComponent extends Component 'field' => $rawMetaTemplateField->field, 'meta_template_id' => $rawMetaTemplateField->meta_template_id, 'meta_template_field_id' => $rawMetaTemplateField->id, + 'meta_template_directory_id' => $allMetaTemplates[$template_id]->meta_template_directory_id, 'parent_id' => $entity->id, 'uuid' => Text::uuid(), ]); @@ -448,6 +503,7 @@ class CRUDComponent extends Component 'field' => $rawMetaTemplateField->field, 'meta_template_id' => $rawMetaTemplateField->meta_template_id, 'meta_template_field_id' => $rawMetaTemplateField->id, + 'meta_template_directory_id' => $template->meta_template_directory_id, 'parent_id' => $entity->id, 'uuid' => Text::uuid(), ]); @@ -466,6 +522,7 @@ class CRUDComponent extends Component } $entity->setDirty('meta_fields', true); + $entity->_metafields_to_delete = $metaFieldsToDelete; return ['entity' => $entity, 'metafields_to_delete' => $metaFieldsToDelete]; } @@ -506,7 +563,7 @@ class CRUDComponent extends Component $params['contain'] = [$params['contain'], 'MetaFields']; } } - $query = $this->Table->find()->where(['id' => $id]); + $query = $this->Table->find()->where(["{$this->TableAlias}.id" => $id]); if (!empty($params['contain'])) { $query->contain($params['contain']); } @@ -514,16 +571,16 @@ class CRUDComponent extends Component $query->where($params['conditions']); } $data = $query->first(); + if ($this->metaFieldsSupported()) { + $metaTemplates = $this->getMetaTemplates(); + $data = $this->attachMetaTemplatesIfNeeded($data, $metaTemplates->toArray()); + } if (isset($params['afterFind'])) { $data = $params['afterFind']($data, $params); } if (empty($data)) { throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias)); } - if ($this->metaFieldsSupported()) { - $metaTemplates = $this->getMetaTemplates(); - $data = $this->attachMetaTemplatesIfNeeded($data, $metaTemplates->toArray()); - } if ($this->request->is(['post', 'put'])) { $patchEntityParams = [ 'associated' => [] @@ -713,6 +770,15 @@ class CRUDComponent extends Component ]); } + protected function setRequestedEntryAmount() + { + $user = $this->Controller->ACL->getUser(); + $tableSettings = IndexSetting::getTableSetting($user, $this->Table); + if (!empty($tableSettings['number_of_element'])) { + $this->Controller->paginate['limit'] = intval($tableSettings['number_of_element']); + } + } + public function view(int $id, array $params = []): void { if (empty($id)) { @@ -785,6 +851,9 @@ class CRUDComponent extends Component $query->contain($params['contain']); } $data = $query->first(); + if (isset($params['afterFind'])) { + $data = $params['afterFind']($data, $params); + } if (empty($data)) { throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias)); } @@ -807,6 +876,9 @@ class CRUDComponent extends Component $query->contain($params['contain']); } $data = $query->first(); + if (isset($params['afterFind'])) { + $data = $params['afterFind']($data, $params); + } if (isset($params['beforeSave'])) { try { $data = $params['beforeSave']($data); @@ -1095,17 +1167,9 @@ class CRUDComponent extends Component public function setQuickFilters(array $params, \Cake\ORM\Query $query, array $options): \Cake\ORM\Query { + $this->setQuickFilterForView($params, $options); $quickFilterFields = $options['quickFilters']; - $queryConditions = []; - $this->Controller->set('quickFilter', empty($quickFilterFields) ? [] : $quickFilterFields); - if ($this->metaFieldsSupported() && !empty($options['quickFilterForMetaField']['enabled'])) { - $this->Controller->set('quickFilterForMetaField', [ - 'enabled' => $options['quickFilterForMetaField']['enabled'] ?? false, - 'wildcard_search' => $options['quickFilterForMetaField']['enabled'] ?? false, - ]); - } if (!empty($params['quickFilter']) && !empty($quickFilterFields)) { - $this->Controller->set('quickFilterValue', $params['quickFilter']); $queryConditions = $this->genQuickFilterConditions($params, $quickFilterFields); if ($this->metaFieldsSupported() && !empty($options['quickFilterForMetaField']['enabled'])) { @@ -1115,10 +1179,25 @@ class CRUDComponent extends Component } $query->where(['OR' => $queryConditions]); + } + return $query; + } + + public function setQuickFilterForView(array $params, array $options): void + { + $quickFilterFields = $options['quickFilters']; + $this->Controller->set('quickFilter', empty($quickFilterFields) ? [] : $quickFilterFields); + if ($this->metaFieldsSupported() && !empty($options['quickFilterForMetaField']['enabled'])) { + $this->Controller->set('quickFilterForMetaField', [ + 'enabled' => $options['quickFilterForMetaField']['enabled'] ?? false, + 'wildcard_search' => $options['quickFilterForMetaField']['enabled'] ?? false, + ]); + } + if (!empty($params['quickFilter']) && !empty($quickFilterFields)) { + $this->Controller->set('quickFilterValue', $params['quickFilter']); } else { $this->Controller->set('quickFilterValue', ''); } - return $query; } public function genQuickFilterConditions(array $params, array $quickFilterFields): array @@ -1175,7 +1254,7 @@ class CRUDComponent extends Component } $activeFilters[$filter] = $filterValue; if (is_array($filterValue)) { - $query->where([($filter . ' IN') => $filterValue]); + $query = $this->setInCondition($query, $filter, $filterValue); } else { $query = $this->setValueCondition($query, $filter, $filterValue); } @@ -1261,6 +1340,27 @@ class CRUDComponent extends Component } } + protected function setInCondition($query, $fieldName, $values) + { + $split = explode(' ', $fieldName); + if (count($split) == 1) { + $field = $fieldName; + $operator = '='; + } else { + $field = $split[0]; + $operator = $split[1]; + } + if ($operator == '=') { + return $query->where(function (QueryExpression $exp, Query $q) use ($field, $values) { + return $exp->in($field, $values); + }); + } else if ($operator == '!=') { + return $query->where(function (QueryExpression $exp, Query $q) use ($field, $values) { + return $exp->notIn($field, $values); + }); + } + } + protected function setFilteringContext($contextFilters, $params) { $filteringContexts = []; @@ -1309,7 +1409,7 @@ class CRUDComponent extends Component { if (empty($params['filteringLabel']) && !empty($options['contextFilters']['custom'])) { foreach ($options['contextFilters']['custom'] as $contextFilter) { - if (!empty($contextFilter['default'])) { + if (!empty($contextFilter['default']) && empty($params)) { $params['filteringLabel'] = $contextFilter['label']; $this->Controller->set('fakeFilteringLabel', $contextFilter['label']); break; @@ -1434,7 +1534,7 @@ class CRUDComponent extends Component ); if ($this->Controller->ParamHandler->isRest()) { } else if ($this->Controller->ParamHandler->isAjax()) { - $this->Controller->ajaxResponsePayload = $this->RestResponse->ajaxFailResponse($this->ObjectAlias, 'toggle', $message, $validationErrors); + $this->Controller->ajaxResponsePayload = $this->RestResponse->ajaxFailResponse($this->ObjectAlias, 'toggle', [], $message, $validationErrors); } else { $this->Controller->Flash->error($message); if (empty($params['redirect'])) { @@ -1530,8 +1630,61 @@ class CRUDComponent extends Component private function renderViewInVariable($templateRelativeName, $data) { $builder = new ViewBuilder(); - $builder->disableAutoLayout()->setTemplate("{$this->TableAlias}/{$templateRelativeName}"); - $view = $builder->build($data); + $builder->disableAutoLayout() + ->setClassName('Monad') + ->setTemplate("{$this->TableAlias}/{$templateRelativeName}") + ->setVars($data); + $view = $builder->build(); return $view->render(); } + + protected function _getAssociatedTypeMap(): array + { + $typeMap = []; + foreach ($this->getFilterFieldsName() as $filter) { + $exploded = explode('.', $filter); + if (count($exploded) > 1) { + $model = $exploded[0]; + $subField = $exploded[1]; + if ($model == $this->Table->getAlias()) { + $typeMap[$filter] = $this->Table->getSchema()->typeMap()[$subField] ?? 'text'; + } else { + $association = $this->Table->associations()->get($model); + $associatedTable = $association->getTarget(); + $typeMap[$filter] = $associatedTable->getSchema()->typeMap()[$subField] ?? 'text'; + } + } + } + return $typeMap; + } + + protected function _validOrderFields($fields): bool + { + if (!is_array($fields)) { + $fields = [$fields]; + } + foreach ($fields as $field) { + $exploded = explode('.', $field); + if (count($exploded) > 1) { + $model = $exploded[0]; + $subField = $exploded[1]; + if ($model == $this->Table->getAlias()) { + if (empty($this->Table->getSchema()->typeMap()[$subField])) { + return false; + } + } else { + $association = $this->Table->associations()->get($model); + $associatedTable = $association->getTarget(); + if (empty($associatedTable->getSchema()->typeMap()[$subField])) { + return false; + } + } + } else { + if (empty($this->Table->getSchema()->typeMap()[$field])) { + return false; + } + } + } + return true; + } } diff --git a/src/Utility/Utils.php b/src/Utility/Utils.php new file mode 100644 index 000000000..9f63c2474 --- /dev/null +++ b/src/Utility/Utils.php @@ -0,0 +1,32 @@ + $value) { + //if the key exists in the second array, recursively call this function + //if it is an array, otherwise check if the value is in arr2 + if (array_key_exists($key, $arr2)) { + if (is_array($value)) { + $recursiveDiff = array_diff_recursive($value, $arr2[$key]); + + if (count($recursiveDiff)) { + $outputDiff[$key] = $recursiveDiff; + } + } else if (!in_array($value, $arr2)) { + $outputDiff[$key] = $value; + } + } + //if the key is not in the second array, check if the value is in + //the second array (this is a quirk of how array_diff works) + else if (!in_array($value, $arr2)) { + $outputDiff[$key] = $value; + } + } + + return $outputDiff; +} diff --git a/src/View/Helper/BootstrapElements/BootstrapAccordion.php b/src/View/Helper/BootstrapElements/BootstrapAccordion.php new file mode 100644 index 000000000..6f0688cfb --- /dev/null +++ b/src/View/Helper/BootstrapElements/BootstrapAccordion.php @@ -0,0 +1,155 @@ +Bootstrap->accordion( + * [ + * 'stayOpen' => true, + * ], + * [ + * [ + * 'open' => true, + * 'header' => [ + * 'variant' => 'danger', + * 'text' => 'nav 1', + * ], + * 'body' => 'body', + * ], + * [ + * 'class' => ['opacity-50'], + * 'variant' => 'success', + * 'header' => [ + * 'html' => 'nav 1', + * ], + * 'body' => 'body', + * ], + * ] + * ); + */ +class BootstrapAccordion extends BootstrapGeneric +{ + private $defaultOptions = [ + 'stayOpen' => false, + 'class' => [], + ]; + + function __construct(array $options, array $content, BootstrapHelper $btHelper) + { + $this->allowedOptionValues = []; + $this->content = $content; + $this->btHelper = $btHelper; + $this->processOptions($options); + } + + private function processOptions(array $options): void + { + $this->options = array_merge($this->defaultOptions, $options); + $this->checkOptionValidity(); + $this->options['class'] = $this->convertToArrayIfNeeded($this->options['class']); + $this->seed = 'acc-' . mt_rand(); + $this->contentSeeds = []; + foreach ($this->content as $accordionItem) { + $this->contentSeeds[] = mt_rand(); + } + + foreach ($this->content as $i => $item) { + $this->content[$i]['class'] = $this->convertToArrayIfNeeded($item['class'] ?? []); + $this->content[$i]['header']['class'] = $this->convertToArrayIfNeeded($item['header']['class'] ?? []); + } + } + + public function accordion(): string + { + return $this->genAccordion(); + } + + private function genHeader(array $accordionItem, int $i): string + { + $html = $this->nodeOpen('h2', [ + 'class' => ['accordion-header'], + 'id' => 'head-' . $this->contentSeeds[$i] + ]); + $content = $accordionItem['header']['html'] ?? h($accordionItem['header']['text']); + $buttonOptions = [ + 'class' => array_merge( + [ + 'accordion-button', + empty($accordionItem['open']) ? 'collapsed' : '', + self::getBGAndTextClassForVariant($accordionItem['header']['variant'] ?? ''), + ], + $accordionItem['header']['class'], + ), + 'type' => 'button', + 'data-bs-toggle' => 'collapse', + 'data-bs-target' => '#body-' . $this->contentSeeds[$i], + 'aria-expanded' => 'false', + 'aria-controls' => 'body-' . $this->contentSeeds[$i], + ]; + $html .= $this->node('button', $buttonOptions, $content); + $html .= $this->nodeClose(('h2')); + return $html; + } + + private function genBody(array $accordionItem, int $i): string + { + $content = $this->node('div', [ + 'class' => ['accordion-body'] + ], $accordionItem['body']); + $divOptions = [ + 'class' => array_merge( + [ + 'accordion-collapse collapse', + empty($accordionItem['open']) ? '' : 'show', + self::getBGAndTextClassForVariant($accordionItem['variant'] ?? ''), + ], + $accordionItem['class'], + ), + 'id' => 'body-' . $this->contentSeeds[$i], + 'aria-labelledby' => 'head-' . $this->contentSeeds[$i], + ]; + if (empty($this->options['stayOpen'])) { + $divOptions['data-bs-parent'] = '#' . $this->seed; + } + $html = $this->node('div', $divOptions, $content); + return $html; + } + + private function genAccordion(): string + { + $html = $this->nodeOpen('div', [ + 'class' => array_merge(['accordion'], $this->options['class']), + 'id' => $this->seed + ]); + foreach ($this->content as $i => $accordionItem) { + $html .= $this->nodeOpen('div', [ + 'class' => array_merge(['accordion-item']) + ]); + $html .= $this->genHeader($accordionItem, $i); + $html .= $this->genBody($accordionItem, $i); + $html .= $this->nodeClose('div'); + } + $html .= $this->nodeClose('div'); + return $html; + } +} \ No newline at end of file diff --git a/src/View/Helper/BootstrapElements/BootstrapAlert.php b/src/View/Helper/BootstrapElements/BootstrapAlert.php new file mode 100644 index 000000000..8bff3c149 --- /dev/null +++ b/src/View/Helper/BootstrapElements/BootstrapAlert.php @@ -0,0 +1,83 @@ +Bootstrap->alert([ + * 'text' => 'This is an alert', + * 'dismissible' => false, + * 'variant' => 'warning', + * 'fade' => false, + * ]); + */ +class BootstrapAlert extends BootstrapGeneric +{ + private $defaultOptions = [ + 'text' => '', + 'html' => null, + 'dismissible' => true, + 'variant' => 'primary', + 'fade' => true, + 'class' => [], + ]; + + function __construct(array $options) + { + $this->allowedOptionValues = [ + 'variant' => BootstrapGeneric::$variants, + ]; + $this->processOptions($options); + } + + private function processOptions(array $options): void + { + $this->options = array_merge($this->defaultOptions, $options); + $this->options['class'] = $this->convertToArrayIfNeeded($this->options['class']); + $this->checkOptionValidity(); + } + + public function alert(): string + { + return $this->genAlert(); + } + + private function genAlert(): string + { + $html = $this->nodeOpen('div', [ + 'class' => array_merge([ + 'alert', + "alert-{$this->options['variant']}", + $this->options['dismissible'] ? 'alert-dismissible' : '', + $this->options['fade'] ? 'fade show' : '', + ], $this->options['class']), + 'role' => "alert" + ]); + + $html .= $this->options['html'] ?? h($this->options['text']); + $html .= $this->genCloseButton(); + $html .= $this->nodeClose('div'); + return $html; + } + + private function genCloseButton(): string + { + $html = ''; + if ($this->options['dismissible']) { + $html .= $this->genericCloseButton('alert'); + } + return $html; + } +} diff --git a/src/View/Helper/BootstrapElements/BootstrapBadge.php b/src/View/Helper/BootstrapElements/BootstrapBadge.php new file mode 100644 index 000000000..3fe745b57 --- /dev/null +++ b/src/View/Helper/BootstrapElements/BootstrapBadge.php @@ -0,0 +1,88 @@ +Bootstrap->badge([ + * 'text' => 'text', + * 'variant' => 'success', + * 'pill' => false, + * ]); + */ +class BootstrapBadge extends BootstrapGeneric +{ + private $defaultOptions = [ + 'id' => '', + 'text' => '', + 'html' => null, + 'variant' => 'primary', + 'pill' => false, + 'icon' => false, + 'title' => '', + 'class' => [], + ]; + + function __construct(array $options) + { + $this->allowedOptionValues = [ + 'variant' => BootstrapGeneric::$variants, + ]; + $this->processOptions($options); + } + + private function processOptions(array $options): void + { + $this->options = array_merge($this->defaultOptions, $options); + $this->options['class'] = $this->convertToArrayIfNeeded($this->options['class']); + $this->checkOptionValidity(); + } + + public function badge(): string + { + return $this->genBadge(); + } + + private function genBadge(): string + { + $html = $this->node('span', [ + 'class' => array_merge($this->options['class'], [ + 'ms-1', + 'badge', + self::getBGAndTextClassForVariant($this->options['variant']), + $this->options['pill'] ? 'rounded-pill' : '', + ]), + 'title' => $this->options['title'], + 'id' => $this->options['id'] ?? '', + ], [ + $this->genIcon(), + $this->options['html'] ?? h($this->options['text']) + ]); + return $html; + } + + private function genIcon(): string + { + if (!empty($this->options['icon'])) { + $bsIcon = new BootstrapIcon($this->options['icon'], [ + 'class' => [(!empty($this->options['text']) ? 'me-1' : '')] + ]); + return $bsIcon->icon(); + } + return ''; + } +} diff --git a/src/View/Helper/BootstrapElements/BootstrapButton.php b/src/View/Helper/BootstrapElements/BootstrapButton.php new file mode 100644 index 000000000..458680ca5 --- /dev/null +++ b/src/View/Helper/BootstrapElements/BootstrapButton.php @@ -0,0 +1,143 @@ +Bootstrap->button([ + * 'text' => 'Press me!', + * 'variant' => 'warning', + * 'icon' => 'exclamation-triangle', + * 'onclick' => 'alert(1)', + * ]); + */ +class BootstrapButton extends BootstrapGeneric +{ + private $defaultOptions = [ + 'id' => '', + 'text' => '', + 'html' => null, + 'variant' => 'primary', + 'outline' => false, + 'size' => '', + 'icon' => null, + 'image' => null, + 'class' => [], + 'type' => 'button', + 'nodeType' => 'button', + 'title' => '', + 'badge' => false, + 'onclick' => false, + 'attrs' => [], + ]; + + private $bsClasses = []; + + function __construct(array $options) + { + $this->allowedOptionValues = [ + 'variant' => array_merge(BootstrapGeneric::$variants, ['link', 'text']), + 'size' => ['', 'xs', 'sm', 'lg'], + 'type' => ['button', 'submit', 'reset'] + ]; + $this->processOptions($options); + } + + private function processOptions(array $options): void + { + $this->options = array_merge($this->defaultOptions, $options); + $this->options['class'] = $this->convertToArrayIfNeeded($this->options['class']); + $this->checkOptionValidity(); + + if (!empty($this->options['id'])) { + $this->options['attrs']['id'] = $this->options['id']; + } + + $this->bsClasses[] = 'btn'; + if ($this->options['outline']) { + $this->bsClasses[] = "btn-outline-{$this->options['variant']}"; + } else { + $this->bsClasses[] = "btn-{$this->options['variant']}"; + } + if (!empty($this->options['size'])) { + $this->bsClasses[] = "btn-{$this->options['size']}"; + } + if ($this->options['variant'] == 'text') { + $this->bsClasses[] = 'p-0'; + $this->bsClasses[] = 'lh-1'; + } + if (!empty($this->options['onclick'])) { + $this->options['attrs']['onclick'] = $this->options['onclick']; + } + } + + public function button(): string + { + return $this->genButton(); + } + + private function genButton(): string + { + $html = $this->nodeOpen($this->options['nodeType'], array_merge($this->options['attrs'], [ + 'class' => array_merge($this->options['class'], $this->bsClasses), + 'role' => "alert", + 'type' => $this->options['type'], + 'title' => h($this->options['title']), + ])); + + $html .= $this->genIcon(); + $html .= $this->genImage(); + $html .= $this->options['html'] ?? h($this->options['text']); + if (!empty($this->options['badge'])) { + $bsBadge = new BootstrapBadge($this->options['badge']); + $html .= $bsBadge->badge(); + } + $html .= $this->nodeClose($this->options['nodeType']); + return $html; + } + + private function genIcon(): string + { + if (!empty($this->options['icon'])) { + $bsIcon = new BootstrapIcon($this->options['icon'], [ + 'class' => [(!empty($this->options['text']) ? 'me-1' : '')] + ]); + return $bsIcon->icon(); + } + return ''; + } + + private function genImage(): string + { + if (!empty($this->options['image'])) { + return $this->node('img', [ + 'src' => $this->options['image']['path'] ?? '', + 'class' => ['img-fluid', 'me-1'], + 'width' => '26', + 'height' => '26', + 'alt' => $this->options['image']['alt'] ?? '' + ]); + } + return ''; + } +} diff --git a/src/View/Helper/BootstrapElements/BootstrapCard.php b/src/View/Helper/BootstrapElements/BootstrapCard.php new file mode 100644 index 000000000..47ecd6802 --- /dev/null +++ b/src/View/Helper/BootstrapElements/BootstrapCard.php @@ -0,0 +1,135 @@ +Bootstrap->card([ + * 'headerText' => 'header', + * 'bodyHTML' => 'body', + * 'footerText' => 'footer', + * 'headerVariant' => 'warning', + * 'footerVariant' => 'dark', + * ); + */ +class BootstrapCard extends BootstrapGeneric +{ + private $defaultOptions = [ + 'headerText' => '', + 'bodyText' => '', + 'footerText' => '', + 'headerHTML' => null, + 'bodyHTML' => null, + 'footerHTML' => null, + 'class' => [], + 'headerVariant' => '', + 'bodyVariant' => '', + 'footerVariant' => '', + 'headerClass' => '', + 'bodyClass' => '', + 'footerClass' => '', + ]; + + public function __construct(array $options) + { + $this->allowedOptionValues = [ + 'headerVariant' => array_merge(BootstrapGeneric::$variants, ['']), + 'bodyVariant' => array_merge(BootstrapGeneric::$variants, ['']), + 'footerVariant' => array_merge(BootstrapGeneric::$variants, ['']), + ]; + $this->processOptions($options); + } + + private function processOptions(array $options): void + { + $this->options = array_merge($this->defaultOptions, $options); + $this->options['headerClass'] = $this->convertToArrayIfNeeded($this->options['headerClass']); + $this->options['bodyClass'] = $this->convertToArrayIfNeeded($this->options['bodyClass']); + $this->options['footerClass'] = $this->convertToArrayIfNeeded($this->options['footerClass']); + $this->checkOptionValidity(); + $this->options['borderVariant'] = !empty($this->options['headerVariant']) ? "border-{$this->options['headerVariant']}" : ''; + } + + public function card(): string + { + return $this->genCard(); + } + + private function genCard(): string + { + $card = $this->node('div', [ + 'class' => array_merge( + [ + 'card', + $this->options['borderVariant'], + ], + $this->options['class'] + ), + ], implode('', [$this->genHeader(), $this->genBody(), $this->genFooter()])); + return $card; + } + + private function genHeader(): string + { + if (empty($this->options['headerHTML']) && empty($this->options['headerText'])) { + return ''; + } + $content = $this->options['headerHTML'] ?? h($this->options['headerText']); + $header = $this->node('div', [ + 'class' => array_merge( + [ + 'card-header', + self::getBGAndTextClassForVariant($this->options['headerVariant']), + ], + $this->options['headerClass'] + ), + ], $content); + return $header; + } + + private function genBody(): string + { + if (empty($this->options['bodyHTML']) && empty($this->options['bodyText'])) { + return ''; + } + $content = $this->options['bodyHTML'] ?? h($this->options['bodyText']); + $body = $this->node('div', [ + 'class' => array_merge( + [ + 'card-body', + self::getBGAndTextClassForVariant($this->options['bodyVariant']), + ], + $this->options['bodyClass'] + ) + ], $content); + return $body; + } + + private function genFooter(): string + { + if (empty($this->options['footerHTML']) && empty($this->options['footerText'])) { + return ''; + } + $content = $this->options['footerHTML'] ?? h($this->options['footerText']); + $footer = $this->node('div', [ + 'class' => array_merge([ + 'card-footer', + self::getBGAndTextClassForVariant($this->options['footerVariant']), + ], + $this->options['footerClass'] + ) + ], $content); + return $footer; + } +} \ No newline at end of file diff --git a/src/View/Helper/BootstrapElements/BootstrapCollapse.php b/src/View/Helper/BootstrapElements/BootstrapCollapse.php new file mode 100644 index 000000000..472cfc109 --- /dev/null +++ b/src/View/Helper/BootstrapElements/BootstrapCollapse.php @@ -0,0 +1,125 @@ +Bootstrap->collapse([ + * 'button' => [ + * 'text' => 'Open sesame', + * 'variant' => 'success', + * ], + * 'card' => [ + * 'bodyClass' => 'p-2 rounded-3', + * 'bodyVariant' => 'secondary', + * ] + * ], 'content'); + */ + +class BootstrapCollapse extends BootstrapGeneric +{ + private $defaultOptions = [ + 'text' => '', + 'html' => null, + 'open' => false, + 'horizontal' => false, + 'class' => [], + 'button' => [], + 'card' => false, + 'attrs' => [], + ]; + + function __construct(array $options, string $content, BootstrapHelper $btHelper) + { + $this->allowedOptionValues = []; + $this->processOptions($options); + $this->content = $content; + $this->btHelper = $btHelper; + } + + private function processOptions(array $options): void + { + $this->options = array_merge($this->defaultOptions, $options); + $this->options['class'] = $this->convertToArrayIfNeeded($this->options['class']); + $this->options['class'][] = 'collapse'; + if (!empty($this->options['horizontal'])) { + $this->options['class'][] = 'collapse-horizontal'; + } + if ($this->options['open']) { + $this->options['class'][] = 'show'; + } + if ($this->options['card'] !== false && empty($this->options['card']['bodyClass'])) { + $this->options['card']['bodyClass'] = ['p-0']; + } + if (empty($this->options['id'])) { + $this->options['id'] = 'c-' . Security::randomString(8); + } + $this->checkOptionValidity(); + } + + public function collapse(): string + { + return $this->genCollapse(); + } + + private function genControl(): string + { + $attrsConfig = [ + 'data-bs-toggle' => 'collapse', + 'role' => 'button', + 'aria-expanded' => 'false', + 'aria-controls' => $this->options['id'], + 'href' => '#' . $this->options['id'], + ]; + $html = ''; + if (!empty($this->options['button'])) { + $btnConfig = array_merge($this->options['button'], ['attrs' => $attrsConfig]); + $html = $this->btHelper->button($btnConfig); + } else { + $nodeConfig = [ + 'class' => ['text-decoration-none'], + ]; + $nodeConfig = array_merge($nodeConfig, $attrsConfig); + $html = $this->node('a', $nodeConfig, $this->options['html'] ?? h($this->options['text'])); + } + return $html; + } + + private function genContent(): string + { + if (!empty($this->options['card'])) { + $cardConfig = $this->options['card']; + $cardConfig['bodyHTML'] = $this->content; + $content = $this->btHelper->card($cardConfig); + } else { + $content = $this->content; + } + $container = $this->node('div', [ + 'class' => $this->options['class'], + 'id' => $this->options['id'], + ], $content); + return $container; + } + + private function genCollapse(): string + { + return $this->genControl() . $this->genContent(); + } +} \ No newline at end of file diff --git a/src/View/Helper/BootstrapElements/BootstrapDropdownMenu.php b/src/View/Helper/BootstrapElements/BootstrapDropdownMenu.php new file mode 100644 index 000000000..58110d199 --- /dev/null +++ b/src/View/Helper/BootstrapElements/BootstrapDropdownMenu.php @@ -0,0 +1,214 @@ + element + * - attrs: Additional HTML attributes to be applied on the entry + * + * # Usage: + * $this->Bootstrap->dropdownMenu([ + * 'dropdown-class' => 'ms-1', + * 'alignment' => 'end', + * 'direction' => 'down', + * 'button' => [ + * 'icon' => 'sliders-h', + * 'variant' => 'primary', + * ], + * 'submenu_alignment' => 'end', + * 'submenu_direction' => 'end', + * 'attrs' => [], + * 'menu' => [ + * [ + * 'text' => __('Eye'), + * 'icon' => 'eye-slash', + * 'keepOpen' => true, + * 'menu' => [ + * ['header' => true, 'text' => 'nested menu'], + * ['text' => 'item 1'], + * ['text' => 'item 2', 'sup' => 'v1'], + * ], + * ], + * [ + * 'html' => 'html item', + * ], + * ] + * ]); + */ + +class BootstrapDropdownMenu extends BootstrapGeneric +{ + private $defaultOptions = [ + 'dropdown-class' => [], + 'alignment' => 'start', + 'direction' => 'end', + 'button' => [], + 'menu' => [], + 'submenu_direction' => 'end', + 'submenu_classes' => [], + 'attrs' => [], + ]; + + function __construct(array $options, BootstrapHelper $btHelper) + { + $this->allowedOptionValues = [ + 'direction' => ['start', 'end', 'up', 'down'], + 'alignment' => ['start', 'end'], + 'submenu_direction' => ['start', 'end', 'up', 'down'], + ]; + $this->processOptions($options); + $this->menu = $this->options['menu']; + $this->btHelper = $btHelper; + } + + private function processOptions(array $options): void + { + $this->options = array_merge($this->defaultOptions, $options); + $this->options['dropdown-class'] = $this->convertToArrayIfNeeded($this->options['dropdown-class']); + $this->checkOptionValidity(); + } + + public function dropdownMenu(): string + { + return $this->fullDropdown(); + } + + public function fullDropdown(): string + { + return $this->genDropdownWrapper($this->genDropdownToggleButton(), $this->genDropdownMenu($this->menu)); + } + + public function genDropdownWrapper(string $toggle = '', string $menu = '', $direction = null, $classes = null): string + { + $classes = !is_null($classes) ? $classes : $this->options['dropdown-class']; + $direction = !is_null($direction) ? $direction : $this->options['direction']; + $content = $toggle . $menu; + $html = $this->node('div', array_merge( + $this->options['attrs'], + [ + 'class' => array_merge( + $classes, + [ + 'dropdown', + "drop{$direction}" + ] + ) + ] + ), $content); + return $html; + } + + public function genDropdownToggleButton(): string + { + $defaultOptions = [ + 'class' => ['dropdown-toggle'], + 'attrs' => [ + 'data-bs-toggle' => 'dropdown', + 'aria-expanded' => 'false', + ] + ]; + $options = array_merge_recursive($this->options['button'], $defaultOptions); + return $this->btHelper->button($options); + } + + private function genDropdownMenu(array $entries, $alignment = null): string + { + $alignment = !is_null($alignment) ? $alignment : $this->options['alignment']; + $html = $this->node('div', [ + 'class' => ['dropdown-menu', "dropdown-menu-{$alignment}"], + ], $this->genEntries($entries)); + return $html; + } + + private function genEntries(array $entries): string + { + $html = ''; + foreach ($entries as $entry) { + $link = $this->genEntry($entry); + if (!empty($entry['menu'])) { + $html .= $this->genDropdownWrapper($link, $this->genDropdownMenu($entry['menu']), $this->options['submenu_direction'], $this->options['submenu_classes']); + } else { + $html .= $link; + } + } + return $html; + } + + private function genEntry(array $entry): string + { + if (!empty($entry['html'])) { + return $entry['html']; + } + $classes = []; + $icon = ''; + if (!empty($entry['icon'])) { + $icon = $this->btHelper->icon($entry['icon'], ['class' => 'me-2']); + } + $badge = ''; + if (!empty($entry['badge'])) { + $bsBadge = new BootstrapBadge(array_merge( + ['class' => ['ms-auto']], + $entry['badge'] + )); + $badge = $bsBadge->badge(); + } + + if (!empty($entry['header'])) { + return $this->node('h6', [ + 'class' => ['dropdown-header',], + ], $icon . h($entry['text']) . $badge); + } + + $classes = ['dropdown-item']; + if (!empty($entry['class'])) { + if (!is_array($entry['class'])) { + $entry['class'] = [$entry['class']]; + } + $classes = array_merge($classes, $entry['class']); + } + $params = $entry['attrs'] ?? []; + $params['href'] = '#'; + + if (!empty($entry['menu'])) { + $classes[] = 'dropdown-toggle'; + $classes[] = 'd-flex align-items-center'; + $params['data-bs-toggle'] = 'dropdown'; + $params['aria-haspopup'] = 'true'; + $params['aria-expanded'] = 'false'; + if (!empty($entry['keepOpen'])) { + $classes[] = 'open-form'; + } + $params['data-open-form-id'] = mt_rand(); + } + + $labelContent = sprintf( + '%s%s', + h($entry['text']), + !empty($entry['sup']) ? $this->node('sup', ['class' => 'ms-1 text-muted'], $entry['sup']) : '' + ); + $label = $this->node('span', ['class' => 'mx-1'], $labelContent); + $content = $icon . $label . $badge; + + return $this->node('a', array_merge([ + 'class' => $classes, + ], $params), $content); + } +} diff --git a/src/View/Helper/BootstrapElements/BootstrapIcon.php b/src/View/Helper/BootstrapElements/BootstrapIcon.php new file mode 100644 index 000000000..837095d4b --- /dev/null +++ b/src/View/Helper/BootstrapElements/BootstrapIcon.php @@ -0,0 +1,63 @@ +Bootstrap->icon('eye-slash', [ + * 'class' => 'm-3', + * ]); + */ +class BootstrapIcon extends BootstrapGeneric +{ + private $icon = ''; + private $defaultOptions = [ + 'id' => '', + 'class' => [], + 'title' => '', + 'attrs' => [], + ]; + + function __construct(string $icon, array $options = []) + { + $this->icon = $icon; + $this->processOptions($options); + } + + private function processOptions(array $options): void + { + $this->options = array_merge($this->defaultOptions, $options); + $this->checkOptionValidity(); + $this->options['class'] = $this->convertToArrayIfNeeded($this->options['class']); + } + + public function icon(): string + { + return $this->genIcon(); + } + + private function genIcon(): string + { + $html = $this->node('span', array_merge( + [ + 'id' => $this->options['id'] ?? '', + 'class' => array_merge( + $this->options['class'], + ["fa fa-{$this->icon}"] + ), + 'title' => h($this->options['title']) + ], + $this->options['attrs'] + )); + return $html; + } +} \ No newline at end of file diff --git a/src/View/Helper/BootstrapElements/BootstrapListGroup.php b/src/View/Helper/BootstrapElements/BootstrapListGroup.php new file mode 100644 index 000000000..5804a3ff2 --- /dev/null +++ b/src/View/Helper/BootstrapElements/BootstrapListGroup.php @@ -0,0 +1,119 @@ +Bootstrap->listGroup( + * [ + * [ + * 'text' => 'test', + * 'badge' => [ + * 'text' => 'test', + * 'variant' => 'warning' + * ], + * 'attrs' => [ + * 'data-test' => 'tes' + * ] + * ], + * [ + * 'html' => 'test2', + * ], + * ], + * [ + * 'class' => 'container-class' + * ] + * ); + */ +class BootstrapListGroup extends BootstrapGeneric +{ + private $defaultOptions = [ + 'class' => [], + 'attrs' => [], + ]; + + private $defaultItemOptions = [ + 'href' => '#', + 'text' => '', + 'html' => null, + 'badge' => '', + 'class' => [], + 'attrs' => [], + ]; + + private static $defaultClasses = ['list-group',]; + private static $defaultItemClasses = ['list-group-item', 'list-group-item-action', 'd-flex', 'align-items-start', 'justify-content-between']; + + function __construct(array $items, array $options, \App\View\BootstrapHelper $btHelper) + { + $this->items = $items; + $this->processOptions($options); + $this->btHelper = $btHelper; + } + + private function processOptions(array $options): void + { + $this->options = array_merge($this->defaultOptions, $options); + $this->options['class'] = $this->convertToArrayIfNeeded($this->options['class']); + } + + public function listGroup() + { + return $this->genListGroup(); + } + + private function genListGroup() + { + $html = $this->nodeOpen('div', array_merge([ + 'class' => array_merge(self::$defaultClasses, $this->options['class']), + ], $this->options['attrs'])); + foreach ($this->items as $item) { + $html .= $this->genItem($item); + } + $html .= $this->nodeClose('div'); + return $html; + } + + private function genItem(array $item): string + { + $item['class'] = !is_array($item['class']) ? [$item['class']] : $item['class']; + $itemOptions = array_merge($this->defaultItemOptions, $item); + $itemOptions['class'] = array_merge(self::$defaultItemClasses, $itemOptions['class']); + + $html = $this->node('a', + array_merge([ + 'class' => array_merge(self::$defaultItemClasses, $itemOptions['class']), + 'href' => '#', + ], $itemOptions['attrs']), + [ + !is_null($itemOptions['html']) ? $this->node('div', ['class' => 'w-100'], $itemOptions['html']) : h($itemOptions['text']), + $this->genBadge($itemOptions['badge']) + ], + ); + return $html; + } + + private function genBadge(array $badge): string + { + if (empty($badge)) { + return ''; + } + return $this->btHelper->badge($badge); + } +} \ No newline at end of file diff --git a/src/View/Helper/BootstrapElements/BootstrapListTable.php b/src/View/Helper/BootstrapElements/BootstrapListTable.php new file mode 100644 index 000000000..a2ba80667 --- /dev/null +++ b/src/View/Helper/BootstrapElements/BootstrapListTable.php @@ -0,0 +1,223 @@ + or array + * + * # Options for fields + * - key: The name of the field to be displayed as a label + * - keyHtml: The HTML of the field to be displayed as a label + * - path: The path to be fed to Hash::get() in order to get the value from the $item + * - raw: The raw value to be displayed. Disable the `path` option + * - rawNoEscaping: If the raw value should not be escaped. False by default + * - type: The type of element to use combined with $elementsRootPath from the table's option + * - formatter: A callback function to format the value + * - cellVariant: The bootstrap variant to be applied on the cell + * - rowVariant: The bootstrap variant to be applied on the row + * - notice_$variant: A text with the passed variant to be append at the end + * + * # Usage: + * $this->Bootstrap->listTable( + * [ + * 'hover' => false, + * 'variant' => 'success', + * ], + * [ + * 'item' => [ + * 'key1' => 'value1', + * 'key2' => true, + * 'key3' => 'value3', + * ], + * 'fields' => [ + * [ + * 'key' => 'Label 1', + * 'path' => 'key1', + * 'notice_warning' => '::warning::', + * 'notice_danger' => '::danger::', + * 'rowVariant' => 'danger', + * 'cellVariant' => 'success', + * ], + * [ + * 'key' => 'Label 2', + * 'path' => 'key2', + * 'type' => 'boolean', + * ], + * [ + * 'key' => 'Label 3', + * 'raw' => 'raw_value', + * 'rawNoEscaping' => true, + * ], + * [ + * 'key' => 'Label 4', + * 'path' => 'key3', + * 'formatter' => function ($value) { + * return '' . $value . ''; + * }, + * ], + * ], + * 'caption' => 'This is a caption' + * ] + * ); + */ +class BootstrapListTable extends BootstrapGeneric +{ + private $defaultOptions = [ + 'striped' => true, + 'bordered' => false, + 'borderless' => false, + 'hover' => true, + 'small' => false, + 'variant' => '', + 'tableClass' => [], + 'bodyClass' => [], + 'id' => '', + 'caption' => '', + 'elementsRootPath' => '/genericElements/SingleViews/Fields/', + ]; + + function __construct(array $options, array $data, BootstrapHelper $btHelper) + { + $this->allowedOptionValues = [ + 'variant' => array_merge(BootstrapGeneric::$variants, ['']) + ]; + $this->processOptions($options); + $this->fields = $data['fields']; + $this->item = $data['item']; + $this->caption = !empty($data['caption']) ? $data['caption'] : ''; + $this->btHelper = $btHelper; + } + + private function processOptions(array $options): void + { + $this->options = array_merge($this->defaultOptions, $options); + $this->options['tableClass'] = $this->convertToArrayIfNeeded($this->options['tableClass']); + $this->options['bodyClass'] = $this->convertToArrayIfNeeded($this->options['bodyClass']); + $this->checkOptionValidity(); + } + + public function table(): string + { + return $this->genTable(); + } + + private function genTable(): string + { + $html = $this->nodeOpen('table', [ + 'class' => [ + 'table', + "table-{$this->options['variant']}", + $this->options['striped'] ? 'table-striped' : '', + $this->options['bordered'] ? 'table-bordered' : '', + $this->options['borderless'] ? 'table-borderless' : '', + $this->options['hover'] ? 'table-hover' : '', + $this->options['small'] ? 'table-sm' : '', + implode(' ', $this->options['tableClass']), + !empty($this->options['variant']) ? "table-{$this->options['variant']}" : '', + ], + 'id' => $this->options['id'] ?? '' + ]); + + $html .= $this->genCaption(); + $html .= $this->genBody(); + + $html .= $this->nodeClose('table'); + return $html; + } + + private function genBody(): string + { + $body = $this->nodeOpen('tbody', [ + 'class' => $this->options['bodyClass'], + ]); + foreach ($this->fields as $i => $field) { + $body .= $this->genRow($field); + } + $body .= $this->nodeClose('tbody'); + return $body; + } + + private function genRow(array $field): string + { + $rowValue = $this->genCell($field); + $rowKey = $this->node('th', [ + 'class' => [ + 'col-4 col-sm-2' + ], + 'scope' => 'row' + ], $field['keyHtml'] ?? h($field['key'])); + $row = $this->node('tr', [ + 'class' => [ + 'd-flex', + !empty($field['rowVariant']) ? "table-{$field['rowVariant']}" : '' + ] + ], [$rowKey, $rowValue]); + return $row; + } + + private function genCell(array $field = []): string + { + if (isset($field['raw'])) { + $cellContent = !empty($field['rawNoEscaping']) ? $field['raw'] : h($field['raw']); + } else if (isset($field['formatter'])) { + $cellContent = $field['formatter']($this->getValueFromObject($field), $this->item); + } else if (isset($field['type'])) { + $cellContent = $this->btHelper->getView()->element($this->getElementPath($field['type']), [ + 'data' => $this->item, + 'field' => $field + ]); + } else { + $cellContent = h($this->getValueFromObject($field)); + } + foreach (BootstrapGeneric::$variants as $variant) { + if (!empty($field["notice_$variant"])) { + $cellContent .= sprintf(' %s', $variant, $field["notice_$variant"]); + } + } + return $this->node('td', [ + 'class' => [ + 'col-8 col-sm-10', + !empty($field['cellVariant']) ? "bg-{$field['cellVariant']}" : '' + ] + ], $cellContent); + } + + private function getValueFromObject(array $field): string + { + $key = is_array($field) ? $field['path'] : $field; + $cellValue = Hash::get($this->item, $key); + return !is_null($cellValue) ? $cellValue : ''; + } + + private function getElementPath($type): string + { + return sprintf( + '%s%sField', + $this->options['elementsRootPath'] ?? '', + $type + ); + } + + private function genCaption(): string + { + return !empty($this->caption) ? $this->node('caption', [], h($this->caption)) : ''; + } +} \ No newline at end of file diff --git a/src/View/Helper/BootstrapElements/BootstrapModal.php b/src/View/Helper/BootstrapElements/BootstrapModal.php new file mode 100644 index 000000000..ba7d2809d --- /dev/null +++ b/src/View/Helper/BootstrapElements/BootstrapModal.php @@ -0,0 +1,349 @@ +Bootstrap->modal([ + * 'title' => 'Modal title', + * 'size' => 'lg', + * 'type' => 'ok-only', + * 'body' => 'Body content', + * 'header-variant' => 'dark', + * 'body-variant' => 'light', + * 'footer-variant' => 'warning', + * 'show' => true, + * ]); + + * ## Modal with custom onclick handler + * $this->Bootstrap->modal([ + * 'type' => 'confirm', + * 'bodyHtml' => 'Body content', + * 'confirmButton' => [ + * 'text' => 'Show modal', + * 'icon' => 'eye', + * 'onclick' => 'UI.toast({"title": "confirmed!"})', + * ], + * 'cancelOnclick' => 'UI.toast({"title": "cancelled"})', + * 'show' => true, + * ]); + * + * ## Modal with a onclick handler with prepared arguments bound to the confirm button + * $this->Bootstrap->modal([ + * 'type' => 'confirm', + * 'confirmButton' => [ + * 'text' => 'Confirm', + * 'icon' => 'check', + * ], + * 'confirmFunction' => 'myConfirmFunction', // myConfirmFunction is called with the $modalObject and $tmpApi intialized + * 'show' => true, + * ]); + * + * /* + * Example of confirm function + * - case 1: If void is returned the modal close automatically regardless of the result + * - case 2: If a promise is returned, the modal close automatically if the promise is a success + * A success is defined as follow: + * - No exceptions + * - No data returned + * - Object returned with key `success` evaluting to true + * - case 3: The modal can be closed manually with: `modalObject.hide()` + * + * function myConfirmFunction(modalObject, tmpApi) { + * const $form = modalObject.$modal.find('form') + * const postPromise = $form.length == 1 ? + * tmpApi.postForm($form[0]) : + * tmpApi.fetchJSON('/users/view/', false, true) + * .then((result) => { + * console.log(result) + * constToReturn = { + * success: true, // will close the modal automatically + * } + * return constToReturn + * }) + * .catch((errors) => { + * console.log(errors) + * }) + * + * return postPromise + * } + + * ## Modal with custom footer made of buttons + * $this->Bootstrap->modal([ + * 'type' => 'custom', + * 'footerButtons' => [ + * [ + * 'text' => 'Confirm', + * 'icon' => 'check', + * 'variant' => 'danger', + * 'clickFunction' => 'testapi', + * ], + * [ + * 'text' => 'Cancel', + * 'onclick' => 'UI.toast({"title": "confirmed!"})', + * ], + * ], + * 'show' => true, + * ]); + */ +class BootstrapModal extends BootstrapGeneric +{ + private $defaultOptions = [ + 'size' => '', + 'centered' => true, + 'scrollable' => true, + 'backdropStatic' => false, + 'show' => false, + 'header-variant' => '', + 'body-variant' => '', + 'footer-variant' => '', + 'title' => '', + 'titleHtml' => null, + 'body' => '', + 'bodyHtml' => null, + 'footerHtml' => null, + 'dialogScrollable' => true, + 'modalClass' => [''], + 'headerClass' => [''], + 'bodyClass' => [''], + 'footerClass' => [''], + 'confirmButton' => [ + 'text' => 'Confirm', + ], + 'cancelButton' => [ + 'text' => 'Cancel', + ], + 'type' => 'ok-only', + 'footerButtons' => [], + 'confirmFunction' => '', // Will be called with the following arguments confirmFunction(modalObject, tmpApi) + 'cancelOnclick' => '' + ]; + + function __construct(array $options) + { + $this->allowedOptionValues = [ + 'size' => ['sm', 'lg', 'xl', ''], + 'type' => ['ok-only', 'confirm', 'custom'], + 'header-variant' => array_merge(BootstrapGeneric::$variants, ['']), + 'body-variant' => array_merge(BootstrapGeneric::$variants, ['']), + 'footer-variant' => array_merge(BootstrapGeneric::$variants, ['']), + ]; + $this->processOptions($options); + } + + private function processOptions(array $options): void + { + $this->options = array_merge($this->defaultOptions, $options); + $this->checkOptionValidity(); + $this->options['modalClass'] = $this->convertToArrayIfNeeded($this->options['modalClass']); + $this->options['headerClass'] = $this->convertToArrayIfNeeded($this->options['headerClass']); + $this->options['bodyClass'] = $this->convertToArrayIfNeeded($this->options['bodyClass']); + $this->options['footerClass'] = $this->convertToArrayIfNeeded($this->options['footerClass']); + + if (!empty($this->options['dialogScrollable'])) { + $this->options['modalClass'][] = 'modal-dialog-scrollable'; + } + + $possiblVariants = ['header-variant', 'body-variant', 'footer-variant']; + foreach ($possiblVariants as $possiblVariant) { + if (!empty($this->options[$possiblVariant])) { + $this->options[sprintf('%sClass', substr($possiblVariant, 0, -8))][] = self::getBGAndTextClassForVariant($this->options[$possiblVariant]); + } + } + + if (!empty($options['confirmFunction']) && !empty($options['confirmButton']['onclick'])) { + throw new \InvalidArgumentException(__('Option `{0}` can not be used in conjuction with `{1}` for the confirm button', 'confirmFunction', 'onclick')); + } + } + + public function modal(): string + { + $modal = $this->genModal(); + if ($this->options['show']) { + return $this->encapsulateWithUIHelper($modal); + } + return $modal; + } + + private function encapsulateWithUIHelper(string $modal): string + { + return $this->node('script', [], sprintf( + "$(document).ready(function() { + setTimeout(() => { + UI.modal({ + rawHtml: \"%s\" + }) + }, 1); + })", + str_replace('"', '\"', $modal) + )); + } + + private function genModal(): string + { + $dialog = $this->nodeOpen('div', [ + 'class' => array_merge( + ['modal-dialog', (!empty($this->options['size'])) ? "modal-{$this->options['size']}" : ''], + $this->options['modalClass'] + ), + ]); + $content = $this->nodeOpen('div', [ + 'class' => ['modal-content'], + ]); + $header = $this->genHeader(); + $body = $this->genBody(); + $footer = $this->genFooter(); + $closedDiv = $this->nodeClose('div'); + + $html = "{$dialog}{$content}{$header}{$body}{$footer}{$closedDiv}{$closedDiv}"; + return $html; + } + + private function genHeader(): string + { + $header = $this->nodeOpen('div', ['class' => array_merge(['modal-header'], $this->options['headerClass'])]); + $header .= $this->options['titleHtml'] ?? $this->node('h5', ['class' => ['modal-title']], h($this->options['title'])); + if (empty($this->options['backdropStatic'])) { + $header .= $this->genericCloseButton('modal'); + } + $header .= $this->nodeClose('div'); + return $header; + } + + private function genBody(): string + { + $body = $this->nodeOpen('div', ['class' => array_merge(['modal-body'], $this->options['bodyClass'])]); + $body .= $this->options['bodyHtml'] ?? h($this->options['body']); + $body .= $this->nodeClose('div'); + return $body; + } + + private function genFooter(): string + { + $footer = $this->nodeOpen('div', [ + 'class' => array_merge(['modal-footer'], $this->options['footerClass']), + 'data-custom-footer' => $this->options['type'] == 'custom' + ]); + $footer .= $this->options['footerHtml'] ?? $this->getFooterBasedOnType(); + $footer .= $this->nodeClose('div'); + return $footer; + } + + private function getFooterBasedOnType(): string + { + if ($this->options['type'] == 'ok-only') { + return $this->getFooterOkOnly(); + } else if (str_contains($this->options['type'], 'confirm')) { + return $this->getFooterConfirm(); + } else if ($this->options['type'] == 'custom') { + return $this->getFooterCustom(); + } else { + return $this->getFooterOkOnly(); + } + } + + private function getFooterOkOnly(): string + { + return (new BootstrapButton([ + 'variant' => 'primary', + 'text' => __('Ok'), + 'onclick' => $this->options['confirmOnclick'], + 'attrs' => [ + 'data-bs-dismiss' => $this->options['confirmOnclick'] ?? 'modal', + ], + ]))->button(); + } + + private function getFooterConfirm(): string + { + $buttonCancelConfig = array_merge( + [ + 'variant' => 'secondary', + 'attrs' => [ + 'data-bs-dismiss' => 'modal', + 'onclick' => $this->options['cancelOnclick'] + ] + ], + $this->options['cancelButton'], + ); + $buttonCancel = (new BootstrapButton($buttonCancelConfig))->button(); + + $defaultConfig = [ + 'variant' => 'primary', + 'class' => 'modal-confirm-button', + ]; + if (!empty($this->options['confirmOnclick'])) { + $defaultConfig['onclick'] = $this->options['confirmOnclick']; + } + if (!empty($this->options['confirmFunction'])) { + $defaultConfig['attrs']['data-confirmFunction'] = $this->options['confirmFunction']; + } + $buttonConfirmConfig = array_merge( + $defaultConfig, + $this->options['confirmButton'], + ); + $buttonConfirm = (new BootstrapButton($buttonConfirmConfig))->button(); + return $buttonCancel . $buttonConfirm; + } + + private function getFooterCustom(): string + { + $buttons = []; + foreach ($this->options['footerButtons'] as $buttonConfig) { + $defaultConfig = [ + 'variant' => 'primary', + 'class' => 'modal-confirm-button', + 'attrs' => [ + 'data-bs-dismiss' => !empty($buttonConfig['clickFunction']) ? '' : 'modal', + ] + ]; + if (!empty($buttonConfig['clickFunction'])) { + $defaultConfig['attrs']['data-clickFunction'] = $buttonConfig['clickFunction']; + } + $buttonConfig = array_merge( + $defaultConfig, + $buttonConfig, + ); + $buttons[] = (new BootstrapButton($buttonConfig))->button(); + } + return implode('', $buttons); + } +} diff --git a/src/View/Helper/BootstrapElements/BootstrapNotificationBubble.php b/src/View/Helper/BootstrapElements/BootstrapNotificationBubble.php new file mode 100644 index 000000000..e0788ab7c --- /dev/null +++ b/src/View/Helper/BootstrapElements/BootstrapNotificationBubble.php @@ -0,0 +1,106 @@ +Bootstrap->notificationBubble([ + * 'text' => '3', + * 'variant' => 'warning', + * 'title' => '3 unread messages', + * ]); + */ +class BootstrapNotificationBubble extends BootstrapGeneric +{ + private $defaultOptions = [ + 'text' => '', + 'variant' => 'warning', + 'borderVariant' => '', + 'title' => '', + 'class' => [], + 'attrs' => [], + ]; + + function __construct(array $options) + { + $this->allowedOptionValues = [ + 'variant' => BootstrapGeneric::$variants, + 'borderVariant' => array_merge(BootstrapGeneric::$variants, ['']), + ]; + $this->defaultOptions['title'] = __('New notifications'); + $this->processOptions($options); + } + + private function processOptions(array $options): void + { + $this->options = array_merge($this->defaultOptions, $options); + $this->checkOptionValidity(); + $this->options['class'] = $this->convertToArrayIfNeeded($this->options['class']); + if (!empty($this->options['borderVariant'])) { + if (!empty($this->options['attrs']['style'])) { + $this->options['attrs']['style'] .= 'box-shadow: 0 0.125rem 0.25rem #00000050;'; + } else { + $this->options['attrs']['style'] = 'box-shadow: 0 0.125rem 0.25rem #00000050;'; + } + } + } + + public function notificationBubble(): string + { + return $this->genNotificationBubble(); + } + + private function genNotificationBubble(): string + { + $tmpId = 'tmp-' . mt_rand(); + $defaultClasses = [ + 'position-absolute', + 'top-0', + 'start-100', + 'translate-middle', + 'p-1', + 'rounded-circle', + ]; + if (!empty($this->options['borderVariant'])) { + $defaultClasses[] = "border border-2 border-{$this->options['borderVariant']}"; + } + if (!empty($this->options['variant'])) { + $defaultClasses[] = "bg-{$this->options['variant']}"; + } + + if (!empty($this->options['text'])) { + $this->options['attrs']['style'] .= ' min-width: 0.7rem; line-height: 1; box-sizing: content-box;'; + $defaultClasses[] = 'text-center'; + $defaultClasses[] = 'fs-8'; + $defaultClasses[] = 'fw-bold'; + } + + $html = $this->node('span', + array_merge( + [ + 'id' => $tmpId, + 'class' => array_merge( + $defaultClasses, + $this->options['class'] + ), + 'title' => h($this->options['title']) + ], + $this->options['attrs'] + ), + !empty($this->options['text']) ? $this->node('span', [], h($this->options['text'])) : '' + ); + return $html; + } +} \ No newline at end of file diff --git a/src/View/Helper/BootstrapElements/BootstrapProgress.php b/src/View/Helper/BootstrapElements/BootstrapProgress.php new file mode 100644 index 000000000..27117b55c --- /dev/null +++ b/src/View/Helper/BootstrapElements/BootstrapProgress.php @@ -0,0 +1,88 @@ +Bootstrap->progress([ + * 'value' => 45, + * 'total' => 100, + * 'label' => true, + * ]); + * + */ +class BootstrapProgress extends BootstrapGeneric +{ + private $defaultOptions = [ + 'value' => 0, + 'total' => 100, + 'label' => true, + 'title' => '', + 'variant' => 'primary', + 'height' => '', + 'striped' => false, + 'animated' => false, + 'attrs' => [], + ]; + + function __construct($options) + { + $this->allowedOptionValues = [ + 'variant' => BootstrapGeneric::$variants, + ]; + $this->processOptions($options); + } + + private function processOptions($options): void + { + $this->options = array_merge($this->defaultOptions, $options); + $this->checkOptionValidity(); + } + + public function progress(): string + { + return $this->genProgress(); + } + + private function genProgress(): string + { + $percentage = round(100 * $this->options['value'] / $this->options['total']); + $heightStyle = !empty($this->options['height']) ? sprintf('height: %s;', h($this->options['height'])) : ''; + $widthStyle = sprintf('width: %s%%;', $percentage); + $label = !empty($this->options['label']) ? ($this->options['label'] === true ? "{$percentage}%" : h($this->options['label'])) : ''; + $pb = $this->node('div', array_merge([ + 'class' => [ + 'progress-bar', + "bg-{$this->options['variant']}", + $this->options['striped'] ? 'progress-bar-striped' : '', + $this->options['animated'] ? 'progress-bar-animated' : '', + ], + 'role' => "progressbar", + 'aria-valuemin' => "0", 'aria-valuemax' => "100", 'aria-valuenow' => $percentage, + 'style' => $widthStyle, + 'title' => h($this->options['title']), + ], $this->options['attrs']), $label); + $container = $this->node('div', [ + 'class' => [ + 'progress', + ], + 'style' => $heightStyle, + 'title' => h($this->options['title']), + ], $pb); + return $container; + } +} diff --git a/src/View/Helper/BootstrapElements/BootstrapProgressTimeline.php b/src/View/Helper/BootstrapElements/BootstrapProgressTimeline.php new file mode 100644 index 000000000..89c20d0e6 --- /dev/null +++ b/src/View/Helper/BootstrapElements/BootstrapProgressTimeline.php @@ -0,0 +1,153 @@ +Bootstrap->progressTimeline([ + * 'selected' => 1, + * 'steps' => [ + * [ + * 'text' => __('Step 1'), + * 'icon' => 'star', + * 'title' => __('Title'), + * ], + * [ + * 'text' => __('Step 3'), + * 'icon' => 'exchange-alt', + * ] + * ], + * ]); + */ +class BootstrapProgressTimeline extends BootstrapGeneric +{ + private $defaultOptions = [ + 'steps' => [], + 'selected' => 0, + 'variant' => 'primary', + 'variantInactive' => 'secondary', + ]; + + function __construct($options, $btHelper) + { + $this->allowedOptionValues = [ + 'variant' => BootstrapGeneric::$variants, + 'variantInactive' => BootstrapGeneric::$variants, + ]; + $this->processOptions($options); + $this->btHelper = $btHelper; + } + + private function processOptions($options): void + { + $this->options = array_merge($this->defaultOptions, $options); + $this->checkOptionValidity(); + } + + public function progressTimeline(): string + { + return $this->genProgressTimeline(); + } + + private function getStepIcon(array $step, int $i, bool $nodeActive, bool $lineActive): string + { + $icon = $this->node('b', [ + 'class' => [ + !empty($step['icon']) ? h($this->btHelper->FontAwesome->getClass($step['icon'])) : '', + $this->getTextClassForVariant($this->options['variant']) + ], + ], empty($step['icon']) ? h($i + 1) : ''); + + $containerDefaultClass = [ + 'd-flex', + 'align-items-center', + 'justify-content-center', + 'rounded-circle', + ]; + $containerDefaultClass[] = $nodeActive ? "bg-{$this->options['variant']}" : "bg-{$this->options['variantInactive']}"; + $iconContainer = $this->node('span', [ + 'class' => $containerDefaultClass, + 'style' => 'width:50px; height:50px' + ], $icon); + $li = $this->node('li', [ + 'class' => [ + 'd-flex', 'flex-column', + $nodeActive ? 'progress-active' : 'progress-inactive', + ], + ], $iconContainer); + $html = $li . $this->getHorizontalLine($i, $nodeActive, $lineActive); + return $html; + } + + private function getHorizontalLine(int $i, bool $nodeActive, bool $lineActive): string + { + $stepCount = count($this->options['steps']); + if ($i == $stepCount - 1) { + return ''; + } + $progressBar = (new BootstrapProgress([ + 'label' => false, + 'value' => $nodeActive ? ($lineActive ? 100 : 50) : 0, + 'height' => '2px', + 'variant' => $this->options['variant'] + ]))->progress(); + $line = $this->node('span', [ + 'class' => [ + 'progress-line', + 'flex-grow-1', 'align-self-center', + $lineActive ? "bg-{$this->options['variant']}" : '' + ], + ], $progressBar); + return $line; + } + + private function getStepText(array $step, bool $isActive): string + { + return $this->node('li', [ + 'class' => [ + 'text-center', + 'fw-bold', + $isActive ? 'progress-active' : 'progress-inactive', + ], + ], h($step['text'] ?? '')); + } + + private function genProgressTimeline(): string + { + $iconLis = ''; + $textLis = ''; + foreach ($this->options['steps'] as $i => $step) { + $nodeActive = $i <= $this->options['selected']; + $lineActive = $i < $this->options['selected']; + $iconLis .= $this->getStepIcon($step, $i, $nodeActive, $lineActive); + $textLis .= $this->getStepText($step, $nodeActive); + } + $ulIcons = $this->node('ul', [ + 'class' => [ + 'd-flex', 'justify-content-around', + ], + ], $iconLis); + $ulText = $this->node('ul', [ + 'class' => [ + 'd-flex', 'justify-content-between', + ], + ], $textLis); + $html = $this->node('div', [ + 'class' => ['progress-timeline', 'mw-75', 'mx-auto'] + ], $ulIcons . $ulText); + return $html; + } +} \ No newline at end of file diff --git a/src/View/Helper/BootstrapElements/BootstrapSwitch.php b/src/View/Helper/BootstrapElements/BootstrapSwitch.php new file mode 100644 index 000000000..b70ab6f93 --- /dev/null +++ b/src/View/Helper/BootstrapElements/BootstrapSwitch.php @@ -0,0 +1,81 @@ +Bootstrap->switch([ + * 'label' => 'my label', + * 'checked' => true, + * ]); + */ +class BootstrapSwitch extends BootstrapGeneric +{ + private $defaultOptions = [ + 'label' => '', + 'variant' => 'primary', + 'disabled' => false, + 'checked' => false, + 'title' => '', + 'class' => [], + 'attrs' => [], + ]; + + public function __construct(array $options) + { + $this->allowedOptionValues = [ + 'variant' => BootstrapGeneric::$variants, + ]; + $this->processOptions($options); + } + + private function processOptions(array $options): void + { + $this->options = array_merge($this->defaultOptions, $options); + $this->checkOptionValidity(); + } + + public function switch(): string + { + return $this->genSwitch(); + } + + public function genSwitch(): string + { + $tmpId = 'tmp-' . mt_rand(); + $input = self::node('input', array_merge( + [ + 'type' => "checkbox", + 'class' => 'form-check-input', + 'id' => $tmpId, + 'disabled' => !empty($this->options['disabled']), + 'checked' => !empty($this->options['checked']), + ], + $this->options['attrs'] + )); + $label = self::node('label', [ + 'class' => 'form-check-label', + 'for' => $tmpId, + ], h($this->options['label'])); + $html = self::node('div', [ + 'class' => [ + 'form-check form-switch', + ], + 'title' => h($this->options['title']), + ], [$input, $label]); + return $html; + } +} \ No newline at end of file diff --git a/src/View/Helper/BootstrapElements/BootstrapTable.php b/src/View/Helper/BootstrapElements/BootstrapTable.php new file mode 100644 index 000000000..d34951d84 --- /dev/null +++ b/src/View/Helper/BootstrapElements/BootstrapTable.php @@ -0,0 +1,243 @@ +Bootstrap->table( + * [ + * 'hover' => false, + * 'striped' => false, + * ], + * [ + * 'items' => [ + * ['column 1' => 'col1', 'column 2' => 'col2', 'key1' => 'val1', 'key2' => true], + * ['column 1' => 'col1', 'column 2' => 'col2', 'key1' => 'val2', 'key2' => false,'_rowVariant' => 'success'], + * ['column 1' => 'col1', 'column 2' => 'col2', 'key1' => 'val3', 'key2' => true], + * ], + * 'fields' => [ + * 'column 1', + * [ + * 'path' => 'column 2', + * 'label' => 'COLUMN 2', + * 'columnVariant' => 'danger', + * ], + * [ + * 'labelHtml' => 'column 3', + * ], + * [ + * 'path' => 'key1', + * 'label' => __('Field'), + * 'formatter' => function ($field, $row) { + * return sprintf('%s', h($field)); + * } + * ], + * [ + * 'path' => 'key2', + * 'element' => 'boolean', + * ], + * ], + * 'caption' => 'This is a caption' + * ] + * ); + */ +class BootstrapTable extends BootstrapGeneric +{ + private $defaultOptions = [ + 'striped' => true, + 'bordered' => true, + 'borderless' => false, + 'hover' => true, + 'small' => false, + 'variant' => '', + 'tableClass' => [], + 'headerClass' => [], + 'bodyClass' => [], + 'id' => '', + 'caption' => '', + 'elementsRootPath' => '/genericElements/SingleViews/Fields/', + ]; + + function __construct(array $options, array $data, BootstrapHelper $btHelper) + { + $this->allowedOptionValues = [ + 'variant' => array_merge(BootstrapGeneric::$variants, ['']) + ]; + $this->processOptions($options); + $this->fields = $data['fields']; + $this->items = $data['items']; + $this->caption = !empty($data['caption']) ? $data['caption'] : ''; + $this->btHelper = $btHelper; + } + + private function processOptions(array $options): void + { + $this->options = array_merge($this->defaultOptions, $options); + $this->checkOptionValidity(); + $this->options['tableClass'] = $this->convertToArrayIfNeeded($this->options['tableClass']); + $this->options['bodyClass'] = $this->convertToArrayIfNeeded($this->options['bodyClass']); + $this->options['headerClass'] = $this->convertToArrayIfNeeded($this->options['headerClass']); + } + + public function table(): string + { + return $this->genTable(); + } + + private function genTable(): string + { + $html = $this->nodeOpen('table', [ + 'class' => [ + 'table', + "table-{$this->options['variant']}", + $this->options['striped'] ? 'table-striped' : '', + $this->options['bordered'] ? 'table-bordered' : '', + $this->options['borderless'] ? 'table-borderless' : '', + $this->options['hover'] ? 'table-hover' : '', + $this->options['small'] ? 'table-sm' : '', + implode(' ', $this->options['tableClass']), + !empty($this->options['variant']) ? "table-{$this->options['variant']}" : '', + ], + 'id' => $this->options['id'] ?? '' + ]); + + $html .= $this->genCaption(); + $html .= $this->genHeader(); + $html .= $this->genBody(); + + $html .= $this->nodeClose('table'); + return $html; + } + + private function genHeader(): string + { + $head = $this->nodeOpen('thead', [ + 'class' => $this->options['headerClass'], + ]); + $head .= $this->nodeOpen('tr'); + foreach ($this->fields as $i => $field) { + if (is_array($field)) { + if (!empty($field['labelHtml'])) { + $label = $field['labelHtml']; + } else { + $label = !empty($field['label']) ? $field['label'] : Inflector::humanize($field['path']); + $label = h($label); + } + } else { + $label = Inflector::humanize($field); + $label = h($label); + } + $head .= $this->node('th', [], $label); + } + $head .= $this->nodeClose('tr'); + $head .= $this->nodeClose('thead'); + return $head; + } + + private function genBody(): string + { + $body = $this->nodeOpen('tbody', [ + 'class' => $this->options['bodyClass'], + ]); + foreach ($this->items as $i => $row) { + $body .= $this->genRow($row, $i); + } + $body .= $this->nodeClose('tbody'); + return $body; + } + + private function genRow(array $row, int $rowIndex): string + { + $html = $this->nodeOpen('tr', [ + 'class' => [ + !empty($row['_rowVariant']) ? "table-{$row['_rowVariant']}" : '' + ] + ]); + if (array_keys($row) !== range(0, count($row) - 1)) { // associative array + foreach ($this->fields as $i => $field) { + $cellValue = $this->getValueFromObject($row, $field); + $html .= $this->genCell($cellValue, $field, $row, $rowIndex); + } + } else { // indexed array + foreach ($row as $i => $cellValue) { + $html .= $this->genCell($cellValue, $this->fields[$i], $row, $rowIndex); + } + } + $html .= $this->nodeClose('tr'); + return $html; + } + + private function genCell($value, array $field = [], array $row = [], int $rowIndex = 0): string + { + if (isset($field['formatter'])) { + $cellContent = $field['formatter']($value, $row, $rowIndex); + } else if (isset($field['element'])) { + $cellContent = $this->btHelper->getView()->element($this->getElementPath($field['element']), [ + 'data' => [$value], + 'field' => ['path' => '0'] + ]); + } else { + $cellContent = h($value); + } + return $this->node('td', [ + 'class' => array_merge( + [ + !empty($field['columnVariant']) ? "table-{$field['columnVariant']}" : '' + ], + $field['class'] ?? [] + ), + ], $cellContent); + } + + private function getValueFromObject(array $row, $field) + { + $path = is_array($field) ? $field['path'] : $field; + $cellValue = Hash::get($row, $path); + return !is_null($cellValue) ? $cellValue : ''; + } + + private function getElementPath(string $type): string + { + return sprintf( + '%s%sField', + $this->options['elementsRootPath'] ?? '', + $type + ); + } + + private function genCaption(): string + { + return !empty($this->caption) ? $this->node('caption', [], h($this->caption)) : ''; + } +} diff --git a/src/View/Helper/BootstrapElements/BootstrapTabs.php b/src/View/Helper/BootstrapElements/BootstrapTabs.php new file mode 100644 index 000000000..60beae59d --- /dev/null +++ b/src/View/Helper/BootstrapElements/BootstrapTabs.php @@ -0,0 +1,303 @@ +Bootstrap->tabs([ + * 'horizontal-position' => 'top', + * 'header-variant' => 'danger', + * 'card' => true, + * 'data' => [ + * 'navs' => [ + * ['text' => 'nav 1'], + * ['html' => 'nav 2', 'active' => true], + * ], + * 'content' => [ + * 'content 1', + * 'content 2', + * ] + * ] + * ]); + * + * ## Simple formatted tabs using the card option and vertical options + * echo $this->Bootstrap->tabs([ + * 'pills' => true, + * 'vertical' => true, + * 'vertical-position' => 'start', + * 'card' => true, + * 'data' => [ + * 'navs' => [ + * ['text' => 'nav 1'], + * ['html' => 'nav 2', 'disabled' => true], + * ], + * 'content' => [ + * 'content 1', + * 'content 2', + * ] + * ] + * ]); + */ +class BootstrapTabs extends BootstrapGeneric +{ + private $defaultOptions = [ + 'fill-header' => false, + 'justify-header' => false, + 'pills' => false, + 'vertical' => false, + 'vertical-size' => 3, + 'vertical-position' => 'start', + 'horizontal-position' => 'top', + 'card' => false, + 'header-variant' => '', + 'body-variant' => '', + 'body-class' => [], + 'nav-class' => [], + 'nav-item-class' => [], + 'content-class' => [], + 'data' => [ + 'navs' => [], + 'content' => [], + ], + ]; + private $bsClasses = null; + + function __construct(array $options) + { + $this->allowedOptionValues = [ + 'justify-header' => [false, 'center', 'end', 'start'], + 'vertical-position' => ['start', 'end'], + 'horizontal-position' => ['top', 'bottom'], + 'body-variant' => array_merge(BootstrapGeneric::$variants, ['']), + 'header-variant' => array_merge(BootstrapGeneric::$variants, ['']), + ]; + $this->processOptions($options); + } + + public function tabs(): string + { + return $this->genTabs(); + } + + private function processOptions(array $options): void + { + $this->options = array_merge($this->defaultOptions, $options); + $this->data = $this->options['data']; + $this->checkOptionValidity(); + if (empty($this->data['navs'])) { + throw new InvalidArgumentException(__('No navigation data provided')); + } + $this->bsClasses = [ + 'nav' => [], + 'nav-item' => $this->options['nav-item-class'], + ]; + + if (!empty($this->options['justify-header'])) { + $this->bsClasses['nav'][] = 'justify-content-' . $this->options['justify-header']; + } + + if ($this->options['vertical'] && !isset($options['pills']) && !isset($options['card'])) { + $this->options['pills'] = true; + $this->options['card'] = true; + } + + if ($this->options['pills']) { + $this->bsClasses['nav'][] = 'nav-pills'; + if ($this->options['vertical']) { + $this->bsClasses['nav'][] = 'flex-column'; + } + if ($this->options['card']) { + $this->bsClasses['nav'][] = 'card-header-pills'; + } + } else { + $this->bsClasses['nav'][] = 'nav-tabs'; + if ($this->options['card']) { + $this->bsClasses['nav'][] = 'card-header-tabs'; + } + } + + if ($this->options['fill-header']) { + $this->bsClasses['nav'][] = 'nav-fill'; + } + if ($this->options['justify-header']) { + $this->bsClasses['nav'][] = 'nav-justify'; + } + + $activeTab = array_key_first($this->data['navs']); + foreach ($this->data['navs'] as $i => $nav) { + if (!is_array($nav)) { + $this->data['navs'][$i] = ['text' => $nav]; + } + if (!isset($this->data['navs'][$i]['id'])) { + $this->data['navs'][$i]['id'] = 't-' . Security::randomString(8); + } + if (!empty($nav['active'])) { + $activeTab = $i; + } + } + $this->data['navs'][$activeTab]['active'] = true; + + 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']; + } + + if (!is_array($this->options['nav-class'])) { + $this->options['nav-class'] = [$this->options['nav-class']]; + } + if (!is_array($this->options['content-class'])) { + $this->options['content-class'] = [$this->options['content-class']]; + } + } + + private function genTabs(): string + { + return $this->options['vertical'] ? $this->genVerticalTabs() : $this->genHorizontalTabs(); + } + + private function genHorizontalTabs(): string + { + if ($this->options['card']) { + $cardOptions = [ + 'bodyHTML' => $this->genContent(), + 'bodyVariant' => $this->options['body-variant'], + ]; + if ($this->options['horizontal-position'] === 'bottom') { + $cardOptions['footerHTML'] = $this->genNav(); + $cardOptions['footerVariant'] = $this->options['header-variant']; + $cardOptions['headerVariant'] = $this->options['header-variant']; + } else { + $cardOptions['headerHTML'] = $this->genNav(); + $cardOptions['headerVariant'] = $this->options['header-variant']; + } + $bsCard = new BootstrapCard($cardOptions); + return $bsCard->card(); + } else { + return $this->genNav() . $this->genContent(); + } + } + + private function genVerticalTabs(): string + { + $header = $this->node('div', ['class' => array_merge( + [ + ($this->options['vertical-size'] != 'auto' ? 'col-' . $this->options['vertical-size'] : ''), + ($this->options['card'] ? 'card-header border-end' : '') + ], + [ + "bg-{$this->options['header-variant']}", + ] + )], $this->genNav()); + $content = $this->node('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']}", + ] + )], $this->genContent()); + + $containerContent = $this->options['vertical-position'] === 'start' ? [$header, $content] : [$content, $header]; + $container = $this->node('div', ['class' => array_merge( + [ + 'row', + ($this->options['card'] ? 'card flex-row' : ''), + ($this->options['vertical-size'] == 'auto' ? 'flex-nowrap' : '') + ], + [ + ] + )], $containerContent); + return $container; + } + + private function genNav(): string + { + $html = $this->nodeOpen('ul', [ + 'class' => array_merge(['nav'], $this->bsClasses['nav'], $this->options['nav-class']), + 'role' => 'tablist', + ]); + foreach ($this->data['navs'] as $navItem) { + $html .= $this->genNavItem($navItem); + } + $html .= $this->nodeClose('ul'); + return $html; + } + + private function genNavItem(array $navItem): string + { + $html = $this->nodeOpen('li', [ + 'class' => array_merge(['nav-item'], $this->bsClasses['nav-item'], $this->options['nav-item-class']), + 'role' => 'presentation', + ]); + $html .= $this->nodeOpen('a', [ + 'class' => array_merge( + ['nav-link'], + [!empty($navItem['active']) ? 'active' : ''], + [!empty($navItem['disabled']) ? 'disabled' : ''] + ), + 'data-bs-toggle' => $this->options['pills'] ? 'pill' : 'tab', + 'id' => $navItem['id'] . '-tab', + 'href' => '#' . $navItem['id'], + 'aria-controls' => $navItem['id'], + 'aria-selected' => !empty($navItem['active']), + 'role' => 'tab', + ]); + $html .= $navItem['html'] ?? h($navItem['text']); + $html .= $this->nodeClose('a'); + $html .= $this->nodeClose('li'); + return $html; + } + + private function genContent(): string + { + $html = $this->nodeOpen('div', [ + 'class' => array_merge(['tab-content'], $this->options['content-class']), + ]); + foreach ($this->data['content'] as $i => $content) { + $navItem = $this->data['navs'][$i]; + $html .= $this->genContentItem($navItem, $content); + } + $html .= $this->nodeClose('div'); + return $html; + } + + private function genContentItem(array $navItem, string $content): string + { + return $this->node('div', [ + 'class' => array_merge(['tab-pane', 'fade'], [!empty($navItem['active']) ? 'show active' : '']), + 'role' => 'tabpanel', + 'id' => $navItem['id'], + 'aria-labelledby' => $navItem['id'] . '-tab' + ], $content); + } +} diff --git a/src/View/Helper/BootstrapElements/BootstrapToast.php b/src/View/Helper/BootstrapElements/BootstrapToast.php new file mode 100644 index 000000000..9df3b8d06 --- /dev/null +++ b/src/View/Helper/BootstrapElements/BootstrapToast.php @@ -0,0 +1,74 @@ +Bootstrap->toast([ + * 'title' => 'Title', + * 'bodyHtml' => 'Body', + * 'muted' => 'Muted text', + * 'variant' => 'warning', + * 'closeButton' => true, + * ]); + */ +class BootstrapToast extends BootstrapGeneric +{ + private $defaultOptions = [ + 'id' => false, + 'title' => false, + 'muted' => false, + 'body' => false, + 'variant' => 'default', + 'autohide' => true, + 'delay' => 'auto', + 'titleHtml' => false, + 'mutedHtml' => false, + 'bodyHtml' => false, + 'closeButton' => true, + ]; + + function __construct(array $options) + { + $this->allowedOptionValues = [ + 'variant' => array_merge(BootstrapGeneric::$variants, ['default']), + ]; + $this->processOptions($options); + } + + private function processOptions(array $options): void + { + $validOptions = array_filter($options, function($optionName) { + return isset($this->defaultOptions[$optionName]); + }, ARRAY_FILTER_USE_KEY); + $this->options = array_merge($this->defaultOptions, $validOptions); + $this->checkOptionValidity(); + } + + public function toast(): string + { + return $this->genToast(); + } + + private function genToast(): string + { + return $this->node('script', [], sprintf( + "$(document).ready(function() { + UI.toast(%s); + })", + json_encode($this->options, JSON_FORCE_OBJECT) + )); + } +} diff --git a/src/View/Helper/BootstrapHelper.php b/src/View/Helper/BootstrapHelper.php index f8d5024b8..2e8d47fbd 100644 --- a/src/View/Helper/BootstrapHelper.php +++ b/src/View/Helper/BootstrapHelper.php @@ -1,165 +1,307 @@ Bootstrap->Tabs([ - * 'pills' => true, - * 'card' => true, - * 'data' => [ - * 'navs' => [ - * 'tab1', - * ['text' => 'tab2', 'active' => true], - * ['html' => 'tab3', 'disabled' => true], - * ], - * 'content' => [ - * 'body1', - * 'body2', - * '~body3~' - * ] - * ] - * ]); + * $this->Bootstrap->{$componentName}($options); */ namespace App\View\Helper; use Cake\View\Helper; -use Cake\Utility\Hash; -use Cake\Utility\Inflector; -use Cake\Utility\Security; +use Cake\Utility\Text; use InvalidArgumentException; +use \App\View\Helper\BootstrapElements\BootstrapSwitch; +use \App\View\Helper\BootstrapElements\BootstrapTabs; +use \App\View\Helper\BootstrapElements\BootstrapAlert; +use \App\View\Helper\BootstrapElements\BootstrapAccordion; +use \App\View\Helper\BootstrapElements\BootstrapBadge; +use \App\View\Helper\BootstrapElements\BootstrapButton; +use \App\View\Helper\BootstrapElements\BootstrapCard; +use \App\View\Helper\BootstrapElements\BootstrapCollapse; +use \App\View\Helper\BootstrapElements\BootstrapDropdownMenu; +use \App\View\Helper\BootstrapElements\BootstrapIcon; +use \App\View\Helper\BootstrapElements\BootstrapListGroup; +use \App\View\Helper\BootstrapElements\BootstrapListTable; +use \App\View\Helper\BootstrapElements\BootstrapModal; +use \App\View\Helper\BootstrapElements\BootstrapNotificationBubble; +use \App\View\Helper\BootstrapElements\BootstrapProgress; +use \App\View\Helper\BootstrapElements\BootstrapProgressTimeline; +use \App\View\Helper\BootstrapElements\BootstrapTable; +use \App\View\Helper\BootstrapElements\BootstrapToast; + + +const COMPACT_ATTRIBUTES = [ + 'checked' => true, + 'default' => true, + 'disabled' => true, + 'enabled' => true, + 'hidden' => true, + 'multiple' => true, + 'novalidate' => true, + 'readonly' => true, + 'required' => true, + 'selected' => true, + 'visible' => true, +]; + class BootstrapHelper extends Helper { public $helpers = ['FontAwesome']; - public function tabs($options) + /** + * Creates a Bootstrap tabs from the given options + * + * @param array $options See BootstrapElements\BootstrapTabs + * @return string + */ + public function tabs(array $options): string { $bsTabs = new BootstrapTabs($options); return $bsTabs->tabs(); } - public function alert($options) + /** + * Creates a Bootstrap tabs from the given options + * + * @param array $options See BootstrapElements\BootstrapTabs + * @return string + */ + public function alert(array $options): string { - $bsAlert = new BoostrapAlert($options); + $bsAlert = new BootstrapAlert($options); return $bsAlert->alert(); } - public function table($options, $data) + /** + * Creates a Bootstrap tabs from the given options + * + * @param array $options See BootstrapElements\BootstrapTabs + * @param array $data See BootstrapElements\BootstrapTabs + * @return string + */ + public function table(array $options, array $data = []): string { - $bsTable = new BoostrapTable($options, $data, $this); + $bsTable = new BootstrapTable($options, $data, $this); return $bsTable->table(); } - public function listTable($options, $data) + /** + * Creates a Bootstrap tabs from the given options + * + * @param array $options See BootstrapElements\BootstrapTabs + * @param array $data See BootstrapElements\BootstrapTabs + * @return string + */ + public function listTable(array $options, array $data = []): string { - $bsListTable = new BoostrapListTable($options, $data, $this); + $bsListTable = new BootstrapListTable($options, $data, $this); return $bsListTable->table(); } - public function button($options) + /** + * Creates a Bootstrap tabs from the given options + * + * @param array $options See BootstrapElements\BootstrapTabs + * @return string + */ + public function button(array $options): string { - $bsButton = new BoostrapButton($options); + $bsButton = new BootstrapButton($options); return $bsButton->button(); } - public function icon($icon, $options = []) + /** + * Creates a Bootstrap tabs from the given options + * + * @param string $icon See BootstrapElements\BootstrapTabs + * @param array $options See BootstrapElements\BootstrapTabs + * @return string + */ + public function icon(string $icon, array $options = []): string { - $bsIcon = new BoostrapIcon($icon, $options); + $bsIcon = new BootstrapIcon($icon, $options); return $bsIcon->icon(); } - public function badge($options) + /** + * Creates a Bootstrap tabs from the given options + * + * @param array $options See BootstrapElements\BootstrapTabs + * @return string + */ + public function badge(array $options): string { - $bsBadge = new BoostrapBadge($options); + $bsBadge = new BootstrapBadge($options); return $bsBadge->badge(); } - public function modal($options) + /** + * Creates a Bootstrap tabs from the given options + * + * @param array $options See BootstrapElements\BootstrapTabs + * @return string + */ + public function modal(array $options): string { - $bsModal = new BoostrapModal($options); + $bsModal = new BootstrapModal($options); return $bsModal->modal(); } - public function card($options) + /** + * Creates a Bootstrap tabs from the given options + * + * @param array $options See BootstrapElements\BootstrapTabs + * @return string + */ + public function card(array $options): string { - $bsCard = new BoostrapCard($options); + $bsCard = new BootstrapCard($options); return $bsCard->card(); } - public function progress($options) + /** + * Creates a Bootstrap tabs from the given options + * + * @param array $options See BootstrapElements\BootstrapTabs + * @return string + */ + public function progress(array $options): string { - $bsProgress = new BoostrapProgress($options); + $bsProgress = new BootstrapProgress($options); return $bsProgress->progress(); } - public function collapse($options, $content) + /** + * Creates a Bootstrap tabs from the given options + * + * @param array $options See BootstrapElements\BootstrapTabs + * @param string $content See BootstrapElements\BootstrapTabs + * @return string + */ + public function collapse(array $options, string $content): string { - $bsCollapse = new BoostrapCollapse($options, $content, $this); + $bsCollapse = new BootstrapCollapse($options, $content, $this); return $bsCollapse->collapse(); } - public function accordion($options, $content) + /** + * Creates a Bootstrap tabs from the given options + * + * @param array $options See BootstrapElements\BootstrapTabs + * @param array $content See BootstrapElements\BootstrapTabs + * @return string + */ + public function accordion(array $options, array $content): string { - $bsAccordion = new BoostrapAccordion($options, $content, $this); + $bsAccordion = new BootstrapAccordion($options, $content, $this); return $bsAccordion->accordion(); } - public function progressTimeline($options) + /** + * Creates a Bootstrap tabs from the given options + * + * @param array $options See BootstrapElements\BootstrapTabs + * @return string + */ + public function progressTimeline(array $options): string { - $bsProgressTimeline = new BoostrapProgressTimeline($options, $this); + $bsProgressTimeline = new BootstrapProgressTimeline($options, $this); return $bsProgressTimeline->progressTimeline(); } - public function listGroup($options, $data) + /** + * Creates a Bootstrap tabs from the given options + * + * @param array $data See BootstrapElements\BootstrapTabs + * @param array $options See BootstrapElements\BootstrapTabs + * @return string + */ + public function listGroup(array $data, array $options = []): string { - $bsListGroup = new BootstrapListGroup($options, $data, $this); + $bsListGroup = new BootstrapListGroup($data, $options, $this); return $bsListGroup->listGroup(); } - public function genNode($node, $params = [], $content = '') + /** + * Creates a Bootstrap tabs from the given options + * + * @param array $options See BootstrapElements\BootstrapTabs + * @return string + */ + public function switch(array $options): string { - return BootstrapGeneric::genNode($node, $params, $content); - } - - public function switch($options) - { - $bsSwitch = new BoostrapSwitch($options, $this); + $bsSwitch = new BootstrapSwitch($options, $this); return $bsSwitch->switch(); } - public function notificationBubble($options) + /** + * Creates a Bootstrap tabs from the given options + * + * @param array $options See BootstrapElements\BootstrapTabs + * @return string + */ + public function notificationBubble(array $options): string { - $bsNotificationBubble = new BoostrapNotificationBuble($options, $this); + $bsNotificationBubble = new BootstrapNotificationBubble($options, $this); return $bsNotificationBubble->notificationBubble(); } - public function dropdownMenu($options) + /** + * Creates a Bootstrap tabs from the given options + * + * @param array $options See BootstrapElements\BootstrapTabs + * @return string + */ + public function dropdownMenu(array $options): string { - $bsDropdownMenu = new BoostrapDropdownMenu($options, $this); + $bsDropdownMenu = new BootstrapDropdownMenu($options, $this); return $bsDropdownMenu->dropdownMenu(); } + + /** + * Creates a Bootstrap toast from the given options + * + * @param array $options + * @return string + */ + public function toast(array $options): string + { + $bsToast = new BootstrapToast($options, $this); + return $bsToast->toast(); + } + + /** + * Creates a HTML node + * + * @param string $tag The tag of the node. Example: `div`, `span`, ... + * @param array $attrs Optional HTML attributes to be added on the node + * @param string $content Optional innerHTML of the node + * @param array $options Optional options to build the node. See BootstrapGeneric\node + * @return string + */ + public function node(string $tag, array $attrs = [], string $content = '', array $options = []): string + { + return BootstrapGeneric::node($tag, $attrs, $content, $options); + } + + /** + * Render the provided template with the given data + * + * @param string $template The template to render. See BootstrapGeneric\render + * @param array $data The data to be used during the template building + * @param array $options Optional options to build the template + * @return string + */ + public function render(string $template, array $data = [], array $options = []): string + { + return BootstrapGeneric::render($template, $data, $options); + } } + class BootstrapGeneric { public static $variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark', 'white', 'transparent']; @@ -190,19 +332,128 @@ class BootstrapGeneric } } - public static function genNode($node, $params = [], $content = "") + /** + * Replaces {{placeholders}} inside a $template with the given $data + * + * Example: + * ``` + * render('{{name}} is {{age}} years old.', ['name' => 'Bob', 'age' => '65']); + * ``` + * Returns: Bob is 65 years old. + * + * @param string $template The template containing the placeholders + * @param array $data A K-V array where keys are placeholder name to be replaced by their value + * @param array $options Array of options passed to the Text::insert function + * @return string + */ + public static function render(string $template, array $data, array $options = []): string { - return sprintf('<%s %s>%s', $node, BootstrapGeneric::genHTMLParams($params), $content, $node); + $defaults = [ + 'before' => '{{', 'after' => '}}', 'escape' => '\\', 'format' => null, 'clean' => false, + ]; + $options += $defaults; + return Text::insert( + $template, + $data, + $options + ); } - protected static function openNode($node, $params = []) + /** + * Creates an HTML node + * + * # Options + * + * - `escape` Set to false to disable escaping of attribute value. + * + * @param string $tag The tag of the node. Example: 'div', 'span' + * @param array $attrs Attributes to be added to the node + * @param string|array $content Optional content to be added as innerHTML. If an array is given, it gets converted into string + * @param array $options Array of options + * @return string + */ + public static function node(string $tag, array $attrs = [], $content = '', array $options = []): string { - return sprintf('<%s %s>', $node, BootstrapGeneric::genHTMLParams($params)); + return self::render( + '<{{tag}} {{attrs}}>{{content}}', + [ + 'tag' => $tag, + 'attrs' => self::buildAttrs($attrs, $options), + 'content' => is_array($content) ? implode('', $content) : $content, + ] + ); } - protected static function closeNode($node) + public static function nodeOpen(string $tag, array $attrs = [], array $options = []): string { - return sprintf('', $node); + return self::render( + '<{{tag}} {{attrs}}>', + [ + 'tag' => $tag, + 'attrs' => self::buildAttrs($attrs, $options), + ] + ); + } + + public static function nodeClose(string $tag): string + { + return self::render( + '', + [ + 'tag' => $tag, + ] + ); + } + + /** + * Build a space-delimited string with each HTML attribute generated. + * + * @param array $attrs + * @param array $options Array of options + * @return string + */ + public static function buildAttrs(array $attrs, array $options): string + { + $defaults = [ + 'escape' => true, + ]; + $options = $options + $defaults; + + $attributes = []; + foreach ($attrs as $key => $value) { + if (!empty($key) && $value !== null) { + $attributes[] = self::__formatAttribute((string) $key, $value, $options['escape']); + } + } + $html = trim(implode(' ', $attributes)); + return $html; + } + + /** + * Format an individual HTML attribute + * Support minimized attributes such as `selected` and `disabled` + * + * @param string $key The name of the attribute + * @param array|string $value The value of the attribute + * @param bool $escape Should the attribute value be escaped + * @return string + */ + public static function __formatAttribute(string $key, $value, bool $escape = true): string + { + $value = is_array($value) ? implode(' ', $value) : $value; + if (is_numeric($key)) { + return sprintf('%s="%s"', h($value), (!empty($escape) ? h($value) : $value)); + } + $isMinimized = isset(COMPACT_ATTRIBUTES[$key]); + if ($isMinimized) { + if (!empty($value)) { + return sprintf('%s="%s"', h($key), (!empty($escape) ? h($value) : $value)); + } + return ''; + } else if (!isset($value)) { + return ''; + } + return sprintf('%s="%s"', h($key), (!empty($escape) ? h($value) : $value)); } protected static function genHTMLParams($params) @@ -224,9 +475,14 @@ class BootstrapGeneric return sprintf('%s="%s"', $paramName, implode(' ', $values)); } + protected static function convertToArrayIfNeeded($data): array + { + return is_array($data) ? $data : [$data]; + } + protected static function genericCloseButton($dismissTarget) { - return BootstrapGeneric::genNode('button', [ + return self::node('button', [ 'type' => 'button', 'class' => 'btn-close', 'data-bs-dismiss' => $dismissTarget, @@ -234,1801 +490,13 @@ class BootstrapGeneric ]); } - protected static function getTextClassForVariant($variant) + protected static function getTextClassForVariant(string $variant): string { return !empty(self::$textClassByVariants[$variant]) ? self::$textClassByVariants[$variant] : 'text-black'; } -} -class BootstrapTabs extends BootstrapGeneric -{ - private $defaultOptions = [ - 'fill' => false, - 'justify' => false, - 'pills' => false, - 'vertical' => false, - 'vertical-size' => 3, - 'card' => false, - 'header-variant' => '', - 'body-variant' => '', - 'body-class' => [], - 'nav-class' => [], - 'nav-item-class' => [], - 'content-class' => [], - 'data' => [ - 'navs' => [], - 'content' => [], - ], - ]; - private $bsClasses = null; - - function __construct($options) + protected static function getBGAndTextClassForVariant(string $variant): string { - $this->allowedOptionValues = [ - 'justify' => [false, 'center', 'end'], - 'body-variant' => array_merge(BootstrapGeneric::$variants, ['']), - 'header-variant' => array_merge(BootstrapGeneric::$variants, ['']), - ]; - $this->processOptions($options); - } - - public function tabs() - { - return $this->genTabs(); - } - - private function processOptions($options) - { - $this->options = array_merge($this->defaultOptions, $options); - $this->data = $this->options['data']; - $this->checkOptionValidity(); - if (empty($this->data['navs'])) { - throw new InvalidArgumentException(__('No navigation data provided')); - } - $this->bsClasses = [ - 'nav' => [], - 'nav-item' => $this->options['nav-item-class'], - ]; - - if (!empty($this->options['justify'])) { - $this->bsClasses['nav'][] = 'justify-content-' . $this->options['justify']; - } - - if ($this->options['vertical'] && !isset($options['pills']) && !isset($options['card'])) { - $this->options['pills'] = true; - $this->options['card'] = true; - } - - if ($this->options['pills']) { - $this->bsClasses['nav'][] = 'nav-pills'; - if ($this->options['vertical']) { - $this->bsClasses['nav'][] = 'flex-column'; - } - if ($this->options['card']) { - $this->bsClasses['nav'][] = 'card-header-pills'; - } - } else { - $this->bsClasses['nav'][] = 'nav-tabs'; - if ($this->options['card']) { - $this->bsClasses['nav'][] = 'card-header-tabs'; - } - } - - if ($this->options['fill']) { - $this->bsClasses['nav'][] = 'nav-fill'; - } - if ($this->options['justify']) { - $this->bsClasses['nav'][] = 'nav-justify'; - } - - $activeTab = array_key_first($this->data['navs']); - foreach ($this->data['navs'] as $i => $nav) { - if (!is_array($nav)) { - $this->data['navs'][$i] = ['text' => $nav]; - } - if (!isset($this->data['navs'][$i]['id'])) { - $this->data['navs'][$i]['id'] = 't-' . Security::randomString(8); - } - if (!empty($nav['active'])) { - $activeTab = $i; - } - } - $this->data['navs'][$activeTab]['active'] = true; - - 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']; - $this->options['body-text-variant'] = $this->options['body-variant'] == '' ? 'body' : 'white'; - - if (!is_array($this->options['nav-class'])) { - $this->options['nav-class'] = [$this->options['nav-class']]; - } - if (!is_array($this->options['content-class'])) { - $this->options['content-class'] = [$this->options['content-class']]; - } - } - - private function genTabs() - { - $html = ''; - if ($this->options['vertical']) { - $html .= $this->genVerticalTabs(); - } else { - $html .= $this->genHorizontalTabs(); - } - return $html; - } - - private function genHorizontalTabs() - { - $html = ''; - if ($this->options['card']) { - $html .= $this->openNode('div', ['class' => array_merge(['card'], ["border-{$this->options['header-border-variant']}"])]); - $html .= $this->openNode('div', ['class' => array_merge(['card-header'], ["bg-{$this->options['header-variant']}", "text-{$this->options['header-text-variant']}"])]); - } - $html .= $this->genNav(); - if ($this->options['card']) { - $html .= $this->closeNode('div'); - $html .= $this->openNode('div', [ - 'class' => array_merge( - ['card-body'], - $this->options['body-class'] ?? [], - ["bg-{$this->options['body-variant']}", "text-{$this->options['body-text-variant']}"] - ) - ]); - } - $html .= $this->genContent(); - if ($this->options['card']) { - $html .= $this->closeNode('div'); - $html .= $this->closeNode('div'); - } - return $html; - } - - private function genVerticalTabs() - { - $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-end' : '') - ], - [ - "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( - [ - ($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'); - return $html; - } - - private function genNav() - { - $html = $this->openNode('ul', [ - 'class' => array_merge(['nav'], $this->bsClasses['nav'], $this->options['nav-class']), - 'role' => 'tablist', - ]); - foreach ($this->data['navs'] as $navItem) { - $html .= $this->genNavItem($navItem); - } - $html .= $this->closeNode('ul'); - return $html; - } - - private function genNavItem($navItem) - { - $html = $this->openNode('li', [ - 'class' => array_merge(['nav-item'], $this->bsClasses['nav-item'], $this->options['nav-item-class']), - 'role' => 'presentation', - ]); - $html .= $this->openNode('a', [ - 'class' => array_merge( - ['nav-link'], - [!empty($navItem['active']) ? 'active' : ''], - [!empty($navItem['disabled']) ? 'disabled' : ''] - ), - 'data-bs-toggle' => $this->options['pills'] ? 'pill' : 'tab', - 'id' => $navItem['id'] . '-tab', - 'href' => '#' . $navItem['id'], - 'aria-controls' => $navItem['id'], - 'aria-selected' => !empty($navItem['active']), - 'role' => 'tab', - ]); - if (!empty($navItem['html'])) { - $html .= $navItem['html']; - } else { - $html .= h($navItem['text']); - } - $html .= $this->closeNode('a'); - $html .= $this->closeNode('li'); - return $html; - } - - private function genContent() - { - $html = $this->openNode('div', [ - 'class' => array_merge(['tab-content'], $this->options['content-class']), - ]); - foreach ($this->data['content'] as $i => $content) { - $navItem = $this->data['navs'][$i]; - $html .= $this->genContentItem($navItem, $content); - } - $html .= $this->closeNode('div'); - return $html; - } - - private function genContentItem($navItem, $content) - { - return $this->genNode('div', [ - 'class' => array_merge(['tab-pane', 'fade'], [!empty($navItem['active']) ? 'show active' : '']), - 'role' => 'tabpanel', - 'id' => $navItem['id'], - 'aria-labelledby' => $navItem['id'] . '-tab' - ], $content); - } -} - -class BoostrapAlert extends BootstrapGeneric -{ - private $defaultOptions = [ - 'text' => '', - 'html' => null, - 'dismissible' => true, - 'variant' => 'primary', - 'fade' => true - ]; - - private $bsClasses = null; - - function __construct($options) - { - $this->allowedOptionValues = [ - 'variant' => BootstrapGeneric::$variants, - ]; - $this->processOptions($options); - } - - private function processOptions($options) - { - $this->options = array_merge($this->defaultOptions, $options); - $this->checkOptionValidity(); - } - - public function alert() - { - return $this->genAlert(); - } - - private function genAlert() - { - $html = $this->openNode('div', [ - 'class' => [ - 'alert', - "alert-{$this->options['variant']}", - $this->options['dismissible'] ? 'alert-dismissible' : '', - $this->options['fade'] ? 'fade show' : '', - ], - 'role' => "alert" - ]); - - $html .= $this->genContent(); - $html .= $this->genCloseButton(); - $html .= $this->closeNode('div'); - return $html; - } - - private function genCloseButton() - { - $html = ''; - if ($this->options['dismissible']) { - $html .= $this->genericCloseButton('alert'); - } - return $html; - } - - private function genContent() - { - return !is_null($this->options['html']) ? $this->options['html'] : h($this->options['text']); - } -} - -class BoostrapTable extends BootstrapGeneric -{ - private $defaultOptions = [ - 'striped' => true, - 'bordered' => true, - 'borderless' => false, - 'hover' => true, - 'small' => false, - 'variant' => '', - 'tableClass' => [], - 'headerClass' => [], - 'bodyClass' => [], - ]; - - private $bsClasses = null; - - function __construct($options, $data, $btHelper) - { - $this->allowedOptionValues = [ - 'variant' => array_merge(BootstrapGeneric::$variants, ['']) - ]; - $this->processOptions($options); - $this->fields = $data['fields']; - $this->items = $data['items']; - $this->caption = !empty($data['caption']) ? $data['caption'] : ''; - $this->btHelper = $btHelper; - } - - private function processOptions($options) - { - $this->options = array_merge($this->defaultOptions, $options); - $this->checkOptionValidity(); - } - - public function table() - { - return $this->genTable(); - } - - private function genTable() - { - $html = $this->openNode('table', [ - 'class' => [ - 'table', - "table-{$this->options['variant']}", - $this->options['striped'] ? 'table-striped' : '', - $this->options['bordered'] ? 'table-bordered' : '', - $this->options['borderless'] ? 'table-borderless' : '', - $this->options['hover'] ? 'table-hover' : '', - $this->options['small'] ? 'table-sm' : '', - !empty($this->options['variant']) ? "table-{$this->options['variant']}" : '', - !empty($this->options['tableClass']) ? (is_array($this->options['tableClass']) ? implode(' ', $this->options['tableClass']) : $this->options['tableClass']) : '' - ], - ]); - - $html .= $this->genCaption(); - $html .= $this->genHeader(); - $html .= $this->genBody(); - - $html .= $this->closeNode('table'); - return $html; - } - - private function genHeader() - { - $head = $this->openNode('thead', [ - 'class' => [ - !empty($this->options['headerClass']) ? $this->options['headerClass'] : '' - ], - ]); - $head .= $this->openNode('tr'); - foreach ($this->fields as $i => $field) { - if (is_array($field)) { - if (!empty($field['labelHtml'])) { - $label = $field['labelHtml']; - } else { - $label = !empty($field['label']) ? $field['label'] : Inflector::humanize($field['key']); - $label = h($label); - } - } else { - $label = Inflector::humanize($field); - $label = h($label); - } - $head .= $this->genNode('th', [], $label); - } - $head .= $this->closeNode('tr'); - $head .= $this->closeNode('thead'); - return $head; - } - - private function genBody() - { - $body = $this->openNode('tbody', [ - 'class' => [ - !empty($this->options['bodyClass']) ? (is_array($this->options['bodyClass']) ? implode(' ', $this->options['bodyClass']) : $this->options['bodyClass']) : '' - ], - ]); - foreach ($this->items as $i => $row) { - $body .= $this->genRow($row, $i); - } - $body .= $this->closeNode('tbody'); - return $body; - } - - private function genRow($row, $rowIndex) - { - $html = $this->openNode('tr', [ - 'class' => [ - !empty($row['_rowVariant']) ? "table-{$row['_rowVariant']}" : '' - ] - ]); - if (array_keys($row) !== range(0, count($row) - 1)) { // associative array - foreach ($this->fields as $i => $field) { - if (is_array($field)) { - $key = $field['key']; - } else { - $key = $field; - } - $cellValue = Hash::get($row, $key); - $html .= $this->genCell($cellValue, $field, $row, $rowIndex); - } - } else { // indexed array - foreach ($row as $i => $cellValue) { - $html .= $this->genCell($cellValue, 'index', $row, $rowIndex); - } - } - $html .= $this->closeNode('tr'); - return $html; - } - - private function genCell($value, $field=[], $row=[], $rowIndex=0) - { - if (isset($field['formatter'])) { - $cellContent = $field['formatter']($value, $row, $rowIndex); - } else if (isset($field['element'])) { - $cellContent = $this->btHelper->getView()->element($field['element'], [ - 'data' => [$value], - 'field' => ['path' => '0'] - ]); - } else { - $cellContent = h($value); - } - return $this->genNode('td', [ - 'class' => [ - !empty($row['_cellVariant']) ? "bg-{$row['_cellVariant']}" : '' - ] - ], $cellContent); - } - - private function genCaption() - { - return !empty($this->caption) ? $this->genNode('caption', [], h($this->caption)) : ''; - } -} - -class BoostrapListTable extends BootstrapGeneric -{ - private $defaultOptions = [ - 'striped' => true, - 'bordered' => false, - 'borderless' => false, - 'hover' => true, - 'small' => false, - 'variant' => '', - 'tableClass' => [], - 'bodyClass' => [], - ]; - - private $bsClasses = null; - - function __construct($options, $data, $btHelper) - { - $this->allowedOptionValues = [ - 'variant' => array_merge(BootstrapGeneric::$variants, ['']) - ]; - $this->processOptions($options); - $this->fields = $data['fields']; - $this->item = $data['item']; - $this->caption = !empty($data['caption']) ? $data['caption'] : ''; - $this->btHelper = $btHelper; - } - - private function processOptions($options) - { - $this->options = array_merge($this->defaultOptions, $options); - $this->checkOptionValidity(); - } - - public function table() - { - return $this->genTable(); - } - - private function genTable() - { - $html = $this->openNode('table', [ - 'class' => [ - 'table', - "table-{$this->options['variant']}", - $this->options['striped'] ? 'table-striped' : '', - $this->options['bordered'] ? 'table-bordered' : '', - $this->options['borderless'] ? 'table-borderless' : '', - $this->options['hover'] ? 'table-hover' : '', - $this->options['small'] ? 'table-sm' : '', - !empty($this->options['variant']) ? "table-{$this->options['variant']}" : '', - !empty($this->options['tableClass']) ? (is_array($this->options['tableClass']) ? implode(' ', $this->options['tableClass']) : $this->options['tableClass']) : '' - ], - 'id' => $this->options['id'] ?? '' - ]); - - $html .= $this->genCaption(); - $html .= $this->genBody(); - - $html .= $this->closeNode('table'); - return $html; - } - - private function genBody() - { - $body = $this->openNode('tbody', [ - 'class' => [ - !empty($this->options['bodyClass']) ? (is_array($this->options['bodyClass']) ? implode(' ', $this->options['bodyClass']) : $this->options['bodyClass']) : '' - ], - ]); - foreach ($this->fields as $i => $field) { - $body .= $this->genRow($field); - } - $body .= $this->closeNode('tbody'); - return $body; - } - - private function genRow($field) - { - $rowValue = $this->genCell($field); - $rowKey = $this->genNode('th', [ - 'class' => [ - 'col-4 col-sm-2' - ], - 'scope' => 'row' - ], h($field['key'])); - $row = $this->genNode('tr', [ - 'class' => [ - 'd-flex', - !empty($field['_rowVariant']) ? "table-{$field['_rowVariant']}" : '' - ] - ], implode('', [$rowKey, $rowValue])); - return $row; - } - - private function genCell($field = []) - { - if (isset($field['raw'])) { - $cellContent = $field['raw']; - if (empty($field['no_escaping'])) { - $field['raw'] = h($field['raw']); - } - } else if (isset($field['formatter'])) { - $cellContent = $field['formatter']($this->getValueFromObject($field), $this->item); - } else if (isset($field['type'])) { - $cellContent = $this->btHelper->getView()->element($this->getElementPath($field['type']), [ - 'data' => $this->item, - 'field' => $field - ]); - } else { - $cellContent = h($this->getValueFromObject($field)); - } - foreach (['info', 'warning', 'danger'] as $message_type) { - if (!empty($field[$message_type])) { - $cellContent .= sprintf(' %s', $message_type, $field[$message_type]); - } - } - return $this->genNode('td', [ - 'class' => [ - 'col-8 col-sm-10', - !empty($field['_cellVariant']) ? "bg-{$field['_cellVariant']}" : '' - ] - ], $cellContent); - } - - private function getValueFromObject($field) - { - if (is_array($field)) { - $key = $field['path']; - } else { - $key = $field; - } - $cellValue = Hash::get($this->item, $key); - return $cellValue; - } - - private function getElementPath($type) - { - return sprintf( - '%s%sField', - $this->options['elementsRootPath'] ?? '', - $type - ); - } - - private function genCaption() - { - return !empty($this->caption) ? $this->genNode('caption', [], h($this->caption)) : ''; - } -} - -class BoostrapButton extends BootstrapGeneric -{ - private $defaultOptions = [ - 'id' => '', - 'text' => '', - 'html' => null, - 'variant' => 'primary', - 'outline' => false, - 'size' => '', - 'icon' => null, - 'image' => null, - 'class' => [], - 'type' => 'button', - 'nodeType' => 'button', - 'title' => '', - 'params' => [], - 'badge' => false - ]; - - private $bsClasses = []; - - function __construct($options) - { - $this->allowedOptionValues = [ - 'variant' => array_merge(BootstrapGeneric::$variants, ['link', 'text']), - 'size' => ['', 'xs', 'sm', 'lg'], - 'type' => ['button', 'submit', 'reset'] - ]; - if (empty($options['class'])) { - $options['class'] = ''; - } - $options['class'] = !is_array($options['class']) ? [$options['class']] : $options['class']; - $this->processOptions($options); - } - - private function processOptions($options) - { - $this->options = array_merge($this->defaultOptions, $options); - $this->checkOptionValidity(); - - if (!empty($this->options['id'])) { - $this->options['params']['id'] = $this->options['id']; - } - - $this->bsClasses[] = 'btn'; - if ($this->options['outline']) { - $this->bsClasses[] = "btn-outline-{$this->options['variant']}"; - } else { - $this->bsClasses[] = "btn-{$this->options['variant']}"; - } - if (!empty($this->options['size'])) { - $this->bsClasses[] = "btn-{$this->options['size']}"; - } - if ($this->options['variant'] == 'text') { - $this->bsClasses[] = 'p-0'; - $this->bsClasses[] = 'lh-1'; - } - } - - public function button() - { - return $this->genButton(); - } - - private function genButton() - { - $html = $this->openNode($this->options['nodeType'], array_merge($this->options['params'], [ - 'class' => array_merge($this->options['class'], $this->bsClasses), - 'role' => "alert", - 'type' => $this->options['type'], - 'title' => h($this->options['title']), - ])); - - $html .= $this->genIcon(); - $html .= $this->genImage(); - $html .= $this->genContent(); - if (!empty($this->options['badge'])) { - $bsBadge = new BoostrapBadge($this->options['badge']); - $html .= $bsBadge->badge(); - } - $html .= $this->closeNode($this->options['nodeType']); - return $html; - } - - private function genIcon() - { - if (!empty($this->options['icon'])) { - $bsIcon = new BoostrapIcon($this->options['icon'], [ - 'class' => [(!empty($this->options['text']) ? 'me-1' : '')] - ]); - return $bsIcon->icon(); - } - return ''; - } - - private function genImage() - { - if (!empty($this->options['image'])) { - return $this->genNode('img', [ - 'src' => $this->options['image']['path'] ?? '', - 'class' => ['img-fluid', 'me-1'], - 'width' => '26', - 'height' => '26', - 'alt' => $this->options['image']['alt'] ?? '' - ]); - } - return ''; - } - - private function genContent() - { - return !is_null($this->options['html']) ? $this->options['html'] : $this->options['text']; - } -} - -class BoostrapBadge extends BootstrapGeneric -{ - private $defaultOptions = [ - 'text' => '', - 'variant' => 'primary', - 'pill' => false, - 'title' => '', - 'class' => [], - ]; - - function __construct($options) - { - $this->allowedOptionValues = [ - 'variant' => BootstrapGeneric::$variants, - ]; - $this->processOptions($options); - } - - private function processOptions($options) - { - $this->options = array_merge($this->defaultOptions, $options); - $this->options['class'] = is_array($this->options['class']) ? $this->options['class'] : [$this->options['class']]; - $this->checkOptionValidity(); - } - - public function badge() - { - return $this->genBadge(); - } - - private function genBadge() - { - $html = $this->genNode('span', [ - 'class' => array_merge($this->options['class'], [ - 'ms-1', - 'badge', - "bg-{$this->options['variant']}", - $this->getTextClassForVariant($this->options['variant']), - $this->options['pill'] ? 'rounded-pill' : '', - ]), - 'title' => $this->options['title'] - ], h($this->options['text'])); - return $html; - } -} - -class BoostrapIcon extends BootstrapGeneric -{ - private $icon = ''; - private $defaultOptions = [ - 'class' => [], - 'title' => '', - 'params' => [], - ]; - - 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', array_merge([ - 'class' => array_merge( - is_array($this->options['class']) ? $this->options['class'] : [$this->options['class']], - ["fa fa-{$this->icon}"] - ), - 'title' => h($this->options['title']) - ], $this->options['params'])); - return $html; - } -} - -class BoostrapModal extends BootstrapGeneric -{ - private $defaultOptions = [ - 'size' => '', - 'centered' => true, - 'scrollable' => true, - 'backdropStatic' => false, - 'title' => '', - 'titleHtml' => false, - 'body' => '', - 'bodyHtml' => false, - 'footerHtml' => false, - 'confirmText' => 'Confirm', - 'cancelText' => 'Cancel', - 'modalClass' => [''], - 'headerClass' => [''], - 'bodyClass' => [''], - 'footerClass' => [''], - 'type' => 'ok-only', - 'variant' => '', - 'confirmFunction' => '', // Will be called with the following arguments confirmFunction(modalObject, tmpApi) - 'cancelFunction' => '' - ]; - - private $bsClasses = null; - - function __construct($options) - { - $this->allowedOptionValues = [ - 'size' => ['sm', 'lg', 'xl', ''], - 'type' => ['ok-only', 'confirm', 'confirm-success', 'confirm-warning', 'confirm-danger', 'custom'], - 'variant' => array_merge(BootstrapGeneric::$variants, ['']), - ]; - $this->processOptions($options); - } - - private function processOptions($options) - { - $this->options = array_merge($this->defaultOptions, $options); - $this->checkOptionValidity(); - } - - public function modal() - { - return $this->genModal(); - } - - private function genModal() - { - $this->options['modalClass'] = !empty($this->options['modalClass']) && !is_array($this->options['modalClass']) ? [$this->options['modalClass']] : $this->options['modalClass']; - $dialog = $this->openNode('div', [ - 'class' => array_merge( - ['modal-dialog', (!empty($this->options['size'])) ? "modal-{$this->options['size']}" : ''], - $this->options['modalClass'] - ), - ]); - $content = $this->openNode('div', [ - 'class' => ['modal-content'], - ]); - $header = $this->genHeader(); - $body = $this->genBody(); - $footer = $this->genFooter(); - $closedDiv = $this->closeNode('div'); - - $html = "{$dialog}{$content}{$header}{$body}{$footer}{$closedDiv}{$closedDiv}"; - return $html; - } - - private function genHeader() - { - $header = $this->openNode('div', ['class' => array_merge(['modal-header'], $this->options['headerClass'])]); - if (!empty($this->options['titleHtml'])) { - $header .= $this->options['titleHtml']; - } else { - $header .= $this->genNode('h5', ['class' => ['modal-title']], h($this->options['title'])); - } - if (empty($this->options['backdropStatic'])) { - $header .= $this->genericCloseButton('modal'); - } - $header .= $this->closeNode('div'); - return $header; - } - - private function genBody() - { - $body = $this->openNode('div', ['class' => array_merge(['modal-body'], $this->options['bodyClass'])]); - if (!empty($this->options['bodyHtml'])) { - $body .= $this->options['bodyHtml']; - } else { - $body .= h($this->options['body']); - } - $body .= $this->closeNode('div'); - return $body; - } - - private function genFooter() - { - $footer = $this->openNode('div', [ - 'class' => array_merge(['modal-footer'], $this->options['footerClass']), - 'data-custom-footer' => $this->options['type'] == 'custom' - ]); - if (!empty($this->options['footerHtml'])) { - $footer .= $this->options['footerHtml']; - } else { - $footer .= $this->getFooterBasedOnType(); - } - $footer .= $this->closeNode('div'); - return $footer; - } - - private function getFooterBasedOnType() - { - if ($this->options['type'] == 'ok-only') { - return $this->getFooterOkOnly(); - } else if (str_contains($this->options['type'], 'confirm')) { - return $this->getFooterConfirm(); - } else if ($this->options['type'] == 'custom') { - return $this->getFooterCustom(); - } else { - return $this->getFooterOkOnly(); - } - } - - private function getFooterOkOnly() - { - return (new BoostrapButton([ - 'variant' => 'primary', - 'text' => __('Ok'), - 'params' => [ - 'data-bs-dismiss' => $this->options['confirmFunction'] ? '' : 'modal', - 'onclick' => $this->options['confirmFunction'] - ] - ]))->button(); - } - - private function getFooterConfirm() - { - if ($this->options['type'] == 'confirm') { - $variant = 'primary'; - } else { - $variant = explode('-', $this->options['type'])[1]; - } - $buttonCancel = (new BoostrapButton([ - 'variant' => 'secondary', - 'text' => h($this->options['cancelText']), - 'params' => [ - 'data-bs-dismiss' => 'modal', - 'onclick' => $this->options['cancelFunction'] - ] - ]))->button(); - - $buttonConfirm = (new BoostrapButton([ - 'variant' => $variant, - 'text' => h($this->options['confirmText']), - 'class' => 'modal-confirm-button', - 'params' => [ - // 'data-bs-dismiss' => $this->options['confirmFunction'] ? '' : 'modal', - 'data-confirmFunction' => sprintf('%s', $this->options['confirmFunction']) - ] - ]))->button(); - return $buttonCancel . $buttonConfirm; - } - - private function getFooterCustom() - { - $buttons = []; - foreach ($this->options['footerButtons'] as $buttonConfig) { - $buttons[] = (new BoostrapButton([ - 'variant' => h($buttonConfig['variant'] ?? 'primary'), - 'text' => h($buttonConfig['text']), - 'class' => 'modal-confirm-button', - 'params' => [ - 'data-bs-dismiss' => !empty($buttonConfig['clickFunction']) ? '' : 'modal', - 'data-clickFunction' => sprintf('%s', $buttonConfig['clickFunction']) - ] - ]))->button(); - } - return implode('', $buttons); - } -} - -class BoostrapCard extends BootstrapGeneric -{ - private $defaultOptions = [ - 'variant' => '', - 'headerText' => '', - 'footerText' => '', - 'bodyText' => '', - 'headerHTML' => '', - 'footerHTML' => '', - 'bodyHTML' => '', - 'class' => '', - 'headerClass' => '', - 'bodyClass' => '', - 'footerClass' => '', - ]; - - public function __construct($options) - { - $this->allowedOptionValues = [ - 'variant' => array_merge(BootstrapGeneric::$variants, ['']), - ]; - $this->processOptions($options); - } - - private function processOptions($options) - { - $this->options = array_merge($this->defaultOptions, $options); - $this->checkOptionValidity(); - } - - public function card() - { - return $this->genCard(); - } - - private function genCard() - { - $card = $this->genNode('div', [ - 'class' => [ - 'card', - !empty($this->options['variant']) ? "bg-{$this->options['variant']}" : '', - !empty($this->options['variant']) ? $this->getTextClassForVariant($this->options['variant']) : '', - h(is_array($this->options['class']) ? implode(' ', $this->options['class']) : $this->options['class']), - ], - ], implode('', [$this->genHeader(), $this->genBody(), $this->genFooter()])); - return $card; - } - - private function genHeader() - { - if (empty($this->options['headerHTML']) && empty($this->options['headerText'])) { - return ''; - } - $content = !empty($this->options['headerHTML']) ? $this->options['headerHTML'] : h($this->options['headerText']); - $header = $this->genNode('div', [ - 'class' => [ - 'card-header', - h($this->options['headerClass']), - ], - ], $content); - return $header; - } - - private function genBody() - { - if (empty($this->options['bodyHTML']) && empty($this->options['bodyText'])) { - return ''; - } - $content = !empty($this->options['bodyHTML']) ? $this->options['bodyHTML'] : h($this->options['bodyText']); - $body = $this->genNode('div', [ - 'class' => [ - 'card-body', - h($this->options['bodyClass']), - ], - ], $content); - return $body; - } - - private function genFooter() - { - if (empty($this->options['footerHTML']) && empty($this->options['footerText'])) { - return ''; - } - $content = !empty($this->options['footerHTML']) ? $this->options['footerHTML'] : h($this->options['footerText']); - $footer = $this->genNode('div', [ - 'class' => [ - 'card-footer', - h($this->options['footerClass']), - ], - ], $content); - return $footer; - } -} - -class BoostrapSwitch extends BootstrapGeneric -{ - private $defaultOptions = [ - 'label' => '', - 'variant' => 'primary', - 'disabled' => false, - 'checked' => false, - 'title' => '', - 'class' => [], - 'attrs' => [], - ]; - - function __construct($options) - { - $this->allowedOptionValues = [ - 'variant' => BootstrapGeneric::$variants, - ]; - $this->processOptions($options); - } - - private function processOptions($options) - { - $this->options = array_merge($this->defaultOptions, $options); - $this->checkOptionValidity(); - } - - public function switch() - { - return $this->genSwitch(); - } - - private function genSwitch() - { - $tmpId = 'tmp-' . mt_rand(); - $html = $this->genNode('div', [ - 'class' => [ - 'form-check form-switch', - ], - 'title' => $this->options['title'] - ], implode('', [ - $this->genNode('input', array_merge([ - 'type' => "checkbox", - 'class' => 'form-check-input', - 'id' => $tmpId, - ($this->options['disabled'] ? 'disabled' : '') => '', - ($this->options['checked'] ? 'checked' : '') => $this->options['checked'] ? 'checked' : '', - ], $this->options['attrs'])), - $this->genNode('label', [ - 'class' => 'form-check-label', - 'for' => $tmpId, - ], h($this->options['label'])) - ])); - return $html; - } -} - -class BoostrapNotificationBuble extends BootstrapGeneric -{ - private $defaultOptions = [ - 'label' => '', - 'variant' => 'warning', - 'borderVariant' => 'ligth', - 'title' => 'Notification', - 'class' => [], - 'attrs' => [], - ]; - - function __construct($options) - { - $this->allowedOptionValues = [ - 'variant' => BootstrapGeneric::$variants, - ]; - $this->defaultOptions['label'] = __('New notifications'); - $this->processOptions($options); - } - - private function processOptions($options) - { - $this->options = array_merge($this->defaultOptions, $options); - $this->checkOptionValidity(); - if (!empty($this->options['attrs']['style'])) { - $this->options['attrs']['style'] += 'box-shadow: 0 0.125rem 0.25rem #00000050;'; - } else { - $this->options['attrs']['style'] = 'box-shadow: 0 0.125rem 0.25rem #00000050;'; - } - } - - public function notificationBubble() - { - return $this->genNotificationBubble(); - } - - private function genNotificationBubble() - { - $tmpId = 'tmp-' . mt_rand(); - $html = $this->genNode('span', [ - 'id' => $tmpId, - 'class' => [ - 'position-absolute', - 'top-0', - 'start-100', - 'translate-middle', - 'p-1', - 'border border-2 rounded-circle', - "border-{$this->options['borderVariant']}", - "bg-{$this->options['variant']}", - ], - 'title' => $this->options['title'] - ], $this->genNode('span', [ - 'class' => [ - ], - $this->options['label'] - ])); - return $html; - } -} - -class BoostrapProgress extends BootstrapGeneric -{ - private $defaultOptions = [ - 'value' => 0, - 'total' => 100, - 'text' => '', - 'title' => '', - 'variant' => 'primary', - 'height' => '', - 'striped' => false, - 'animated' => false, - 'label' => true - ]; - - function __construct($options) - { - $this->allowedOptionValues = [ - 'variant' => BootstrapGeneric::$variants, - ]; - $this->processOptions($options); - } - - private function processOptions($options) - { - $this->options = array_merge($this->defaultOptions, $options); - $this->checkOptionValidity(); - } - - public function progress() - { - return $this->genProgress(); - } - - private function genProgress() - { - $percentage = round(100 * $this->options['value'] / $this->options['total']); - $heightStyle = !empty($this->options['height']) ? sprintf('height: %s;', h($this->options['height'])) : ''; - $widthStyle = sprintf('width: %s%%;', $percentage); - $label = $this->options['label'] ? "{$percentage}%" : ''; - $pb = $this->genNode('div', [ - 'class' => [ - 'progress-bar', - "bg-{$this->options['variant']}", - $this->options['striped'] ? 'progress-bar-striped' : '', - $this->options['animated'] ? 'progress-bar-animated' : '', - ], - 'role' => "progressbar", - 'aria-valuemin' => "0", 'aria-valuemax' => "100", 'aria-valuenow' => $percentage, - 'style' => "${widthStyle}", - 'title' => $this->options['title'] - ], $label); - $container = $this->genNode('div', [ - 'class' => [ - 'progress', - ], - 'style' => "${heightStyle}", - 'title' => h($this->options['title']), - ], $pb); - return $container; - } -} - -class BoostrapCollapse extends BootstrapGeneric -{ - private $defaultOptions = [ - 'title' => '', - 'open' => false, - ]; - - function __construct($options, $content, $btHelper) - { - $this->allowedOptionValues = []; - $this->processOptions($options); - $this->content = $content; - $this->btHelper = $btHelper; - } - - private function processOptions($options) - { - $this->options = array_merge($this->defaultOptions, $options); - $this->checkOptionValidity(); - } - - public function collapse() - { - return $this->genCollapse(); - } - - private function genControl() - { - $html = $this->genNode('a', [ - 'class' => ['text-decoration-none'], - 'data-bs-toggle' => 'collapse', - 'href' => '#collapseExample', - 'role' => 'button', - 'aria-expanded' => 'false', - 'aria-controls' => 'collapseExample', - ], h($this->options['title'])); - return $html; - } - - private function genContent() - { - $content = $this->genNode('div', [ - 'class' => 'card', - ], $this->content); - $container = $this->genNode('div', [ - 'class' => ['collapse', $this->options['open'] ? 'show' : ''], - 'id' => 'collapseExample', - ], $content); - return $container; - } - - private function genCollapse() - { - $html = $this->genControl(); - $html .= $this->genContent(); - return $html; - } -} - -class BoostrapAccordion extends BootstrapGeneric -{ - private $defaultOptions = [ - 'stayOpen' => true, - 'class' => [], - ]; - - function __construct($options, $content, $btHelper) - { - $this->allowedOptionValues = []; - $this->content = $content; - $this->btHelper = $btHelper; - $this->processOptions($options); - } - - private function processOptions($options) - { - $this->options = array_merge($this->defaultOptions, $options); - $this->checkOptionValidity(); - if (!is_array($this->options['class']) && !empty($this->options['class'])) { - $this->options['class'] = [$this->options['class']]; - } - $this->seed = 'acc-' . mt_rand(); - $this->contentSeeds = []; - foreach ($this->content as $accordionItem) { - $this->contentSeeds[] = mt_rand(); - } - } - - public function accordion() - { - return $this->genAccordion(); - } - - private function genHeader($accordionItem, $i) - { - $html = $this->openNode('h2', [ - 'class' => ['accordion-header'], - 'id' => 'head-' . $this->contentSeeds[$i] - ]); - $content = !empty($accordionItem['header']['html']) ? $accordionItem['header']['html'] : h($accordionItem['header']['title'] ?? '- no title -'); - $buttonOptions = [ - 'class' => array_merge(['accordion-button', empty($accordionItem['_open']) ? 'collapsed' : ''], $accordionItem['header']['__class'] ?? []), - 'type' => 'button', - 'data-bs-toggle' => 'collapse', - 'data-bs-target' => '#body-' . $this->contentSeeds[$i], - 'aria-expanded' => 'false', - 'aria-controls' => 'body-' . $this->contentSeeds[$i], - ]; - $html .= $this->genNode('button', $buttonOptions, $content); - $html .= $this->closeNode(('h2')); - return $html; - } - - private function genBody($accordionItem, $i) - { - $content = $this->genNode('div', [ - 'class' => ['accordion-body'] - ], $accordionItem['body']); - $divOptions = [ - 'class' => array_merge(['accordion-collapse collapse', empty($accordionItem['_open']) ? '' : 'show'], $accordionItem['body']['__class'] ?? []), - 'id' => 'body-' . $this->contentSeeds[$i], - 'aria-labelledby' => 'head-' . $this->contentSeeds[$i], - ]; - if (!empty($this->options['stayOpen'])) { - $divOptions['data-bs-parent'] = '#' . $this->seed; - } - $html = $this->genNode('div', $divOptions, $content); - return $html; - } - - private function genAccordion() - { - $html = $this->openNode('div', [ - 'class' => array_merge(['accordion'], $this->options['class']), - 'id' => $this->seed - ]); - foreach ($this->content as $i => $accordionItem) { - $html .= $this->openNode('div', [ - 'class' => array_merge(['accordion-item'], $accordionItem['__class'] ?? []) - ]); - $html .= $this->genHeader($accordionItem, $i); - $html .= $this->genBody($accordionItem, $i); - $html .= $this->closeNode('div'); - } - $html .= $this->closeNode('div'); - return $html; - } -} - -class BoostrapProgressTimeline extends BootstrapGeneric -{ - private $defaultOptions = [ - 'steps' => [], - 'selected' => 0, - 'variant' => 'info', - 'variantInactive' => 'secondary', - ]; - - function __construct($options, $btHelper) - { - $this->allowedOptionValues = [ - 'variant' => BootstrapGeneric::$variants, - 'variantInactive' => BootstrapGeneric::$variants, - ]; - $this->processOptions($options); - $this->btHelper = $btHelper; - } - - private function processOptions($options) - { - $this->options = array_merge($this->defaultOptions, $options); - $this->checkOptionValidity(); - } - - public function progressTimeline() - { - return $this->genProgressTimeline(); - } - - private function getStepIcon($step, $i, $nodeActive, $lineActive) - { - $icon = $this->genNode('b', [ - 'class' => [ - !empty($step['icon']) ? h($this->btHelper->FontAwesome->getClass($step['icon'])) : '', - $this->getTextClassForVariant($this->options['variant']) - ], - ], empty($step['icon']) ? h($i + 1) : ''); - $iconContainer = $this->genNode('span', [ - 'class' => [ - 'd-flex', 'align-items-center', 'justify-content-center', - 'rounded-circle', - $nodeActive ? "bg-{$this->options['variant']}" : "bg-{$this->options['variantInactive']}" - ], - 'style' => 'width:50px; height:50px' - ], $icon); - $li = $this->genNode('li', [ - 'class' => [ - 'd-flex', 'flex-column', - $nodeActive ? 'progress-active' : 'progress-inactive', - ], - ], $iconContainer); - $html = $li . $this->getHorizontalLine($i, $nodeActive, $lineActive); - return $html; - } - - private function getHorizontalLine($i, $nodeActive, $lineActive) - { - $stepCount = count($this->options['steps']); - if ($i == $stepCount - 1) { - return ''; - } - $progressBar = (new BoostrapProgress([ - 'label' => false, - 'value' => $nodeActive ? ($lineActive ? 100 : 50) : 0, - 'height' => '2px', - 'variant' => $this->options['variant'] - ]))->progress(); - $line = $this->genNode('span', [ - 'class' => [ - 'progress-line', - 'flex-grow-1', 'align-self-center', - $lineActive ? "bg-{$this->options['variant']}" : '' - ], - ], $progressBar); - return $line; - } - - private function getStepText($step, $isActive) - { - return $this->genNode('li', [ - 'class' => [ - 'text-center', - 'fw-bold', - $isActive ? 'progress-active' : 'progress-inactive', - ], - ], h($step['text'] ?? '')); - } - - private function genProgressTimeline() - { - $iconLis = ''; - $textLis = ''; - foreach ($this->options['steps'] as $i => $step) { - $nodeActive = $i <= $this->options['selected']; - $lineActive = $i < $this->options['selected']; - $iconLis .= $this->getStepIcon($step, $i, $nodeActive, $lineActive); - $textLis .= $this->getStepText($step, $nodeActive); - } - $ulIcons = $this->genNode('ul', [ - 'class' => [ - 'd-flex', 'justify-content-around', - ], - ], $iconLis); - $ulText = $this->genNode('ul', [ - 'class' => [ - 'd-flex', 'justify-content-between', - ], - ], $textLis); - $html = $this->genNode('div', [ - 'class' => ['progress-timeline', 'mw-75', 'mx-auto'] - ], $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 rounded-pill', (!empty($item['badge-variant']) ? "bg-{$item['badge-variant']}" : 'bg-primary')], - ], h($item['badge'])); - } - - private function genBody($item) - { - if (!empty($item['bodyHTML'])) { - return $item['bodyHTML']; - } - return !empty($item['body']) ? h($item['body']) : ''; - } -} - - -class BoostrapDropdownMenu extends BootstrapGeneric -{ - private $defaultOptions = [ - 'dropdown-class' => [], - 'toggle-button' => [], - 'menu' => [], - 'direction' => 'end', - 'alignment' => 'start', - 'submenu_alignment' => 'start', - 'submenu_direction' => 'end', - 'submenu_classes' => [], - ]; - - function __construct($options, $btHelper) - { - $this->allowedOptionValues = [ - 'direction' => ['start', 'end', 'up', 'down'], - 'alignment' => ['start', 'end'], - ]; - $this->processOptions($options); - $this->menu = $this->options['menu']; - $this->btHelper = $btHelper; - } - - private function processOptions($options) - { - $this->options = array_merge($this->defaultOptions, $options); - if (!empty($this->options['dropdown-class']) && !is_array($this->options['dropdown-class'])) { - $this->options['dropdown-class'] = [$this->options['dropdown-class']]; - } - $this->checkOptionValidity(); - } - - public function dropdownMenu() - { - return $this->fullDropdown(); - } - - public function fullDropdown() - { - return $this->genDropdownWrapper($this->genDropdownToggleButton(), $this->genDropdownMenu($this->menu)); - } - - public function genDropdownWrapper($toggle = '', $menu = '', $direction = null, $classes = null) - { - $classes = !is_null($classes) ? $classes : $this->options['dropdown-class']; - $direction = !is_null($direction) ? $direction : $this->options['direction']; - $content = $toggle . $menu; - $html = $this->genNode('div', array_merge( - $this->options['params'], - [ - 'class' => array_merge( - $classes, - [ - 'dropdown', - "drop{$direction}" - ] - ) - ] - ), $content); - return $html; - } - - public function genDropdownToggleButton() - { - $defaultOptions = [ - 'class' => ['dropdown-toggle'], - 'params' => [ - 'data-bs-toggle' => 'dropdown', - 'aria-expanded' => 'false', - ] - ]; - $options = array_merge($this->options['toggle-button'], $defaultOptions); - return $this->btHelper->button($options); - } - - private function genDropdownMenu($entries, $alignment = null) - { - $alignment = !is_null($alignment) ? $alignment : $this->options['alignment']; - $html = $this->genNode('div', [ - 'class' => ['dropdown-menu', "dropdown-menu-{$alignment}"], - ], $this->genEntries($entries)); - return $html; - } - - private function genEntries($entries) - { - $html = ''; - foreach ($entries as $entry) { - $link = $this->genEntry($entry); - if (!empty($entry['menu'])) { - $html .= $this->genDropdownWrapper($link, $this->genDropdownMenu($entry['menu']), $this->options['submenu_direction'], $this->options['submenu_classes']); - } else { - $html .= $link; - } - } - return $html; - } - - private function genEntry($entry) - { - if (!empty($entry['html'])) { - return $entry['html']; - } - $classes = []; - $icon = ''; - if (!empty($entry['icon'])) { - $icon = $this->btHelper->icon($entry['icon'], ['class' => 'me-2']); - } - $badge = ''; - if (!empty($entry['badge'])) { - $bsBadge = new BoostrapBadge(array_merge( - ['class' => ['ms-auto']], - $entry['badge'] - )); - $badge = $bsBadge->badge(); - } - - if (!empty($entry['header'])) { - return $this->genNode('h6', [ - 'class' => ['dropdown-header',], - ], $icon . h($entry['text']) . $badge); - } - - $classes = ['dropdown-item']; - $params = ['href' => '#']; - - if (!empty($entry['menu'])) { - $classes[] = 'dropdown-toggle'; - $classes[] = 'd-flex align-items-center'; - $params['data-bs-toggle'] = 'dropdown'; - $params['aria-haspopup'] = 'true'; - $params['aria-expanded'] = 'false'; - if (!empty($entry['keepOpen'])) { - $classes[] = 'open-form'; - } - $params['data-open-form-id'] = mt_rand(); - } - - $labelContent = sprintf('%s%s', - h($entry['text']), - !empty($entry['sup']) ? $this->genNode('sup', ['class' => 'ms-1 text-muted'], $entry['sup']) : '' - ); - $label = $this->genNode('span', ['class' => 'mx-1'], $labelContent); - $content = $icon . $label . $badge; - - return $this->genNode('a', array_merge([ - 'class' => $classes, - ], $params), $content); + return sprintf('bg-%s %s', $variant, self::getTextClassForVariant($variant)); } } diff --git a/src/View/Helper/FormFieldMassageHelper.php b/src/View/Helper/FormFieldMassageHelper.php index 183a9ed6b..a89f3bec3 100644 --- a/src/View/Helper/FormFieldMassageHelper.php +++ b/src/View/Helper/FormFieldMassageHelper.php @@ -8,6 +8,15 @@ class FormFieldMassageHelper extends Helper { public function prepareFormElement(\Cake\View\Helper\FormHelper $form, array $controlParams, array $fieldData): string { + if (!empty($fieldData['tooltip'])) { + $form->setTemplates([ + 'label' => '{{text}}{{tooltip}}', + ]); + $controlParams['templateVars'] = array_merge( + $controlParams['templateVars'] ?? [], + ['tooltip' => $fieldData['tooltip'],] + ); + } if (!empty($fieldData['stateDependence'])) { $controlParams['data-dependence-source'] = h($fieldData['stateDependence']['source']); $controlParams['data-dependence-option'] = h($fieldData['stateDependence']['option']); diff --git a/src/View/Helper/SocialProviderHelper.php b/src/View/Helper/SocialProviderHelper.php index effb8c50b..adfd6a88c 100644 --- a/src/View/Helper/SocialProviderHelper.php +++ b/src/View/Helper/SocialProviderHelper.php @@ -18,22 +18,22 @@ class SocialProviderHelper extends Helper return !empty($identity['social_profile']); } - public function getIcon($identity) + public function getIcon($identity, array $classes=[]) { if (!empty($identity['social_profile'])) { $provider = $identity['social_profile']['provider']; if (!empty($this->providerImageMapping[$provider])) { - return $this->genImage($this->providerImageMapping[$provider], h($provider)); + return $this->genImage($this->providerImageMapping[$provider], h($provider), $classes); } } return ''; } - private function genImage($url, $alt) + private function genImage($url, $alt, array $classes=[]) { - return $this->Bootstrap->genNode('img', [ + return $this->Bootstrap->node('img', [ 'src' => $url, - 'class' => ['img-fluid'], + 'class' => array_merge(['img-fluid'], $classes), 'width' => '16', 'height' => '16', 'alt' => $alt, diff --git a/templates/Users/login.php b/templates/Users/login.php index 64712dda3..ef7b6ff12 100644 --- a/templates/Users/login.php +++ b/templates/Users/login.php @@ -9,7 +9,7 @@ use Cake\Core\Configure; $this->Html->image('misp-logo.png', [ 'alt' => __('MISP logo'), 'height' => 100, - 'style' => ['filter: drop-shadow(4px 4px 4px #924da666);'] + 'style' => ['filter: drop-shadow(4px 4px 4px #22222233);'] ]) ); $template = [ @@ -20,7 +20,7 @@ use Cake\Core\Configure; $this->Form->setTemplates($template); if (!Configure::check('password_auth.enabled') || Configure::read('password_auth.enabled')) { echo sprintf('

    %s

    ', __('Sign In')); - echo $this->Form->create(null, ['url' => ['controller' => 'users', 'action' => 'login']]); + echo $this->Form->create(null, ['url' => ['controller' => 'users', 'action' => 'login', '?' => ['redirect' => $this->request->getQuery('redirect')],]]); echo $this->Form->control('email', ['label' => 'Email', 'class' => 'form-control mb-2', 'placeholder' => __('Email')]); echo $this->Form->control('password', ['type' => 'password', 'label' => 'Password', 'class' => 'form-control mb-3', 'placeholder' => __('Password')]); echo $this->Form->control(__('Login'), ['type' => 'submit', 'class' => 'btn btn-primary']); @@ -52,8 +52,8 @@ use Cake\Core\Configure; 'class' => ['d-block', 'w-100'], 'image' => [ 'path' => '/img/keycloak_logo.png', - 'alt' => 'Keycloak' - ] + 'alt' => 'Keycloak', + ], ]); echo $this->Form->end(); } diff --git a/templates/element/Settings/field.php b/templates/element/Settings/field.php index d52bc146f..ed297f038 100644 --- a/templates/element/Settings/field.php +++ b/templates/element/Settings/field.php @@ -2,7 +2,7 @@ if ($setting['type'] == 'string' || $setting['type'] == 'textarea' || empty($setting['type'])) { $input = (function ($settingName, $setting, $appView) { $settingId = str_replace('.', '_', $settingName); - return $appView->Bootstrap->genNode( + return $appView->Bootstrap->node( $setting['type'] == 'textarea' ? 'textarea' : 'input', [ 'class' => [ @@ -43,7 +43,7 @@ } elseif ($setting['type'] == 'integer') { $input = (function ($settingName, $setting, $appView) { $settingId = str_replace('.', '_', $settingName); - return $appView->Bootstrap->genNode('input', [ + return $appView->Bootstrap->node('input', [ 'class' => [ 'form-control', (!empty($setting['error']) ? 'is-invalid' : ''), @@ -73,7 +73,7 @@ } } $options = []; - $options[] = $appView->Bootstrap->genNode('option', ['value' => '-1', 'data-is-empty-option' => '1'], __('Select an option')); + $options[] = $appView->Bootstrap->node('option', ['value' => '-1', 'data-is-empty-option' => '1'], __('Select an option')); foreach ($setting['options'] as $key => $value) { $optionParam = [ 'class' => [], @@ -88,10 +88,10 @@ $optionParam['selected'] = 'selected'; } } - $options[] = $appView->Bootstrap->genNode('option', $optionParam, h($value)); + $options[] = $appView->Bootstrap->node('option', $optionParam, h($value)); } $options = implode('', $options); - return $appView->Bootstrap->genNode('select', [ + return $appView->Bootstrap->node('select', [ 'class' => [ 'form-select', 'pe-4', diff --git a/templates/element/Settings/fieldGroup.php b/templates/element/Settings/fieldGroup.php index cd60977fd..462ae22f2 100644 --- a/templates/element/Settings/fieldGroup.php +++ b/templates/element/Settings/fieldGroup.php @@ -3,7 +3,7 @@ $dependsOnHtml = ''; if (!empty($setting['dependsOn'])) { - $dependsOnHtml = $this->Bootstrap->genNode('span', [ + $dependsOnHtml = $this->Bootstrap->node('span', [ 'class' => [ 'ms-1', 'd-inline-block', @@ -11,18 +11,18 @@ ], 'style' => 'min-width: 0.75em;', 'title' => __('This setting depends on the validity of: {0}', h($setting['dependsOn'])), - ], $this->Bootstrap->genNode('sup', [ + ], $this->Bootstrap->node('sup', [ 'class' => $this->FontAwesome->getClass('info'), ])); } - $label = $this->Bootstrap->genNode('label', [ + $label = $this->Bootstrap->node('label', [ 'class' => ['form-label', 'fw-bolder', 'mb-0'], 'for' => $settingId ], sprintf('%s', h($settingId), h($settingId), h($setting['name'])) . $dependsOnHtml); $description = ''; if (!empty($setting['description']) && (empty($setting['type']) || $setting['type'] != 'boolean')) { - $description = $this->Bootstrap->genNode('small', [ + $description = $this->Bootstrap->node('small', [ 'class' => ['form-text', 'text-muted', 'mt-0'], 'id' => "{$settingId}Help" ], h($setting['description'])); @@ -31,7 +31,7 @@ if (!empty($setting['severity'])) { $textColor = "text-{$this->get('variantFromSeverity')[$setting['severity']]}"; } - $validationError = $this->Bootstrap->genNode('div', [ + $validationError = $this->Bootstrap->node('div', [ 'class' => ['d-block', 'invalid-feedback', $textColor], ], (!empty($setting['error']) ? h($setting['errorMessage']) : '')); @@ -50,11 +50,11 @@ 'variant' => 'success', 'class' => ['btn-setting-action', 'btn-save-setting', 'd-none'], ]); - $inputGroup = $this->Bootstrap->genNode('div', [ + $inputGroup = $this->Bootstrap->node('div', [ 'class' => ['input-group'], ], implode('', [$input, $inputGroupSave])); - $container = $this->Bootstrap->genNode('div', [ + $container = $this->Bootstrap->node('div', [ 'class' => ['setting-group', 'row', 'mb-2'] ], implode('', [$label, $inputGroup, $description, $validationError])); diff --git a/templates/element/Settings/notice.php b/templates/element/Settings/notice.php index 9e7987798..c7baeb5fe 100644 --- a/templates/element/Settings/notice.php +++ b/templates/element/Settings/notice.php @@ -50,14 +50,14 @@ foreach (array_keys($mainNoticeHeading) as $level) { 'bordered' => false, ], [ 'fields' => [ - ['key' => 'name', 'label' => __('Name'), 'formatter' => function($name, $row) { + ['path' => 'name', 'label' => __('Name'), 'formatter' => function($name, $row) { $settingID = preg_replace('/(\.|\W)/', '_', h($row['true-name'])); return sprintf('%s', $settingID, $settingID, h($name)); }], - ['key' => 'setting-path', 'label' => __('Category'), 'formatter' => function($path, $row) { + ['path' => 'setting-path', 'label' => __('Category'), 'formatter' => function($path, $row) { return '' . h(str_replace('.', ' â–¸ ', $path)) . ''; }], - ['key' => 'value', 'label' => __('Value'), 'formatter' => function($value, $row) { + ['path' => 'value', 'label' => __('Value'), 'formatter' => function($value, $row) { $formatedValue = ''; if (is_null($value)) { $formatedValue .= '' . __('No value') . ''; @@ -71,7 +71,7 @@ foreach (array_keys($mainNoticeHeading) as $level) { $formatedValue .= ''; return $formatedValue; }], - ['key' => 'description', 'label' => __('Description')] + ['path' => 'description', 'label' => __('Description')] ], 'items' => $notices[$level], ]); @@ -87,14 +87,14 @@ $alertBody = $this->Bootstrap->table([ 'tableClass' => 'mb-0' ], [ 'fields' => [ - ['key' => 'severity', 'label' => __('Severity')], - ['key' => 'issues', 'label' => __('Issues'), 'formatter' => function($count, $row) { + ['path' => 'severity', 'label' => __('Severity')], + ['path' => 'issues', 'label' => __('Issues'), 'formatter' => function($count, $row) { return $this->Bootstrap->badge([ 'variant' => $row['badge-variant'], 'text' => $count ]); }], - ['key' => 'description', 'label' => __('Description')] + ['path' => 'description', 'label' => __('Description')] ], 'items' => $tableItems, ]); diff --git a/templates/element/Settings/panel.php b/templates/element/Settings/panel.php index 6fdb0b042..bc3791959 100644 --- a/templates/element/Settings/panel.php +++ b/templates/element/Settings/panel.php @@ -34,7 +34,7 @@ if (isLeaf($panelSettings)) { h($panelName) ); if (!empty($panelSettings['_description'])) { - $panelHTML .= $this->Bootstrap->genNode('div', [ + $panelHTML .= $this->Bootstrap->node('div', [ 'class' => ['mb-1',], ], h($panelSettings['_description'])); } @@ -59,7 +59,7 @@ if (isLeaf($panelSettings)) { } } } - $panelHTML = $this->Bootstrap->genNode('div', [ + $panelHTML = $this->Bootstrap->node('div', [ 'class' => [ 'shadow', 'p-2', diff --git a/templates/element/UserSettings/saved-bookmarks.php b/templates/element/UserSettings/saved-bookmarks.php index c3b5a5fef..8671c82ed 100644 --- a/templates/element/UserSettings/saved-bookmarks.php +++ b/templates/element/UserSettings/saved-bookmarks.php @@ -5,19 +5,17 @@ $table = $this->Bootstrap->table([ 'hover' => false, ], [ 'fields' => [ - ['key' => 'label', 'label' => __('Label')], - ['key' => 'name', 'label' => __('Name')], - ['key' => 'url', 'label' => __('URL'), 'formatter' => function ($value, $row) { + ['path' => 'label', 'label' => __('Label')], + ['path' => 'name', 'label' => __('Name')], + ['path' => 'url', 'label' => __('URL'), 'formatter' => function ($value, $row) { return sprintf('%s', h($value)); }], - ['key' => 'action', 'label' => __('Action'), 'formatter' => function ($value, $row, $index) { + ['path' => 'action', 'label' => __('Action'), 'formatter' => function ($value, $row, $index) { return $this->Bootstrap->button([ 'icon' => 'trash', 'variant' => 'danger', 'size' => 'sm', - 'params' => [ - 'onclick' => sprintf('deleteBookmark(window.bookmarks[%s])', $index), - ] + 'onclick' => sprintf('deleteBookmark(window.bookmarks[%s])', $index), ]); }], ], diff --git a/templates/element/genericElements/Form/Fields/dropdownField.php b/templates/element/genericElements/Form/Fields/dropdownField.php index b71b15b48..5f84e5c33 100644 --- a/templates/element/genericElements/Form/Fields/dropdownField.php +++ b/templates/element/genericElements/Form/Fields/dropdownField.php @@ -2,7 +2,7 @@ $seed = 's-' . mt_rand(); $controlParams = [ 'type' => 'select', - 'options' => $fieldData['options'], + 'options' => $fieldData['options'] ?? [], 'empty' => $fieldData['empty'] ?? false, 'value' => $fieldData['value'] ?? null, 'multiple' => $fieldData['multiple'] ?? false, @@ -19,6 +19,13 @@ if (!empty($fieldData['label'])) { if ($controlParams['options'] instanceof \Cake\ORM\Query) { $controlParams['options'] = $controlParams['options']->all()->toList(); } +$initSelect2 = false; +if (isset($fieldData['select2']) && $fieldData['select2'] == true) { + $initSelect2 = true; + $fieldData['select2'] = $fieldData['select2'] === true ? [] : $fieldData['select2']; + $controlParams['class'] .= ' select2-input'; +} +$controlParams['class'] .= ' dropdown-custom-value' . "-$seed"; if (in_array('_custom', array_keys($controlParams['options']))) { $customInputValue = $this->Form->getSourceValue($fieldData['field']); if (!in_array($customInputValue, $controlParams['options'])) { @@ -31,7 +38,6 @@ if (in_array('_custom', array_keys($controlParams['options']))) { } else { $customInputValue = ''; } - $controlParams['class'] .= ' dropdown-custom-value' . "-$seed"; $adaptedField = $fieldData['field'] . '_custom'; $controlParams['templates']['formGroup'] = sprintf( '
    {{input}}{{error}}%s
    ', @@ -49,6 +55,19 @@ echo $this->FormFieldMassage->prepareFormElement($this->Form, $controlParams, $f $select.attr('onclick', 'toggleFreetextSelectField(this)') $select.parent().find('input.custom-value').attr('oninput', 'updateAssociatedSelect(this)') updateAssociatedSelect($select.parent().find('input.custom-value')[0]) + + // let $container = $select.closest('.modal-dialog .modal-body') + let $container = [] + if ($container.length == 0) { + $container = $(document.body) + } + const defaultSelect2Options = { + dropdownParent: $container, + } + const passedSelect2Options = ; + const select2Options = Object.assign({}, passedSelect2Options, defaultSelect2Options) + $select.select2(select2Options) + }) })() diff --git a/templates/element/genericElements/Form/Fields/uuidField.php b/templates/element/genericElements/Form/Fields/uuidField.php index b065e0878..b63e551f4 100644 --- a/templates/element/genericElements/Form/Fields/uuidField.php +++ b/templates/element/genericElements/Form/Fields/uuidField.php @@ -1,43 +1,43 @@ Form->setTemplates([ - 'inputContainer' => '{{content}}', - 'inputContainerError' => '{{content}}', - 'formGroup' => '{{input}}', - ]); - $label = $fieldData['label']; - $formElement = $this->FormFieldMassage->prepareFormElement($this->Form, $params, $fieldData); - $temp = sprintf( - '
    -
    %s
    -
    -
    - %s%s -
    -
    -
    ', - h($label), - $formElement, - sprintf( - '%s', - $random, - __('Generate') - ) - ); - echo $temp; +$random = Cake\Utility\Security::randomString(8); +$params['div'] = false; + +$genUUIDButton = $this->Bootstrap->button([ + 'id' => "uuid-gen-{$random}", + 'variant' => 'secondary', + 'text' => __('Generate'), +]); + +$this->Form->setTemplates([ + 'input' => sprintf('
    %s{{genUUIDButton}}
    ', $this->Form->getTemplates('input')), +]); +$params['templateVars'] = [ + 'genUUIDButton' => $genUUIDButton, +]; + +$formElement = $this->FormFieldMassage->prepareFormElement($this->Form, $params, $fieldData); +echo $formElement; ?> + \ No newline at end of file diff --git a/templates/element/genericElements/Form/fieldScaffold.php b/templates/element/genericElements/Form/fieldScaffold.php index 18e6000c1..7ffe04809 100644 --- a/templates/element/genericElements/Form/fieldScaffold.php +++ b/templates/element/genericElements/Form/fieldScaffold.php @@ -1,52 +1,54 @@ element( - 'genericElements/Form/formInfo', array( - 'field' => $fieldData, - 'fieldDesc' => $fieldDesc[$fieldData['field']], - 'modelForForm' => $modelForForm - ) - ); - } - $params = array(); - if (!empty($fieldData['class'])) { - if (is_array($fieldData['class'])) { - $class = implode(' ', $fieldData['class']); - } else { - $class = $fieldData['class']; - } - $params['class'] = $class; - } else { - $params['class'] = ''; - } - if (empty($fieldData['type']) || ($fieldData['type'] !== 'checkbox' && $fieldData['type'] !== 'radio')) { - $params['class'] .= ' form-control'; - } - foreach ($fieldData as $k => $fd) { - if (!isset($simpleFieldWhitelist) || in_array($k, $simpleFieldWhitelist) || strpos($k, 'data-') === 0) { - $params[$k] = $fd; - } - } - $temp = $this->element('genericElements/Form/Fields/' . $fieldTemplate, array( - 'fieldData' => $fieldData, - 'params' => $params - )); - if (!empty($fieldData['hidden'])) { - $temp = ''; - } - echo $temp; - } else { - echo $fieldData; } + if (empty($fieldData['label'])) { + if (!isset($fieldData['label']) || $fieldData['label'] !== false) { + $fieldData['label'] = \Cake\Utility\Inflector::humanize($fieldData['field']); + } + } + $fieldDescription = $fieldData['tooltip'] ?? ($fieldDesc[$fieldData['field']] ?? false); + if (!empty($fieldDescription)) { + $fieldData['tooltip'] = $this->element( + 'genericElements/Form/formInfo', + array( + 'field' => $fieldData, + 'fieldDesc' => $fieldDescription, + 'modelForForm' => $modelForForm + ) + ); + } + $params = array(); + if (!empty($fieldData['class'])) { + if (is_array($fieldData['class'])) { + $class = implode(' ', $fieldData['class']); + } else { + $class = $fieldData['class']; + } + $params['class'] = $class; + } else { + $params['class'] = ''; + } + if (empty($fieldData['type']) || ($fieldData['type'] !== 'checkbox' && $fieldData['type'] !== 'radio')) { + $params['class'] .= ' form-control'; + } + foreach ($fieldData as $k => $fd) { + if (!isset($simpleFieldWhitelist) || in_array($k, $simpleFieldWhitelist) || strpos($k, 'data-') === 0) { + $params[$k] = $fd; + } + } + $temp = $this->element('genericElements/Form/Fields/' . $fieldTemplate, array( + 'fieldData' => $fieldData, + 'params' => $params + )); + if (!empty($fieldData['hidden'])) { + $temp = ''; + } + echo $temp; +} else { + echo $fieldData; +} diff --git a/templates/element/genericElements/Form/formInfo.php b/templates/element/genericElements/Form/formInfo.php index 89dd0b7ea..d5691424e 100644 --- a/templates/element/genericElements/Form/formInfo.php +++ b/templates/element/genericElements/Form/formInfo.php @@ -1,4 +1,5 @@ $fieldDesc); $default = 'info'; @@ -16,32 +17,46 @@ $default = 'info'; } } - echo sprintf( - '', - h($field['field']) - ); + $popoverID = sprintf("%sInfoPopover%s", h($field['field']), $seed); + echo $this->Bootstrap->icon('info-circle', [ + 'id' => $popoverID, + 'class' => ['ms-1'], + 'attrs' => [ + 'data-bs-toggle' => 'popover', + 'data-bs-trigger' => 'hover', + ] + ]); ?> diff --git a/templates/element/genericElements/Form/formLayouts/formDefault.php b/templates/element/genericElements/Form/formLayouts/formDefault.php index 165888995..5561eddf9 100644 --- a/templates/element/genericElements/Form/formLayouts/formDefault.php +++ b/templates/element/genericElements/Form/formLayouts/formDefault.php @@ -22,9 +22,9 @@ ], [ [ - '_open' => true, + 'open' => true, 'header' => [ - 'title' => __('Meta fields') + 'text' => __('Meta fields') ], 'body' => $metaTemplateString, ], diff --git a/templates/element/genericElements/Form/formLayouts/formRaw.php b/templates/element/genericElements/Form/formLayouts/formRaw.php index 8b66baef9..05be8b83a 100644 --- a/templates/element/genericElements/Form/formLayouts/formRaw.php +++ b/templates/element/genericElements/Form/formLayouts/formRaw.php @@ -15,9 +15,9 @@ ], [ [ - '_open' => true, + 'open' => true, 'header' => [ - 'title' => __('Meta fields') + 'text' => __('Meta fields') ], 'body' => $metaTemplateString, ], diff --git a/templates/element/genericElements/Form/genericForm.php b/templates/element/genericElements/Form/genericForm.php index 7dafa1836..c0164a758 100644 --- a/templates/element/genericElements/Form/genericForm.php +++ b/templates/element/genericElements/Form/genericForm.php @@ -19,7 +19,7 @@ $entity = isset($entity) ? $entity : null; $fieldsString = ''; $simpleFieldWhitelist = [ - 'default', 'type', 'placeholder', 'label', 'empty', 'rows', 'div', 'required', 'templates', 'options', 'value', 'checked' + 'default', 'type', 'placeholder', 'label', 'empty', 'rows', 'div', 'required', 'templates', 'options', 'value', 'checked', ]; if (empty($data['url'])) { $data['url'] = ["controller" => $this->request->getParam('controller'), "action" => $this->request->getParam('url')]; @@ -155,14 +155,5 @@ $('.formDropdown').on('change', function() { executeStateDependencyChecks('#' + this.id); }) - - - $('select.select2-input').select2({ - dropdownParent: , - width: '100%', - }) - }); \ No newline at end of file diff --git a/templates/element/genericElements/Form/metaTemplateForm.php b/templates/element/genericElements/Form/metaTemplateForm.php index f52cbb291..86612e501 100644 --- a/templates/element/genericElements/Form/metaTemplateForm.php +++ b/templates/element/genericElements/Form/metaTemplateForm.php @@ -4,7 +4,7 @@ use Cake\Utility\Inflector; $default_template = [ 'inputContainer' => '
    {{content}}
    ', - 'inputContainerError' => '
    {{content}}
    ', + 'inputContainerError' => '
    {{content}}{{error}}
    ', 'formGroup' => '
    {{input}}{{error}}
    ', 'error' => '
    {{content}}
    ', 'errorList' => '
      {{content}}
    ', @@ -82,7 +82,7 @@ foreach ($metaTemplate->meta_template_fields as $metaTemplateField) { } } } -$fieldContainer = $this->Bootstrap->genNode('div', [ +$fieldContainer = $this->Bootstrap->node('div', [ 'class' => [], ], $fieldsHtml); -echo $fieldContainer; \ No newline at end of file +echo $fieldContainer; diff --git a/templates/element/genericElements/IndexTable/Statistics/index_statistic_field_amount.php b/templates/element/genericElements/IndexTable/Statistics/index_statistic_field_amount.php index 123da84c1..dcaff63c8 100644 --- a/templates/element/genericElements/IndexTable/Statistics/index_statistic_field_amount.php +++ b/templates/element/genericElements/IndexTable/Statistics/index_statistic_field_amount.php @@ -41,7 +41,7 @@ foreach ($statistics['usage'] as $scope => $graphData) { 'nodeType' => 'a', 'onclick' => '', 'class' => ['btn-statistics-pie-configurator-' . $seedPiechart], - 'params' => [ + 'attrs' => [ 'data-bs-toggle' => 'popover', ] ]) @@ -52,7 +52,7 @@ foreach ($statistics['usage'] as $scope => $graphData) { $pieChart ); $statPie = $this->Bootstrap->card([ - 'variant' => 'secondary', + 'bodyVariant' => 'secondary', 'bodyHTML' => $panelHtml, 'bodyClass' => 'py-1 px-2', 'class' => ['shadow-sm', 'h-100'] diff --git a/templates/element/genericElements/IndexTable/Statistics/index_statistic_timestamp.php b/templates/element/genericElements/IndexTable/Statistics/index_statistic_timestamp.php index bd0d43b76..11e2a2b74 100644 --- a/templates/element/genericElements/IndexTable/Statistics/index_statistic_timestamp.php +++ b/templates/element/genericElements/IndexTable/Statistics/index_statistic_timestamp.php @@ -38,7 +38,7 @@ $panelControlHtml = sprintf( 'nodeType' => 'a', 'onclick' => '', 'class' => ['btn-statistics-days-configurator-' . $seed,], - 'params' => [ + 'attrs' => [ 'data-bs-toggle' => 'popover', ] ]) @@ -46,13 +46,13 @@ $panelControlHtml = sprintf( $createdNumber = empty($timeline['created']) ? '' : sprintf( '
    %s %s
    ', __('{0} Created', $timeline['created']['variation']), - $this->Bootstrap->icon('plus', ['class' => ['fa-fw'], 'params' => ['style' => 'font-size: 60%;']]), + $this->Bootstrap->icon('plus', ['class' => ['fa-fw'], 'attrs' => ['style' => 'font-size: 60%;']]), $timeline['created']['variation'] ); $modifiedNumber = empty($timeline['modified']) ? '' : sprintf( '
    %s %s
    ', __('{0} Modified', $timeline['modified']['variation']), - $this->Bootstrap->icon('edit', ['class' => ['fa-fw'], 'params' => ['style' => 'font-size: 60%;']]), + $this->Bootstrap->icon('edit', ['class' => ['fa-fw'], 'attrs' => ['style' => 'font-size: 60%;']]), $timeline['modified']['variation'] ); $activityNumbers = sprintf('
    %s%s
    ', $createdNumber, $modifiedNumber); @@ -87,7 +87,7 @@ $cardContent = sprintf( ); $card = $this->Bootstrap->card([ - 'variant' => 'secondary', + 'bodyVariant' => 'secondary', 'bodyHTML' => $cardContent, 'bodyClass' => 'py-1 px-2', 'class' => ['shadow-sm', 'h-100'] diff --git a/templates/element/genericElements/IndexTable/index_table.php b/templates/element/genericElements/IndexTable/index_table.php index 956fcf836..b358247ae 100644 --- a/templates/element/genericElements/IndexTable/index_table.php +++ b/templates/element/genericElements/IndexTable/index_table.php @@ -2,23 +2,24 @@ use Cake\Utility\Text; /* - * echo $this->element('/genericElements/IndexTable/index_table', [ - * 'top_bar' => ( - * // search/filter bar information compliant with ListTopBar - * ), - * 'data' => [ - // the actual data to be used - * ), - * 'fields' => [ - * // field list with information for the paginator, the elements used for the individual cells, etc - * ), - * 'title' => optional title, - * 'description' => optional description, - * 'index_statistics' => optional statistics to be displayed for the index, - * 'primary_id_path' => path to each primary ID (extracted and passed as $primary to fields) - * )); - * - */ +* echo $this->element('/genericElements/IndexTable/index_table', [ +* 'top_bar' => ( +* // search/filter bar information compliant with ListTopBar +* ), +* 'data' => [ + // the actual data to be used +* ), +* 'fields' => [ +* // field list with information for the paginator, the elements used for the individual cells, etc +* ), +* 'title' => optional title, +* 'description' => optional description, +* 'notice' => optional alert to be placed at the top, +* 'index_statistics' => optional statistics to be displayed for the index, +* 'primary_id_path' => path to each primary ID (extracted and passed as $primary to fields) +* )); +* +*/ $newMetaFields = []; if (!empty($requestedMetaFields)) { // Create mapping for new index table fields on the fly @@ -39,49 +40,56 @@ if (!empty($requestedMetaFields)) { // Create mapping for new index table fields $data['fields'] = array_merge($data['fields'], $newMetaFields); $tableRandomValue = Cake\Utility\Security::randomString(8); -echo '
    '; +$html = '
    '; if (!empty($data['title'])) { - echo Text::insert( - '

    :title :help

    ', - [ - 'title' => $this->ValueGetter->get($data['title']), - 'help' => $this->Bootstrap->icon('info', [ - 'class' => ['fs-6', 'align-text-top',], - 'title' => empty($data['description']) ? '' : h($data['description']), - 'params' => [ - 'data-bs-toggle' => 'tooltip', - ] - ]), - ] - ); + if (empty($embedInModal)) { + $html .= Text::insert( + '

    :title :help

    ', + [ + 'title' => h($this->ValueGetter->get($data['title'])), + 'help' => $this->Bootstrap->icon('info', [ + 'class' => ['fs-6', 'align-text-top',], + 'title' => empty($data['description']) ? '' : h($data['description']), + 'attrs' => [ + 'data-bs-toggle' => 'tooltip', + ] + ]), + ] + ); + } else { + $pageTitle = $this->Bootstrap->node('h5', [], h($this->ValueGetter->get($data['title']))); + } } if(!empty($notice)) { - echo $this->Bootstrap->alert($notice); + $html .= $this->Bootstrap->alert($notice); } if (!empty($modelStatistics)) { - echo $this->element('genericElements/IndexTable/Statistics/index_statistic_scaffold', [ + $html .= $this->element('genericElements/IndexTable/Statistics/index_statistic_scaffold', [ 'statistics' => $modelStatistics, ]); } -echo '
    '; +$html .= '
    '; if (!empty($data['html'])) { - echo sprintf('
    %s
    ', $data['html']); + $html .= sprintf('
    %s
    ', $data['html']); } $skipPagination = isset($data['skip_pagination']) ? $data['skip_pagination'] : 0; if (!$skipPagination) { $paginationData = !empty($data['paginatorOptions']) ? $data['paginatorOptions'] : []; - echo $this->element( + if (!empty($embedInModal)) { + $paginationData['update'] = ".modal-main-{$tableRandomValue}"; + } + $html .= $this->element( '/genericElements/IndexTable/pagination', [ 'paginationOptions' => $paginationData, 'tableRandomValue' => $tableRandomValue ] ); - echo $this->element( + $html .= $this->element( '/genericElements/IndexTable/pagination_links' ); } @@ -94,8 +102,8 @@ if (!empty($multiSelectData)) { ]; array_unshift($data['fields'], $multiSelectField); } -if (!empty($data['top_bar'])) { - echo $this->element( +if (!empty($data['top_bar']) && empty($skipTableToolbar)) { + $html .= $this->element( '/genericElements/ListTopBar/scaffold', [ 'data' => $data['top_bar'], @@ -143,7 +151,7 @@ foreach ($data['data'] as $k => $data_row) { ); } $tbody = '' . $rows . ''; -echo sprintf( +$html .= sprintf( '%s%s
    ', $tableRandomValue, $tableRandomValue, @@ -160,11 +168,23 @@ echo sprintf( $tbody ); if (!$skipPagination) { - echo $this->element('/genericElements/IndexTable/pagination_counter', $paginationData); - echo $this->element('/genericElements/IndexTable/pagination_links'); + $html .= $this->element('/genericElements/IndexTable/pagination_counter', $paginationData); + $html .= $this->element('/genericElements/IndexTable/pagination_links'); +} +$html .= '
    '; +$html .= '
    '; + +if (!empty($embedInModal)) { + echo $this->Bootstrap->modal([ + 'titleHtml' => $pageTitle ?? '', + 'bodyHtml' => $html, + 'size' => 'xl', + 'type' => 'ok-only', + 'modalClass' => "modal-main-{$tableRandomValue}" + ]); +} else { + echo $html; } -echo '
    '; -echo '
    '; ?> +element('genericElements/Form/genericForm', [ + 'entity' => null, + 'ajax' => false, + 'raw' => true, + 'data' => [ + 'fields' => [ + ], + 'submit' => [ + 'action' => $this->request->getParam('action') + ] + ] +]); +$formHTML = sprintf('
    %s
    ', $form); +$bodyMessage = h($question ?? ''); +$bodyHTML = sprintf('%s%s', $formHTML, $bodyMessage); + +$defaultOptions = [ + 'size' => 'lg', + 'title' => isset($title) ? h($title) : __('Confirm'), + 'type' => 'confirm', + 'confirmButton' => [ + 'text' => !empty($actionName) ? h($actionName) : __('Confirm'), + 'variant' => 'primary', + ], +]; +$modalOptions = array_merge($defaultOptions, $modalOptions ?? []); +$modalOptions['bodyHtml'] = $bodyHTML; + +echo $this->Bootstrap->modal($modalOptions); diff --git a/templates/genericTemplates/delete.php b/templates/genericTemplates/delete.php index de45ab65f..763750a13 100644 --- a/templates/genericTemplates/delete.php +++ b/templates/genericTemplates/delete.php @@ -28,8 +28,11 @@ $bodyHTML = sprintf('%s%s', $formHTML, $bodyMessage); echo $this->Bootstrap->modal([ 'size' => 'lg', 'title' => !empty($deletionTitle) ? $deletionTitle : __('Delete {0}', h(Cake\Utility\Inflector::singularize(Cake\Utility\Inflector::humanize($this->request->getParam('controller'))))), - 'type' => 'confirm-danger', - 'confirmText' => !empty($deletionConfirm) ? $deletionConfirm : __('Delete'), + 'type' => 'confirm', + 'confirmButton' => [ + 'text' => !empty($deletionConfirm) ? $deletionConfirm : __('Delete'), + 'variant' => 'danger', + ], 'bodyHtml' => $bodyHTML, ]); ?> diff --git a/templates/genericTemplates/filters.php b/templates/genericTemplates/filters.php index a193721a6..4cb1df007 100644 --- a/templates/genericTemplates/filters.php +++ b/templates/genericTemplates/filters.php @@ -19,12 +19,12 @@ $filteringForm = $this->Bootstrap->table( [ 'fields' => [ [ - 'key' => 'fieldname', 'label' => __('Field'), 'formatter' => function ($field, $row) { + 'path' => 'fieldname', 'label' => __('Field'), 'formatter' => function ($field, $row) { return sprintf('%s', h($field), h($field)); } ], [ - 'key' => 'operator', 'label' => __('Operator'), 'formatter' => function ($field, $row) use ($typeMap) { + 'path' => 'operator', 'label' => __('Operator'), 'formatter' => function ($field, $row) use ($typeMap) { $fieldName = $row['fieldname']; $type = $typeMap[$fieldName] ?? 'text'; $options = [ @@ -41,25 +41,34 @@ $filteringForm = $this->Bootstrap->table( } ], [ - 'key' => 'value', + 'path' => 'value', 'labelHtml' => sprintf( '%s %s', __('Value'), sprintf('', __('Supports strict matches and LIKE matches with the `%` character. Example: `%.com`')) ), - 'formatter' => function ($field, $row) use ($typeMap, $formTypeMap) { + 'formatter' => function ($field, $row) use ($typeMap, $formTypeMap, $filtersConfig) { $fieldName = $row['fieldname']; $formType = $formTypeMap[$typeMap[$fieldName]] ?? 'text'; + $fieldData = [ + 'field' => $fieldName, + 'type' => $formType, + 'label' => '', + 'class' => 'fieldValue form-control-sm' + ]; + if (!empty($filtersConfig[$fieldName]['multiple'])) { + $fieldData['type'] = 'dropdown'; + $fieldData['multiple'] = true; + $fieldData['select2'] = [ + 'tags' => true, + 'tokenSeparators' => [',', ' '], + ]; + } $this->Form->setTemplates([ 'formGroup' => '
    {{input}}
    ', ]); return $this->element('genericElements/Form/fieldScaffold', [ - 'fieldData' => [ - 'field' => $fieldName, - 'type' => $formType, - 'label' => '', - 'class' => 'fieldValue form-control-sm' - ], + 'fieldData' => $fieldData, 'params' => [] ]); } @@ -71,23 +80,23 @@ $filteringForm = $this->Bootstrap->table( $filteringMetafields = ''; if ($metaFieldsEnabled) { - $helpText = $this->Bootstrap->genNode('sup', [ + $helpText = $this->Bootstrap->node('sup', [ 'class' => ['ms-1 fa fa-info'], 'title' => __('Include help'), 'data-bs-toggle' => 'tooltip', ]); - $filteringMetafields = $this->Bootstrap->genNode('h5', [], __('Meta Fields') . $helpText); + $filteringMetafields = $this->Bootstrap->node('h5', [], __('Meta Fields') . $helpText); $filteringMetafields .= $this->element('genericElements/IndexTable/metafield_filtering', $metaTemplates); } $filteringTags = ''; if ($taggingEnabled) { - $helpText = $this->Bootstrap->genNode('sup', [ + $helpText = $this->Bootstrap->node('sup', [ 'class' => ['ms-1 fa fa-info'], 'title' => __('Supports negation matches (with the `!` character) and LIKE matches (with the `%` character). Example: `!exportable`, `%able`'), 'data-bs-toggle' => 'tooltip', ]); - $filteringTags = $this->Bootstrap->genNode('h5', [ + $filteringTags = $this->Bootstrap->node('h5', [ 'class' => 'mt-2' ], __('Tags') . $helpText); $filteringTags .= $this->Tag->tags([], [ @@ -104,7 +113,9 @@ echo $this->Bootstrap->modal([ 'size' => !empty($metaFieldsEnabled) ? 'xl' : 'lg', 'type' => 'confirm', 'bodyHtml' => $modalBody, - 'confirmText' => __('Filter'), + 'confirmButton' => [ + 'text' => __('Filter'), + ], 'confirmFunction' => 'filterIndex' ]); ?> @@ -167,6 +178,36 @@ echo $this->Bootstrap->modal([ } setFilteringValues($filteringTable, field, value, operator) } + if (tags.length > 0) { + setFilteringTags($filteringTable, tags) + } + } + + function setFilteringValues($filteringTable, field, value, operator) { + $row = $filteringTable.find('td > span.fieldName').filter(function() { + return $(this).data('fieldname') == field + }).closest('tr') + $row.find('.fieldOperator').val(operator) + const $formElement = $row.find('.fieldValue'); + if ($formElement.attr('type') === 'datetime-local') { + $formElement.val(moment(value).format('yyyy-MM-DDThh:mm:ss')) + } else if ($formElement.is('select') && Array.isArray(value)) { + let newOptions = []; + value.forEach(aValue => { + const existingOption = $formElement.find('option').filter(function() { + return $(this).val() === aValue + }) + if (existingOption.length == 0) { + newOptions.push(new Option(aValue, aValue, true, true)) + } + }) + $formElement.append(newOptions).trigger('change'); + } else { + $formElement.val(value) + } + } + + function setFilteringTags($filteringTable, tags) { $select = $filteringTable.closest('.modal-body').find('select.select2-input') let passedTags = [] tags.forEach(tagname => { @@ -183,19 +224,6 @@ echo $this->Bootstrap->modal([ .trigger('change') } - function setFilteringValues($filteringTable, field, value, operator) { - $row = $filteringTable.find('td > span.fieldName').filter(function() { - return $(this).data('fieldname') == field - }).closest('tr') - $row.find('.fieldOperator').val(operator) - const $formElement = $row.find('.fieldValue'); - if ($formElement.attr('type') === 'datetime-local') { - $formElement.val(moment(value).format('yyyy-MM-DDThh:mm:ss')) - } else { - $formElement.val(value) - } - } - function getDataFromRow($row) { const rowData = {}; rowData['name'] = $row.find('td > span.fieldName').data('fieldname') diff --git a/templates/genericTemplates/toggle.php b/templates/genericTemplates/toggle.php index 426249c23..83358e3f3 100644 --- a/templates/genericTemplates/toggle.php +++ b/templates/genericTemplates/toggle.php @@ -1 +1,13 @@ -Form->postLink(__('Toggle'), ['action' => 'toggle', $entity->id, $fieldName], ['confirm' => __('Are you sure you want to toggle {0} of {1}?', $fieldName. $entity->id)]) ?> \ No newline at end of file +Form->postLink(__('Toggle'), ['action' => 'toggle', $entity->id, $fieldName], ['confirm' => __('Are you sure you want to toggle `{0}` of {1}?', h($fieldName), h($entity->id))]); +$formHTML = sprintf('
    %s
    ', $form); +$bodyHTML = $formHTML; + +$modalOptions = [ + 'title' => __('Are you sure you want to toggle `{0}` of {1}?', h($fieldName), h($entity->id)), + 'type' => 'confirm', + 'bodyHtml' => $bodyHTML, +]; + +echo $this->Bootstrap->modal($modalOptions); diff --git a/templates/layout/default.php b/templates/layout/default.php index c7d8209bc..6085e7e32 100644 --- a/templates/layout/default.php +++ b/templates/layout/default.php @@ -58,7 +58,7 @@ $sidebarOpen = $loggedUser->user_settings_by_name_with_fallback['ui.sidebar.expa Html->css('CodeMirror/addon/hint/show-hint') ?> Html->css('CodeMirror/addon/lint/lint') ?> Html->css('select2.min') ?> - Html->css('select2-bootstrap5') ?> + Html->css('select2-bootstrap5-vars') ?> Html->script('apexcharts.min') ?> Html->script('moment-with-locales.min') ?> Html->css('apexcharts') ?> diff --git a/templates/layout/login.php b/templates/layout/login.php index 42558a47c..d0f92a4bb 100644 --- a/templates/layout/login.php +++ b/templates/layout/login.php @@ -27,7 +27,7 @@ - +
    Flash->render() ?> fetch('content') ?> diff --git a/webroot/css/layout.css b/webroot/css/layout.css index d390ecd5a..8779bbd39 100644 --- a/webroot/css/layout.css +++ b/webroot/css/layout.css @@ -19,6 +19,10 @@ body { margin-bottom: 1rem; } +.top-navbar .left-navbar .navbar-brand { + margin-right: 0; +} + /* navbar-toggler breakpoint */ @media (max-width: 576px) { body { @@ -576,31 +580,65 @@ ul.sidebar-elements > li.category > span.category-divider > hr { .composed-app-icon-container { height: calc(var(--navbar-height) - 10px); - width: calc(var(--navbar-height) - 10px); + width: calc(var(--navbar-height) - 0px); position: relative; display: block; } .composed-app-icon-container > .app-icon { display: inline-block; - mask-image: url(/img/icon-composition/sheet-all.svg), url(/img/icon-composition/z.svg); - mask-position: 0 0, 2.4px calc((var(--navbar-height) - 10px) / 2); + mask-image: url(/img/icon-composition/sheet-all.svg), + url(/img/icon-composition/bubble.svg), + url(/img/icon-composition/bubble.svg), + url(/img/icon-composition/bubble.svg); + mask-position: 0 0, 0 0, 6px 0, 12px 0; mask-repeat: no-repeat; - mask-size: contain, cover; - mask-composite: source-out; - mask-composite: subtract; - -webkit-mask-image: url(/img/icon-composition/sheet-all.svg), url(/img/icon-composition/z.svg); - -webkit-mask-position: 0 0, 2.4px calc((var(--navbar-height) - 10px) / 2); + mask-size: contain, 100%, 100%, 100%; + mask-composite: xor; + -webkit-mask-image: url(/img/icon-composition/sheet-all.svg), + url(/img/icon-composition/bubble.svg), + url(/img/icon-composition/bubble.svg), + url(/img/icon-composition/bubble.svg); + -webkit-mask-position: 0 0, 0 0, 6px 0, 12px 0; -webkit-mask-repeat: no-repeat; - -webkit-mask-size: contain, cover; - -webkit-mask-composite: source-out; - -webkit-mask-composite: subtract; - transition-timing-function: ease-out; - transition-duration: 0.2s; - transition-property: -webkit-mask-position; + -webkit-mask-size: contain, 100%, 100%, 100%; + -webkit-mask-composite: xor; } .composed-app-icon-container > .app-icon:hover { - mask-position: 0 0, 2.4px calc(0.75 * (var(--navbar-height) - 10px) / 2); - -webkit-mask-position: 0 0, 2.4px calc(0.75 * (var(--navbar-height) - 10px) / 2); -} \ No newline at end of file + animation: MoveUpDown 1.5s linear infinite; + background: linear-gradient(0deg, #fff 0%, #fff 54%, #2fa1db 54%); +} + +@keyframes MoveUpDown { + 0%, 100% { + mask-size: contain, 100%, 100%, 100%; + -webkit-mask-size: contain, 100%, 100%, 100%; + mask-position: 0 0, 0 0, 6px 0, 12px 0; + -webkit-mask-position: 0 0, 0 0, 6px 0, 12px 0; + } + 5% { + mask-size: contain, 200%, 100%, 100%; + -webkit-mask-size: contain, 200%, 100%, 100%; + mask-position: 0 0, -30px -8px, 6px 0, 12px 0; + -webkit-mask-position: 0 0, -30px -8px, 6px 0, 12px 0; + } + 15% { + mask-size: contain, 100%, 200%, 100%; + -webkit-mask-size: contain, 100%, 200%, 100%; + mask-position: 0 0, 0 0, -24px -8px, 12px 0; + -webkit-mask-position: 0 0, 0 0, -24px -8px, 12px 0; + } + 25% { + mask-size: contain, 100%, 100%, 200%; + -webkit-mask-size: contain, 100%, 100%, 200%; + mask-position: 0 0, 0 0, 6px 0, -18.5px -8px; + -webkit-mask-position: 0 0, 0 0, 6px 0, -18.5px -8px; + } + 35% { + mask-size: contain, 100%, 100%, 100%; + -webkit-mask-size: contain, 100%, 100%, 100%; + mask-position: 0 0, 0 0, 6px 0, 12px 0; + -webkit-mask-position: 0 0, 0 0, 6px 0, 12px 0; + } +} diff --git a/webroot/css/login.css b/webroot/css/login.css index 044e3f2ea..0fd31958f 100644 --- a/webroot/css/login.css +++ b/webroot/css/login.css @@ -17,30 +17,6 @@ body { /* background by SVGBackgrounds.com */ } -.cerebrate-background-logo { - background-color: var(--application-color); - -webkit-mask: url(/img/icon-composition/z.svg) no-repeat; - mask: url(/img/icon-composition/z.svg) no-repeat; - -webkit-mask-size: 400px 400px; - mask-size: 400px 400px; - width: 400px; - height: 400px; - opacity: 0.15; - -webkit-transform: rotate(100deg); - transform: rotate(100deg); - -webkit-transform-origin: center; - transform-origin: center; - left: calc(50% - 120px); - top: 30%; -} - -.cerebrate-background-logo:hover { - -webkit-animation:spin 6s linear infinite; - animation:spin 6s linear infinite; - -webkit-animation-delay: 2s; - animation-delay: 2s; -} - @-webkit-keyframes spin { 100% { -webkit-transform: rotate(460deg); } } diff --git a/webroot/css/select2-bootstrap5-vars.css b/webroot/css/select2-bootstrap5-vars.css new file mode 100644 index 000000000..c628064b2 --- /dev/null +++ b/webroot/css/select2-bootstrap5-vars.css @@ -0,0 +1,516 @@ +/*! + * Select2 v4 Bootstrap 5 theme v1.1.1 +*/ +.select2-container--bootstrap-5 { + display: block; + } + .select2-container--bootstrap-5 *:focus { + outline: 0; + } + .select2-container--bootstrap-5 .select2-selection { + width: 100%; + min-height: calc(1.5em + (0.75rem + 2px)); + padding: 0.375rem 0.75rem; + font-family: inherit; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: var(--bs-body-color); + background-color: var(--bs-body-bg); + border: 1px solid var(--bs-gray-400); + border-radius: 0.25rem; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + } + @media (prefers-reduced-motion: reduce) { + .select2-container--bootstrap-5 .select2-selection { + transition: none; + } + } + .select2-container--bootstrap-5.select2-container--focus .select2-selection, .select2-container--bootstrap-5.select2-container--open .select2-selection { + border-color: rgba(var(--bs-primary-rgb), 0.5); + box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.25); + } + .select2-container--bootstrap-5.select2-container--open.select2-container--below .select2-selection { + border-bottom: 1px solid transparent; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + .select2-container--bootstrap-5.select2-container--open.select2-container--above .select2-selection { + border-top: 1px solid transparent; + border-top-left-radius: 0; + border-top-right-radius: 0; + } + .select2-container--bootstrap-5 .select2-search { + width: 100%; + } + .select2-container--bootstrap-5 .select2-selection--single .select2-selection__clear, + .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__clear { + position: absolute; + top: 50%; + right: 2.25rem; + width: 0.75rem; + height: 0.75rem; + padding: 0.25em 0.25em; + overflow: hidden; + text-indent: 100%; + white-space: nowrap; + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23676a6d'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/0.75rem auto no-repeat; + transform: translateY(-50%); + } + .select2-container--bootstrap-5 .select2-selection--single .select2-selection__clear:hover, + .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__clear:hover { + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/0.75rem auto no-repeat; + } + .select2-container--bootstrap-5 .select2-selection--single .select2-selection__clear > span, + .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__clear > span { + display: none; + } + + .select2-container--bootstrap-5 .select2-dropdown { + border-color: var(--bs-gray-400); + border-radius: 0.25rem; + } + .select2-container--bootstrap-5 .select2-dropdown.select2-dropdown--below { + border-top: 0 solid transparent; + border-top-left-radius: 0; + border-top-right-radius: 0; + } + .select2-container--bootstrap-5 .select2-dropdown.select2-dropdown--above { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + .select2-container--bootstrap-5 .select2-dropdown .select2-search { + padding: 0.375rem 0.75rem; + } + .select2-container--bootstrap-5 .select2-dropdown .select2-search .select2-search__field { + display: block; + width: 100%; + padding: 0.375rem 0.75rem; + font-family: inherit; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: var(--bs-body-color); + background-color: var(--bs-body-bg); + background-clip: padding-box; + border: 1px solid var(--bs-gray-400); + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + border-radius: 0.25rem; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + } + @media (prefers-reduced-motion: reduce) { + .select2-container--bootstrap-5 .select2-dropdown .select2-search .select2-search__field { + transition: none; + } + } + .select2-container--bootstrap-5 .select2-dropdown .select2-search .select2-search__field:focus { + border-color: rgba(var(--bs-primary-rgb), 0.5); + box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.25); + } + .select2-container--bootstrap-5 .select2-dropdown .select2-results__options:not(.select2-results__options--nested) { + max-height: 15rem; + overflow-y: auto; + } + .select2-container--bootstrap-5 .select2-dropdown .select2-results__options .select2-results__option { + padding: 0.375rem 0.75rem; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: var(--bs-body-color); + background-color: var(--bs-body-bg); + } + .select2-container--bootstrap-5 .select2-dropdown .select2-results__options .select2-results__option.select2-results__message { + color: var(--bs-secondary); + } + .select2-container--bootstrap-5 .select2-dropdown .select2-results__options .select2-results__option.select2-results__option--highlighted { + color: #000; + background-color: var(--bs-light); + } + .select2-container--bootstrap-5 .select2-dropdown .select2-results__options .select2-results__option.select2-results__option--selected, .select2-container--bootstrap-5 .select2-dropdown .select2-results__options .select2-results__option[aria-selected=true] { + color: var(--bs-body-color); + background-color: var(--bs-primary); + } + .select2-container--bootstrap-5 .select2-dropdown .select2-results__options .select2-results__option.select2-results__option--disabled, .select2-container--bootstrap-5 .select2-dropdown .select2-results__options .select2-results__option[aria-disabled=true] { + color: var(--bs-gray); + } + .select2-container--bootstrap-5 .select2-dropdown .select2-results__options .select2-results__option[role=group] { + padding: 0; + } + .select2-container--bootstrap-5 .select2-dropdown .select2-results__options .select2-results__option[role=group] .select2-results__group { + padding: 0.375rem 0.375rem; + font-weight: 500; + line-height: 1.5; + color: var(--bs-gray); + } + .select2-container--bootstrap-5 .select2-dropdown .select2-results__options .select2-results__option[role=group] .select2-results__options--nested .select2-results__option { + padding: 0.375rem 0.75rem; + } + + .select2-container--bootstrap-5 .select2-selection--single { + padding: 0.375rem 2.25rem 0.375rem 0.75rem; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + background-size: 16px 12px; + } + .select2-container--bootstrap-5 .select2-selection--single .select2-selection__rendered { + padding: 0; + font-weight: 400; + line-height: 1.5; + color: var(--bs-body-color); + } + .select2-container--bootstrap-5 .select2-selection--single .select2-selection__rendered .select2-selection__placeholder { + font-weight: 400; + line-height: 1.5; + color: var(--bs-body-color); + } + .select2-container--bootstrap-5 .select2-selection--single .select2-selection__rendered .select2-selection__arrow { + display: none; + } + + .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__rendered { + display: flex; + flex-direction: row; + flex-wrap: wrap; + padding-left: 0; + margin: 0; + list-style: none; + } + .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__rendered .select2-selection__choice { + display: flex; + flex-direction: row; + align-items: center; + padding: 0.35em 0.65em; + margin-right: 0.375rem; + margin-bottom: 0.375rem; + font-size: 1rem; + color: var(--bs-body-color); + cursor: auto; + border: 1px solid var(--bs-gray-400); + border-radius: 0.25rem; + } + .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__rendered .select2-selection__choice .select2-selection__choice__remove { + width: 0.75rem; + height: 0.75rem; + padding: 0.25em 0.25em; + margin-right: 0.25rem; + overflow: hidden; + text-indent: 100%; + white-space: nowrap; + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23676a6d'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/0.75rem auto no-repeat; + border: 0; + } + .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__rendered .select2-selection__choice .select2-selection__choice__remove:hover { + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/0.75rem auto no-repeat; + } + .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__rendered .select2-selection__choice .select2-selection__choice__remove > span { + display: none; + } + .select2-container--bootstrap-5 .select2-selection--multiple .select2-search { + display: block; + width: 100%; + height: 1.5rem; + } + .select2-container--bootstrap-5 .select2-selection--multiple .select2-search .select2-search__field { + width: 100%; + height: 1.5rem; + margin-top: 0; + margin-left: 0; + font-family: inherit; + line-height: 1.5; + background-color: transparent; + } + .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__clear { + right: 0.75rem; + } + + .select2-container--bootstrap-5.select2-container--disabled .select2-selection, .select2-container--bootstrap-5.select2-container--disabled.select2-container--focus .select2-selection { + color: var(--bs-secondary); + cursor: not-allowed; + background-color: var(--bs-gray-200); + border-color: var(--bs-gray-400); + box-shadow: none; + } + .select2-container--bootstrap-5.select2-container--disabled .select2-selection--multiple .select2-selection__clear, .select2-container--bootstrap-5.select2-container--disabled.select2-container--focus .select2-selection--multiple .select2-selection__clear { + display: none; + } + .select2-container--bootstrap-5.select2-container--disabled .select2-selection--multiple .select2-selection__choice, .select2-container--bootstrap-5.select2-container--disabled.select2-container--focus .select2-selection--multiple .select2-selection__choice { + cursor: not-allowed; + } + .select2-container--bootstrap-5.select2-container--disabled .select2-selection--multiple .select2-selection__choice .select2-selection__choice__remove, .select2-container--bootstrap-5.select2-container--disabled.select2-container--focus .select2-selection--multiple .select2-selection__choice .select2-selection__choice__remove { + display: none; + } + .select2-container--bootstrap-5.select2-container--disabled .select2-selection--multiple .select2-selection__rendered:not(:empty), .select2-container--bootstrap-5.select2-container--disabled.select2-container--focus .select2-selection--multiple .select2-selection__rendered:not(:empty) { + padding-bottom: 0; + } + .select2-container--bootstrap-5.select2-container--disabled .select2-selection--multiple .select2-selection__rendered:not(:empty) + .select2-search, .select2-container--bootstrap-5.select2-container--disabled.select2-container--focus .select2-selection--multiple .select2-selection__rendered:not(:empty) + .select2-search { + display: none; + } + + .input-group:not(.has-validation) > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu).select2-container--bootstrap-5 .select2-selection { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + .input-group.has-validation > :nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu).select2-container--bootstrap-5 .select2-selection { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + .input-group > .input-group-text ~ .select2-container--bootstrap-5 .select2-selection, + .input-group > .btn ~ .select2-container--bootstrap-5 .select2-selection, + .input-group > .dropdown-menu ~ .select2-container--bootstrap-5 .select2-selection { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + .input-group .select2-container--bootstrap-5 { + flex-grow: 1; + } + .input-group .select2-container--bootstrap-5 .select2-selection { + height: 100%; + } + + .is-valid + .select2-container--bootstrap-5 .select2-selection, form.was-validated select:valid + .select2-container--bootstrap-5 .select2-selection { + border-color: var(--bs-success); + } + .is-valid + .select2-container--bootstrap-5.select2-container--focus .select2-selection, .is-valid + .select2-container--bootstrap-5.select2-container--open .select2-selection, form.was-validated select:valid + .select2-container--bootstrap-5.select2-container--focus .select2-selection, form.was-validated select:valid + .select2-container--bootstrap-5.select2-container--open .select2-selection { + border-color: var(--bs-success); + box-shadow: 0 0 0 0.25rem rgba( var(--bs-success-rgb), 0.25); + } + .is-valid + .select2-container--bootstrap-5.select2-container--open.select2-container--below .select2-selection, form.was-validated select:valid + .select2-container--bootstrap-5.select2-container--open.select2-container--below .select2-selection { + border-bottom: 1px solid transparent; + } + .is-valid + .select2-container--bootstrap-5.select2-container--open.select2-container--above .select2-selection, form.was-validated select:valid + .select2-container--bootstrap-5.select2-container--open.select2-container--above .select2-selection { + border-top: 1px solid transparent; + border-top-left-radius: 0; + border-top-right-radius: 0; + } + .is-invalid + .select2-container--bootstrap-5 .select2-selection, form.was-validated select:invalid + .select2-container--bootstrap-5 .select2-selection { + border-color: var(--bs-danger); + } + .is-invalid + .select2-container--bootstrap-5.select2-container--focus .select2-selection, .is-invalid + .select2-container--bootstrap-5.select2-container--open .select2-selection, form.was-validated select:invalid + .select2-container--bootstrap-5.select2-container--focus .select2-selection, form.was-validated select:invalid + .select2-container--bootstrap-5.select2-container--open .select2-selection { + border-color: var(--bs-danger); + box-shadow: 0 0 0 0.25rem rgba( var(--bs-danger-rgb), 0.25); + } + .is-invalid + .select2-container--bootstrap-5.select2-container--open.select2-container--below .select2-selection, form.was-validated select:invalid + .select2-container--bootstrap-5.select2-container--open.select2-container--below .select2-selection { + border-bottom: 1px solid transparent; + } + .is-invalid + .select2-container--bootstrap-5.select2-container--open.select2-container--above .select2-selection, form.was-validated select:invalid + .select2-container--bootstrap-5.select2-container--open.select2-container--above .select2-selection { + border-top: 1px solid transparent; + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + .select2-container--bootstrap-5 .select2--small ~ .select2-selection { + min-height: calc(1.5em + (0.5rem + 2px)); + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + } + .select2-container--bootstrap-5 .select2--small ~ .select2-selection--single .select2-selection__clear, + .select2-container--bootstrap-5 .select2--small ~ .select2-selection--multiple .select2-selection__clear { + width: 0.5rem; + height: 0.5rem; + padding: 0.125rem 0.125rem; + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23676a6d'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/0.5rem auto no-repeat; + } + .select2-container--bootstrap-5 .select2--small ~ .select2-selection--single .select2-selection__clear:hover, + .select2-container--bootstrap-5 .select2--small ~ .select2-selection--multiple .select2-selection__clear:hover { + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/0.5rem auto no-repeat; + } + .select2-container--bootstrap-5 .select2--small ~ .select2-dropdown .select2-search .select2-search__field { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + } + .select2-container--bootstrap-5 .select2--small ~ .select2-dropdown .select2-results__options .select2-results__option { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + } + .select2-container--bootstrap-5 .select2--small ~ .select2-dropdown .select2-results__options .select2-results__option[role=group] .select2-results__group { + padding: 0.25rem 0.25rem; + } + .select2-container--bootstrap-5 .select2--small ~ .select2-dropdown .select2-results__options .select2-results__option[role=group] .select2-results__options--nested .select2-results__option { + padding: 0.25rem 0.5rem; + } + .select2-container--bootstrap-5 .select2--small ~ .select2-selection--single { + padding: 0.25rem 2.25rem 0.25rem 0.5rem; + } + .select2-container--bootstrap-5 .select2--small ~ .select2-selection--multiple .select2-selection__rendered:not(:empty) { + padding-bottom: 0.25rem; + } + .select2-container--bootstrap-5 .select2--small ~ .select2-selection--multiple .select2-selection__rendered .select2-selection__choice { + padding: 0.35em 0.65em; + font-size: 0.875rem; + } + .select2-container--bootstrap-5 .select2--small ~ .select2-selection--multiple .select2-selection__rendered .select2-selection__choice .select2-selection__choice__remove { + width: 0.5rem; + height: 0.5rem; + padding: 0.125rem 0.125rem; + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23676a6d'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/0.5rem auto no-repeat; + } + .select2-container--bootstrap-5 .select2--small ~ .select2-selection--multiple .select2-selection__rendered .select2-selection__choice .select2-selection__choice__remove:hover { + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/0.5rem auto no-repeat; + } + .select2-container--bootstrap-5 .select2--small ~ .select2-selection--multiple .select2-selection__clear { + right: 0.5rem; + } + .select2-container--bootstrap-5 .select2--large ~ .select2-selection { + min-height: calc(1.5em + (1rem + 2px)); + padding: 0.5rem 1rem; + font-size: 1.25rem; + } + .select2-container--bootstrap-5 .select2--large ~ .select2-selection--single .select2-selection__clear, + .select2-container--bootstrap-5 .select2--large ~ .select2-selection--multiple .select2-selection__clear { + width: 1rem; + height: 1rem; + padding: 0.5rem 0.5rem; + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23676a6d'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1rem auto no-repeat; + } + .select2-container--bootstrap-5 .select2--large ~ .select2-selection--single .select2-selection__clear:hover, + .select2-container--bootstrap-5 .select2--large ~ .select2-selection--multiple .select2-selection__clear:hover { + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1rem auto no-repeat; + } + .select2-container--bootstrap-5 .select2--large ~ .select2-dropdown .select2-search .select2-search__field { + padding: 0.5rem 1rem; + font-size: 1.25rem; + } + .select2-container--bootstrap-5 .select2--large ~ .select2-dropdown .select2-results__options .select2-results__option { + padding: 0.5rem 1rem; + font-size: 1.25rem; + } + .select2-container--bootstrap-5 .select2--large ~ .select2-dropdown .select2-results__options .select2-results__option[role=group] .select2-results__group { + padding: 0.5rem 0.5rem; + } + .select2-container--bootstrap-5 .select2--large ~ .select2-dropdown .select2-results__options .select2-results__option[role=group] .select2-results__options--nested .select2-results__option { + padding: 0.5rem 1rem; + } + .select2-container--bootstrap-5 .select2--large ~ .select2-selection--single { + padding: 0.5rem 2.25rem 0.5rem 1rem; + } + .select2-container--bootstrap-5 .select2--large ~ .select2-selection--multiple .select2-selection__rendered:not(:empty) { + padding-bottom: 0.5rem; + } + .select2-container--bootstrap-5 .select2--large ~ .select2-selection--multiple .select2-selection__rendered .select2-selection__choice { + padding: 0.35em 0.65em; + font-size: 1.25rem; + } + .select2-container--bootstrap-5 .select2--large ~ .select2-selection--multiple .select2-selection__rendered .select2-selection__choice .select2-selection__choice__remove { + width: 1rem; + height: 1rem; + padding: 0.5rem 0.5rem; + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23676a6d'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1rem auto no-repeat; + } + .select2-container--bootstrap-5 .select2--large ~ .select2-selection--multiple .select2-selection__rendered .select2-selection__choice .select2-selection__choice__remove:hover { + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1rem auto no-repeat; + } + .select2-container--bootstrap-5 .select2--large ~ .select2-selection--multiple .select2-selection__clear { + right: 1rem; + } + + .form-select-sm ~ .select2-container--bootstrap-5 .select2-selection { + min-height: calc(1.5em + (0.5rem + 2px)); + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + } + .form-select-sm ~ .select2-container--bootstrap-5 .select2-selection--single .select2-selection__clear, + .form-select-sm ~ .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__clear { + width: 0.5rem; + height: 0.5rem; + padding: 0.125rem 0.125rem; + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23676a6d'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/0.5rem auto no-repeat; + } + .form-select-sm ~ .select2-container--bootstrap-5 .select2-selection--single .select2-selection__clear:hover, + .form-select-sm ~ .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__clear:hover { + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/0.5rem auto no-repeat; + } + .form-select-sm ~ .select2-container--bootstrap-5 .select2-dropdown .select2-search .select2-search__field { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + } + .form-select-sm ~ .select2-container--bootstrap-5 .select2-dropdown .select2-results__options .select2-results__option { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + } + .form-select-sm ~ .select2-container--bootstrap-5 .select2-dropdown .select2-results__options .select2-results__option[role=group] .select2-results__group { + padding: 0.25rem 0.25rem; + } + .form-select-sm ~ .select2-container--bootstrap-5 .select2-dropdown .select2-results__options .select2-results__option[role=group] .select2-results__options--nested .select2-results__option { + padding: 0.25rem 0.5rem; + } + .form-select-sm ~ .select2-container--bootstrap-5 .select2-selection--single { + padding: 0.25rem 2.25rem 0.25rem 0.5rem; + } + .form-select-sm ~ .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__rendered:not(:empty) { + padding-bottom: 0.25rem; + } + .form-select-sm ~ .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__rendered .select2-selection__choice { + padding: 0.35em 0.65em; + font-size: 0.875rem; + } + .form-select-sm ~ .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__rendered .select2-selection__choice .select2-selection__choice__remove { + width: 0.5rem; + height: 0.5rem; + padding: 0.125rem 0.125rem; + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23676a6d'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/0.5rem auto no-repeat; + } + .form-select-sm ~ .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__rendered .select2-selection__choice .select2-selection__choice__remove:hover { + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/0.5rem auto no-repeat; + } + .form-select-sm ~ .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__clear { + right: 0.5rem; + } + + .form-select-lg ~ .select2-container--bootstrap-5 .select2-selection { + min-height: calc(1.5em + (1rem + 2px)); + padding: 0.5rem 1rem; + font-size: 1.25rem; + } + .form-select-lg ~ .select2-container--bootstrap-5 .select2-selection--single .select2-selection__clear, + .form-select-lg ~ .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__clear { + width: 1rem; + height: 1rem; + padding: 0.5rem 0.5rem; + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23676a6d'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1rem auto no-repeat; + } + .form-select-lg ~ .select2-container--bootstrap-5 .select2-selection--single .select2-selection__clear:hover, + .form-select-lg ~ .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__clear:hover { + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1rem auto no-repeat; + } + .form-select-lg ~ .select2-container--bootstrap-5 .select2-dropdown .select2-search .select2-search__field { + padding: 0.5rem 1rem; + font-size: 1.25rem; + } + .form-select-lg ~ .select2-container--bootstrap-5 .select2-dropdown .select2-results__options .select2-results__option { + padding: 0.5rem 1rem; + font-size: 1.25rem; + } + .form-select-lg ~ .select2-container--bootstrap-5 .select2-dropdown .select2-results__options .select2-results__option[role=group] .select2-results__group { + padding: 0.5rem 0.5rem; + } + .form-select-lg ~ .select2-container--bootstrap-5 .select2-dropdown .select2-results__options .select2-results__option[role=group] .select2-results__options--nested .select2-results__option { + padding: 0.5rem 1rem; + } + .form-select-lg ~ .select2-container--bootstrap-5 .select2-selection--single { + padding: 0.5rem 2.25rem 0.5rem 1rem; + } + .form-select-lg ~ .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__rendered:not(:empty) { + padding-bottom: 0.5rem; + } + .form-select-lg ~ .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__rendered .select2-selection__choice { + padding: 0.35em 0.65em; + font-size: 1.25rem; + } + .form-select-lg ~ .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__rendered .select2-selection__choice .select2-selection__choice__remove { + width: 1rem; + height: 1rem; + padding: 0.5rem 0.5rem; + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23676a6d'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1rem auto no-repeat; + } + .form-select-lg ~ .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__rendered .select2-selection__choice .select2-selection__choice__remove:hover { + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1rem auto no-repeat; + } + .form-select-lg ~ .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__clear { + right: 1rem; + } \ No newline at end of file diff --git a/webroot/img/icon-composition/bubble.svg b/webroot/img/icon-composition/bubble.svg new file mode 100644 index 000000000..89bc28fd5 --- /dev/null +++ b/webroot/img/icon-composition/bubble.svg @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/webroot/img/icon-composition/sheet-all.svg b/webroot/img/icon-composition/sheet-all.svg index e6054714c..6540a0f17 100644 --- a/webroot/img/icon-composition/sheet-all.svg +++ b/webroot/img/icon-composition/sheet-all.svg @@ -2,2479 +2,167 @@ + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + width="67.622406mm" + height="49.558979mm" + viewBox="0 0 67.622406 49.558979" + version="1.1" + id="svg108" + inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)" + sodipodi:docname="sheet-all.svg"> + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + inkscape:window-maximized="0" /> + + + + image/svg+xml + + + + + - - - - - + transform="translate(-6.2542725,-124.80979)"> - - - - - - - - - - CEREBRATE Project - - - - - - - - - - - - + d="M 0,0 H 14.014 L 22.629,-14.129 31.245,0 H 45.258 V -40.204 H 31.875 v 19.93 l -9.246,-14.302 h -0.23 l -9.247,14.302 v -19.93 H 0 Z" + style="fill:#5f6062;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path1078" + inkscape:connector-curvature="0" /> - - - - - - - + inkscape:export-ydpi="100" /> - - - - - - - - - - + inkscape:export-ydpi="100"> + + + + + + + + + + Threat Sharing + + + + + + + + + + diff --git a/webroot/img/icon-composition/z.svg b/webroot/img/icon-composition/z.svg deleted file mode 100644 index 9f01fec57..000000000 --- a/webroot/img/icon-composition/z.svg +++ /dev/null @@ -1,2659 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - CEREBRATE Project - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/webroot/js/api-helper.js b/webroot/js/api-helper.js index 9062c23d6..d01a94133 100644 --- a/webroot/js/api-helper.js +++ b/webroot/js/api-helper.js @@ -359,6 +359,9 @@ class AJAXApi { if (!skipRequestHooks) { this.beforeRequest() } + if (form === undefined || form.nodeName !== 'FORM') { + throw new Error(`Form argument must be a valid HTMLFormELement.`) + } let toReturn let feedbackShown = false try { @@ -381,7 +384,7 @@ class AJAXApi { this.provideSuccessFeedback(data, {}, skipFeedback) toReturn = data; } else { - this.provideFailureFeedback(data, {}, skipFeedback) + this.provideFailureFeedback(data, {}, false) feedbackShown = true this.injectFormValidationFeedback(form, data.errors) toReturn = Promise.reject(data.errors); diff --git a/webroot/js/bootstrap-helper.js b/webroot/js/bootstrap-helper.js index 1d5a2ae2e..d29d0c711 100644 --- a/webroot/js/bootstrap-helper.js +++ b/webroot/js/bootstrap-helper.js @@ -24,6 +24,18 @@ class UIFactory { return theModal } + /** + * Create a popover + * @param {Object} element - The target element on which to attach the popover + * @param {Object} options - The options to be passed to the PopoverFactory class + * @return {Object} The PopoverFactory object + */ + popover(element, options) { + const thePopover = new PopoverFactory(element, options); + thePopover.makePopover() + return thePopover + } + /** * Create and display a modal where the modal's content is fetched from the provided URL. Link an AJAXApi to the submission button * @param {string} url - The URL from which the modal's content should be fetched @@ -48,7 +60,33 @@ class UIFactory { }).catch((error) => { UI.toast({ variant: 'danger', - title: 'Error while loading the processor', + title: 'Error while loading the modal', + body: error.message + }) + }) + } + + /** + * Create and display a modal where the modal's content is fetched from the provided URL + * @param {string} url - The URL from which the modal's content should be fetched + * @param {Object=[]} modalOptions - Additional options to be passed to the modal constructor + * @return {Promise} Promise object resolving to the ModalFactory object + */ + async modalFromUrl(url, modalOptions={}) { + return AJAXApi.quickFetchURL(url).then((modalHTML) => { + const defaultOptions = { + rawHtml: modalHTML, + } + const options = Object.assign({}, defaultOptions, modalOptions) + const theModal = new ModalFactory(options); + theModal.makeModal() + theModal.show() + theModal.$modal.data('modalObject', theModal) + return [theModal, theModal.ajaxApi] + }).catch((error) => { + UI.toast({ + variant: 'danger', + title: 'Error while loading the modal', body: error.message }) }) @@ -171,7 +209,6 @@ class UIFactory { } return UI.submissionModal(url, successCallback) } - /** * Creates and displays a modal where the modal's content is fetched from the provided URL. Reloads the provided element after a successful operation. @@ -247,6 +284,67 @@ class UIFactory { }) return promise } + + /** + * Place an overlay onto a node and remove it whenever the promise resolves + * @param {(jQuery|string)} node - The node on which the confirm popover should be palced + * @param {Object} options - The options to be passed to the overlay class + * @return {Promise} Result of the passed promised + */ + quickConfirm(node, options={}) { + const $node = $(node) + const defaultOptions = { + title: 'Confirm action', + description: '', + descriptionHtml: false, + container: 'body', + variant: 'success', + confirmText: 'Confirm', + confirm: function() {} + } + options = Object.assign({}, defaultOptions, options) + options.description = options.descriptionHtml ? options.descriptionHtml : sanitize(options.description) + const popoverOptions = { + title: options.title, + titleHtml: options.titleHtml, + container: options.container, + html: true, + } + + var promiseResolve, promiseReject; + const confirmPromise = new Promise(function (resolve, reject) { + promiseResolve = resolve; + promiseReject = reject; + }) + popoverOptions.content = function() { + const $node = $(this) + const $container = $('
    ') + const $buttonCancel = $('Cancel') + .click(function() { + const popover = bootstrap.Popover.getInstance($node[0]) + popover.dispose() + }) + const $buttonSubmit = $(`${options.confirmText}`) + .click(function() { + options.confirm() + .then(function(result) { + promiseResolve(result) + }) + .catch(function(error) { + promiseReject(error) + }) + const popover = bootstrap.Popover.getInstance($node[0]) + popover.dispose() + }) + $container.append(`

    ${options.description}

    `) + $container.append($(`
    `).append($buttonCancel, $buttonSubmit)) + return $container + } + + const thePopover = this.popover($node, popoverOptions) + thePopover.show() + return confirmPromise // have to return a promise to avoid closing the modal + } } /** Class representing a Toast */ @@ -257,6 +355,9 @@ class Toaster { */ constructor(options) { this.options = Object.assign({}, Toaster.defaultOptions, options) + if (this.options.delay == 'auto') { + this.options.delay = this.computeDelay() + } this.bsToastOptions = { autohide: this.options.autohide, delay: this.options.delay, @@ -271,7 +372,7 @@ class Toaster { * @property {string} body - The body's content of the toast * @property {string=('primary'|'secondary'|'success'|'danger'|'warning'|'info'|'light'|'dark'|'white'|'transparent')} variant - The variant of the toast * @property {boolean} autohide - If the toast show be hidden after some time defined by the delay - * @property {number} delay - The number of milliseconds the toast should stay visible before being hidden + * @property {(number|string)} delay - The number of milliseconds the toast should stay visible before being hidden or 'auto' to deduce the delay based on the content * @property {(jQuery|string)} titleHtml - The raw HTML title's content of the toast * @property {(jQuery|string)} mutedHtml - The raw HTML muted's content of the toast * @property {(jQuery|string)} bodyHtml - The raw HTML body's content of the toast @@ -284,7 +385,7 @@ class Toaster { body: false, variant: 'default', autohide: true, - delay: 5000, + delay: 'auto', titleHtml: false, mutedHtml: false, bodyHtml: false, @@ -350,18 +451,16 @@ class Toaster { $toast.attr('id', options.id) } $toast.addClass('toast-' + options.variant) - if (options.title !== false || options.titleHtml !== false || options.muted !== false || options.mutedHtml !== false) { + if (options.title !== false || options.titleHtml !== false || options.muted !== false || options.mutedHtml !== false || options.closeButton) { var $toastHeader = $('
    ') $toastHeader.addClass('toast-' + options.variant) - if (options.title !== false || options.titleHtml !== false) { - var $toastHeaderText - if (options.titleHtml !== false) { - $toastHeaderText = $('
    ').html(options.titleHtml); - } else { - $toastHeaderText = $('').text(options.title) - } - $toastHeader.append($toastHeaderText) + let $toastHeaderText = $('') + if (options.titleHtml !== false) { + $toastHeaderText = $('
    ').html(options.titleHtml); + } else if (options.title !== false) { + $toastHeaderText = $('').text(options.title) } + $toastHeader.append($toastHeaderText) if (options.muted !== false || options.mutedHtml !== false) { var $toastHeaderMuted if (options.mutedHtml !== false) { @@ -391,6 +490,12 @@ class Toaster { } return $toast } + + computeDelay() { + return 3000 + + 40*((this.options.title?.length ?? 0) + (this.options.body?.length ?? 0)) + + (['danger', 'warning'].includes(this.options.variant) ? 5000 : 0) + } } /** Class representing a Modal */ @@ -402,15 +507,16 @@ class ModalFactory { constructor(options) { this.options = Object.assign({}, ModalFactory.defaultOptions, options) if (options.POSTSuccessCallback !== undefined) { - if (this.options.rawHtml) { - this.attachSubmitButtonListener = true - } else { + if (!this.options.rawHtml) { UI.toast({ variant: 'danger', bodyHtml: 'POSTSuccessCallback can only be used in conjuction with the rawHtml option. Instead, use the promise instead returned by the API call in APIConfirm.' }) } } + if (this.options.rawHtml) { + this.attachSubmitButtonListener = true + } if (options.type === undefined && options.cancel !== undefined) { this.options.type = 'confirm' } @@ -428,13 +534,13 @@ class ModalFactory { */ /** * @callback ModalFactory~confirm - * @param {ModalFactory~closeModalFunction} closeFunction - A function that will close the modal if called + * @param {ModalFactory~confirm} closeFunction - A function that will close the modal if called * @param {Object} modalFactory - The instance of the ModalFactory * @param {Object} evt - The event that triggered the confirm operation */ /** * @callback ModalFactory~cancel - * @param {ModalFactory~closeModalFunction} closeFunction - A function that will close the modal if called + * @param {ModalFactory~cancel} closeFunction - A function that will close the modal if called * @param {Object} modalFactory - The instance of the ModalFactory * @param {Object} evt - The event that triggered the confirm operation */ @@ -621,6 +727,9 @@ class ModalFactory { } } else { $modalDialog = $('