├── .gitignore ├── .gitlab-ci.yml ├── .travis.yml ├── Bootstrap.php ├── Module.php ├── README.md ├── assets ├── PagesBackendAsset.php ├── PagesFrontendAsset.php ├── backend │ ├── page-route-items.js │ └── page-select.js └── frontend │ ├── backend.less │ ├── frontend.js │ └── frontend.less ├── components └── PageUrlRule.php ├── composer.json ├── controllers ├── DefaultController.php ├── TestController.php ├── api │ └── DefaultController.php └── crud │ └── TreeTranslationController.php ├── example-views └── column1.php ├── helpers └── PageHelper.php ├── migrations ├── m150309_153255_create_tree_manager_table.php ├── m150623_164544_auth_items.php ├── m150918_031100_auth_items.php ├── m160411_082658_rename_name_id_column.php ├── m160411_111111_name_id_to_domain_id_renamer.php ├── m161029_011345_settings.php ├── m161118_101349_alter_charset_to_utf8.php ├── m170220_121800_auth_items.php ├── m170314_062644_set_default_access_read.php ├── m170314_111404_remove_owner_column.php ├── m170315_221005_set_default_update_and_delete_access.php ├── m170322_204909_update_timestamp_columns.php ├── m170327_120427_update_icon_column.php ├── m180321_090927_add_translation_table.php ├── m180321_103245_alter_table_names.php ├── m180702_153622_add_translation_meta_table.php └── workbench │ └── dmstr_yii2-pages-module.mwb ├── models ├── BaseTree.php ├── Tree.php ├── TreeCache.php ├── TreeTranslation.php └── TreeTranslationMeta.php ├── tests ├── .dockerignore ├── .env ├── Dockerfile ├── Makefile ├── codeception.yml ├── codeception │ ├── _bootstrap.php │ ├── _config │ │ └── codeception-module.php │ ├── _pages │ │ └── LoginPage.php │ ├── _support │ │ ├── CliTester.php │ │ ├── E2eTester.php │ │ ├── FunctionalTester.php │ │ ├── Helper │ │ │ ├── Acceptance.php │ │ │ ├── Cli.php │ │ │ ├── E2e.php │ │ │ ├── Functional.php │ │ │ └── Unit.php │ │ └── UnitTester.php │ ├── cli.suite.yml │ ├── cli │ │ └── _bootstrap.php │ ├── e2e.suite.yml │ ├── e2e │ │ ├── 00-base │ │ │ └── BaseCept.php │ │ ├── UrlCept.php │ │ └── _bootstrap.php │ ├── functional.suite.yml │ ├── functional │ │ └── _bootstrap.php │ ├── unit.suite.yml │ └── unit │ │ ├── ApplicationTest.php │ │ ├── ModelTest.php │ │ ├── UrlTest.php │ │ └── _bootstrap.php ├── db.env ├── docker-compose.yml ├── migrations │ ├── m160415_095116_add_root_node.php │ └── m170315_215033_update_nodes_default_permission.php └── project │ ├── composer.json │ ├── composer.lock │ ├── config │ ├── rbac │ │ ├── assignments.php │ │ ├── items.php │ │ └── rules.php │ ├── test.php │ └── web-debug.php │ └── src │ └── components │ └── EditorIdentity.php ├── traits └── RequestParamActionTrait.php └── views ├── default └── index.php ├── test └── index.php └── treeview └── _form.php /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .php_cs.cache 3 | 4 | tests/vendor 5 | tests/codeception/_output 6 | tests/codeception/_support/_generated 7 | 8 | migrations/workbench/dmstr_yii2-pages-module.mwb.bak 9 | 10 | tests/project/vendor 11 | tests/_output 12 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | before_script: 2 | - export BUILD_PREFIX=buildpipeline${CI_PIPELINE_ID} 3 | - export COMPOSE_PROJECT_NAME=${BUILD_PREFIX}yii2pages 4 | - cd tests 5 | 6 | after_script: 7 | - export BUILD_PREFIX=buildpipeline${CI_PIPELINE_ID} 8 | - export COMPOSE_PROJECT_NAME=${BUILD_PREFIX}yii2pages 9 | - cd tests 10 | - make clean 11 | 12 | stages: 13 | - test 14 | 15 | test: 16 | stage: test 17 | script: 18 | - make all 19 | - make run-tests 20 | artifacts: 21 | name: ${CI_PROJECT_PATH}-p${CI_PIPELINE_ID}-codeception 22 | when: always 23 | expire_in: 4 weeks 24 | paths: 25 | - tests/codeception/_output 26 | 27 | lint: 28 | stage: test 29 | script: 30 | - export COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}lint 31 | - make build 32 | - make lint 33 | allow_failure: true 34 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | sudo: required 3 | 4 | language: generic 5 | 6 | services: 7 | - docker 8 | 9 | env: 10 | global: 11 | - DOCKER_COMPOSE_VERSION=1.13.0 12 | matrix: 13 | - DB_SERVICE_IMAGE=percona:5.7 14 | - DB_SERVICE_IMAGE=mariadb:10.1.22 15 | 16 | before_install: 17 | - git fetch --unshallow 18 | - sudo rm /usr/local/bin/docker-compose || true 19 | - curl -L https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-`uname -s`-`uname -m` > docker-compose 20 | - chmod +x docker-compose 21 | - sudo mv docker-compose /usr/local/bin 22 | 23 | before_script: 24 | - cd tests 25 | - make all 26 | 27 | script: 28 | - make run-tests 29 | 30 | after_script: 31 | - make clean 32 | -------------------------------------------------------------------------------- /Bootstrap.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class Bootstrap implements BootstrapInterface 23 | { 24 | /** 25 | * Bootstrap method to be called during application bootstrap stage. 26 | * 27 | * @param Application $app the application currently running 28 | */ 29 | public function bootstrap($app) 30 | { 31 | // register migration 32 | $app->params['yii.migrations'][] = '@vendor/dmstr/yii2-pages-module/migrations'; 33 | 34 | // register module 35 | if (\Yii::$app->hasModule('pages') && !\Yii::$app->hasModule('treemanager')) { 36 | $app->setModule( 37 | 'treemanager', 38 | [ 39 | 'class' => 'kartik\tree\Module', 40 | 'layout' => '@admin-views/layouts/main', 41 | 'treeViewSettings' => [ 42 | 'nodeView' => '@vendor/dmstr/yii2-pages-module/views/treeview/_form', 43 | 'fontAwesome' => true, 44 | ], 45 | 46 | ] 47 | ); 48 | } 49 | 50 | // provide default page url rule 51 | $app->urlManager->addRules( 52 | [ 53 | // pages default page route 54 | ['class' => PageUrlRule::className()], 55 | [ 56 | 'pattern' => 'p/<'.Tree::REQUEST_PARAM_PATH.':[a-zA-Z0-9_\-\./\+]*>/<'.Tree::REQUEST_PARAM_SLUG.':[a-zA-Z0-9_\-\.]*>-<'.Tree::REQUEST_PARAM_ID.':[0-9]*>.html', 57 | 'route' => 'pages/default/page', 58 | 'encodeParams' => false, 59 | ], 60 | 'p/<'.Tree::REQUEST_PARAM_SLUG.':[a-zA-Z0-9_\-\.]*>-<'.Tree::REQUEST_PARAM_ID.':[0-9]*>.html' => 'pages/default/page', 61 | 62 | // Backward compatibility 63 | 'page/<'.Tree::REQUEST_PARAM_PATH.':[a-zA-Z0-9_\-\./\+]*>/<'.Tree::REQUEST_PARAM_SLUG.':[a-zA-Z0-9_\-\.]*>-<'.Tree::REQUEST_PARAM_ID.':[0-9]*>.html' => 'pages/default/page', 64 | 'page/<'.Tree::REQUEST_PARAM_SLUG.':[a-zA-Z0-9_\-\.]*>-<'.Tree::REQUEST_PARAM_ID.':[0-9]*>.html' => 'pages/default/page', 65 | '<'.Tree::REQUEST_PARAM_PATH.':[a-zA-Z0-9_\-\./\+]*>/<'.Tree::REQUEST_PARAM_SLUG.':[a-zA-Z0-9_\-\.]*>-<'.Tree::REQUEST_PARAM_ID.':[0-9]*>' => 'pages/default/page', 66 | '<'.Tree::REQUEST_PARAM_SLUG.':[a-zA-Z0-9_\-\.]*>-<'.Tree::REQUEST_PARAM_ID.':[0-9]*>' => 'pages/default/page', 67 | ], 68 | true 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Module.php: -------------------------------------------------------------------------------- 1 | 20 | * 21 | */ 22 | class Module extends \yii\base\Module 23 | { 24 | use AccessBehaviorTrait; 25 | 26 | /** 27 | * The name of this module 28 | */ 29 | const NAME = 'pages'; 30 | 31 | /** 32 | * @var array the list of rights that are allowed to access this module. 33 | * If you modify, you also need to enable authManager. 34 | * http://www.yiiframework.com/doc-2.0/guide-security-authorization.html 35 | */ 36 | public $roles = []; 37 | 38 | /** 39 | * alias for the pages/default/page action 40 | * 41 | * @var string 42 | */ 43 | public $defaultPageLayout = '@app/views/layouts/main'; 44 | 45 | /** 46 | * @var array 47 | */ 48 | public $availableRoutes = []; 49 | 50 | /** 51 | * @var array 52 | */ 53 | public $availableViews = []; 54 | 55 | /** 56 | * Whether access_domain should be used as constraint in default/page action select 57 | * 58 | * @var bool 59 | */ 60 | public $pageCheckAccessDomain = false; 61 | 62 | /** 63 | * Whether to search fallbackPage according to domain_id 64 | * 65 | * see: \dmstr\modules\pages\controllers\DefaultController::resolveFallbackPage 66 | * @var bool 67 | */ 68 | public $pageUseFallbackPage = true; 69 | 70 | /** 71 | * @inheritdoc 72 | */ 73 | public function init() 74 | { 75 | parent::init(); 76 | 77 | // add routes from settings module 78 | if (self::checkSettingsInstalled()) { 79 | $routes = explode("\n", (string)\Yii::$app->settings->get('pages.availableRoutes')); 80 | foreach ($routes as $route) { 81 | $routeEntry = trim($route); 82 | $this->availableRoutes[$routeEntry] = $routeEntry; 83 | } 84 | 85 | $views = explode("\n", (string)\Yii::$app->settings->get('pages.availableViews')); 86 | foreach ($views as $view) { 87 | // use custom name if appended after a semicolon (;) 88 | $viewEntry = explode(';', trim($view)); 89 | $this->availableViews[$viewEntry[0]] = $viewEntry[1] ?? $viewEntry[0]; 90 | } 91 | 92 | if (!\Yii::$app instanceof Application && \Yii::$app->has('user') && \Yii::$app->user->can(Tree::GLOBAL_ACCESS_PERMISSION)) { 93 | $globalRoutes = explode("\n", (string)\Yii::$app->settings->get('pages.availableGlobalRoutes')); 94 | foreach ($globalRoutes as $globalRoute) { 95 | $globalRouteEntry = trim($globalRoute); 96 | $this->availableRoutes[$globalRouteEntry] = $globalRouteEntry; 97 | } 98 | 99 | $globalViews = explode("\n", (string)\Yii::$app->settings->get('pages.availableGlobalViews')); 100 | foreach ($globalViews as $globalView) { 101 | // use custom name if appended after a semicolon (;) 102 | $globalViewEntry = explode(';', trim($globalView)); 103 | $this->availableViews[$globalViewEntry[0]] = $globalViewEntry[1] ?? $globalViewEntry[0]; 104 | } 105 | } 106 | } 107 | } 108 | 109 | /** 110 | * Check for "pheme/yii2-settings" component and module 111 | * @return bool 112 | */ 113 | public static function checkSettingsInstalled() 114 | { 115 | return \Yii::$app->hasModule('settings') && \Yii::$app->has('settings'); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Yii2 Page Manager 2 | ================= 3 | 4 | [![Latest Stable Version](https://poser.pugx.org/dmstr/yii2-pages-module/v/stable.svg)](https://packagist.org/packages/dmstr/yii2-pages-module) 5 | [![Total Downloads](https://poser.pugx.org/dmstr/yii2-pages-module/downloads.svg)](https://packagist.org/packages/dmstr/yii2-pages-module) 6 | [![License](https://poser.pugx.org/dmstr/yii2-pages-module/license.svg)](https://packagist.org/packages/dmstr/yii2-pages-module) 7 | 8 | Application sitemap and navigation manager module for Yii 2.0 Framework 9 | 10 | - **:warning: Breaking changes in 0.14.0 and 0.18.0** 11 | - **:warning: copy pages is removed in versions > 2.5.0** 12 | 13 | `data structure` and `public properties` are updated and query menu items from now on via `domain_id` 14 | 15 | Requirements 16 | ------------ 17 | 18 | - URL manager from [codemix/yii2-localeurls](https://github.com/codemix/yii2-localeurls) configured in application 19 | - role based access control; `auth_items` for every `module_controller_action` 20 | 21 | 22 | Installation 23 | ------------ 24 | 25 | The preferred way to install this extension is through [composer](http://getcomposer.org/download/). 26 | 27 | Either run 28 | 29 | ``` 30 | composer require dmstr/yii2-pages-module "*" 31 | ``` 32 | 33 | or add 34 | 35 | ``` 36 | "dmstr/yii2-pages-module": "*" 37 | ``` 38 | 39 | to the require section of your `composer.json` file. 40 | 41 | 42 | Setup 43 | ----- 44 | 45 | Run migrations 46 | 47 | ``` 48 | ./yii migrate \ 49 | --disableLookup=1 \ 50 | --migrationPath=@vendor/dmstr/yii2-pages-module/migrations 51 | ``` 52 | 53 | 54 | Configuration 55 | ------------- 56 | 57 | Enable module in application configuration 58 | 59 | ``` 60 | // module configuration 61 | 'pages' => [ 62 | 'class' => 'dmstr\modules\pages\Module', 63 | 'layout' => '@admin-views/layouts/main', 64 | 'roles' => ['Admin', 'Editor'], 65 | 'defaultPageLayout' => '@app/modules/frontend/layouts/main', 66 | 'availableRoutes' => [ 67 | 'site/index' => 'Index Route', 68 | ], 69 | 'availableViews' => [ 70 | '@app/views/site/index.php' => 'Index View', 71 | ], 72 | ], 73 | 74 | 75 | // if used want a url suffix, e.g. '.html', add Url rules for that 76 | 'urlManager' => [ 77 | ... 78 | 'rules' => [ 79 | '/-.html' => 'pages/default/page', 80 | '-.html' => 'pages/default/page', 81 | ], 82 | ... 83 | ], 84 | 85 | // register frontend asset for hiding pages via CookieButton 86 | 'on '. \yii\web\Application::EVENT_BEFORE_ACTION => function () { 87 | \dmstr\modules\pages\assets\PagesFrontendAsset::register(Yii::$app->controller->view); 88 | }, 89 | ``` 90 | 91 | Use settings module to configure additional controllers 92 | 93 | - Add one controller route per line to section `pages`, key `availableRoutes` 94 | 95 | ### Settings 96 | 97 | - `pages.availableRoutes` - routes per access_domain (for non-admin users) 98 | - `pages.availableViews` - views per access_domain (for non-admin users) 99 | - `pages.availableGlobalRoutes` - global routes (for admin users) 100 | - `pages.availableGlobalViews` - global views(for admin users) 101 | 102 | 103 | Usage 104 | ----- 105 | 106 | #### Navbar (eg. `layouts/main`) 107 | 108 | *find a root node / leave node* 109 | 110 | by `domain_id` i.e. `root` 111 | 112 | ``` 113 | $menuItems = \dmstr\modules\pages\models\Tree::getMenuItems('root'); 114 | ``` 115 | 116 | *use for example with bootstrap Navbar* 117 | 118 | ``` 119 | echo yii\bootstrap\Nav::widget( 120 | [ 121 | 'options' => ['class' => 'navbar-nav navbar-right'], 122 | 'activateItems' => false, 123 | 'encodeLabels' => false, 124 | 'activateParents' => true, 125 | 'items' => Tree::getMenuItems('root'), 126 | ] 127 | ); 128 | ``` 129 | 130 | #### Backend 131 | 132 | - visit `/pages` to create a root-node for your current application language. 133 | - click the *tree* icon 134 | - enter `name identifier (no spaces and special chars)` as *Domain ID* and *Menu name* and save 135 | - create child node 136 | - assign name, title, language and route/view 137 | - save 138 | 139 | Now you should be able to see the page in your `Nav` widget in the frontend of your application. 140 | 141 | #### Traits 142 | 143 | We use the `\dmstr\activeRecordPermissions\ActiveRecordAccessTrait` to have a check access behavior on active record level 144 | 145 | - Owner Access 146 | - Read Access 147 | - Update Access 148 | - Delete Access 149 | 150 | 151 | #### Anchors 152 | 153 | *available since 0.12.0-beta1* 154 | 155 | :construction_worker: A workaround for creating anchor links is to define a route, like `/en/mysite-2` in the settings module. 156 | On a node you can attach an anchor by using *Advanced URL settings*, with `{'#':'myanchor'}`. 157 | 158 | It is recommended to create a new entry in *Tree* mode. 159 | 160 | 161 | #### i18n - sibling pages 162 | 163 | Find sibling page in target language 164 | 165 | ``` 166 | /** 167 | * Find the sibling page in target language if exists 168 | * 169 | * @param string $targetLanguage 170 | * @param integer $sourceId 171 | * @param string $route 172 | * 173 | * @return Tree|null 174 | * @throws \yii\console\Exception 175 | */ 176 | public function sibling($targetLanguage, $sourceId = null, $route = self::DEFAULT_PAGE_ROUTE); 177 | 178 | 179 | Example 1: 180 | --- 181 | 182 | // page id 12 is a node in language 'en' 183 | $sourcePage = Tree::findOne(12); 184 | 185 | // returns corresponding page object in language 'de' or null if not exists 186 | $targetPage = $sourcePage->sibling('de'); 187 | 188 | 189 | Example 2: 190 | --- 191 | 192 | // find by params 193 | $targetPage = (new Tree())->sibling('de', 12, '/pages/default/page') 194 | 195 | ``` 196 | 197 | Testing 198 | ------- 199 | 200 | Requirements: 201 | 202 | - docker >=1.9.1 203 | - docker-compose >= 1.6.2 204 | 205 | Codeception is run via "Potemkin"-Phundament. 206 | 207 | 208 | cd tests 209 | 210 | Start test stack 211 | 212 | make all 213 | 214 | Run tests 215 | 216 | make run-tests 217 | 218 | 219 | Changelog 220 | ---------- 221 | 222 | 2.5.10 223 | 224 | - Removed localized root node message 225 | - Updated kartik-v/yii2-tree-manager requirement to ^1.1.2 226 | - Update Tree model to support new child_allowed attribute (since kartik-v/yii2-tree-manager 1.0.9) 227 | - Improved permission check for page nodes so allowed child nodes in not allowed parents do not show up 228 | 229 | ### ![dmstr logo](http://t.phundament.com/dmstr-16-cropped.png) Built by [dmstr](http://diemeisterei.de) 230 | -------------------------------------------------------------------------------- /assets/PagesBackendAsset.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | class PageUrlRule extends BaseObject implements UrlRuleInterface 22 | { 23 | /** 24 | * @param \yii\web\UrlManager $manager 25 | * @param string $route 26 | * @param array $params 27 | * 28 | * @return bool|string 29 | */ 30 | public function createUrl($manager, $route, $params) 31 | { 32 | if ($route === Tree::DEFAULT_PAGE_ROUTE) { 33 | 34 | /** 35 | * Build page url 36 | */ 37 | $pageId = ''; 38 | if (isset($params[Tree::REQUEST_PARAM_ID])) { 39 | $pageId = '-' . $params[Tree::REQUEST_PARAM_ID]; 40 | unset($params[Tree::REQUEST_PARAM_ID]); 41 | } 42 | 43 | $pageSlug = ''; 44 | if (isset($params[Tree::REQUEST_PARAM_SLUG])) { 45 | $pageSlug = $params[Tree::REQUEST_PARAM_SLUG]; 46 | unset($params[Tree::REQUEST_PARAM_SLUG]); 47 | } 48 | 49 | $pagePath = ''; 50 | if (isset($params[Tree::REQUEST_PARAM_PATH])) { 51 | $pagePath = $params[Tree::REQUEST_PARAM_PATH] . '/'; 52 | unset($params[Tree::REQUEST_PARAM_PATH]); 53 | } 54 | 55 | $pageUrl = $pagePath . $pageSlug . $pageId; 56 | 57 | /** 58 | * Add additional request params if set 59 | */ 60 | if (!empty($params) && ($query = http_build_query($params)) !== '') { 61 | $pageUrl .= '?' . $query; 62 | } 63 | 64 | return $pageUrl; 65 | } 66 | 67 | return false; // this rule does not apply 68 | } 69 | 70 | /** 71 | * @param \yii\web\UrlManager $manager 72 | * @param \yii\web\Request $request 73 | * 74 | * @return bool 75 | */ 76 | public function parseRequest($manager, $request) 77 | { 78 | return false; // this rule does not apply 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dmstr/yii2-pages-module", 3 | "description": "Application sitemap and navigation manager module for Yii 2.0 Framework", 4 | "type": "yii2-extension", 5 | "keywords": [ 6 | "yii2", 7 | "extension", 8 | "sitemap", 9 | "navigation" 10 | ], 11 | "license": "BSD-3-Clause", 12 | "authors": [ 13 | { 14 | "name": "Tobias Munk", 15 | "email": "tobias@diemeisterei.de" 16 | }, 17 | { 18 | "name": "Christopher Stebe", 19 | "email": "c.stebe@herzogkommunikation.de" 20 | } 21 | ], 22 | "require": { 23 | "yiisoft/yii2": "*", 24 | "kartik-v/yii2-tree-manager": "^1.1.3", 25 | "kartik-v/yii2-widget-select2": "^2.0.1", 26 | "2amigos/yii2-translateable-behavior": "^1.1.0", 27 | "insolita/yii2-adminlte-widgets": "^1.1.4", 28 | "rmrevin/yii2-fontawesome": "~2.9", 29 | "dmstr/yii2-json-editor": "^1.0.0", 30 | "dmstr/yii2-web": "^1.0.0", 31 | "dmstr/yii2-active-record-permissions": "^1.1.0", 32 | "dmstr/yii2-backend-module": "^2.0.0", 33 | "bedezign/yii2-audit": "^1.1", 34 | "mikehaertl/php-shellcommand": "^1.2.4", 35 | "pheme/yii2-settings": "^0.5.0 || ^0.7.0", 36 | "justinrainbow/json-schema": "^5.2.0" 37 | }, 38 | "require-dev": { 39 | "codeception/codeception": "^2.2" 40 | }, 41 | "autoload": { 42 | "psr-4": { 43 | "dmstr\\modules\\pages\\": "" 44 | } 45 | }, 46 | "extra": { 47 | "bootstrap": "dmstr\\modules\\pages\\Bootstrap" 48 | }, 49 | "repositories": { 50 | "tree-view": { 51 | "name": "kartik-v/yii2-tree-manager", 52 | "type": "vcs", 53 | "url": "https://github.com/dmstr-forks/kartikv-yii2-tree-manager.git" 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /controllers/DefaultController.php: -------------------------------------------------------------------------------- 1 | 39 | */ 40 | class DefaultController extends Controller implements ContextMenuItemsInterface 41 | { 42 | 43 | use RequestParamActionTrait; 44 | 45 | /** 46 | * ignore pageId param as req-param for actionPage as the id is provided from model->id itself 47 | * required as we use RequestParamActionTrait in this controller 48 | * 49 | * @return false 50 | */ 51 | protected function pageActionParamPageId() 52 | { 53 | return false; 54 | } 55 | 56 | /** 57 | * pageId param provider for actionRefPage() 58 | * pages are fetched from defined rootIds 59 | * 60 | * @return array 61 | */ 62 | protected function refPageActionParamPageId() 63 | { 64 | 65 | $rootIds = [Tree::ROOT_NODE_PREFIX]; 66 | /** @var Settings Yii::$app->settings */ 67 | if (Module::checkSettingsInstalled() && Yii::$app->settings->get('refPageRootIds', 'pages', null)) { 68 | $tmp = explode("\n", Yii::$app->settings->get('pages.refPageRootIds')); 69 | $tmp = array_filter(array_map('trim', $tmp)); 70 | $rootIds = $tmp ?? $rootIds; 71 | } 72 | 73 | $pages = []; 74 | foreach ($rootIds as $rootId) { 75 | $rootNode = Tree::getRootByDomainId($rootId); 76 | if ($rootNode) { 77 | $leaves = Tree::getLeavesFromRoot($rootNode)->andWhere(['route' => Tree::DEFAULT_PAGE_ROUTE])->all(); 78 | if (!empty($leaves)) { 79 | $leaves = array_filter($leaves, function($leave) { 80 | if (!empty($leave->request_params)) { 81 | $params = Json::decode($leave->request_params); 82 | if (!empty($params) && isset($params->pageId) && $params->pageId == $leave->id) { 83 | return false; 84 | } 85 | } 86 | return true; 87 | }); 88 | /** @var Tree $leave */ 89 | foreach ($leaves as $leave) { 90 | Yii::debug(ArrayHelper::map($leave->parents()->all(), 'id', 'name')); 91 | if (!$leave->isPage()) { 92 | continue; 93 | } 94 | // build human-readable label for each leave 95 | $pages[$leave->id] = Html::encode(implode(' :: ', ArrayHelper::merge(ArrayHelper::map($leave->parents()->all(), 'id', 'name'), [$leave->name . ' (' . $leave->id . ')']))); 96 | } 97 | } 98 | } 99 | } 100 | 101 | $params = ArrayHelper::merge(['' => Html::encode(Yii::t('pages', 'Select target page'))], $pages); 102 | return $params; 103 | 104 | } 105 | 106 | /** 107 | * @inheritdoc 108 | */ 109 | public function init() 110 | { 111 | if (Yii::$app->user->can('pages', ['route' => true])) { 112 | Yii::$app->trigger('registerMenuItems', new Event(['sender' => $this])); 113 | } 114 | 115 | parent::init(); 116 | } 117 | 118 | /** 119 | * @return mixed 120 | */ 121 | public function actionIndex($pageId = null) 122 | { 123 | 124 | // do rbac permission check if page is readable. The active record permssion check does not show the page if it does not exist 125 | if (!empty($pageId) && empty(Tree::findOne($pageId))) { 126 | throw new NotFoundHttpException(Yii::t('pages', 'The requested page does not exist.')); 127 | } 128 | 129 | $query = Tree::getAccessibleItemsQuery(); 130 | 131 | $headerTemplate = <<< HTML 132 |
133 |
134 | {heading} 135 |
136 | 139 |
140 | HTML; 141 | 142 | $toolbar = []; 143 | 144 | // check settings component and module existence 145 | if (Yii::$app->has('settings') && Yii::$app->hasModule('settings')) { 146 | 147 | // check module permissions 148 | $settingPermission = false; 149 | if (Yii::$app->getModule('settings')->accessRoles === null) { 150 | $settingPermission = true; 151 | } else { 152 | foreach (Yii::$app->getModule('settings')->accessRoles as $role) { 153 | $settingPermission = Yii::$app->user->can($role); 154 | } 155 | } 156 | 157 | if ($settingPermission) { 158 | $settings = [ 159 | 'icon' => 'cogs', 160 | 'url' => ['/settings', 'SettingSearch' => ['section' => 'pages']], 161 | 'options' => [ 162 | 'title' => Yii::t('pages', 'Settings'), 163 | 'class' => 'btn btn-info' 164 | ] 165 | ]; 166 | $toolbar[] = TreeView::BTN_SEPARATOR; 167 | $toolbar['settings'] = $settings; 168 | } 169 | } 170 | 171 | $mainTemplate = <<< HTML 172 |
173 |
174 |
175 | {wrapper} 176 |
177 |
178 |
179 | {detail} 180 |
181 |
182 | HTML; 183 | 184 | 185 | PagesBackendAsset::register($this->view); 186 | $this->view->title = Yii::t('pages', 'Pages'); 187 | 188 | return $this->render('index', [ 189 | 'query' => $query, 190 | 'headerTemplate' => $headerTemplate, 191 | 'toolbar' => $toolbar, 192 | 'mainTemplate' => $mainTemplate, 193 | 'pageId' => $pageId 194 | ]); 195 | } 196 | 197 | /** 198 | * @return Yii\web\Response 199 | * @throws MethodNotAllowedHttpException 200 | * @throws Yii\base\InvalidConfigException 201 | */ 202 | public function actionResolveRouteToSchema() 203 | { 204 | if (Yii::$app->request->isAjax && Yii::$app->request->post('value') !== null) { 205 | $route = Yii::$app->request->post('value'); 206 | 207 | $response['schema'] = PageHelper::routeToSchema($route); 208 | return $this->asJson($response); 209 | } 210 | throw new MethodNotAllowedHttpException(Yii::t('pages', 'You are not allowed to access this page like this')); 211 | } 212 | 213 | 214 | /** 215 | * Redirect to URL for given pageId 216 | * This is useful if one will create multiple menu items to one existing content page 217 | * 218 | * @param $pageId 219 | * 220 | * @throws NotFoundHttpException 221 | */ 222 | public function actionRefPage($pageId) 223 | { 224 | 225 | $page = Tree::findOne(['id' => $pageId]); 226 | if ($page && $page instanceof Tree) { 227 | return $this->redirect($page->createUrl()); 228 | } else { 229 | throw new NotFoundHttpException(); 230 | } 231 | } 232 | 233 | /** 234 | * renders a page view from the database. 235 | * 236 | * @param $pageId 237 | * 238 | * @return string 239 | * 240 | * @throws HttpException 241 | */ 242 | public function actionPage($pageId) 243 | { 244 | Url::remember(); 245 | Yii::$app->session->set('__crudReturnUrl', null); 246 | 247 | // Set layout 248 | $this->layout = $this->module->defaultPageLayout; 249 | 250 | // deactivate access_* check in ActiveRecordAccessTrait::find to be able to handle 'forbidden pages' here. 251 | Tree::$activeAccessTrait = false; 252 | 253 | // Get active Tree object, allow access to invisible pages 254 | // @todo: improve handling, using also roles 255 | $pageQuery = Tree::find()->andWhere( 256 | [ 257 | Tree::ATTR_ID => $pageId, 258 | Tree::ATTR_ACTIVE => Tree::ACTIVE, 259 | ] 260 | ); 261 | 262 | if ($this->module->pageCheckAccessDomain) { 263 | $pageQuery->andWhere(['access_domain' => [Yii::$app->language, Tree::$_all]]); 264 | } 265 | 266 | // get page 267 | /** @var $page Tree */ 268 | $page = $pageQuery->one(); 269 | 270 | // Show disabled pages for admins 271 | if ($page !== null && $page->isDisabled() && !Yii::$app->user->can('pages')) { 272 | $page = null; 273 | } 274 | 275 | // if the route of the $page does not point to $this->route, make a redirect to the destination url 276 | if ($page !== null && ltrim($page->route, '/') !== $this->route) { 277 | $destRoute = [$page->route]; 278 | if (!empty($page->request_params) && Json::decode($page->request_params)) { 279 | $destRoute = ArrayHelper::merge($destRoute, Json::decode($page->request_params)); 280 | } 281 | return $this->redirect($destRoute); 282 | } 283 | 284 | # reactivate access_* check in ActiveRecordAccessTrait::find for further queries 285 | Tree::$activeAccessTrait = true; 286 | // check if page has access_read permissions set, if yes check if user is allowed 287 | if (!empty($page->access_read) && $page->access_read !== '*') { 288 | if (!Yii::$app->user->can($page->access_read)) { 289 | # if userIsGuest, redirect to login page 290 | if (!Yii::$app->user->isGuest) { 291 | throw new HttpException(403, Yii::t('pages', 'Forbidden')); 292 | } 293 | 294 | return $this->redirect(Yii::$app->user->loginUrl); 295 | } 296 | } 297 | 298 | if ($page !== null) { 299 | // Set page title, use name as fallback 300 | $this->view->title = $page->page_title ?: $page->name; 301 | 302 | // Register default SEO meta tags 303 | if (!empty($page->default_meta_keywords)) { 304 | $this->view->registerMetaTag(['name' => 'keywords', 'content' => $page->default_meta_keywords],'keywords'); 305 | } 306 | 307 | if (!empty($page->default_meta_description)) { 308 | $this->view->registerMetaTag(['name' => 'description', 'content' => $page->default_meta_description], 'description'); 309 | } 310 | 311 | // Render view 312 | if (empty($page->view)) { 313 | throw new HttpException(404, Yii::t('pages', 'Page not found.') . ' [ID: ' . $pageId . ']'); 314 | } 315 | return $this->render($page->view, ['page' => $page]); 316 | } else { 317 | if ($fallbackPage = $this->resolveFallbackPage($pageId)) { 318 | Yii::trace('Resolved fallback URL for ' . $fallbackPage->id, __METHOD__); 319 | return $this->redirect($fallbackPage->createUrl(['language' => $fallbackPage->access_domain])); 320 | } else { 321 | throw new HttpException(404, Yii::t('pages', 'Page not found.') . ' [ID: ' . $pageId . ']'); 322 | } 323 | } 324 | 325 | if ($fallbackPage = $this->resolveFallbackPage($pageId)) { 326 | Yii::trace('Resolved fallback URL for ' . $fallbackPage->id, __METHOD__); 327 | return $this->redirect($fallbackPage->createUrl(['language' => $fallbackPage->access_domain])); 328 | } 329 | 330 | throw new HttpException(404, Yii::t('pages', 'Page not found.') . ' [ID: ' . $pageId . ']'); 331 | } 332 | 333 | 334 | /** 335 | * @param $pageId 336 | * @return Tree|bool 337 | */ 338 | private function resolveFallbackPage($pageId) 339 | { 340 | 341 | if (!$this->module->pageUseFallbackPage) { 342 | return false; 343 | } 344 | 345 | $original = Tree::find()->where(['id' => $pageId])->one(); 346 | 347 | if (empty($original)) { 348 | return false; 349 | } 350 | return Tree::find()->andWhere(['domain_id' => $original->domain_id])->one(); 351 | } 352 | 353 | /** 354 | * @return array 355 | */ 356 | public function getMenuItems() 357 | { 358 | return [ 359 | [ 360 | 'label' => Yii::t('pages', 'Edit page'), 361 | 'url' => ['/' . $this->module->id . '/default/index', 'pageId' => Yii::$app->request->get('pageId')] 362 | 363 | ] 364 | ]; 365 | } 366 | } 367 | -------------------------------------------------------------------------------- /controllers/TestController.php: -------------------------------------------------------------------------------- 1 | render('index', ['tree' => $tree]); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /controllers/api/DefaultController.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class DefaultController extends \yii\rest\ActiveController 11 | { 12 | /** 13 | * The limit for the \yii\data\ActiveDataProvider. 14 | */ 15 | const QUERY_LIMIT = 2000; 16 | 17 | public $modelClass = 'dmstr\modules\pages\models\Tree'; 18 | 19 | /** 20 | * {@inheritdoc} 21 | */ 22 | public function actions() 23 | { 24 | return [ 25 | /* 26 | * Supported $_GET params for /pages/api/default/index 27 | * 28 | * @param dmstr\modules\pages\models\Tree::ATTR_ID 29 | * @param dmstr\modules\pages\models\Tree::ATTR_DOMAIN_ID 30 | * @param dmstr\modules\pages\models\Tree::ATTR_ROOT 31 | * @param dmstr\modules\pages\models\Tree::ATTR_ACCESS_DOMAIN 32 | */ 33 | 'index' => [ 34 | 'class' => 'yii\rest\IndexAction', 35 | 'modelClass' => $this->modelClass, 36 | 'checkAccess' => [$this, 'checkAccess'], 37 | 'prepareDataProvider' => function () { 38 | 39 | /* @var $modelClass \yii\db\BaseActiveRecord */ 40 | $modelClass = $this->modelClass; 41 | 42 | $query = $modelClass::find(); 43 | 44 | if (isset($_GET[$modelClass::ATTR_ID])) { 45 | $query->andFilterWhere([$modelClass::ATTR_ID => $_GET[$modelClass::ATTR_ID]]); 46 | } 47 | if (isset($_GET[$modelClass::ATTR_DOMAIN_ID])) { 48 | $query->andFilterWhere([$modelClass::ATTR_DOMAIN_ID => $_GET[$modelClass::ATTR_DOMAIN_ID]]); 49 | } 50 | if (isset($_GET[$modelClass::ATTR_ACCESS_DOMAIN])) { 51 | $query->andFilterWhere([$modelClass::ATTR_ACCESS_DOMAIN => $_GET[$modelClass::ATTR_ACCESS_DOMAIN]]); 52 | } 53 | if (isset($_GET[$modelClass::ATTR_ROOT])) { 54 | $query->andFilterWhere([$modelClass::ATTR_ROOT => $_GET[$modelClass::ATTR_ROOT]]); 55 | } 56 | 57 | return new \yii\data\ActiveDataProvider( 58 | [ 59 | 'query' => $query, 60 | 'pagination' => [ 61 | 'pageSize' => self::QUERY_LIMIT, 62 | ], 63 | ] 64 | ); 65 | }, 66 | ], 67 | ]; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /controllers/crud/TreeTranslationController.php: -------------------------------------------------------------------------------- 1 | findModel($id)->delete(); 17 | } catch (\Exception $e) { 18 | $msg = (isset($e->errorInfo[2]))?$e->errorInfo[2]:$e->getMessage(); 19 | \Yii::$app->getSession()->addFlash('error', $msg); 20 | return $this->redirect(Url::previous()); 21 | } 22 | 23 | // TODO: should just reload form (AJAX) 24 | return $this->redirect('/pages'); 25 | } 26 | 27 | 28 | /** 29 | * Finds the TreeTranslation model based on its primary key value. 30 | * If the model is not found, a 404 HTTP exception will be thrown. 31 | * 32 | * @throws HttpException if the model cannot be found 33 | * @param integer $id 34 | * @return TreeTranslation the loaded model 35 | */ 36 | protected function findModel($id) { 37 | if (($model = \dmstr\modules\pages\models\TreeTranslation::findOne($id)) !== null) { 38 | return $model; 39 | } else { 40 | throw new HttpException(404, 'The requested page does not exist.'); 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /example-views/column1.php: -------------------------------------------------------------------------------- 1 | params['breadcrumbs'][] = $this->title; 15 | ?> 16 | 17 |
18 |

title) ?>

19 | 20 |

21 | This is an empty page. You may modify the following file to customize its content: 22 |

23 | 24 |

25 | 26 |

27 |
28 | -------------------------------------------------------------------------------- /helpers/PageHelper.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class PageHelper 21 | { 22 | /** 23 | * @param $route 24 | * @return string 25 | * @throws \yii\base\InvalidConfigException 26 | */ 27 | public static function routeToSchema($route) 28 | { 29 | if (!empty($route)) { 30 | $responseCluster = \Yii::$app->createController($route); 31 | 32 | if (isset($responseCluster[0])) { 33 | $controller = $responseCluster[0]; 34 | /** @var BaseObject $controller */ 35 | if ($controller->hasMethod('jsonFromAction')) { 36 | /** @var RequestParamActionTrait $controller */ 37 | return $controller->jsonFromAction($route); 38 | } 39 | } 40 | } 41 | return static::defaultJsonSchema(); 42 | } 43 | 44 | public static function defaultJsonSchema() 45 | { 46 | return <<createTable( 17 | 'dmstr_page', 18 | [ 19 | 'id' => $this->primaryKey(), 20 | 'root' => $this->integer()->notNull()->defaultValue(0), 21 | 'lft' => $this->integer()->notNull(), 22 | 'rgt' => $this->integer()->notNull(), 23 | 'lvl' => $this->smallInteger()->notNull(), 24 | 'page_title' => $this->string(255), 25 | 'name' => $this->string(60)->notNull(), 26 | 'name_id' => $this->string(255)->notNull(), 27 | 'slug' => $this->string(255), 28 | 'route' => $this->string(255), 29 | 'view' => $this->string(255), 30 | 'default_meta_keywords' => $this->string(255), 31 | 'default_meta_description' => $this->text(), 32 | 'request_params' => $this->text(), 33 | 'owner' => $this->integer()->defaultValue(null), 34 | 'access_owner' => $this->integer()->defaultValue(null), 35 | 'access_domain' => $this->string(8)->defaultValue(null), 36 | 'access_read' => $this->string(255)->defaultValue(null), 37 | 'access_update' => $this->string(255)->defaultValue(null), 38 | 'access_delete' => $this->string(255)->defaultValue(null), 39 | 'icon' => $this->string(255)->defaultValue(null), 40 | 'icon_type' => $this->smallInteger()->defaultValue(1), 41 | 'active' => $this->smallInteger()->defaultValue(1), 42 | 'selected' => $this->smallInteger()->defaultValue(0), 43 | 'disabled' => $this->smallInteger()->defaultValue(0), 44 | 'readonly' => $this->smallInteger()->defaultValue(0), 45 | 'visible' => $this->smallInteger()->defaultValue(1), 46 | 'collapsed' => $this->smallInteger()->defaultValue(0), 47 | 'movable_u' => $this->smallInteger()->defaultValue(1), 48 | 'movable_d' => $this->smallInteger()->defaultValue(1), 49 | 'movable_l' => $this->smallInteger()->defaultValue(1), 50 | 'movable_r' => $this->smallInteger()->defaultValue(1), 51 | 'removable' => $this->smallInteger()->defaultValue(1), 52 | 'removable_all' => $this->smallInteger()->defaultValue(0), 53 | 'created_at' => $this->timestamp()->defaultExpression('NOW()'), 54 | 'updated_at' => $this->timestamp()->defaultExpression('NOW()'), 55 | ] 56 | ); 57 | 58 | $this->createIndex('tbl_tree_NK1', 'dmstr_page', 'root'); 59 | $this->createIndex('tbl_tree_NK2', 'dmstr_page', 'lft'); 60 | $this->createIndex('tbl_tree_NK3', 'dmstr_page', 'rgt'); 61 | $this->createIndex('tbl_tree_NK4', 'dmstr_page', 'lvl'); 62 | $this->createIndex('tbl_tree_NK5', 'dmstr_page', 'active'); 63 | 64 | $this->createIndex('name_id_UNIQUE', 'dmstr_page', 'name_id', true); 65 | } 66 | 67 | public function down() 68 | { 69 | $this->dropTable('dmstr_page'); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /migrations/m150623_164544_auth_items.php: -------------------------------------------------------------------------------- 1 | authManager; 10 | 11 | if ($auth) { 12 | $permission = $auth->createPermission('pages'); 13 | $permission->description = 'Pages Module'; 14 | $auth->add($permission); 15 | } 16 | } 17 | 18 | public function down() 19 | { 20 | echo "m150623_164544_auth_items cannot be reverted.\n"; 21 | 22 | return false; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /migrations/m150918_031100_auth_items.php: -------------------------------------------------------------------------------- 1 | authManager; 10 | 11 | if ($auth) { 12 | $permission = $auth->createPermission('pages_default_page'); 13 | $permission->description = 'CMS-Page Action'; 14 | $auth->add($permission); 15 | } 16 | } 17 | 18 | public function down() 19 | { 20 | echo "m150623_164544_auth_items cannot be reverted.\n"; 21 | 22 | return false; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /migrations/m160411_082658_rename_name_id_column.php: -------------------------------------------------------------------------------- 1 | dropIndex('name_id_UNIQUE', 'dmstr_page'); 10 | $this->renameColumn('dmstr_page', 'name_id', 'domain_id'); 11 | if ($this->db->driverName === 'pgsql') { 12 | $this->execute('ALTER TABLE dmstr_page ALTER COLUMN domain_id SET NOT NULL;'); 13 | $this->execute( 14 | 'CREATE UNIQUE INDEX name_id_UNIQUE ON dmstr_page (domain_id, access_domain);' 15 | ); 16 | } else { 17 | $this->execute('ALTER TABLE dmstr_page MODIFY COLUMN domain_id VARCHAR(255) NOT NULL;'); 18 | $this->execute( 19 | 'ALTER TABLE `dmstr_page` ADD UNIQUE INDEX `name_id_UNIQUE` (`domain_id`, `access_domain`);' 20 | ); 21 | } 22 | } 23 | 24 | public function down() 25 | { 26 | return false; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /migrations/m160411_111111_name_id_to_domain_id_renamer.php: -------------------------------------------------------------------------------- 1 | execute( 13 | " 14 | UPDATE dmstr_page SET domain_id = REPLACE (domain_id, '_$language', '') WHERE domain_id LIKE '%_$language'; 15 | " 16 | ); 17 | } 18 | 19 | return true; 20 | } 21 | 22 | public function down() 23 | { 24 | return false; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /migrations/m161029_011345_settings.php: -------------------------------------------------------------------------------- 1 | has('settings')) { 10 | if (!Yii::$app->settings->get('availableRoutes', 'pages', false)) { 11 | Yii::$app->settings->set('availableRoutes', '/pages/default/page', 'pages', 'string'); 12 | } 13 | if (!Yii::$app->settings->get('availableGlobalRoutes', 'pages', false)) { 14 | Yii::$app->settings->set('availableGlobalRoutes', '/pages/default', 'pages', 'string'); 15 | } 16 | if (!Yii::$app->settings->get('availableViews', 'pages', false)) { 17 | Yii::$app->settings->set('availableViews', 18 | '@vendor/dmstr/yii2-pages-module/example-views/column1.php', 19 | 'pages', 'string'); 20 | } 21 | } 22 | return true; 23 | } 24 | 25 | public function down() 26 | { 27 | if (Yii::$app->has('settings')) { 28 | Yii::$app->settings->delete('availableRoutes', 'pages'); 29 | Yii::$app->settings->delete('availableGlobalRoutes', 'pages'); 30 | Yii::$app->settings->delete('availableViews', 'pages'); 31 | } 32 | return true; 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /migrations/m161118_101349_alter_charset_to_utf8.php: -------------------------------------------------------------------------------- 1 | db->driverName === 'mysql') { 10 | Yii::$app->db->createCommand("ALTER TABLE dmstr_page CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci ;")->execute(); 11 | } 12 | } 13 | 14 | public function down() 15 | { 16 | echo "m160721_101347_alter_charset_to_utf8 cannot be reverted.\n"; 17 | 18 | return false; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /migrations/m170220_121800_auth_items.php: -------------------------------------------------------------------------------- 1 | authManager; 10 | 11 | if ($auth) { 12 | $permission = $auth->createPermission('pages_copy'); 13 | $permission->description = 'Pages Copy'; 14 | $auth->add($permission); 15 | } 16 | } 17 | 18 | public function down() 19 | { 20 | echo "m170220_121800_auth_items cannot be reverted.\n"; 21 | 22 | return false; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /migrations/m170314_062644_set_default_access_read.php: -------------------------------------------------------------------------------- 1 | update('dmstr_page', ['access_read' => '*']); 10 | } 11 | 12 | public function down() 13 | { 14 | $this->update('dmstr_page', ['access_read' => null]); 15 | } 16 | 17 | /* 18 | // Use safeUp/safeDown to run migration code within a transaction 19 | public function safeUp() 20 | { 21 | } 22 | 23 | public function safeDown() 24 | { 25 | } 26 | */ 27 | } 28 | -------------------------------------------------------------------------------- /migrations/m170314_111404_remove_owner_column.php: -------------------------------------------------------------------------------- 1 | dropColumn('dmstr_page', 'owner'); 10 | } 11 | 12 | public function down() 13 | { 14 | return false; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /migrations/m170315_221005_set_default_update_and_delete_access.php: -------------------------------------------------------------------------------- 1 | update('dmstr_page', ['access_update' => '*']); 11 | $this->update('dmstr_page', ['access_delete' => '*']); 12 | } 13 | 14 | public function down() 15 | { 16 | $this->update('dmstr_page', ['access_update' => null]); 17 | $this->update('dmstr_page', ['access_delete' => null]); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /migrations/m170322_204909_update_timestamp_columns.php: -------------------------------------------------------------------------------- 1 | alterColumn('dmstr_page', 'created_at', $this->dateTime()); 11 | $this->alterColumn('dmstr_page', 'updated_at', $this->dateTime()); 12 | } 13 | 14 | public function down() 15 | { 16 | echo "m170322_204909_update_timestamp_columns cannot be reverted.\n"; 17 | 18 | return false; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /migrations/m170327_120427_update_icon_column.php: -------------------------------------------------------------------------------- 1 | update('dmstr_page', ['icon' => new \yii\db\Expression("REPLACE(icon, 'fa fa-', '')")]); 11 | } 12 | 13 | public function down() 14 | { 15 | echo "m170327_120427_update_icon_column cannot be reverted.\n"; 16 | 17 | return false; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /migrations/m180321_090927_add_translation_table.php: -------------------------------------------------------------------------------- 1 | db->getDriverName() === 'mysql' ? 'CHARACTER SET utf8 COLLATE utf8_unicode_ci' : null; 16 | 17 | $this->createTable( 18 | 'dmstr_page_translation', 19 | [ 20 | 'id' => $this->primaryKey(), 21 | 'page_id' => $this->integer()->notNull(), 22 | 'language' => $this->char(7)->notNull(), 23 | 'name' => $this->string(60)->notNull(), 24 | 'page_title' => $this->string(255), 25 | 'default_meta_keywords' => $this->string(255), 26 | 'default_meta_description' => $this->text(), 27 | 'created_at' => $this->timestamp()->defaultExpression('NOW()'), 28 | 'updated_at' => $this->timestamp()->defaultExpression('NOW()'), 29 | ],$tableOptions 30 | ); 31 | 32 | $this->addForeignKey('FK_page_translation_page', 'dmstr_page_translation','page_id','dmstr_page','id'); 33 | 34 | // select all pages to insert them into the translation table 35 | $query = new \yii\db\Query(); 36 | $pages = $query->select([ 37 | 'id', 38 | 'name', 39 | 'page_title', 40 | 'access_domain', 41 | 'default_meta_keywords', 42 | 'default_meta_description', 43 | ])->from('dmstr_page')->all(); 44 | 45 | // insert them into translation table 46 | foreach ($pages as $page) { 47 | // if access domain is global iterate over all configured languages to add a translation for every single one 48 | if ($page['access_domain'] === '*') { 49 | foreach (\Yii::$app->urlManager->languages as $language) { 50 | $this->insertPageTranslation($page,$language); 51 | } 52 | } else { 53 | $this->insertPageTranslation($page,$page['access_domain']); 54 | } 55 | 56 | } 57 | 58 | 59 | $this->dropColumn('dmstr_page', 'name'); 60 | $this->dropColumn('dmstr_page', 'page_title'); 61 | $this->dropColumn('dmstr_page', 'default_meta_keywords'); 62 | $this->dropColumn('dmstr_page', 'default_meta_description'); 63 | } 64 | 65 | /** 66 | * {@inheritdoc} 67 | */ 68 | public function safeDown() 69 | { 70 | echo "m180321_090927_add_translation_table cannot be reverted.\n"; 71 | return false; 72 | } 73 | 74 | private function insertPageTranslation($page, $language) { 75 | $this->insert('dmstr_page_translation', [ 76 | 'page_id' => $page['id'], 77 | 'language' => $language, 78 | 'name' => $page['name'], 79 | 'page_title' => $page['page_title'], 80 | 'default_meta_keywords' => $page['default_meta_keywords'], 81 | 'default_meta_description' => $page['default_meta_description'], 82 | ]); 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /migrations/m180321_103245_alter_table_names.php: -------------------------------------------------------------------------------- 1 | getDb()->tablePrefix)) { 17 | $this->renameTable('dmstr_page','{{%dmstr_page}}'); 18 | $this->renameTable('dmstr_page_translation','{{%dmstr_page_translation}}'); 19 | } 20 | 21 | } 22 | 23 | /** 24 | * {@inheritdoc} 25 | */ 26 | public function safeDown() 27 | { 28 | echo "m180321_103245_alter_table_names cannot be reverted.\n"; 29 | return false; 30 | } 31 | 32 | 33 | } 34 | -------------------------------------------------------------------------------- /migrations/m180702_153622_add_translation_meta_table.php: -------------------------------------------------------------------------------- 1 | db->getDriverName() === 'mysql' ? 'CHARACTER SET utf8 COLLATE utf8_unicode_ci' : null; 16 | 17 | $this->createTable('{{%dmstr_page_translation_meta}}', [ 18 | 'id' => $this->primaryKey(), 19 | 'page_id' => $this->integer()->notNull(), 20 | 'language' => $this->char(7)->notNull(), 21 | 'disabled' => $this->smallInteger()->defaultValue(0), 22 | 'visible' => $this->smallInteger()->defaultValue(1), 23 | 'created_at' => $this->dateTime(), 24 | 'updated_at' => $this->dateTime(), 25 | ], $tableOptions); 26 | 27 | $this->addForeignKey( 28 | 'fk_page_page_translation_meta_id', 29 | '{{%dmstr_page_translation_meta}}', 30 | 'page_id', 31 | '{{%dmstr_page}}', 32 | 'id', 33 | 'CASCADE', 34 | 'CASCADE'); 35 | 36 | 37 | // select all contents to insert them into the translation table 38 | $query = new \yii\db\Query(); 39 | $pages = $query->select([ 40 | 'id', 41 | 'disabled', 42 | 'visible', 43 | ])->from('{{%dmstr_page}}')->all(); 44 | 45 | foreach ($pages as $page) { 46 | $this->insert('{{%dmstr_page_translation_meta}}', [ 47 | 'page_id' => $page['id'], 48 | 'language' => Yii::$app->language, 49 | 'disabled' => $page['disabled'], 50 | 'visible' => $page['visible'], 51 | ]); 52 | } 53 | 54 | $this->dropColumn('{{%dmstr_page}}', 'disabled'); 55 | $this->dropColumn('{{%dmstr_page}}', 'visible'); 56 | } 57 | 58 | /** 59 | * @inheritdoc 60 | */ 61 | public function safeDown() 62 | { 63 | $this->addColumn('{{%dmstr_page}}', 'disabled', 64 | $this->smallInteger()->defaultValue(0)->after('selected')); 65 | $this->addColumn('{{%dmstr_page}}', 'visible', 66 | $this->smallInteger()->defaultValue(1)->after('readonly')); 67 | 68 | // select all content translations to insert them back into the content table 69 | $query = new \yii\db\Query(); 70 | $pages = $query->select([ 71 | 'page_id', 72 | 'language', 73 | 'disabled', 74 | 'visible' 75 | ])->from('{{%dmstr_page_translation_meta}}')->all(); 76 | 77 | foreach ($pages as $page) { 78 | $this->update('{{%dmstr_page}}', [ 79 | 'disabled' => $page['disabled'], 80 | 'visible' => $page['visible'] 81 | ],['id' => $page['page_id']]); 82 | } 83 | 84 | $this->dropForeignKey('fk_page_page_translation_meta_id', '{{%dmstr_page_translation_meta}}'); 85 | $this->dropTable('{{%dmstr_page_translation_meta}}'); 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /migrations/workbench/dmstr_yii2-pages-module.mwb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmstr/yii2-pages-module/HEAD/migrations/workbench/dmstr_yii2-pages-module.mwb -------------------------------------------------------------------------------- /models/BaseTree.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | class BaseTree extends \kartik\tree\models\Tree 30 | { 31 | use ActiveRecordAccessTrait; 32 | 33 | // needed since 1.0.9. Currently we want all children to have children so new need for a extra db field (yet) 34 | // If that changes in the future, the default value must be `1` to ensure backwards compatibility 35 | public $child_allowed = 1; 36 | 37 | /** 38 | * Icon type css 39 | */ 40 | const ICON_TYPE_CSS = 1; 41 | 42 | /** 43 | * Icon type raw 44 | */ 45 | const ICON_TYPE_RAW = 2; 46 | 47 | /** 48 | * Node active 49 | */ 50 | const ACTIVE = 1; 51 | 52 | /** 53 | * Node not active 54 | */ 55 | const NOT_ACTIVE = 0; 56 | 57 | /** 58 | * Node selected 59 | */ 60 | const SELECTED = 1; 61 | 62 | /** 63 | * Node not selected 64 | */ 65 | const NOT_SELECTED = 0; 66 | 67 | /** 68 | * Node disabled 69 | */ 70 | const DISABLED = 1; 71 | 72 | /** 73 | * Node not disabled 74 | */ 75 | const NOT_DISABLED = 0; 76 | 77 | /** 78 | * Node read only 79 | */ 80 | const READ_ONLY = 1; 81 | 82 | /** 83 | * Node not read only 84 | */ 85 | const NOT_READ_ONLY = 0; 86 | 87 | /** 88 | * Node visible 89 | */ 90 | const VISIBLE = 1; 91 | 92 | /** 93 | * Node not visible 94 | */ 95 | const NOT_VISIBLE = 0; 96 | 97 | /** 98 | * Node collapsed 99 | */ 100 | const COLLAPSED = 1; 101 | 102 | /** 103 | * Node not collapsed 104 | */ 105 | const NOT_COLLAPSED = 0; 106 | 107 | /** 108 | * The root node domain_id prefix and level identifier. 109 | */ 110 | const ROOT_NODE_PREFIX = 'root'; 111 | 112 | /** 113 | * The root node level identifier. 114 | */ 115 | const ROOT_NODE_LVL = 0; 116 | 117 | /** 118 | * The default page route 119 | */ 120 | const DEFAULT_PAGE_ROUTE = '/pages/default/page'; 121 | 122 | /** 123 | * The request param for a page identifier 124 | */ 125 | const REQUEST_PARAM_ID = 'pageId'; 126 | 127 | /** 128 | * The request param for a page slug 129 | */ 130 | const REQUEST_PARAM_SLUG = 'pageSlug'; 131 | 132 | /** 133 | * The request param for a page path 134 | */ 135 | const REQUEST_PARAM_PATH = 'pagePath'; 136 | 137 | /** 138 | * Column attribute 'id' 139 | */ 140 | const ATTR_ID = 'id'; 141 | 142 | /** 143 | * Column attribute 'name' 144 | */ 145 | const ATTR_NAME = 'name'; 146 | 147 | /** 148 | * Column attribute 'domain_id' 149 | */ 150 | const ATTR_DOMAIN_ID = 'domain_id'; 151 | 152 | /** 153 | * Column attribute 'slug' 154 | */ 155 | const ATTR_SLUG = 'slug'; 156 | 157 | /** 158 | * Column attribute 'root' 159 | */ 160 | const ATTR_ROOT = 'root'; 161 | 162 | /** 163 | * Column attribute 'lvl' 164 | */ 165 | const ATTR_LVL = 'lvl'; 166 | 167 | /** 168 | * Column attribute 'page_title' 169 | */ 170 | const ATTR_PAGE_TITLE = 'page_title'; 171 | 172 | /** 173 | * Column attribute 'route' 174 | */ 175 | const ATTR_ROUTE = 'route'; 176 | 177 | /** 178 | * Column attribute 'view' 179 | */ 180 | const ATTR_VIEW = 'view'; 181 | 182 | /** 183 | * Column attribute 'default_meta_keywords' 184 | */ 185 | const ATTR_DEFAULT_META_KEYWORDS = 'default_meta_keywords'; 186 | 187 | /** 188 | * Column attribute 'default_meta_description' 189 | */ 190 | const ATTR_DEFAULT_META_DESCRIPTION = 'default_meta_description'; 191 | 192 | /** 193 | * Column attribute 'request_params' 194 | */ 195 | const ATTR_REQUEST_PARAMS = 'request_params'; 196 | 197 | /** 198 | * Column attribute 'access_domain' 199 | */ 200 | const ATTR_ACCESS_DOMAIN = 'access_domain'; 201 | 202 | /** 203 | * Column attribute 'access_owner' 204 | */ 205 | const ATTR_ACCESS_OWNER = 'access_owner'; 206 | 207 | /** 208 | * Column attribute 'access_read' 209 | */ 210 | const ATTR_ACCESS_READ = 'access_read'; 211 | 212 | /** 213 | * Column attribute 'access_update' 214 | */ 215 | const ATTR_ACCESS_UPDATE = 'access_update'; 216 | 217 | /** 218 | * Column attribute 'access_delete' 219 | */ 220 | const ATTR_ACCESS_DELETE = 'access_delete'; 221 | 222 | /** 223 | * Column attribute 'icon' 224 | */ 225 | const ATTR_ICON = 'icon'; 226 | 227 | /** 228 | * Column attribute 'icon_type' 229 | */ 230 | const ATTR_ICON_TYPE = 'icon_type'; 231 | 232 | /** 233 | * Column attribute 'active' 234 | */ 235 | const ATTR_ACTIVE = 'active'; 236 | 237 | /** 238 | * Column attribute 'selected' 239 | */ 240 | const ATTR_SELECTED = 'selected'; 241 | 242 | /** 243 | * Column attribute 'disabled' 244 | */ 245 | const ATTR_DISABLED = 'disabled'; 246 | 247 | /** 248 | * Column attribute 'readonly' 249 | */ 250 | const ATTR_READ_ONLY = 'readonly'; 251 | 252 | /** 253 | * Column attribute 'visible' 254 | */ 255 | const ATTR_VISIBLE = 'visible'; 256 | 257 | /** 258 | * Column attribute 'collapsed' 259 | */ 260 | const ATTR_COLLAPSED = 'collapsed'; 261 | 262 | /** 263 | * Column attribute 'created_at' 264 | */ 265 | const ATTR_CREATED_AT = 'created_at'; 266 | 267 | /** 268 | * Column attribute 'updated_at' 269 | */ 270 | const ATTR_UPDATED_AT = 'updated_at'; 271 | 272 | /** 273 | * Global identifier for a access_domain 274 | */ 275 | const GLOBAL_ACCESS_DOMAIN = '*'; 276 | 277 | /** 278 | * RBAC permission name to manage global access domain page nodes 279 | */ 280 | const GLOBAL_ACCESS_PERMISSION = 'pages.globalAccess'; 281 | 282 | /** 283 | * RBAC permission access pages module 284 | */ 285 | const PAGES_ACCESS_PERMISSION = 'pages'; 286 | 287 | /** 288 | * @var bool whether to HTML encode the tree node names. Defaults to `false`. 289 | */ 290 | public $encodeNodeNames = false; 291 | 292 | /** 293 | * Virtual attribute generated from "domain_id"_"access_domain". 294 | * 295 | * @var string 296 | */ 297 | public $name_id; 298 | 299 | /** 300 | * The pages module instance 301 | * 302 | * @var PagesModule 303 | */ 304 | public $module; 305 | 306 | /** 307 | * @inheritdoc 308 | */ 309 | public function init() 310 | { 311 | parent::init(); 312 | 313 | // set the pages module instance 314 | if (null === $this->module = \Yii::$app->getModule(PagesModule::NAME)) { 315 | throw new HttpException(404, 'Module "' . PagesModule::NAME . '" not found in ' . __METHOD__); 316 | } 317 | } 318 | 319 | /** 320 | * @inheritdoc 321 | */ 322 | public static function tableName() 323 | { 324 | return '{{%dmstr_page}}'; 325 | } 326 | 327 | /** 328 | * Access checks of a page node 329 | * 330 | * - 'access_domain' enabled 331 | * - 'access_owner' enabled 332 | * - 'access_read' enabled 333 | * - 'access_update' enabled 334 | * - 'access_delete' enabled 335 | * 336 | * @return array 337 | */ 338 | public static function accessColumnAttributes() 339 | { 340 | return [ 341 | 'domain' => self::ATTR_ACCESS_DOMAIN, 342 | 'owner' => self::ATTR_ACCESS_OWNER, 343 | 'read' => self::ATTR_ACCESS_READ, 344 | 'update' => self::ATTR_ACCESS_UPDATE, 345 | 'delete' => self::ATTR_ACCESS_DELETE, 346 | ]; 347 | } 348 | 349 | /** 350 | * @inheritdoc 351 | * 352 | * Use yii\behaviors\TimestampBehavior for created_at and updated_at attribute 353 | * 354 | * @return array 355 | */ 356 | public function behaviors() 357 | { 358 | 359 | $behaviors = parent::behaviors(); 360 | 361 | $behaviors['audit'] = [ 362 | 'class' => AuditTrailBehavior::class 363 | ]; 364 | 365 | $behaviors['timestamp'] = [ 366 | 'class' => TimestampBehavior::class, 367 | 'createdAtAttribute' => self::ATTR_CREATED_AT, 368 | 'updatedAtAttribute' => self::ATTR_UPDATED_AT, 369 | 'value' => new Expression('NOW()'), 370 | ]; 371 | 372 | $behaviors['translatable'] = [ 373 | 'class' => TranslateableBehavior::class, 374 | 'languageField' => 'language', 375 | 'skipSavingDuplicateTranslation' => true, 376 | 'translationAttributes' => [ 377 | self::ATTR_NAME, 378 | self::ATTR_PAGE_TITLE, 379 | self::ATTR_DEFAULT_META_KEYWORDS, 380 | self::ATTR_DEFAULT_META_DESCRIPTION, 381 | ], 382 | 'deleteEvent' => ActiveRecord::EVENT_BEFORE_DELETE, 383 | 'restrictDeletion' => TranslateableBehavior::DELETE_LAST, 384 | ]; 385 | 386 | $behaviors['translation_meta'] = [ 387 | 'class' => TranslateableBehavior::class, 388 | 'relation' => 'translationsMeta', 389 | 'languageField' => 'language', 390 | 'fallbackLanguage' => false, 391 | 'skipSavingDuplicateTranslation' => false, 392 | 'translationAttributes' => [ 393 | self::ATTR_DISABLED, 394 | self::ATTR_VISIBLE, 395 | ], 396 | 'deleteEvent' => ActiveRecord::EVENT_BEFORE_DELETE, 397 | ]; 398 | 399 | return $behaviors; 400 | } 401 | 402 | /** 403 | * @return \yii\db\ActiveQuery 404 | */ 405 | public function getTranslations() 406 | { 407 | return $this->hasMany(TreeTranslation::class, ['page_id' => 'id']); 408 | } 409 | 410 | /** 411 | * @return \yii\db\ActiveQuery 412 | */ 413 | public function getTranslationsMeta() 414 | { 415 | return $this->hasMany(TreeTranslationMeta::class, ['page_id' => 'id']); 416 | } 417 | 418 | 419 | /** 420 | * @inheritdoc 421 | */ 422 | public function rules() 423 | { 424 | return ArrayHelper::merge( 425 | parent::rules(), 426 | [ 427 | [ 428 | self::ATTR_DOMAIN_ID, 429 | 'default', 430 | 'value' => function () { 431 | return uniqid(); 432 | } 433 | ], 434 | [ 435 | [ 436 | self::ATTR_ACCESS_READ, 437 | ], 438 | 'default', 439 | 'value' => self::$_all 440 | ], 441 | [ 442 | [ 443 | self::ATTR_ACCESS_UPDATE, 444 | self::ATTR_ACCESS_DELETE 445 | ], 446 | 'default', 447 | 'value' => static::getDefaultAccessUpdateDelete() 448 | ], 449 | [ 450 | [self::ATTR_DOMAIN_ID, self::ATTR_ACCESS_DOMAIN], 451 | 'unique', 452 | 'targetAttribute' => [self::ATTR_DOMAIN_ID, self::ATTR_ACCESS_DOMAIN], 453 | 'message' => \Yii::t('pages', 'Combination ' . self::ATTR_DOMAIN_ID . ' and ' . self::ATTR_ACCESS_DOMAIN . ' must be unique!'), 454 | ], 455 | [ 456 | self::ATTR_DOMAIN_ID, 457 | 'match', 458 | 'pattern' => '/^[a-z0-9_-]+$/', 459 | 'message' => \Yii::t( 460 | 'pages', 461 | '{0} should not contain any uppercase and special chars!', ['{attribute}'] 462 | ) 463 | ], 464 | [ 465 | [ 466 | self::ATTR_DOMAIN_ID, 467 | self::ATTR_PAGE_TITLE, 468 | self::ATTR_SLUG, 469 | self::ATTR_ROUTE, 470 | self::ATTR_VIEW, 471 | self::ATTR_ICON, 472 | self::ATTR_DEFAULT_META_KEYWORDS, 473 | self::ATTR_REQUEST_PARAMS, 474 | self::ATTR_ACCESS_READ, 475 | self::ATTR_ACCESS_UPDATE, 476 | self::ATTR_ACCESS_DELETE, 477 | ], 478 | 'string', 479 | 'max' => 255, 480 | ], 481 | [ 482 | self::ATTR_ROUTE, 483 | 'match', 484 | 'pattern' => '@^/[^/]@i', 485 | 'message' => \Yii::t('pages', '{0} should begin with one slash!', ['{attribute}']) 486 | ], 487 | [ 488 | 'view', 489 | 'required', 490 | 'when' => function ($model) { 491 | return $model->route === self::DEFAULT_PAGE_ROUTE; 492 | }, 493 | 'whenClient' => 'function (attribute, value) { 494 | return $("#tree-route").find(":selected").val() == "' . self::DEFAULT_PAGE_ROUTE . '"; 495 | }', 496 | 'message' => 'Route ' . self::DEFAULT_PAGE_ROUTE . ' requires a view.' 497 | ], 498 | [ 499 | [ 500 | self::ATTR_DEFAULT_META_DESCRIPTION, 501 | ], 502 | 'string', 503 | 'max' => 160, 504 | ], 505 | [ 506 | [ 507 | self::ATTR_NAME, 508 | ], 509 | 'string', 510 | 'max' => 60, 511 | ], 512 | [ 513 | [ 514 | self::ATTR_ACCESS_DOMAIN, 515 | ], 516 | 'string', 517 | 'max' => 8, 518 | ], 519 | [ 520 | [ 521 | self::ATTR_ACCESS_DOMAIN, 522 | ], 523 | 'default', 524 | 'value' => mb_strtolower(\Yii::$app->language), 525 | ], 526 | [ 527 | [ 528 | self::ATTR_ROOT, 529 | self::ATTR_ACCESS_OWNER, 530 | ], 531 | 'integer', 532 | 'integerOnly' => true, 533 | ], 534 | [ 535 | [ 536 | self::ATTR_ROOT, 537 | self::ATTR_ACCESS_OWNER, 538 | self::ATTR_COLLAPSED, 539 | self::ATTR_ICON_TYPE 540 | ], 541 | 'filter', 542 | 'filter' => 'intval' 543 | ], 544 | [ 545 | [ 546 | self::ATTR_PAGE_TITLE, 547 | self::ATTR_DEFAULT_META_KEYWORDS, 548 | self::ATTR_DEFAULT_META_DESCRIPTION, 549 | ], 550 | 'default', 551 | ], 552 | [ 553 | [ 554 | self::ATTR_DOMAIN_ID, 555 | self::ATTR_PAGE_TITLE, 556 | self::ATTR_NAME, 557 | self::ATTR_SLUG, 558 | self::ATTR_ROUTE, 559 | self::ATTR_VIEW, 560 | self::ATTR_DEFAULT_META_KEYWORDS, 561 | self::ATTR_DEFAULT_META_DESCRIPTION, 562 | self::ATTR_REQUEST_PARAMS, 563 | self::ATTR_ACCESS_DOMAIN, 564 | self::ATTR_ACCESS_OWNER, 565 | self::ATTR_ACCESS_UPDATE, 566 | self::ATTR_ACCESS_DELETE, 567 | self::ATTR_CREATED_AT, 568 | self::ATTR_UPDATED_AT, 569 | ], 570 | 'safe', 571 | ], 572 | ] 573 | ); 574 | } 575 | } 576 | -------------------------------------------------------------------------------- /models/Tree.php: -------------------------------------------------------------------------------- 1 | 57 | */ 58 | class Tree extends BaseTree 59 | { 60 | 61 | public static $enableRecursiveRoles = true; 62 | 63 | /** 64 | * @inheritdoc 65 | */ 66 | public function rules() 67 | { 68 | return ArrayHelper::merge( 69 | parent::rules(), 70 | [ 71 | [ 72 | self::ATTR_REQUEST_PARAMS, 73 | function ($attribute, $params) { 74 | 75 | $validator = new Validator(); 76 | 77 | $obj = Json::decode($this->requestParamsSchema, false); 78 | $data = Json::decode($this->{$attribute}, false); 79 | $validator->check($data, $obj); 80 | if ($validator->getErrors()) { 81 | foreach ($validator->getErrors() as $error) { 82 | $this->addError($error['property'], "{$error['property']}: {$error['message']}"); 83 | } 84 | } 85 | 86 | }, 87 | ], 88 | ] 89 | ); 90 | } 91 | 92 | /** 93 | * @inheritdoc 94 | */ 95 | public function afterFind() 96 | { 97 | parent::afterFind(); 98 | $this->setNameId($this->domain_id . '_' . $this->access_domain); 99 | } 100 | 101 | public function afterSave($insert, $changedAttributes) 102 | { 103 | parent::afterSave($insert, $changedAttributes); 104 | TagDependency::invalidate(\Yii::$app->cache, 'pages'); 105 | } 106 | 107 | 108 | /** 109 | * @return bool 110 | */ 111 | public function beforeDelete() 112 | { 113 | if (!$this->isDeletable) { 114 | // send message to user so he knows whats going on 115 | Yii::$app->session->addFlash('info', Yii::t('pages', 'You can not delete this record. There is still a translation that uses this entry as a reference.')); 116 | } 117 | 118 | return parent::beforeDelete(); 119 | } 120 | 121 | public function afterDelete() 122 | { 123 | parent::afterDelete(); 124 | TagDependency::invalidate(\Yii::$app->cache, 'pages'); 125 | } 126 | 127 | /** 128 | * Disallow node movement when user has no update permissions 129 | * 130 | * @param string $dir 131 | * 132 | * @return bool 133 | */ 134 | public function isMovable($dir) 135 | { 136 | if (!$this->hasPermission('access_update')) { 137 | return false; 138 | } 139 | 140 | return parent::isMovable($dir); 141 | } 142 | 143 | /** 144 | * @return array 145 | */ 146 | public static function optsAccessDomain() 147 | { 148 | $currentLanguage = mb_strtolower(Yii::$app->language); 149 | $availableLanguages[$currentLanguage] = $currentLanguage; 150 | 151 | if (Yii::$app->user->can(self::GLOBAL_ACCESS_PERMISSION)) { 152 | $availableLanguages[self::GLOBAL_ACCESS_DOMAIN] = Yii::t(self::PAGES_ACCESS_PERMISSION, 'GLOBAL'); 153 | } 154 | 155 | return $availableLanguages; 156 | } 157 | 158 | /** 159 | * Renders all available root nodes as mapped array, `id` => `name_id` 160 | * 161 | * @return array 162 | */ 163 | public static function optsSourceRootId() 164 | { 165 | // disable access trait to find root nodes in all languages 166 | self::$activeAccessTrait = false; 167 | 168 | // find all root nodes but global access domain nodes 169 | $rootNodes = self::find() 170 | ->where([self::ATTR_LVL => self::ROOT_NODE_LVL]) 171 | ->andWhere(['NOT', [self::ATTR_ACCESS_DOMAIN => self::GLOBAL_ACCESS_DOMAIN]]) 172 | ->all(); 173 | 174 | if (empty($rootNodes)) { 175 | return []; 176 | } 177 | 178 | return ArrayHelper::map($rootNodes, 'id', 'name_id'); 179 | } 180 | 181 | /** 182 | * Get all configured views 183 | * 184 | * @return array list of options 185 | */ 186 | public static function optsView() 187 | { 188 | return \Yii::$app->getModule(PagesModule::NAME)->availableViews; 189 | } 190 | 191 | /** 192 | * Get all configured routs 193 | * 194 | * @return array list of options 195 | */ 196 | public static function optsRoute() 197 | { 198 | return \Yii::$app->getModule(PagesModule::NAME)->availableRoutes; 199 | } 200 | 201 | /** 202 | * Get all icon constants for dropdown list in example 203 | * 204 | * @param bool $html whether to render icon as array value prefix 205 | * 206 | * @throws \ReflectionException 207 | * @return array 208 | */ 209 | public static function optsIcon($html = false) 210 | { 211 | $result = []; 212 | foreach ((new \ReflectionClass(FA::class))->getConstants() as $constant) { 213 | $key = $constant; 214 | 215 | $result[$key] = $html 216 | ? FA::icon($constant) . '  ' . $constant 217 | : $constant; 218 | } 219 | return $result; 220 | } 221 | 222 | /** 223 | * @param array $additionalParams 224 | * 225 | * @return array|string|null 226 | */ 227 | public function createRoute($additionalParams = []) 228 | { 229 | if (!$this->route) { 230 | return null; 231 | } 232 | 233 | $pageId = null; 234 | $slug = null; 235 | $slugFolder = null; 236 | 237 | // us this params only for the default page route 238 | if ($this->route === self::DEFAULT_PAGE_ROUTE) { 239 | $pageId = $this->id; 240 | $slug = $this->page_title 241 | ? Inflector::slug($this->page_title) 242 | : Inflector::slug($this->name); 243 | $slugFolder = $this->resolvePagePath(true); 244 | } 245 | 246 | $route = [ 247 | $this->route, 248 | self::REQUEST_PARAM_ID => $pageId, 249 | self::REQUEST_PARAM_SLUG => $slug, 250 | self::REQUEST_PARAM_PATH => $slugFolder 251 | ]; 252 | 253 | if (Json::decode($this->request_params)) { 254 | $route = ArrayHelper::merge($route, Json::decode($this->request_params)); 255 | } 256 | 257 | if (!empty($additionalParams)) { 258 | $route = ArrayHelper::merge($route, $additionalParams); 259 | } 260 | 261 | return $route; 262 | } 263 | 264 | /** 265 | * @param array $additionalParams 266 | * 267 | * @return string 268 | */ 269 | public function createUrl($additionalParams = []) 270 | { 271 | return Url::to($this->createRoute($additionalParams)); 272 | } 273 | 274 | /** 275 | * get Root node by given domainId 276 | * 277 | * @param $domainId 278 | * 279 | * @return Tree|null 280 | */ 281 | public static function getRootByDomainId($domainId) 282 | { 283 | $rootCondition[self::ATTR_DOMAIN_ID] = $domainId; 284 | $rootCondition[self::ATTR_ACCESS_DOMAIN] = [self::GLOBAL_ACCESS_DOMAIN, mb_strtolower(\Yii::$app->language)]; 285 | return self::findOne($rootCondition); 286 | } 287 | 288 | /** 289 | * return activeQuery that select children for given root Node 290 | * 291 | * @param Tree $rootNode 292 | * 293 | * @return TreeQuery 294 | */ 295 | public static function getLeavesFromRoot(Tree $rootNode) 296 | { 297 | $leavesQuery = $rootNode->children()->andWhere( 298 | [ 299 | self::ATTR_ACTIVE => self::ACTIVE, 300 | self::ATTR_ACCESS_DOMAIN => [self::GLOBAL_ACCESS_DOMAIN, mb_strtolower(\Yii::$app->language)], 301 | ] 302 | ); 303 | return $leavesQuery->with('translationsMeta'); 304 | } 305 | 306 | /** 307 | * Get all nodes where user has access to with hierarchical permission checking 308 | * 309 | * @return \yii\db\ActiveQuery 310 | */ 311 | public static function getAccessibleItemsQuery() 312 | { 313 | $query = self::find(); 314 | 315 | // Always order by root and left values for nested set 316 | $query->orderBy(['root' => SORT_ASC, 'lft' => SORT_ASC]); 317 | 318 | // If user is admin, return all items 319 | if (Yii::$app->user->can(self::getAdminRole())) { 320 | return $query; 321 | } 322 | 323 | $userId = static::currentUserId(); 324 | $userAuthItems = array_keys(self::getUsersAuthItems()); 325 | 326 | // Build access condition: owner OR has read permission 327 | $ownerCondition = [self::ATTR_ACCESS_OWNER => $userId]; 328 | 329 | // Public access 330 | $publicCondition = [self::ATTR_ACCESS_READ => self::$_all]; 331 | 332 | // Build read permission conditions using precise matching 333 | $readConditions = [$publicCondition]; 334 | 335 | // Use FIND_IN_SET or array matching for precise permission checking 336 | $authItemsString = implode(',', array_filter($userAuthItems, function($item) { 337 | return $item !== self::$_all; 338 | })); 339 | 340 | if (!empty($authItemsString)) { 341 | $dbName = Yii::$app->getDb()->getDriverName(); 342 | if ($dbName === 'mysql') { 343 | $readConditions[] = 'FIND_IN_SET(' . self::ATTR_ACCESS_READ . ', "' . $authItemsString . '") > 0'; 344 | } else { 345 | // For PostgreSQL and other databases, use array matching 346 | $readConditions[] = [self::ATTR_ACCESS_READ => array_filter($userAuthItems, function($item) { 347 | return $item !== self::$_all; 348 | })]; 349 | } 350 | } 351 | 352 | $directAccessCondition = ['OR', $ownerCondition, ['OR', ...$readConditions]]; 353 | $query->andWhere($directAccessCondition); 354 | 355 | // Add hierarchical access control exclude nodes where ANY parent lacks access 356 | // This uses NOT EXISTS to exclude nodes that have inaccessible parents 357 | $tableName = self::tableName(); 358 | $parentQuery = self::find() 359 | ->alias('parent') 360 | ->where([ 361 | 'and', 362 | ['<', 'parent.lft', new Expression($tableName . '.lft')], 363 | ['>', 'parent.rgt', new Expression($tableName . '.rgt')], 364 | ['=', 'parent.root', new Expression($tableName . '.root')], 365 | ['>=', 'parent.lvl', 0], // Include root nodes in parent check 366 | [Tree::ATTR_ACCESS_DOMAIN => [Yii::$app->language, Tree::GLOBAL_ACCESS_DOMAIN]] 367 | ]) 368 | ->andWhere(['NOT', $directAccessCondition]); // Parents that DON'T have access 369 | 370 | $query->andWhere(['NOT EXISTS', $parentQuery]); 371 | 372 | return $query; 373 | } 374 | 375 | /** 376 | * Build array with active and visible menu nodes for the current application language 377 | * 378 | * @param string $domainId the domain id of the root node 379 | * @param bool|false $checkUserPermissions weather to check permissions for the node leave routes 380 | * @param array $linkOptions 381 | * 382 | * @return array 383 | */ 384 | public static function getMenuItems($domainId, $checkUserPermissions = false, array $linkOptions = []) 385 | { 386 | $cache = Yii::$app->cache; 387 | $cacheKey = Json::encode([self::class, Yii::$app->language, $domainId, $checkUserPermissions, $linkOptions]); 388 | $data = $cache->get($cacheKey); 389 | 390 | if ($data !== false && Yii::$app->user->isGuest) { 391 | return $data; 392 | } 393 | 394 | Yii::trace(['Building menu items', $cacheKey], __METHOD__); 395 | // Get root node by domain id 396 | $rootNode = self::getRootByDomainId($domainId); 397 | 398 | if ($rootNode === null) { 399 | return []; 400 | } 401 | 402 | if ($rootNode->isDisabled() && !Yii::$app->user->can(self::PAGES_ACCESS_PERMISSION)) { 403 | return []; 404 | } 405 | 406 | /* 407 | * @var $leaves Tree[] 408 | */ 409 | 410 | // Get all leaves from this root node 411 | $leaves = self::getLeavesFromRoot($rootNode)->all(); 412 | if ($leaves === null) { 413 | return []; 414 | } 415 | 416 | // filter out invisible models and disabled models (if needed) 417 | // this is not done in the SQL query to reflect translation_meta values for "visible" and "disabled" attributes. 418 | $canAccessPages = Yii::$app->user->can(self::PAGES_ACCESS_PERMISSION); 419 | $leaves = array_filter($leaves, function (Tree $leave) use ($canAccessPages) { 420 | if (!$leave->isVisible()) { 421 | return false; 422 | } 423 | if (!$canAccessPages && $leave->isDisabled()) { 424 | return false; 425 | } 426 | return true; 427 | }); 428 | 429 | 430 | // tree mapping and leave stack 431 | $treeMap = []; 432 | $stack = []; 433 | 434 | if (count($leaves) > 0) { 435 | foreach ($leaves as $page) { 436 | /** @var Tree $page */ 437 | 438 | // prepare node identifiers 439 | $linkOptions = ArrayHelper::merge( 440 | $linkOptions, 441 | [ 442 | 'data-page-id' => $page->id, 443 | 'data-domain-id' => $page->domain_id, 444 | 'data-lvl' => $page->lvl, 445 | 'class' => $page->isDisabled() ? 'dmstr-pages-invisible-frontend' : '' 446 | ] 447 | ); 448 | 449 | $visible = true; 450 | if ($checkUserPermissions) { 451 | if ($page->access_read !== '*') { 452 | \Yii::trace('Checking Access_read permissions for page ' . $page->id, __METHOD__); 453 | $visible = Yii::$app->user->can($page->access_read); 454 | } else if (!empty($page->route)) { 455 | $visible = Yii::$app->user->can(substr(str_replace('/', '_', $page->route), 1), ['route' => true]); 456 | } 457 | } 458 | 459 | 460 | // prepare item template 461 | $itemTemplate = [ 462 | 'label' => $page->getMenuLabel(), 463 | 'url' => $page->createRoute() ?: null, 464 | 'icon' => $page->icon, 465 | 'linkOptions' => $linkOptions, 466 | 'dropDownOptions' => [ 467 | 'data-parent-domain-id' => $page->domain_id, 468 | ], 469 | 'visible' => $visible, 470 | ]; 471 | $item = $itemTemplate; 472 | 473 | // Count items in stack 474 | $counter = count($stack); 475 | 476 | // Check on different levels 477 | while ($counter > 0 && $stack[$counter - 1]['linkOptions']['data-lvl'] >= $item['linkOptions']['data-lvl']) { 478 | array_pop($stack); 479 | --$counter; 480 | } 481 | 482 | // Stack is now empty (check root again) 483 | if ($counter == 0) { 484 | // assign root node 485 | $i = count($treeMap); 486 | $treeMap[$i] = $item; 487 | $stack[] = &$treeMap[$i]; 488 | } else { 489 | if (!isset($stack[$counter - 1]['items'])) { 490 | $stack[$counter - 1]['items'] = []; 491 | } 492 | // add the node to parent node 493 | $i = count($stack[$counter - 1]['items']); 494 | $stack[$counter - 1]['items'][$i] = $item; 495 | $stack[] = &$stack[$counter - 1]['items'][$i]; 496 | } 497 | } 498 | } 499 | 500 | $data = array_filter($treeMap); 501 | 502 | if (Yii::$app->user->isGuest) { 503 | $cacheDependency = new TagDependency(['tags' => 'pages']); 504 | $cache->set($cacheKey, $data, 3600, $cacheDependency); 505 | } 506 | 507 | return $data; 508 | } 509 | 510 | public function getMenuLabel() 511 | { 512 | return !empty($this->name) ? htmlentities($this->name) : "({$this->domain_id})"; 513 | } 514 | 515 | /** 516 | * Get virtual name_id. 517 | * 518 | * @return string 519 | */ 520 | public function getNameId() 521 | { 522 | return $this->name_id; 523 | } 524 | 525 | /** 526 | * Generate and Set virtual attribute name_id. 527 | * 528 | * @param mixed $name_id 529 | */ 530 | public function setNameId($name_id) 531 | { 532 | $this->name_id = $name_id; 533 | } 534 | 535 | /** 536 | * @param bool|false $activeNode 537 | * 538 | * @return null|string 539 | */ 540 | protected function resolvePagePath($activeNode = false) 541 | { 542 | 543 | // get TreeCache singleton instance as cache 544 | $cache = TreeCache::getInstance(); 545 | 546 | // define cache key fro model id + app->lang 547 | $cacheKey = $this->id . Yii::$app->language; 548 | 549 | // if set, return path from cache 550 | if (isset($cache->path[$cacheKey])) { 551 | return $cache->path[$cacheKey]; 552 | } 553 | 554 | Yii::beginProfile('Resolving page path', __METHOD__); 555 | 556 | // return no path for root nodes 557 | $parent = $this->parents(1)->one(); 558 | if (!$parent) { 559 | return null; 560 | } 561 | /** @var Tree $parent */ 562 | 563 | // return no path for first level nodes 564 | if ($activeNode && $parent->isRoot()) { 565 | return null; 566 | } 567 | 568 | if (!$activeNode && $parent->isRoot()) { 569 | // start-point for building path 570 | $path = Inflector::slug(($this->page_title ?: $this->name)); 571 | } else if (!$activeNode) { 572 | // if not active, build up path 573 | $path = $parent->resolvePagePath() . '/' . Inflector::slug(($this->page_title ?: $this->name)); 574 | } else if ($activeNode && !$parent->isRoot()) { 575 | // building path finished 576 | $path = $parent->resolvePagePath(); 577 | } else { 578 | $path = null; 579 | } 580 | 581 | // store path in cache 582 | $cache->path[$cacheKey] = $path; 583 | Yii::endProfile('Resolving page path', __METHOD__); 584 | 585 | return $path; 586 | } 587 | 588 | /** 589 | * Conditions for a full page object 590 | * 591 | * @return bool 592 | */ 593 | public function isPage() 594 | { 595 | switch (true) { 596 | case $this->isRoot(): 597 | case $this->isLeaf(): 598 | case $this->isNewRecord: 599 | return true; 600 | break; 601 | default: 602 | return false; 603 | } 604 | } 605 | 606 | /** 607 | * Find the sibling page in target language if exists 608 | * 609 | * @param string $targetLanguage 610 | * @param integer $sourceId 611 | * @param string $route 612 | * 613 | * @throws \yii\console\Exception 614 | * @return Tree|null 615 | */ 616 | public function sibling($targetLanguage, $sourceId = null, $route = self::DEFAULT_PAGE_ROUTE) 617 | { 618 | if (strpos(self::DEFAULT_PAGE_ROUTE, $route) === false) { 619 | return null; 620 | } 621 | 622 | // Disable access trait access_domain checks in find 623 | self::$activeAccessTrait = false; 624 | 625 | if ($sourceId === null && !$this->isNewRecord) { 626 | $sourcePage = $this; 627 | } else { 628 | /** 629 | * find page with page id and source language 630 | * 631 | * @var Tree $sourcePage 632 | */ 633 | $sourcePage = self::findOne($sourceId); 634 | if ($sourcePage === null) { 635 | $message = \Yii::t( 636 | 'pages', 637 | 'Page with id {PAGE_ID} not found!"', 638 | ['PAGE_ID' => $sourceId] 639 | ); 640 | $errorCode = 404; 641 | $this->outputError($message, $errorCode); 642 | } 643 | } 644 | 645 | /** 646 | * find page with domain_id and destination language 647 | * 648 | * @var Tree $destinationPage 649 | */ 650 | $destinationPage = self::findOne( 651 | [ 652 | self::ATTR_DOMAIN_ID => $sourcePage->domain_id, 653 | self::ATTR_ACCESS_DOMAIN => mb_strtolower($targetLanguage) 654 | ] 655 | ); 656 | if ($destinationPage === null) { 657 | $message = \Yii::t( 658 | 'pages', 659 | 'Page with domain_id {DOMAIN_ID} in language "{LANGUAGE}" does not exists!', 660 | [ 661 | 'DOMAIN_ID' => $sourcePage->domain_id, 662 | 'LANGUAGE' => mb_strtolower($targetLanguage) 663 | ] 664 | ); 665 | $errorCode = 404; 666 | $this->outputError($message, $errorCode); 667 | } 668 | return $destinationPage; 669 | } 670 | 671 | /** 672 | * @param $message 673 | * @param $code 674 | * 675 | * @throws \yii\console\Exception 676 | * @return bool 677 | */ 678 | protected function outputError($message, $code) 679 | { 680 | if (PHP_SAPI === 'cli') { 681 | throw new \yii\console\Exception($message, $code); 682 | } 683 | 684 | \Yii::$app->session->set('error', $code . ': ' . $message); 685 | return false; 686 | } 687 | 688 | /** 689 | * @throws \yii\base\InvalidConfigException 690 | * @return string 691 | */ 692 | public function getRequestParamsSchema() 693 | { 694 | return PageHelper::routeToSchema($this->route); 695 | } 696 | 697 | 698 | /** 699 | * Checks if model can be deleted in case it has only one (or less) translation left 700 | * 701 | * @return bool 702 | */ 703 | public function getIsDeletable() 704 | { 705 | /** @var TranslateableBehavior $translatableBehavior */ 706 | $translatableBehavior = $this->getBehavior('translatable'); 707 | 708 | if ($translatableBehavior->restrictDeletion === TranslateableBehavior::DELETE_LAST) { 709 | return (int)$this->getTranslations()->count() <= 1; 710 | } 711 | 712 | return true; 713 | } 714 | } 715 | -------------------------------------------------------------------------------- /models/TreeCache.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | class TreeTranslation extends ActiveRecord 25 | { 26 | public function afterSave($insert, $changedAttributes) 27 | { 28 | parent::afterSave($insert, $changedAttributes); 29 | TagDependency::invalidate(\Yii::$app->cache, 'pages'); 30 | } 31 | 32 | public function afterDelete() 33 | { 34 | parent::afterDelete(); 35 | TagDependency::invalidate(\Yii::$app->cache, 'pages'); 36 | } 37 | 38 | /** 39 | * @inheritdoc 40 | * 41 | * Use yii\behaviors\TimestampBehavior for created_at and updated_at attribute 42 | * 43 | * @return array 44 | */ 45 | public function behaviors() 46 | { 47 | 48 | $behaviors = parent::behaviors(); 49 | 50 | $behaviors['audit'] = [ 51 | 'class' => AuditTrailBehavior::class 52 | ]; 53 | 54 | $behaviors['timestamp'] = [ 55 | 'class' => TimestampBehavior::class, 56 | 'value' => new Expression('NOW()'), 57 | ]; 58 | 59 | return $behaviors; 60 | } 61 | 62 | /** 63 | * @return string 64 | */ 65 | public static function tableName() 66 | { 67 | return '{{%dmstr_page_translation}}'; 68 | } 69 | 70 | /** 71 | * {@inheritdoc} 72 | */ 73 | public function rules() 74 | { 75 | return [ 76 | [['page_id'], 'integer'], 77 | [['language'], 'required'], 78 | [['name'], 'string', 'max' => 60], 79 | [ 80 | [ 81 | BaseTree::ATTR_PAGE_TITLE, 82 | BaseTree::ATTR_DEFAULT_META_KEYWORDS, 83 | ], 84 | 'string', 85 | 'max' => 255, 86 | ], 87 | [[BaseTree::ATTR_DEFAULT_META_DESCRIPTION], 'safe'], 88 | [ 89 | ['page_id'], 90 | 'exist', 91 | 'skipOnError' => true, 92 | 'targetClass' => Tree::class, 93 | 'targetAttribute' => ['page_id' => 'id'] 94 | ] 95 | ]; 96 | } 97 | 98 | } -------------------------------------------------------------------------------- /models/TreeTranslationMeta.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class TreeTranslationMeta extends ActiveRecord 24 | { 25 | 26 | public function afterSave($insert, $changedAttributes) 27 | { 28 | parent::afterSave($insert, $changedAttributes); 29 | TagDependency::invalidate(\Yii::$app->cache, 'pages'); 30 | } 31 | 32 | public function afterDelete() 33 | { 34 | parent::afterDelete(); 35 | TagDependency::invalidate(\Yii::$app->cache, 'pages'); 36 | } 37 | 38 | /** 39 | * @inheritdoc 40 | * 41 | * Use yii\behaviors\TimestampBehavior for created_at and updated_at attribute 42 | * 43 | * @return array 44 | */ 45 | public function behaviors() 46 | { 47 | 48 | $behaviors = parent::behaviors(); 49 | 50 | $behaviors['timestamp'] = [ 51 | 'class' => TimestampBehavior::class, 52 | 'value' => new Expression('NOW()'), 53 | ]; 54 | 55 | return $behaviors; 56 | } 57 | 58 | /** 59 | * @return string 60 | */ 61 | public static function tableName() 62 | { 63 | return '{{%dmstr_page_translation_meta}}'; 64 | } 65 | 66 | /** 67 | * {@inheritdoc} 68 | */ 69 | public function rules() 70 | { 71 | return [ 72 | [['page_id'], 'integer'], 73 | [['language'], 'required'], 74 | [['disabled'], 'boolean'], 75 | [['visible'], 'boolean'], 76 | [ 77 | ['page_id'], 78 | 'exist', 79 | 'skipOnError' => true, 80 | 'targetClass' => Tree::class, 81 | 'targetAttribute' => ['page_id' => 'id'] 82 | ] 83 | ]; 84 | } 85 | 86 | } -------------------------------------------------------------------------------- /tests/.dockerignore: -------------------------------------------------------------------------------- 1 | project/vendor -------------------------------------------------------------------------------- /tests/.env: -------------------------------------------------------------------------------- 1 | # Compose project name 2 | # --- 3 | COMPOSE_PROJECT_NAME=test-yii2-pages-module 4 | 5 | # Image name 6 | # --- 7 | STACK_PHP_IMAGE=test/yii2-pages-module 8 | 9 | STACK_DB_IMAGE=mariadb:10.1.22 -------------------------------------------------------------------------------- /tests/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM dmstr/yii2-app:0.7.1 2 | 3 | # Install npm and lessc 4 | RUN curl -sL https://deb.nodesource.com/setup_8.x | bash - \ 5 | && apt-get install -y npm \ 6 | && npm install -g less 7 | ENV PATH /app:/repo/tests/project/vendor/bin:/usr/lib/node_modules/less/bin:$PATH 8 | 9 | ENV COMPOSER=/repo/tests/project/composer.json 10 | 11 | # Clean vendor from base image 12 | RUN rm -rf /app/vendor 13 | RUN ln -s /repo/tests/project/vendor /app/vendor 14 | -------------------------------------------------------------------------------- /tests/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build run-tests 2 | 3 | DOCKER_COMPOSE ?= docker-compose 4 | PHP_SERVICE ?= php 5 | WEB_SERVICE ?= php 6 | CODECEPT_SERVICE ?= forrest 7 | 8 | UNAME_S := $(shell uname -s) 9 | ifeq ($(UNAME_S), Darwin) 10 | OPEN_CMD ?= open 11 | DOCKER_HOST_IP ?= $(shell echo $(DOCKER_HOST) | sed 's/tcp:\/\///' | sed 's/:[0-9.]*//') 12 | else 13 | OPEN_CMD ?= xdg-open 14 | DOCKER_HOST_IP ?= 127.0.0.1 15 | endif 16 | 17 | # Targets 18 | # ------- 19 | 20 | default: help 21 | 22 | all: build dev up setup 23 | 24 | dev: ##@development install composer package (enable host-volume in docker-compose config) 25 | dev: 26 | # 27 | # Running composer installation in development environment 28 | # This may take a while on your first install... 29 | # 30 | $(DOCKER_COMPOSE) run -w /repo/tests/project --rm php composer install 31 | 32 | up: ##@docker start application stack 33 | $(DOCKER_COMPOSE) up -d 34 | $(DOCKER_COMPOSE) ps 35 | 36 | build: ##@docker build application image 37 | $(DOCKER_COMPOSE) build --pull 38 | 39 | clean: ##@docker cleanup application stack 40 | $(DOCKER_COMPOSE) kill 41 | $(DOCKER_COMPOSE) down -v --remove-orphans 42 | 43 | open: ##@docker open application web service in browser 44 | $(OPEN_CMD) http://$(DOCKER_HOST_IP):$(shell $(DOCKER_COMPOSE) port $(WEB_SERVICE) 80 | sed 's/[0-9.]*://') 45 | 46 | open-db: ##@docker open application web service in browser 47 | $(OPEN_CMD) mysql://root:secretadmin@$(DOCKER_HOST_IP):$(shell $(DOCKER_COMPOSE) port db 3306 | sed 's/[0-9.]*://') 48 | 49 | open-vnc: ##@test open application database service in browser 50 | $(OPEN_CMD) vnc://x:secret@$(DOCKER_HOST_IP):$(shell $(DOCKER_COMPOSE) port firefox 5900 | sed 's/[0-9.]*://') 51 | 52 | bash: ##@docker open application development bash 53 | $(DOCKER_COMPOSE) run --rm -e YII_ENV=test $(PHP_SERVICE) bash 54 | 55 | setup: ##@docker wait for database and setup application stack 56 | $(DOCKER_COMPOSE) run --rm $(PHP_SERVICE) yii db/wait-for-connection mysql:host=db pages pages 57 | $(DOCKER_COMPOSE) run --rm $(PHP_SERVICE) yii migrate --interactive=0 58 | 59 | clean-tests: ##@test clean codeception output 60 | $(DOCKER_COMPOSE) run --rm $(PHP_SERVICE) codecept clean 61 | 62 | run-tests: ##@test run codeception tests 63 | $(DOCKER_COMPOSE) run --rm -e YII_ENV=test $(PHP_SERVICE) /repo/tests/project/vendor/bin/codecept run e2e,cli,unit -x optional 64 | 65 | lint: ##@test run php cs-fixer 66 | $(DOCKER_COMPOSE) run --rm $(PHP_SERVICE) /app/vendor/bin/php-cs-fixer fix --format=txt -v --dry-run . 67 | 68 | # Help based on https://gist.github.com/prwhite/8168133 thanks to @nowox and @prwhite 69 | # And add help text after each target name starting with '\#\#' 70 | # A category can be added with @category 71 | 72 | HELP_FUN = \ 73 | %help; \ 74 | while(<>) { push @{$$help{$$2 // 'options'}}, [$$1, $$3] if /^([\w-]+)\s*:.*\#\#(?:@([\w-]+))?\s(.*)$$/ }; \ 75 | print "\nusage: make [target ...]\n\n"; \ 76 | for (keys %help) { \ 77 | print "$$_:\n"; \ 78 | for (@{$$help{$$_}}) { \ 79 | $$sep = "." x (25 - length $$_->[0]); \ 80 | print " $$_->[0]$$sep$$_->[1]\n"; \ 81 | } \ 82 | print "\n"; } 83 | 84 | help: ##@system show this help 85 | # 86 | # General targets 87 | # 88 | @perl -e '$(HELP_FUN)' $(MAKEFILE_LIST) -------------------------------------------------------------------------------- /tests/codeception.yml: -------------------------------------------------------------------------------- 1 | actor: Tester 2 | paths: 3 | tests: codeception 4 | log: codeception/_output 5 | data: codeception/_data 6 | support: codeception/_support 7 | envs: codeception/_envs 8 | settings: 9 | #shuffle: true 10 | bootstrap: _bootstrap.php 11 | colors: true 12 | memory_limit: 1024M 13 | extensions: 14 | enabled: 15 | - Codeception\Extension\RunFailed 16 | modules: 17 | config: 18 | Yii2: 19 | configFile: 'codeception/_config/codeception-module.php' 20 | cleanup: false 21 | config: 22 | test_entry_url: http://web:80/index.php 23 | coverage: 24 | enabled: true 25 | c3_url: http://web/ 26 | remote: false 27 | whitelist: 28 | include: 29 | - ../src/*.php 30 | - ../vendor/dmstr/*.php 31 | - ../vendor/hrzg/*.php 32 | exclude: 33 | - ../src/commands/* 34 | - ../src/migrations/* 35 | -------------------------------------------------------------------------------- /tests/codeception/_bootstrap.php: -------------------------------------------------------------------------------- 1 | 'en', 22 | 'components' => [ 23 | 'request' => [ 24 | 'cookieValidationKey' => 'FUNCTIONAL_TESTING' 25 | ], 26 | 'urlManager' => [ 27 | 'scriptUrl' => '', 28 | #'enableDefaultLanguageUrlCode' => false, 29 | ] 30 | ] 31 | ] 32 | ); -------------------------------------------------------------------------------- /tests/codeception/_pages/LoginPage.php: -------------------------------------------------------------------------------- 1 | fillField('input[name="login-form[login]"]', $username); 23 | $actor->fillField('input[name="login-form[password]"]', $password); 24 | $actor->click('button[type=submit]'); 25 | $actor->wait(3); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/codeception/_support/CliTester.php: -------------------------------------------------------------------------------- 1 | amOnPage('/user/security/login'); 28 | $this->fillField('input[name="login-form[login]"]', $username); 29 | $this->fillField('input[name="login-form[password]"]', $password); 30 | $this->click('#login-form button'); 31 | $this->waitForElementNotVisible('#login-form', 10); 32 | } 33 | 34 | public function dontSeeHorizontalScrollbars(){ 35 | $this->assertFalse( 36 | $this->executeJS("return document.getElementsByTagName(\"html\")[0].scrollWidth > document.getElementsByTagName(\"html\")[0].clientWidth"), 37 | 'Horizontal scrollbar' 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/codeception/_support/FunctionalTester.php: -------------------------------------------------------------------------------- 1 | amOnPage('/user/security/login'); 28 | $this->fillField('input[name="login-form[login]"]', $username); 29 | $this->fillField('input[name="login-form[password]"]', $password); 30 | $this->click('#login-form button'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/codeception/_support/Helper/Acceptance.php: -------------------------------------------------------------------------------- 1 | wantTo('ensure that Pages works'); 9 | 10 | $I->amGoingTo('try to view pages'); 11 | $I->amOnPage('/pages'); 12 | // Settings link 13 | $I->seeElement('.kv-icon-10.fa.fa-cogs'); 14 | $I->makeScreenshot('success-pages-index'); -------------------------------------------------------------------------------- /tests/codeception/e2e/UrlCept.php: -------------------------------------------------------------------------------- 1 | wantTo('ensure that Page URL rules work'); 9 | 10 | $I->amGoingTo('try to view a page with different url rule patterns'); 11 | $I->amOnPage('/p/test-urls-2.html'); 12 | $I->dontSee('Page not found.'); 13 | $I->makeScreenshot('success-pages-url-1'); 14 | 15 | $I->amOnPage('/de/page/test-urls-2.html'); 16 | $I->dontSee('Page not found.'); 17 | $I->makeScreenshot('success-pages-url-2'); 18 | 19 | $I->amOnPage('/de/test-urls-2'); 20 | $I->dontSee('Page not found.'); 21 | $I->makeScreenshot('success-pages-url-3'); -------------------------------------------------------------------------------- /tests/codeception/e2e/_bootstrap.php: -------------------------------------------------------------------------------- 1 | assertNotEquals(null,Yii::$app); 10 | } 11 | 12 | public function testRequest() 13 | { 14 | $this->assertNotEquals(null,Yii::$app->request); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/codeception/unit/ModelTest.php: -------------------------------------------------------------------------------- 1 | language = 'de'; 12 | 13 | $root = Tree::findOne( 14 | [ 15 | Tree::ATTR_DOMAIN_ID => Tree::ROOT_NODE_PREFIX, 16 | Tree::ATTR_ACCESS_DOMAIN => 'de', 17 | ] 18 | ); 19 | 20 | if (empty($root)) { 21 | $root = $this->createRootNode('de'); 22 | } 23 | 24 | $this->assertSame($root->domain_id, 'root', 'Root node has errors'); 25 | } 26 | 27 | /** 28 | * - Add menu items to root node 29 | * - Check domain id will be automatically generated if not set 30 | */ 31 | public function testAddMenuItems() 32 | { 33 | \Yii::$app->language = 'de'; 34 | 35 | $root = Tree::findOne( 36 | [ 37 | Tree::ATTR_DOMAIN_ID => Tree::ROOT_NODE_PREFIX, 38 | Tree::ATTR_ACCESS_DOMAIN => 'de', 39 | ] 40 | ); 41 | 42 | $this->assertNotNull($root, 'Root node not found'); 43 | 44 | /** 45 | * Insert a leave and append to root node 46 | */ 47 | $leave = new Tree(); 48 | $leave->name = 'Seite 1'; 49 | 50 | // treemanager settings 51 | $leave->purifyNodeIcons = false; 52 | $leave->encodeNodeNames = false; 53 | 54 | $leave->appendTo($root); 55 | 56 | /** 57 | * Insert another leave and append to root node 58 | */ 59 | $leave = new Tree(); 60 | $leave->name = 'Seite 1'; 61 | 62 | // treemanager settings 63 | $leave->purifyNodeIcons = false; 64 | $leave->encodeNodeNames = false; 65 | 66 | $leave->appendTo($root); 67 | 68 | // get root node menu items 69 | $tree = Tree::getMenuItems('root'); 70 | 71 | $this->assertNotNull(count($tree), 'Root node not found'); 72 | } 73 | 74 | /** 75 | * Test the virtual name_id attribute setter and getter for 'de' and 'en' root pages 76 | * @return mixed 77 | */ 78 | public function testvalidateNameIdGeneration() 79 | { 80 | $pages = Tree::findAll( 81 | [ 82 | Tree::ATTR_DOMAIN_ID => Tree::ROOT_NODE_PREFIX, 83 | Tree::ATTR_ACTIVE => Tree::ACTIVE, 84 | Tree::ATTR_VISIBLE => Tree::VISIBLE, 85 | ] 86 | ); 87 | if ($pages !== null) { 88 | foreach ($pages as $page) { 89 | $buildNameId = $page->domain_id . '_' . $page->access_domain; 90 | $this->assertSame($buildNameId, $page->name_id, 'NameID was not set properly'); 91 | } 92 | } else { 93 | $this->assertNotEmpty($pages, 'No Pages found!'); 94 | } 95 | } 96 | 97 | /** 98 | * remove a root node 99 | */ 100 | public function testRemoveRootNodeWithChildren() 101 | { 102 | \Yii::$app->language = 'de'; 103 | 104 | $root = Tree::findOne( 105 | [ 106 | Tree::ATTR_DOMAIN_ID => Tree::ROOT_NODE_PREFIX, 107 | Tree::ATTR_ACCESS_DOMAIN => 'de', 108 | ] 109 | ); 110 | 111 | $this->assertNotNull($root, 'Root node not found'); 112 | 113 | $root->purifyNodeIcons = false; 114 | $root->encodeNodeNames = false; 115 | 116 | if ($root->isRemovable()) { 117 | $root->deleteWithChildren(); 118 | $this->assertNotEmpty($root->attributes, 'Root node deleted'); 119 | } else { 120 | $this->assertFalse($root->attributes, 'Root node can not be deleted'); 121 | } 122 | $root = Tree::findOne( 123 | [ 124 | Tree::ATTR_DOMAIN_ID => Tree::ROOT_NODE_PREFIX, 125 | Tree::ATTR_ACCESS_DOMAIN => 'de', 126 | ] 127 | ); 128 | $this->assertNull($root); 129 | } 130 | 131 | /** 132 | * Test find records only for current access domain, feature from 133 | * \dmstr\activeRecordPermissions\ActiveRecordAccessTrait 134 | */ 135 | public function testAccessDomainCheckOnFind() 136 | { 137 | // ensure a 'de' root node exists 138 | $root = Tree::findOne( 139 | [ 140 | Tree::ATTR_DOMAIN_ID => Tree::ROOT_NODE_PREFIX, 141 | Tree::ATTR_ACCESS_DOMAIN => 'de', 142 | ] 143 | ); 144 | 145 | // switch to app language 'en' 146 | \Yii::$app->language = 'en'; 147 | 148 | // try to find the 'de' root not in from app language 'en' 149 | $root = Tree::findOne( 150 | [ 151 | Tree::ATTR_DOMAIN_ID => Tree::ROOT_NODE_PREFIX, 152 | Tree::ATTR_ACCESS_DOMAIN => 'de', 153 | ] 154 | ); 155 | // expect false 156 | $this->assertNull($root, 'Root node "de" found from app language "en"'); 157 | 158 | // switch to app language 'de' 159 | \Yii::$app->language = 'de'; 160 | 161 | // try to find the 'de' root from app language 'de' 162 | $root = Tree::findOne( 163 | [ 164 | Tree::ATTR_DOMAIN_ID => Tree::ROOT_NODE_PREFIX, 165 | Tree::ATTR_ACCESS_DOMAIN => 'de', 166 | ] 167 | ); 168 | 169 | // expect true 170 | $this->assertNotNull($root, 'Root node "de" found from app language "de"'); 171 | } 172 | 173 | /** 174 | * Test update an attribute of a tree node 175 | */ 176 | public function testUpdatePageNode() 177 | { 178 | // switch to app language 'de' 179 | \Yii::$app->language = 'de'; 180 | 181 | // try to find the 'de' root from app language 'de' 182 | $root = Tree::findOne( 183 | [ 184 | Tree::ATTR_DOMAIN_ID => Tree::ROOT_NODE_PREFIX, 185 | Tree::ATTR_ACCESS_DOMAIN => 'de', 186 | ] 187 | ); 188 | // expect true 189 | $this->assertNotNull($root, 'Root node "de" found from app language "de"'); 190 | 191 | /** @var Tree $root */ 192 | $root->purifyNodeIcons = false; 193 | $root->encodeNodeNames = false; 194 | $root->page_title = "Updated Page Title"; 195 | $root->save(); 196 | 197 | // expect true 198 | $this->assertSame($root->page_title, 'Updated Page Title'); 199 | } 200 | 201 | /** 202 | * create empty root node fo a language 203 | * @param $language 204 | * 205 | * @return Tree 206 | */ 207 | private function createRootNode($language) 208 | { 209 | $root = new Tree(); 210 | $root->name = 'Willkommen'; 211 | $root->domain_id = 'root'; 212 | $root->access_domain = $language; 213 | 214 | // treemanager settings 215 | $root->purifyNodeIcons = false; 216 | $root->encodeNodeNames = false; 217 | 218 | $root->makeRoot(); 219 | 220 | return $root; 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /tests/codeception/unit/UrlTest.php: -------------------------------------------------------------------------------- 1 | urlManager; 17 | $urlManager->enablePrettyUrl = 1; 18 | $urlManager->showScriptName = false; 19 | 20 | // Pages rule globals 21 | $rule = new PageUrlRule(); 22 | $route = '/pages/default/page'; 23 | 24 | /** 25 | * Check url with params 26 | * - pageId 27 | */ 28 | $params = [Tree::REQUEST_PARAM_ID => 1]; 29 | 30 | $createdUrl = $rule->createUrl($urlManager, $route, $params); 31 | $expectedUrl = '-1'; 32 | 33 | $this->assertEquals($expectedUrl, $createdUrl); 34 | 35 | /** 36 | * Check url with params 37 | * - pageId 38 | * - pageSlug 39 | */ 40 | $params = [Tree::REQUEST_PARAM_ID => 1, Tree::REQUEST_PARAM_SLUG => 'slug']; 41 | 42 | $createdUrl = $rule->createUrl($urlManager, $route, $params); 43 | $expectedUrl = 'slug-1'; 44 | 45 | $this->assertEquals($expectedUrl, $createdUrl); 46 | 47 | /** 48 | * Check url with params 49 | * - pageId 50 | * - pageSlug 51 | * - pagePath 52 | */ 53 | $params = [Tree::REQUEST_PARAM_ID => 1, Tree::REQUEST_PARAM_SLUG => 'slug', Tree::REQUEST_PARAM_PATH => 'subpage/next-subpage/next-subpage']; 54 | 55 | $createdUrl = $rule->createUrl($urlManager, $route, $params); 56 | $expectedUrl = 'subpage/next-subpage/next-subpage/slug-1'; 57 | 58 | $this->assertEquals($expectedUrl, $createdUrl); 59 | 60 | /** 61 | * Check url for static routes without params 62 | * - pageId 63 | */ 64 | $params = [0 => '/static-route']; 65 | 66 | $createdUrl = $urlManager->createUrl($params); 67 | $expectedUrl = '/static-route'; 68 | 69 | $this->assertEquals($expectedUrl, $createdUrl); 70 | 71 | /** 72 | * Check url for static routes with params 73 | * - param1 => value1 74 | */ 75 | $params = [0 => '/static-route', 'param1' => 'value1']; 76 | 77 | $createdUrl = $urlManager->createUrl($params); 78 | $expectedUrl = '/static-route?param1=value1'; 79 | 80 | $this->assertEquals($expectedUrl, $createdUrl); 81 | 82 | /** 83 | * add URL rule 84 | */ 85 | $urlManager->addRules( 86 | [ 87 | '/static-route/-.html' => 'static-route', 88 | ] 89 | ); 90 | 91 | /** 92 | * Check url for static routes with params 93 | * - pageId => 5 94 | * - param1 => value1 95 | */ 96 | $route = 'static-route'; 97 | $params = [Tree::REQUEST_PARAM_ID => 5, 'param1' => 'value1']; 98 | $createdUrl = $rule->createUrl($urlManager, $route, $params); 99 | 100 | /** 101 | * if not pages/default/page route, the PageUrlRule will not match 102 | * and the application url manager will be used 103 | */ 104 | if ($createdUrl === false) { 105 | $params = [0 => '/static-route', Tree::REQUEST_PARAM_ID => 5, 'param1' => 'value1']; 106 | 107 | $createdUrl = $urlManager->createUrl($params); 108 | } 109 | $expectedUrl = '/static-route/value1-5.html'; 110 | 111 | $this->assertEquals($expectedUrl, $createdUrl); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /tests/codeception/unit/_bootstrap.php: -------------------------------------------------------------------------------- 1 | execute( 11 | " 12 | INSERT INTO `dmstr_page` (`id`, `root`, `lft`, `rgt`, `lvl`, `page_title`, `name`, `domain_id`, `slug`, `route`, `view`, `default_meta_keywords`, `default_meta_description`, `request_params`, `owner`, `access_owner`, `access_domain`, `access_read`, `access_update`, `access_delete`, `icon`, `icon_type`, `active`, `selected`, `disabled`, `readonly`, `visible`, `collapsed`, `movable_u`, `movable_d`, `movable_l`, `movable_r`, `removable`, `removable_all`, `created_at`, `updated_at`) 13 | VALUES 14 | (1, 1, 1, 4, 0, '', 'root_de', 'root', NULL, '', '', '', '', '{}', NULL, NULL, 'de', NULL, NULL, NULL, '', 1, 1, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 0, '2017-01-25 09:23:17', '2017-01-25 09:23:20'), 15 | (2, 1, 2, 3, 1, '', 'test-urls', 'test-urls', NULL, '/pages/default/page', '@vendor/dmstr/yii2-prototype-module/src/views/render/twig.php', '', '', '{}', NULL, NULL, 'de', NULL, NULL, NULL, '', 1, 1, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 0, '2017-01-25 09:23:40', '2017-01-25 09:23:45'); 16 | 17 | " 18 | ); 19 | } 20 | 21 | public function down() 22 | { 23 | echo "m160415_095116_add_root_node cannot be reverted.\n"; 24 | 25 | return false; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/migrations/m170315_215033_update_nodes_default_permission.php: -------------------------------------------------------------------------------- 1 | update('dmstr_page', ['access_read' => '*', 'access_update' => '*', 'access_delete' => '*'], ['id' => 1]); 11 | $this->update('dmstr_page', ['access_read' => '*', 'access_update' => '*', 'access_delete' => '*'], ['id' => 2]); 12 | } 13 | 14 | public function down() 15 | { 16 | echo "m170315_215033_update_nodes_default_permission cannot be reverted.\n"; 17 | 18 | return false; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/project/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "repositories": [ 3 | { 4 | "type": "vcs", 5 | "url": "file:///repo" 6 | }, 7 | { 8 | "type": "composer", 9 | "url": "https://asset-packagist.org" 10 | } 11 | ], 12 | "require": { 13 | "wikimedia/composer-merge-plugin": "~1.4" 14 | }, 15 | "extra": { 16 | "merge-plugin": { 17 | "require": [ 18 | "/app/composer.json", 19 | "/repo/composer.json" 20 | ] 21 | } 22 | }, 23 | "config": { 24 | "fxp-asset": { 25 | "installer-paths": { 26 | "npm-asset-library": "vendor/npm", 27 | "bower-asset-library": "vendor/bower" 28 | }, 29 | "vcs-driver-options": { 30 | "github-no-api": true 31 | }, 32 | "git-skip-update": "2 days", 33 | "pattern-skip-version": "(-build|-patch)", 34 | "optimize-with-installed-packages": false 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /tests/project/config/rbac/assignments.php: -------------------------------------------------------------------------------- 1 | [ 4 | 'Editor', 5 | ], 6 | ]; 7 | -------------------------------------------------------------------------------- /tests/project/config/rbac/items.php: -------------------------------------------------------------------------------- 1 | [ 4 | 'type' => 2, 5 | 'description' => 'Pages Module', 6 | ], 7 | 'Editor' => [ 8 | 'type' => 1, 9 | 'description' => 'Editor user', 10 | 'children' => [ 11 | 'pages', 12 | ], 13 | ], 14 | 'pages_default_page' => [ 15 | 'type' => 2, 16 | 'description' => 'CMS-Page Action', 17 | ] 18 | ]; 19 | -------------------------------------------------------------------------------- /tests/project/config/rbac/rules.php: -------------------------------------------------------------------------------- 1 | 'test', 25 | 'vendorPath' => '/repo/tests/project/vendor', 26 | 'runtimePath' => '@app/../runtime', 27 | 'language' => 'de', 28 | 'aliases' => [ 29 | 'repo' => '/repo', 30 | 'dmstr/modules/pages' => '/repo', 31 | 'vendor/dmstr/yii2-pages-module' => '/repo', 32 | 'tests' => '@repo/tests', 33 | #'backend' => '@vendor/dmstr/yii2-backend-module/src', 34 | ], 35 | 'components' => [ 36 | 'request' => array( 37 | 'enableCsrfValidation' => false, 38 | ), 39 | 'cache' => \yii\caching\DummyCache::class, 40 | 'authManager' => [ 41 | 'class' => PhpManager::class, 42 | 'itemFile' => '@repo/tests/project/config/rbac/items.php', 43 | 'assignmentFile' => '@repo/tests/project/config/rbac/assignments.php', 44 | 'ruleFile' => '@repo/tests/project/config/rbac/rules.php', 45 | ], 46 | 'db' => [ 47 | 'class' => 'yii\db\Connection', 48 | 'dsn' => getenv('DATABASE_DSN'), 49 | 'username' => getenv('DATABASE_USER'), 50 | 'password' => getenv('DATABASE_PASSWORD'), 51 | 'charset' => 'utf8', 52 | 'tablePrefix' => 'test_' . getenv('DATABASE_TABLE_PREFIX'), 53 | 'enableSchemaCache' => YII_ENV_PROD ? true : false, 54 | ], 55 | 'i18n' => [ 56 | 'translations' => [ 57 | '*' => [ 58 | 'class' => 'yii\i18n\PhpMessageSource', 59 | ], 60 | ], 61 | ], 62 | 'settings' => [ 63 | 'class' => '\pheme\settings\components\Settings', 64 | ], 65 | 'user' => [ 66 | 'class' => 'dmstr\web\User', 67 | 'identityClass' => 'app\components\EditorIdentity', 68 | ], 69 | ], 70 | 'modules' => [ 71 | 'audit' => [ 72 | 'class' => 'bedezign\yii2\audit\Audit', 73 | 'accessRoles' => ['audit-module'], 74 | 'layout' => '@backend/views/layouts/box', 75 | 'panels' => [ 76 | 'audit/request', 77 | 'audit/mail', 78 | 'audit/trail', 79 | 'audit/javascript', # enable app.assets.registerJSLoggingAsset via settings 80 | // These provide special functionality and get loaded to activate it 81 | 'audit/error', // Links the extra error reporting functions (`exception()` and `errorMessage()`) 82 | 'audit/extra', // Links the data functions (`data()`) 83 | 'audit/curl', // Links the curl tracking function (`curlBegin()`, `curlEnd()` and `curlExec()`) 84 | //'audit/db', 85 | //'audit/log', 86 | //'audit/profiling', 87 | ], 88 | 'ignoreActions' => [ 89 | (getenv('APP_AUDIT_DISABLE_ALL_ACTIONS') ? '*' : '_'), 90 | 'app/*', 91 | 'audit/*', 92 | 'help/*', 93 | 'gii/*', 94 | 'asset/*', 95 | 'debug/*', 96 | 'app/*', 97 | 'resque/*', 98 | 'db/create', 99 | 'migrate/up', 100 | ], 101 | 'maxAge' => 7, 102 | ], 103 | 'pages' => [ 104 | 'class' => 'dmstr\modules\pages\Module', 105 | 'layout' => '@app/views/layouts/main', 106 | ], 107 | 'settings' => [ 108 | 'class' => '\pheme\settings\Module', 109 | ], 110 | 'treemanager' => [ 111 | 'class' => 'kartik\tree\Module', 112 | 'treeViewSettings' => [ 113 | 'nodeView' => '@vendor/dmstr/yii2-pages-module/views/treeview/_form', 114 | 'fontAwesome' => true, 115 | ], 116 | ], 117 | /* 'user' => [ 118 | 'class' => '\dektrium\user\Module' 119 | ]*/ 120 | ], 121 | 'params' => [ 122 | 'yii.migrations' => [ 123 | '@vendor/dektrium/yii2-user/migrations', 124 | '@vendor/yiisoft/yii2/rbac/migrations', 125 | '@vendor/bedezign/yii2-audit/src/migrations', 126 | '@vendor/pheme/yii2-settings/migrations', 127 | '@vendor/dmstr/yii2-prototype-module/src/migrations', 128 | '@vendor/dmstr/yii2-pages-module/migrations', 129 | '@vendor/dmstr/yii2-pages-module/tests/migrations', 130 | ], 131 | ], 132 | ]; 133 | 134 | $web = [ 135 | 'on ' . Application::EVENT_BEFORE_REQUEST => function () { 136 | Yii::$app->user->login(new EditorIdentity()); 137 | }, 138 | 'bootstrap' => [ 139 | 'debug', 140 | ], 141 | 'modules' => [ 142 | 'debug' => [ 143 | 'class' => 'yii\debug\Module', 144 | // allow all private IPs by default 145 | 'allowedIPs' => [ 146 | '127.0.0.1', 147 | '::1', 148 | '10.*', 149 | '192.168.*', 150 | '172.16.*', 151 | '172.17.*', 152 | '172.18.*', 153 | '172.19.*', 154 | '172.20.*', 155 | '172.21.*', 156 | '172.22.*', 157 | '172.23.*', 158 | '172.24.*', 159 | '172.25.*', 160 | '172.26.*', 161 | '172.27.*', 162 | '172.28.*', 163 | '172.29.*', 164 | '172.30.*', 165 | '172.31.*', 166 | ], 167 | ], 168 | ], 169 | ]; 170 | 171 | $console = [ 172 | 'components' => [ 173 | 'urlManager' => [ 174 | 'scriptUrl' => '/', 175 | ], 176 | ], 177 | 'controllerMap' => [ 178 | 'db' => '\dmstr\console\controllers\MysqlController', 179 | 'migrate' => [ 180 | 'class' => MigrateController::class, 181 | 'migrationPath' => [ 182 | '@dmstr/modules/pages/migrations', 183 | '@pheme/settings/migrations', 184 | '@bedezign/yii2/audit/migrations', 185 | '@tests/migrations', 186 | ], 187 | ] 188 | ], 189 | ]; 190 | 191 | return \yii\helpers\ArrayHelper::merge($common, (PHP_SAPI === 'cli') ? $console : $web); -------------------------------------------------------------------------------- /tests/project/config/web-debug.php: -------------------------------------------------------------------------------- 1 | [ 15 | 'debug', 16 | ], 17 | 'modules' => [ 18 | 'debug' => [ 19 | 'class' => 'yii\debug\Module', 20 | // allow all private IPs by default 21 | 'allowedIPs' => [ 22 | '127.0.0.1', 23 | '::1', 24 | '10.*', 25 | '192.168.*', 26 | '172.16.*', 27 | '172.17.*', 28 | '172.18.*', 29 | '172.19.*', 30 | '172.20.*', 31 | '172.21.*', 32 | '172.22.*', 33 | '172.23.*', 34 | '172.24.*', 35 | '172.25.*', 36 | '172.26.*', 37 | '172.27.*', 38 | '172.28.*', 39 | '172.29.*', 40 | '172.30.*', 41 | '172.31.*', 42 | ], 43 | ], 44 | ], 45 | ]; 46 | -------------------------------------------------------------------------------- /tests/project/src/components/EditorIdentity.php: -------------------------------------------------------------------------------- 1 | all(),'id','name'); 58 | * 59 | * 60 | * Hints: 61 | * 62 | * - If the method as described above returns false, then this property will be ignored. 63 | * 64 | * - If the method as described above returns true, then this property will be displayed. This functionality can be used 65 | * to manipulate e.g. title or description (see class property `$allowedProperties`) 66 | * 67 | * - You can use php doc block to add options to properties: 68 | * 69 | * Example: 70 | * 71 | * /** 72 | * * @editor title My Title 73 | * *\/ 74 | * public function detailActionParamProductName() { 75 | * return true; 76 | * } 77 | * 78 | * This will generate an input with defined title for an *existing* parameter 79 | * 80 | * - If property is NOT optional, it will be set as required in json schema. 81 | * However, since this only implies that the property must be set in the data, but not that a value must also be set, 82 | * a validation rule should be defined using notations (see above). For properties of type 'string' a minLength: 1 83 | * option is set as fallback. 84 | * 85 | * @package dmstr\modules\pages\traits 86 | * @author Elias Luhr 87 | */ 88 | trait RequestParamActionTrait 89 | { 90 | 91 | // get json by route 92 | public function jsonFromAction($route) 93 | { 94 | try { 95 | // catch routes without named action 96 | // and use the controller default action as fallback 97 | if ($this->getUniqueId() === trim($route, '/')) { 98 | $actionId = lcfirst($this->defaultAction); 99 | } else { 100 | // in all other cases get action id from route 101 | $actionId = lcfirst(Inflector::camelize(basename($route))); 102 | } 103 | 104 | // get potential action name in controller 105 | $actionName = 'action' . Inflector::camelize($actionId); 106 | 107 | // get reflection class instance of controller 108 | $controllerRefl = new ReflectionClass(static::class); 109 | 110 | // get method reflection of action. If not exist exception will be thrown an catched underneath 111 | $actionRefl = $controllerRefl->getMethod($actionName); 112 | 113 | // first: try to get self defined schema 114 | $schema = $this->getActionsParamsSchema($actionRefl->getParameters(), $actionId); 115 | if ($schema !== false) { 116 | return $schema; 117 | } 118 | 119 | // otherwise try to build json from *ActionParam* methods 120 | return $this->generateJson($actionRefl->getParameters(), $actionId); 121 | 122 | } catch (ReflectionException $e) { 123 | return PageHelper::defaultJsonSchema(); 124 | } 125 | } 126 | 127 | /** 128 | * 129 | * try to get the editor schema from $actionId . 'ActionParamSchema' Method 130 | * return of this method can be: 131 | * - an object or array that can be encoded as json 132 | * - valid json string 133 | * 134 | * in all other cases this method returns false 135 | * json encode errors generate a warning but are suppressed 136 | * 137 | * @param $parameters 138 | * @param $actionId 139 | * 140 | * @return false|string 141 | */ 142 | private function getActionsParamsSchema($parameters, $actionId) 143 | { 144 | $methodName = $actionId . 'ActionParamSchema'; 145 | if ($this->hasMethod($methodName)) { 146 | $schema = $this->$methodName($parameters); 147 | // some base validation on the schema 148 | try { 149 | if (is_array($schema)) { 150 | return json_encode($schema, JSON_THROW_ON_ERROR); 151 | } 152 | if (is_object($schema)) { 153 | return json_encode($schema, JSON_THROW_ON_ERROR); 154 | } 155 | if (is_string($schema)) { 156 | $valid = \json_decode($schema); 157 | return $schema; 158 | } 159 | } catch (\Exception $e) { 160 | Yii::warning(''); 161 | return false; 162 | } 163 | } 164 | return false; 165 | } 166 | 167 | /** 168 | * Generate json for request param json editor 169 | * 170 | * @param ReflectionParameter[] $parameters 171 | * @param string $actionId 172 | * @return string 173 | * @throws ReflectionException 174 | */ 175 | private function generateJson($parameters, $actionId) 176 | { 177 | 178 | $requiredFields = []; 179 | 180 | // init main json struct object with defaults 181 | $jsonStruct = new \stdClass(); 182 | $jsonStruct->title = Yii::t('pages', 'Request Params'); 183 | $jsonStruct->type = "object"; 184 | $jsonStruct->properties = []; 185 | 186 | foreach ($parameters as $parameter) { 187 | // get name 188 | $parameterName = $parameter->name; 189 | 190 | // init obj for each property and set defaults 191 | $paramStruct = new \stdClass(); 192 | $paramStruct->title = Inflector::camel2words($parameterName); 193 | $paramStruct->type = 'string'; 194 | 195 | // nameActionParamId 196 | $methodName = $actionId . 'ActionParam' . ucfirst($parameterName); 197 | // use data from method if it exists 198 | if ($this->hasMethod($methodName)) { 199 | $enumData = $this->$methodName(); 200 | 201 | // hide field if method returns false 202 | if ($enumData === false) { 203 | continue; 204 | } 205 | 206 | // instantiate reflection of the actionParam method to be able to get (optional) docBlock 207 | $methodRefl = new ReflectionMethod($this, $methodName); 208 | 209 | // get docs from actionParam method 210 | $docs = $methodRefl->getDocComment(); 211 | $additionalData = []; 212 | if ($docs !== false) { 213 | // matches e.g. 214 | // @editor description My custom description 215 | // in php doc blocks 216 | preg_match_all('/@editor[\s]+([a-zA-Z-_]+)[\s]+(.*)\n/', $docs, $matches); 217 | if (isset($matches[1], $matches[2]) && \count($matches[1]) === \count($matches[2])) { 218 | $matchIndex = 0; 219 | foreach ($matches[1] as $propertyName) { 220 | $additionalData[$propertyName] = $matches[2][$matchIndex]; 221 | $matchIndex++; 222 | } 223 | } 224 | } 225 | 226 | // assign additionalData from docBlock in paramStruct 227 | foreach ($additionalData as $name => $value) { 228 | // if value looks like json object or array, get struct from json string 229 | $value = trim($value); 230 | if ( preg_match('#^(\{.+\})|(\[.+\])$#', $value)) { 231 | $value = json_decode($value); 232 | } 233 | $paramStruct->$name = $value; 234 | } 235 | 236 | // set enum options 237 | if (\is_array($enumData)) { 238 | // if we want string, cast keys to string, otherwise we would get IDs as int 239 | if ($paramStruct->type === 'string') { 240 | $paramStruct->enum = array_map('strval', array_keys($enumData)); 241 | } else { 242 | $paramStruct->enum = array_keys($enumData); 243 | } 244 | 245 | // ensure options is set... 246 | if (!isset($paramStruct->options)) { 247 | $paramStruct->options = new \stdClass(); 248 | } 249 | // ... and add enum_titles, ensure strings 250 | $paramStruct->options->enum_titles = array_map('strval', array_values($enumData)); 251 | } 252 | 253 | } 254 | 255 | // add to required list if param is not optional 256 | if (!$parameter->isOptional()) { 257 | $requiredFields[] = $parameterName; 258 | // TODO: how to check other types? 259 | if (($paramStruct->type === 'string') && (!isset($paramStruct->minLength))) { 260 | $paramStruct->minLength = 1; 261 | } 262 | } 263 | 264 | $jsonStruct->properties[$parameterName] = $paramStruct; 265 | 266 | } 267 | 268 | if (!empty($requiredFields)) { 269 | $jsonStruct->required = $requiredFields; 270 | } 271 | 272 | return json_encode($jsonStruct); 273 | 274 | } 275 | 276 | } -------------------------------------------------------------------------------- /views/default/index.php: -------------------------------------------------------------------------------- 1 | $query, 19 | 'isAdmin' => true, 20 | 'softDelete' => false, 21 | 'displayValue' => $pageId, 22 | 'showTooltips' => false, 23 | 'wrapperTemplate' => '{header}{footer}{tree}', 24 | 'headingOptions' => ['label' => Yii::t('pages', 'Nodes')], 25 | 'treeOptions' => ['style' => 'height:auto; min-height:400px'], 26 | 'headerTemplate' => $headerTemplate, 27 | 'mainTemplate' => $mainTemplate, 28 | 'toolbar' => $toolbar, 29 | 'krajeeDialogSettings' => [ 30 | 'useNative' => true 31 | ] 32 | ] 33 | ); 34 | -------------------------------------------------------------------------------- /views/test/index.php: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 |

Pages

15 |

Test

16 | 17 | ['class' => 'navbar navbar-default'], 20 | 'encodeLabels' => false, 21 | 'items' => $tree, 22 | ] 23 | ) ?> 24 | 25 |
26 | 27 | 30 | 31 |
32 | -------------------------------------------------------------------------------- /views/treeview/_form.php: -------------------------------------------------------------------------------- 1 | registerJs( 58 | "$(function () { 59 | $('[data-toggle=\'tooltip\']').tooltip({'html': false}); 60 | });" 61 | ); 62 | 63 | // Extract $_POST to @vars 64 | extract($params); 65 | 66 | // Set isAdmin @var 67 | $isAdmin = ($isAdmin === true || $isAdmin === 'true'); 68 | 69 | if (empty($parentKey)) { 70 | $parent = $node->parents(1)->one(); 71 | $parentKey = empty($parent) ? '' : Html::getAttributeValue($parent, $keyAttribute); 72 | } 73 | 74 | $inputOpts = []; 75 | $flagOptions = ['class' => 'kv-parent-flag']; 76 | 77 | if (!$node->isNewRecord) { 78 | if ($node->isReadonly()) { 79 | $inputOpts['readonly'] = true; 80 | } 81 | if ($node->isDisabled()) { 82 | $inputOpts['disabled'] = true; 83 | } 84 | $flagOptions['disabled'] = $node->isLeaf(); 85 | } 86 | 87 | /* 88 | * Begin active form 89 | * @controller NodeController 90 | */ 91 | $form = ActiveForm::begin(['action' => $formAction, 'options' => $formOptions]); 92 | 93 | // Get tree manager module 94 | $treeViewModule = TreeView::module(); 95 | 96 | // create node Url 97 | $nodeUrl = $node->createUrl(); 98 | 99 | // In case you are extending this form, it is mandatory to set 100 | // all these hidden inputs as defined below. 101 | echo Html::hiddenInput(Html::getInputName($node, $keyAttribute), $node->id); 102 | echo Html::hiddenInput('treeNodeModify', $node->isNewRecord); 103 | echo Html::hiddenInput('parentKey', $parentKey); 104 | echo Html::hiddenInput('currUrl', Url::to(['/pages', 'pageId' => $node->id])); 105 | echo Html::hiddenInput('modelClass', $modelClass); 106 | echo Html::hiddenInput('softDelete', $softDelete); 107 | echo Html::hiddenInput('treeManageHash', $treeManageHash); 108 | echo Html::hiddenInput('treeRemoveHash', $treeRemoveHash); 109 | echo Html::hiddenInput('treeMoveHash', $treeMoveHash); 110 | ?> 111 | 112 | beginBlock('buttons') ?> 113 | 114 |
115 |
116 | 'btn btn-success'] 119 | ) ?> 120 |
121 |
122 | 123 | endBlock() ?> 124 | 125 | 126 | 127 | 'solid' 130 | ] 131 | ) ?> 132 | 133 | blocks['buttons'] ?> 134 | 135 | 136 |

