+
+
+
diff --git a/resources/js/app/pages/modules/index.js b/resources/js/app/pages/modules/index.js
index b91262f50..198498bdd 100644
--- a/resources/js/app/pages/modules/index.js
+++ b/resources/js/app/pages/modules/index.js
@@ -16,6 +16,7 @@ export default {
FolderPicker: () => import(/* webpackChunkName: "folder-picker" */'app/components/folder-picker/folder-picker'),
DateRangesList: () => import(/* webpackChunkName: "date-ranges-list" */'app/components/date-ranges-list/date-ranges-list'),
TreeView: () => import(/* webpackChunkName: "tree-view" */'app/components/tree-view/tree-view'),
+ TreeCompact: () => import(/* webpackChunkName: "tree-compact" */'app/components/tree-compact/tree-compact'),
FilterBoxView: () => import(/* webpackChunkName: "tree-view" */'app/components/filter-box'),
IndexCell: () => import(/* webpackChunkName: "index-cell" */'app/components/index-cell/index-cell'),
PermissionToggle: () => import(/* webpackChunkName: "permission-toggle" */'app/components/permission-toggle/permission-toggle'),
diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php
index 8bc8f9fc9..7e3f79e05 100644
--- a/src/Controller/AppController.php
+++ b/src/Controller/AppController.php
@@ -311,7 +311,7 @@ protected function prepareRelations(array &$data): void
if (!empty($data['relations'])) {
$api = [];
foreach ($data['relations'] as $relation => $relationData) {
- $id = $data['id'];
+ $id = (string)Hash::get($data, 'id');
foreach ($relationData as $method => $ids) {
$relatedIds = $this->relatedIds($ids);
if ($method === 'replaceRelated' || !empty($relatedIds)) {
diff --git a/src/Controller/Component/SchemaComponent.php b/src/Controller/Component/SchemaComponent.php
index 8ac5bfe35..700ab82d4 100644
--- a/src/Controller/Component/SchemaComponent.php
+++ b/src/Controller/Component/SchemaComponent.php
@@ -522,6 +522,25 @@ public function abstractTypes(): array
return $types;
}
+ /**
+ * Get all concrete types, i.e. all descendants of abstract types.
+ *
+ * @return array
+ */
+ public function allConcreteTypes(): array
+ {
+ $features = $this->objectTypesFeatures();
+ $types = [];
+ foreach ($features['descendants'] as $descendants) {
+ if (!empty($descendants)) {
+ $types = array_unique(array_merge($types, $descendants));
+ }
+ }
+ sort($types);
+
+ return $types;
+ }
+
/**
* Clear schema cache
*
diff --git a/src/Controller/ModulesController.php b/src/Controller/ModulesController.php
index bc7da310f..bc4ea4ca5 100644
--- a/src/Controller/ModulesController.php
+++ b/src/Controller/ModulesController.php
@@ -143,6 +143,9 @@ public function index(): ?Response
// custom properties
$this->set('customProps', $this->Schema->customProps($this->objectType));
+ // set all types (use cache)
+ $this->set('allConcreteTypes', $this->Schema->allConcreteTypes());
+
// set prevNext for views navigations
$this->setObjectNav($objects);
diff --git a/src/Controller/TreeController.php b/src/Controller/TreeController.php
index fbf6053ac..9b26712c9 100644
--- a/src/Controller/TreeController.php
+++ b/src/Controller/TreeController.php
@@ -53,6 +53,49 @@ public function get(): void
$this->setSerialize(['tree']);
}
+ /**
+ * Get all tree data.
+ * Use cache to store data.
+ *
+ * @return void
+ */
+ public function loadAll(): void
+ {
+ $this->getRequest()->allowMethod(['get']);
+ $this->viewBuilder()->setClassName('Json');
+ $data = $this->compactTreeData();
+ $this->set('data', $data);
+ $this->setSerialize(['data']);
+ }
+
+ /**
+ * Get children of a folder by ID.
+ * Use cache to store data.
+ *
+ * @param string $id The ID.
+ * @return void
+ */
+ public function children(string $id): void
+ {
+ $this->getRequest()->allowMethod(['get']);
+ $this->viewBuilder()->setClassName('Json');
+ $data = $meta = [];
+ try {
+ $query = $this->getRequest()->getQueryParams();
+ $response = $this->apiClient->get(sprintf('/folders/%s/children', $id), $query);
+ $data = (array)Hash::get($response, 'data');
+ foreach ($data as &$item) {
+ $item = $this->minimalDataWithMeta((array)$item);
+ }
+ $meta = (array)Hash::get($response, 'meta');
+ } catch (BEditaClientException $e) {
+ $this->log($e->getMessage(), LogLevel::ERROR);
+ }
+ $this->set('data', $data);
+ $this->set('meta', $meta);
+ $this->setSerialize(['data', 'meta']);
+ }
+
/**
* Get node by ID.
* Use cache to store data.
@@ -104,6 +147,8 @@ public function parents(string $type, string $id): void
/**
* Saves the current slug
+ *
+ * @return \Cake\Http\Response|null
*/
public function slug(): ?Response
{
@@ -143,6 +188,36 @@ public function slug(): ?Response
return null;
}
+ /**
+ * Get compact tree data.
+ * Use cache to store data.
+ *
+ * @return array
+ */
+ public function compactTreeData(): array
+ {
+ $objectType = $this->getRequest()->getParam('object_type');
+ $key = CacheTools::cacheKey(sprintf('compact-tree-%s', $objectType));
+ $noCache = (bool)$this->getRequest()->getQuery('no_cache');
+ if ($noCache === true) {
+ Cache::clearGroup('tree', TreeCacheEventHandler::CACHE_CONFIG);
+ }
+ $data = [];
+ try {
+ $data = Cache::remember(
+ $key,
+ function () {
+ return $this->fetchCompactTreeData();
+ },
+ TreeCacheEventHandler::CACHE_CONFIG
+ );
+ } catch (BEditaClientException $e) {
+ $this->log($e->getMessage(), LogLevel::ERROR);
+ }
+
+ return $data;
+ }
+
/**
* Get tree data by query params.
* Use cache to store data.
@@ -281,6 +356,88 @@ function () use ($id, $type) {
return $data;
}
+ /**
+ * Fetch compact tree data from API.
+ * Retrieve minimal data for folders: id, status, title, meta.
+ * Return tree and folders.
+ * Return an array with 'tree' and 'folders' keys.
+ *
+ * @return array
+ */
+ protected function fetchCompactTreeData(): array
+ {
+ $done = false;
+ $page = 1;
+ $pageSize = 100;
+ $folders = [];
+ $paths = [];
+ while (!$done) {
+ $response = ApiClientProvider::getApiClient()->get('/folders', [
+ 'page_size' => $pageSize,
+ 'page' => $page,
+ ]);
+ $data = (array)Hash::get($response, 'data');
+ foreach ($data as $item) {
+ $folders[$item['id']] = $this->minimalDataWithMeta((array)$item);
+ $path = (string)Hash::get($item, 'meta.path');
+ $paths[$path] = $item['id'];
+ }
+ $page++;
+ $meta = (array)Hash::get($response, 'meta');
+ if ($page > (int)Hash::get($meta, 'pagination.page_count')) {
+ $done = true;
+ }
+ }
+ // organize the tree as roots and children
+ $tree = [];
+ foreach ($paths as $path => $id) {
+ $countSlash = substr_count($path, '/');
+ if ($countSlash === 1) {
+ $tree[$id] = compact('id');
+ continue;
+ }
+
+ $parentPath = substr($path, 0, strrpos($path, '/'));
+ $parentId = $paths[$parentPath];
+ if (!empty($parentId)) {
+ $this->pushIntoTree($tree, $parentId, $id, 'subfolders');
+ }
+ }
+
+ return compact('tree', 'folders');
+ }
+
+ /**
+ * Push child into tree, searching parent inside the tree structure.
+ *
+ * @param array $tree The tree.
+ * @param string $searchParentId The parent ID.
+ * @param string $childId The child ID.
+ * @param string $subtreeKey The subtree key.
+ * @return bool
+ */
+ public function pushIntoTree(array &$tree, string $searchParentId, string $childId, string $subtreeKey): bool
+ {
+ if (Hash::check($tree, $searchParentId)) {
+ $tree[$searchParentId][$subtreeKey][$childId] = ['id' => $childId];
+
+ return true;
+ }
+ foreach ($tree as &$node) {
+ $subtree = (array)Hash::get($node, $subtreeKey);
+ if (empty($subtree)) {
+ continue;
+ }
+ if ($this->pushIntoTree($subtree, $searchParentId, $childId, $subtreeKey)) {
+ $node[$subtreeKey] = $subtree;
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
/**
* Fetch tree data from API.
* Retrieve minimal data for folders: id, status, title.
@@ -344,9 +501,12 @@ protected function minimalDataWithMeta(array $fullData): ?array
'type' => (string)Hash::get($fullData, 'type'),
'attributes' => [
'title' => (string)Hash::get($fullData, 'attributes.title'),
+ 'uname' => (string)Hash::get($fullData, 'attributes.uname'),
+ 'lang' => (string)Hash::get($fullData, 'attributes.lang'),
'status' => (string)Hash::get($fullData, 'attributes.status'),
],
'meta' => [
+ 'modified' => (string)Hash::get($fullData, 'meta.modified'),
'path' => (string)Hash::get($fullData, 'meta.path'),
'slug_path' => (array)Hash::get($fullData, 'meta.slug_path'),
'slug_path_compact' => $this->slugPathCompact((array)Hash::get($fullData, 'meta.slug_path')),
diff --git a/src/View/Helper/LayoutHelper.php b/src/View/Helper/LayoutHelper.php
index 44c89e7c3..a4beebe81 100644
--- a/src/View/Helper/LayoutHelper.php
+++ b/src/View/Helper/LayoutHelper.php
@@ -26,6 +26,7 @@
* @property \Cake\View\Helper\HtmlHelper $Html
* @property \App\View\Helper\LinkHelper $Link
* @property \App\View\Helper\PermsHelper $Perms
+ * @property \App\View\Helper\PropertyHelper $Property
* @property \App\View\Helper\SystemHelper $System
* @property \Cake\View\Helper\UrlHelper $Url
*/
@@ -36,7 +37,7 @@ class LayoutHelper extends Helper
*
* @var array
*/
- public $helpers = ['Editors', 'Html', 'Link', 'Perms', 'System', 'Url'];
+ public $helpers = ['Editors', 'Html', 'Link', 'Perms', 'Property', 'System', 'Url'];
/**
* Is Dashboard
@@ -303,7 +304,63 @@ public function moduleIndexViewTypes(): array
{
$defaultType = $this->moduleIndexDefaultViewType();
- return $defaultType === 'tree' ? ['tree', 'list'] : ['list'];
+ return $defaultType === 'tree' ? ['tree', 'tree-compact', 'list'] : ['list'];
+ }
+
+ /**
+ * Append view type buttons to the sidebar
+ *
+ * @return void
+ */
+ public function appendViewTypeButtons(): void
+ {
+ $indexViewTypes = $this->moduleIndexViewTypes();
+ if (count($indexViewTypes) > 1) {
+ $indexViewType = $this->moduleIndexViewType();
+ foreach ($indexViewTypes as $t) {
+ if ($t !== $indexViewType) {
+ $append = false;
+ $icon = '';
+ $label = '';
+ switch ($t) {
+ case 'tree':
+ $icon = 'carbon:tree-view';
+ $label = __('Tree view');
+ $append = true;
+ break;
+ case 'tree-compact':
+ $icon = 'carbon:tree-view';
+ $label = __('Tree compact');
+ $meta = (array)$this->getView()->get('meta');
+ $count = (int)Hash::get($meta, 'pagination.count');
+ $append = $count <= Configure::read('UI.tree_compact_view_limit', 100);
+ break;
+ case 'list':
+ $icon = 'carbon:list';
+ $label = __('List view');
+ $append = true;
+ break;
+ }
+ if ($append) {
+ $url = $this->Url->build(
+ [
+ '_name' => 'modules:list',
+ 'object_type' => $this->getView()->get('objectType'),
+ ],
+ );
+ $url = sprintf('%s?view_type=%s', $url, $t);
+ $anchor = sprintf(
+ '%s',
+ $url,
+ $this->getView()->get('currentModule.name'),
+ $icon,
+ __($label)
+ );
+ $this->getView()->append('app-module-buttons', $anchor);
+ }
+ }
+ }
+ }
}
/**
@@ -377,6 +434,8 @@ public function metaConfig(): array
'richeditorConfig' => (array)Configure::read('Richeditor'),
'richeditorByPropertyConfig' => $this->uiRicheditorConfig(),
'indexLists' => (array)$this->indexLists(),
+ 'fastCreateFields' => (array)$this->Property->fastCreateFieldsMap(),
+ 'concreteTypes' => (array)$this->getView()->get('allConcreteTypes', []),
];
}
diff --git a/src/View/Helper/PermsHelper.php b/src/View/Helper/PermsHelper.php
index 5164344a3..491a98ae1 100644
--- a/src/View/Helper/PermsHelper.php
+++ b/src/View/Helper/PermsHelper.php
@@ -130,7 +130,23 @@ public function canDelete(array $object): bool
*/
public function canSave(?string $module = null): bool
{
- return $this->isAllowed('PATCH', $module) && $this->userIsAllowed($module);
+ return $this->userIsAdmin() || ($this->isAllowed('PATCH', $module) && $this->userIsAllowed($module));
+ }
+
+ /**
+ * Map of modules and their save permissions for the authenticated user.
+ *
+ * @return array
+ */
+ public function canSaveMap(): array
+ {
+ $modules = array_keys((array)$this->_View->get('modules'));
+ $map = [];
+ foreach ($modules as $module) {
+ $map[$module] = $this->canSave($module);
+ }
+
+ return $map;
}
/**
diff --git a/templates/Element/Modules/sidebar.twig b/templates/Element/Modules/sidebar.twig
index 4b0ff0223..aaea6daf6 100644
--- a/templates/Element/Modules/sidebar.twig
+++ b/templates/Element/Modules/sidebar.twig
@@ -36,20 +36,7 @@
{% endfor %}
{% endif %}
-{% set indexViewTypes = Layout.moduleIndexViewTypes() %}
-{% if indexViewTypes|length > 1 %}
- {% set indexViewType = Layout.moduleIndexViewType() %}
- {% for t in indexViewTypes %}
- {% if t != indexViewType %}
- {% set icon = t == 'tree' ? 'carbon:tree-view' : 'carbon:list' %}
- {% set label = t == 'tree' ? __('Tree view') : __('List view') %}
- {% do _view.append(
- 'app-module-buttons',
- '' ~ __(label) ~ ''
- ) %}
- {% endif %}
- {% endfor %}
-{% endif %}
+{{ Layout.appendViewTypeButtons() }}
{% if in_array('admin', user.roles) %}
{% do _view.append(
diff --git a/templates/Element/Modules/tree-compact.twig b/templates/Element/Modules/tree-compact.twig
new file mode 100644
index 000000000..ed642dc76
--- /dev/null
+++ b/templates/Element/Modules/tree-compact.twig
@@ -0,0 +1,24 @@
+{% set limit = config('UI.tree_compact_view_limit', 100) %}
+{% set count = meta.pagination.count %}
+{% if count > limit %}
+
+
+
{{ __('You cannot see folders in tree-compact view') }}
+
+
+
+ {{ __('The number of folders is too high to be displayed in tree-compact view') }}.
+ {{ __('Number of folders') }}: {{ count }}.
+ {{ __('Limit') }}: {{ limit }}.
+