137 | icon ?: 'file') . ' ' . $node->name, $nodeUrl) : $node->name ?> 138 |

139 | 140 |

141 |
142 | 143 |
144 | getNameId() ?> 145 | id ?> 146 |

147 | 148 | 149 |
150 | 151 | 152 | 153 | 154 |
155 |
156 |
157 |
158 | disabled = $node->isDisabled() ? 1 : 0; 161 | echo $form->field($node, 'disabled')->dropDownList([0 => 'Online', 1 => 'Offline'])->label('Status'); 162 | ?> 163 |
164 |
165 |
166 |
167 | 168 | 169 | 170 | getBehavior('translatable')->isFallbackTranslation): ?> 171 |
172 |
173 |
174 | 175 | 177 |
178 |
179 |
180 | 181 | ' . \Yii::t('pages', 'Delete Translation'), 183 | ['/pages/crud/tree-translation/delete', 'id' => $node->getTranslation()->id], 184 | [ 185 | 'class' => 'btn btn-default pull-right', 186 | 'data-confirm' => '' . \Yii::t('pages', 'Are you sure to delete the current translation?') . '', 187 | 'data-method' => 'post', 188 | ] 189 | ); ?> 190 | 191 | 192 |
193 |
194 | field($node, $node::ATTR_NAME) ?> 195 |
196 | 197 |
198 | field($node, $node::ATTR_PAGE_TITLE)->textInput() ?> 199 |
200 |
201 | 202 |
203 |
204 | field($node, $node::ATTR_DEFAULT_META_KEYWORDS)->textInput() ?> 205 |
206 |
207 | field($node, $node::ATTR_DEFAULT_META_DESCRIPTION)->textarea(['rows' => 5]) ?> 208 |
209 |
210 | 211 | 212 |
213 |
214 | visible = $node->isVisible() ? 1 : 0; 217 | echo $form->field($node, 'visible')->checkbox() 218 | ?> 219 |
220 |
221 | field($node, 'collapsed')->checkbox($flagOptions) ?> 222 |
223 |
224 | 225 | 226 | 227 | 228 | Box::TYPE_PRIMARY 231 | ] 232 | ) ?> 233 | 234 |
235 | 236 |
237 | field($node, $node::ATTR_DOMAIN_ID)->textInput() ?> 238 |
239 | 240 |
241 | field($node, $node::ATTR_ROUTE)->widget( 242 | Select2::class, 243 | [ 244 | 245 | 'data' => $node::optsRoute(), 246 | 'options' => [ 247 | 'placeholder' => Yii::t('pages', 'Select ...'), 248 | 'data-request-url' => Url::to(['/pages/default/resolve-route-to-schema']), 249 | 'data-editor-id' => 'tree-request_params-container' 250 | ], 251 | 'pluginOptions' => ['allowClear' => true], 252 | ] 253 | ); 254 | ?> 255 |
256 |
257 | field($node, $node::ATTR_VIEW)->widget( 258 | Select2::class, 259 | [ 260 | 261 | 'data' => $node::optsView(), 262 | 'options' => ['placeholder' => Yii::t('pages', 'Select ...')], 263 | 'pluginOptions' => ['allowClear' => true], 264 | ] 265 | ); ?> 266 |
267 | 268 |
269 | field($node, $node::ATTR_REQUEST_PARAMS 270 | )->widget(JsonEditorWidget::class, 271 | [ 272 | 'schema' => Json::decode($node->requestParamsSchema), 273 | 'id' => 'requestParamEditor', 274 | 'clientOptions' => [ 275 | 'theme' => 'bootstrap3', 276 | 'ajax' => true, 277 | 'disable_collapse' => true, 278 | // 'required_by_default' => true, 279 | // 'disable_edit_json' => true, 280 | // 'disable_properties' => true 281 | ] 282 | ]) ?> 283 |
284 | 285 |
286 | 287 |
288 | 289 |
290 | treeViewSettings['fontAwesome']) && $treeViewModule->treeViewSettings['fontAwesome'] === true): ?> 291 | field($node, $iconAttribute)->widget( 292 | Select2::class, 293 | [ 294 | 295 | 'data' => $node::optsIcon(true), 296 | 'options' => ['placeholder' => Yii::t('pages', 'Select ...')], 297 | 'pluginOptions' => [ 298 | 'escapeMarkup' => new \yii\web\JsExpression('function(m) { return m; }'), 299 | 'allowClear' => true, 300 | ], 301 | ] 302 | ) ?> 303 | 304 | field($node, $iconAttribute)->textInput($inputOpts) ?> 305 | 306 |
307 | 308 | 309 |
310 | field($node, $iconTypeAttribute)->widget( 311 | Select2::class, 312 | [ 313 | 314 | 'data' => [ 315 | TreeView::ICON_CSS => 'CSS Suffix', 316 | TreeView::ICON_RAW => 'Raw Markup', 317 | ], 318 | 'options' => [ 319 | 'id' => 'tree-' . $iconTypeAttribute, 320 | 'placeholder' => Yii::t('pages', 'Select'), 321 | 'multiple' => false, 322 | ] + $inputOpts, 323 | 'pluginOptions' => [ 324 | 'allowClear' => false, 325 | ], 326 | ] 327 | ); 328 | ?> 329 |
330 | 331 | 332 |
333 | 334 |
335 | 336 | 337 |
338 | $form, 341 | 'model' => $node, 342 | 'accessFields' => [ 343 | 'domain', 344 | 'read', 345 | 'update', 346 | 'delete' 347 | ] 348 | ]) ?> 349 |
350 | 351 | 352 |
353 | 354 | 355 | 356 |
357 |
358 | 359 | field($node, $nameAttribute)->textarea(['rows' => 2] + $inputOpts) ?> 360 |
361 |
362 | field($node, $iconAttribute)->multiselect( 363 | $iconsList, 364 | [ 365 | 'item' => function ($index, $label, $name, $checked, $value) use ($inputOpts) { 366 | if ($index == 0 && $value == '') { 367 | $checked = true; 368 | $value = ''; 369 | } 370 | 371 | return '
' . Html::radio( 372 | $name, 373 | $checked, 374 | [ 375 | 'value' => $value, 376 | 'label' => $label, 377 | 'disabled' => !empty($inputOpts['readonly']) || !empty($inputOpts['disabled']), 378 | ] 379 | ) . '
'; 380 | }, 381 | 'selector' => 'radio', 382 | ] 383 | ) ?> 384 |
385 |
386 | 387 | 388 | blocks['buttons'] ?> 389 | 390 | 391 | 392 | --------------------------------------------------------------------------------