diff --git a/config/routes.php b/config/routes.php index e2a97b138..cfab1100d 100644 --- a/config/routes.php +++ b/config/routes.php @@ -101,6 +101,11 @@ ['controller' => 'Tree', 'action' => 'get'], ['_name' => 'tree:get'] ); + $routes->connect( + '/tree/loadAll', + ['controller' => 'Tree', 'action' => 'loadAll'], + ['_name' => 'tree:load:all'] + ); $routes->connect( '/tree/node/{id}', ['controller' => 'Tree', 'action' => 'node'], @@ -122,6 +127,11 @@ ['controller' => 'Tree', 'action' => 'slug'], ['_name' => 'tree:slug'] ); + $routes->connect( + '/tree/{id}/children', + ['controller' => 'Tree', 'action' => 'children'], + ['_name' => 'tree:children', 'pass' => ['id']] + ); // Admin. $routes->prefix('admin', ['_namePrefix' => 'admin:'], function (RouteBuilder $routes) { diff --git a/locales/default.pot b/locales/default.pot index 9199b4de5..dabbffe82 100644 --- a/locales/default.pot +++ b/locales/default.pot @@ -451,6 +451,9 @@ msgstr "" msgid "Less" msgstr "" +msgid "Limit" +msgstr "" + msgid "List" msgstr "" @@ -585,6 +588,9 @@ msgstr "" msgid "Number of created objects" msgstr "" +msgid "Number of folders" +msgstr "" + msgid "Number of login errors" msgstr "" @@ -876,6 +882,9 @@ msgstr "" msgid "Temporary folder missing" msgstr "" +msgid "The number of folders is too high to be displayed in tree-compact view" +msgstr "" + msgid "There were errors creating the thumbnail(s)" msgstr "" @@ -927,6 +936,9 @@ msgstr "" msgid "Trashd" msgstr "" +msgid "Tree compact" +msgstr "" + msgid "Tree view" msgstr "" @@ -1017,6 +1029,9 @@ msgstr "" msgid "You are not authorized to access this resource" msgstr "" +msgid "You cannot see folders in tree-compact view" +msgstr "" + msgid "You do not have the required permissions to view this page." msgstr "" @@ -1550,6 +1565,30 @@ msgstr "" msgid "View" msgstr "" +msgid "New content" +msgstr "" + +msgid "New folder" +msgstr "" + +msgid "Open in new tab" +msgstr "" + +msgid "Undo" +msgstr "" + +msgid "Choose object type" +msgstr "" + +msgid "Object type" +msgstr "" + +msgid "Parent folder" +msgstr "" + +msgid "/ (root)" +msgstr "" + msgid "Add new tag" msgstr "" @@ -1919,9 +1958,6 @@ msgstr "" msgid "Invalid date range" msgstr "" -msgid "Undo" -msgstr "" - msgid "Copied!" msgstr "" diff --git a/locales/en_US/default.po b/locales/en_US/default.po index 4fea704f5..4a5d81f74 100644 --- a/locales/en_US/default.po +++ b/locales/en_US/default.po @@ -454,6 +454,9 @@ msgstr "" msgid "Less" msgstr "" +msgid "Limit" +msgstr "" + msgid "List" msgstr "" @@ -588,6 +591,9 @@ msgstr "" msgid "Number of created objects" msgstr "" +msgid "Number of folders" +msgstr "" + msgid "Number of login errors" msgstr "" @@ -879,6 +885,9 @@ msgstr "" msgid "Temporary folder missing" msgstr "" +msgid "The number of folders is too high to be displayed in tree-compact view" +msgstr "" + msgid "There were errors creating the thumbnail(s)" msgstr "" @@ -930,6 +939,9 @@ msgstr "" msgid "Trashd" msgstr "Trashed" +msgid "Tree compact" +msgstr "" + msgid "Tree view" msgstr "" @@ -1020,6 +1032,9 @@ msgstr "" msgid "You are not authorized to access this resource" msgstr "" +msgid "You cannot see folders in tree-compact view" +msgstr "" + msgid "You do not have the required permissions to view this page." msgstr "" @@ -1553,6 +1568,30 @@ msgstr "" msgid "View" msgstr "" +msgid "New content" +msgstr "" + +msgid "New folder" +msgstr "" + +msgid "Open in new tab" +msgstr "" + +msgid "Undo" +msgstr "" + +msgid "Choose object type" +msgstr "" + +msgid "Object type" +msgstr "" + +msgid "Parent folder" +msgstr "" + +msgid "/ (root)" +msgstr "" + msgid "Add new tag" msgstr "" @@ -1922,9 +1961,6 @@ msgstr "" msgid "Invalid date range" msgstr "" -msgid "Undo" -msgstr "" - msgid "Copied!" msgstr "" diff --git a/locales/it_IT/default.po b/locales/it_IT/default.po index 9ab7d401b..a2a34bc3f 100644 --- a/locales/it_IT/default.po +++ b/locales/it_IT/default.po @@ -457,6 +457,9 @@ msgstr "Tipi di sinistra" msgid "Less" msgstr "Meno" +msgid "Limit" +msgstr "Limite" + msgid "List" msgstr "Lista" @@ -593,6 +596,9 @@ msgstr "Non trovato" msgid "Number of created objects" msgstr "Numero di oggetti creati" +msgid "Number of folders" +msgstr "Numero di cartelle" + msgid "Number of login errors" msgstr "Numero di errori di accesso" @@ -887,6 +893,9 @@ msgstr "Tag" msgid "Temporary folder missing" msgstr "Cartella temporanea mancante" +msgid "The number of folders is too high to be displayed in tree-compact view" +msgstr "Il numero di cartelle è troppo elevato per essere visualizzato nella vista compatta ad albero." + msgid "There were errors creating the thumbnail(s)" msgstr "È avvenuto un errore nella creazione delle miniature" @@ -938,6 +947,9 @@ msgstr "Cestino" msgid "Trashd" msgstr "Cancellato" +msgid "Tree compact" +msgstr "Albero compatto" + msgid "Tree view" msgstr "Vista ad albero" @@ -1028,6 +1040,9 @@ msgstr "Non sei autorizzato ad accedere a quest'area." msgid "You are not authorized to access this resource" msgstr "Non sei autorizzato ad accedere a questa risorsa" +msgid "You cannot see folders in tree-compact view" +msgstr "Non puoi vedere le cartelle nella vista compatta ad albero." + msgid "You do not have the required permissions to view this page." msgstr "Non hai i permessi necessari per visualizzare questa pagina." @@ -1572,6 +1587,30 @@ msgstr "pagina" msgid "View" msgstr "Vedi" +msgid "New content" +msgstr "Nuovo contenuto" + +msgid "New folder" +msgstr "Nuova cartella" + +msgid "Open in new tab" +msgstr "Apri in una nuova scheda" + +msgid "Undo" +msgstr "Annulla" + +msgid "Choose object type" +msgstr "Scegli il tipo di oggetto" + +msgid "Object type" +msgstr "Tipo di oggetto" + +msgid "Parent folder" +msgstr "Cartella padre" + +msgid "/ (root)" +msgstr "/ (radice)" + msgid "Add new tag" msgstr "Aggiungi un nuovo tag" @@ -1947,9 +1986,6 @@ msgstr "Tutti i giorni" msgid "Invalid date range" msgstr "Intervallo date non valido" -msgid "Undo" -msgstr "Annulla" - msgid "Copied!" msgstr "Copiato!" diff --git a/resources/js/app/app.js b/resources/js/app/app.js index 746b02120..d8baa8255 100644 --- a/resources/js/app/app.js +++ b/resources/js/app/app.js @@ -49,6 +49,7 @@ const _vueInstance = new Vue({ 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'), TreeSlug: () => import(/* webpackChunkName: "tree-slug" */'app/components/tree-slug/tree-slug'), + TreeCompact: () => import(/* webpackChunkName: "tree-compact" */'app/components/tree-compact/tree-compact'), IndexCell: () => import(/* webpackChunkName: "index-cell" */'app/components/index-cell/index-cell'), ModulesIndex: () => import(/* webpackChunkName: "modules-index" */'app/pages/modules/index'), ModulesView: () => import(/* webpackChunkName: "modules-view" */'app/pages/modules/view'), diff --git a/resources/js/app/components/folder-picker/folder-picker.js b/resources/js/app/components/folder-picker/folder-picker.js index a2ecb7b83..99a4219e2 100644 --- a/resources/js/app/components/folder-picker/folder-picker.js +++ b/resources/js/app/components/folder-picker/folder-picker.js @@ -63,7 +63,7 @@ export default { async created() { if (this.initialFolder) { - const response = await fetch(`${API_URL}api/folders/${this.initialFolder}`); + const response = await fetch(`${API_URL}tree/${this.initialFolder}/children`); const json = await response.json(); const data = json.data; const folder = { id: data.id, label: data.attributes.title }; diff --git a/resources/js/app/components/module/module-properties.vue b/resources/js/app/components/module/module-properties.vue index 418033fd0..7062ca5ce 100644 --- a/resources/js/app/components/module/module-properties.vue +++ b/resources/js/app/components/module/module-properties.vue @@ -75,7 +75,7 @@ export default { props: { module: { type: Object, - required: true, + default: () => ({}), }, objectType: { type: String, @@ -150,7 +150,7 @@ export default { this.$nextTick(() => { this.originalVal = JSON.stringify(this.module); this.fields.forEach((field) => { - this.map[field] = this.module[field] || ''; + this.map[field] = this.module?.[field] || ''; }); }); }, diff --git a/resources/js/app/components/object-info/object-info.vue b/resources/js/app/components/object-info/object-info.vue index eb0ce58fa..5724d5ee8 100644 --- a/resources/js/app/components/object-info/object-info.vue +++ b/resources/js/app/components/object-info/object-info.vue @@ -17,6 +17,14 @@ import { t } from 'ttag'; export default { name: 'ObjectInfo', props: { + borderColor: { + type: String, + default: 'black', + }, + color: { + type: String, + default: 'white', + }, objectData: { type: Object, required: true @@ -29,6 +37,10 @@ export default { labelsMap: new Map(), msgShowObjectInfo: t`Show object info`, reloadedData: this.objectData || {}, + styles: { + borderColor: this.borderColor, + color: this.color, + }, values: {}, }; }, @@ -55,7 +67,7 @@ export default { }).join(' '); }, fillData() { - const source = BEDITA?.indexLists?.[this.reloadedData?.type] || {}; + const source = BEDITA?.indexLists?.[this.reloadedData?.type] || []; this.fields = source || ['title', 'description']; this.fields = this.fields?.filter((value, index, array) => { return array.indexOf(value) === index; diff --git a/resources/js/app/components/tree-compact/tree-compact.vue b/resources/js/app/components/tree-compact/tree-compact.vue new file mode 100644 index 000000000..35be44477 --- /dev/null +++ b/resources/js/app/components/tree-compact/tree-compact.vue @@ -0,0 +1,170 @@ + + + diff --git a/resources/js/app/components/tree-compact/tree-content.vue b/resources/js/app/components/tree-compact/tree-content.vue new file mode 100644 index 000000000..6946144f4 --- /dev/null +++ b/resources/js/app/components/tree-compact/tree-content.vue @@ -0,0 +1,449 @@ + + + diff --git a/resources/js/app/components/tree-compact/tree-folder.vue b/resources/js/app/components/tree-compact/tree-folder.vue new file mode 100644 index 000000000..317dd856c --- /dev/null +++ b/resources/js/app/components/tree-compact/tree-folder.vue @@ -0,0 +1,416 @@ + + + diff --git a/resources/js/app/components/tree-compact/tree-node.vue b/resources/js/app/components/tree-compact/tree-node.vue new file mode 100644 index 000000000..482871743 --- /dev/null +++ b/resources/js/app/components/tree-compact/tree-node.vue @@ -0,0 +1,138 @@ + + + diff --git a/resources/js/app/components/tree-compact/tree-panel.vue b/resources/js/app/components/tree-compact/tree-panel.vue new file mode 100644 index 000000000..9d32ff971 --- /dev/null +++ b/resources/js/app/components/tree-compact/tree-panel.vue @@ -0,0 +1,462 @@ + + + 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 }}. +

+
+
+{% else %} + + +{% endif %} diff --git a/templates/Pages/Modules/index.twig b/templates/Pages/Modules/index.twig index b525f6927..cf78872d4 100644 --- a/templates/Pages/Modules/index.twig +++ b/templates/Pages/Modules/index.twig @@ -1,7 +1,9 @@ {% do _view.assign('title', __(currentModule.name|humanize)) %} {% set indexViewType = Layout.moduleIndexViewType() %} -{{ element('Modules/index_header', { 'meta': meta, 'filter': filter, 'Schema': Schema, 'hidePagination': indexViewType == 'tree'}) }} +{% if indexViewType != 'tree-compact' %} + {{ element('Modules/index_header', { 'meta': meta, 'filter': filter, 'Schema': Schema, 'hidePagination': indexViewType == 'tree'}) }} +{% endif %} {% set ids = Array.extract(objects, '{*}.id') %} diff --git a/tests/TestCase/Controller/Component/SchemaComponentTest.php b/tests/TestCase/Controller/Component/SchemaComponentTest.php index 8d53135cc..d6faf1218 100644 --- a/tests/TestCase/Controller/Component/SchemaComponentTest.php +++ b/tests/TestCase/Controller/Component/SchemaComponentTest.php @@ -810,4 +810,22 @@ public function testTagsInUse(): void $actual = $this->Schema->tagsInUse(); static::assertFalse($actual); } + + /** + * Test `allConcreteTypes` + * + * @return void + * @covers ::allConcreteTypes() + */ + public function testAllConcreteTypes(): void + { + $actual = $this->Schema->allConcreteTypes(); + static::assertIsArray($actual); + // do not contain abstract types as objects or media + static::assertNotContains('objects', $actual); + static::assertNotContains('media', $actual); + // contain concrete types as documents, images, etc. + static::assertContains('documents', $actual); + static::assertContains('images', $actual); + } } diff --git a/tests/TestCase/Controller/TreeControllerTest.php b/tests/TestCase/Controller/TreeControllerTest.php index c74591e96..2a987140f 100644 --- a/tests/TestCase/Controller/TreeControllerTest.php +++ b/tests/TestCase/Controller/TreeControllerTest.php @@ -17,7 +17,9 @@ use App\Controller\TreeController; use App\Event\TreeCacheEventHandler; use App\Utility\CacheTools; +use BEdita\SDK\BEditaClient; use BEdita\SDK\BEditaClientException; +use BEdita\WebTools\ApiClientProvider; use Cake\Cache\Cache; use Cake\Http\ServerRequest; use Cake\Utility\Hash; @@ -68,6 +70,311 @@ public function testGet(): void } } + /** + * Test `loadAll` method + * + * @return void + * @covers ::loadAll() + * @covers ::compactTreeData() + * @covers ::fetchCompactTreeData() + */ + public function testLoadAll(): void + { + $this->setupApi(); + $config = [ + 'environment' => [ + 'REQUEST_METHOD' => 'GET', + ], + 'get' => [], + ]; + $request = new ServerRequest($config); + $tree = new TreeController($request); + $tree->loadAll(); + $actual = $tree->viewBuilder()->getVar('data'); + static::assertNotEmpty($actual); + $vars = ['tree', 'folders']; + foreach ($vars as $var) { + static::assertArrayHasKey($var, $actual); + } + } + + /** + * Test `children` method + * + * @return void + * @covers ::children() + * @covers ::minimalDataWithMeta() + */ + public function testChildren(): void + { + $this->setupApi(); + $config = [ + 'environment' => [ + 'REQUEST_METHOD' => 'GET', + ], + 'get' => [], + ]; + $request = new ServerRequest($config); + $tree = new TreeController($request); + $folder = $this->createTestFolder(); + $child = $this->createTestObject(); + $this->client->addRelated($folder['id'], 'folders', 'children', [ + [ + 'id' => (string)Hash::get($child, 'id'), + 'type' => (string)Hash::get($child, 'type'), + ], + ]); + $tree->children($folder['id']); + $actual = $tree->viewBuilder()->getVar('data'); + static::assertNotEmpty($actual); + $actual = $tree->viewBuilder()->getVar('meta'); + static::assertNotEmpty($actual); + } + + /** + * Test `children` method on exception + * + * @return void + * @covers ::children() + */ + public function testChildrenException(): void + { + $this->setupApi(); + $config = [ + 'environment' => [ + 'REQUEST_METHOD' => 'GET', + ], + 'get' => [], + ]; + $request = new ServerRequest($config); + $tree = new TreeController($request); + $id = '99999999'; + $tree->children($id); + $actual = $tree->viewBuilder()->getVar('data'); + static::assertEmpty($actual); + } + + /** + * Test `compactTreeData` method with query parameter `no_cache` set to true + * + * @return void + * @covers ::compactTreeData() + * @covers ::fetchCompactTreeData() + */ + public function testCompactTreeDataNoCache(): void + { + Cache::enable(); + $this->setupApi(); + $config = [ + 'environment' => [ + 'REQUEST_METHOD' => 'GET', + ], + 'query' => ['no_cache' => true], + ]; + $request = new ServerRequest($config); + $tree = new class ($request) extends TreeController { + public bool $usingCache = true; + public function fetchCompactTreeData(): array + { + $this->usingCache = false; + + return parent::fetchCompactTreeData(); + } + }; + $tree->loadAll(); + static::assertFalse($tree->usingCache); + Cache::disable(); + } + + /** + * Test `compactTreeData` method on exception + * + * @return void + * @covers ::compactTreeData() + * @covers ::fetchCompactTreeData() + */ + public function testCompactTreeDataException(): void + { + $this->setupApi(); + $config = [ + 'environment' => [ + 'REQUEST_METHOD' => 'GET', + ], + 'get' => [], + ]; + $request = new ServerRequest($config); + $tree = new class ($request) extends TreeController { + public function fetchCompactTreeData(): array + { + throw new BEditaClientException('test exception'); + } + }; + $tree->loadAll(); + $actual = $tree->viewBuilder()->getVar('data'); + static::assertEmpty($actual); + } + + /** + * Test `fetchCompactTreeData` method + * + * @return void + * @covers ::fetchCompactTreeData() + * @covers ::pushIntoTree() + * @covers ::minimalDataWithMeta() + */ + public function testFetchCompactTreeData(): void + { + $this->setupApi(); + $config = [ + 'environment' => [ + 'REQUEST_METHOD' => 'GET', + ], + 'get' => [], + ]; + $request = new ServerRequest($config); + $tree = new TreeController($request); + $tree = new class ($request) extends TreeController { + public function fetchCompactTreeData(): array + { + return parent::fetchCompactTreeData(); + } + }; + $mockResponse = [ + 'data' => [ + [ + 'id' => '1', + 'type' => 'folders', + 'attributes' => [ + 'title' => 'Folder 1', + 'status' => 'published', + 'description' => 'This is folder 1', + 'body' => 'This is the body of folder 1', + ], + 'meta' => [ + 'path' => '/1', + ], + ], + [ + 'id' => '2', + 'type' => 'folders', + 'attributes' => [ + 'title' => 'Folder 2', + 'status' => 'draft', + 'description' => 'This is folder 2', + 'body' => 'This is the body of folder 2', + ], + 'meta' => [ + 'path' => '/1/2', + ], + ], + [ + 'id' => '3', + 'type' => 'folders', + 'attributes' => [ + 'title' => 'Folder 3', + 'status' => 'draft', + 'description' => 'This is folder 3', + 'body' => 'This is the body of folder 3', + ], + 'meta' => [ + 'path' => '/3', + ], + ], + ], + 'meta' => [ + 'pagination' => [ + 'page' => 1, + 'page_size' => 10, + 'page_count' => 1, + 'total' => 2, + 'count' => 2, + ], + ], + ]; + $apiClient = $this->getMockBuilder(BEditaClient::class) + ->setConstructorArgs(['https://media.example.com']) + ->getMock(); + $apiClient->method('get') + ->with('/folders') + ->willReturn($mockResponse); + $safeClient = ApiClientProvider::getApiClient(); + ApiClientProvider::setApiClient($apiClient); + $data = $tree->fetchCompactTreeData(); + static::assertNotEmpty($data); + static::assertArrayHasKey('tree', $data); + static::assertArrayHasKey('folders', $data); + $expectedTree = [ + '1' => [ + 'id' => '1', + 'subfolders' => [ + '2' => [ + 'id' => '2', + ], + ], + ], + '3' => [ + 'id' => '3', + ], + ]; + static::assertEquals($expectedTree, $data['tree']); + ApiClientProvider::setApiClient($safeClient); + } + + /** + * Test `pushIntoTree` method + * + * @return void + * @covers ::pushIntoTree() + */ + public function testPushIntoTree(): void + { + $this->setupApi(); + $tree = new TreeController(new ServerRequest()); + $treeData = [ + '1' => [ + 'id' => '1', + 'subfolders' => [ + '3' => [ + 'id' => '3', + 'subfolders' => [ + '4' => [ + 'id' => '4', + ], + ], + ], + ], + ], + ]; + $actual = $tree->pushIntoTree($treeData, '1', '2', 'subfolders'); + static::assertTrue($actual); + $actual = $tree->pushIntoTree($treeData, '3', '5', 'subfolders'); + static::assertTrue($actual); + $actual = $tree->pushIntoTree($treeData, '7', '6', 'subfolders'); + static::assertFalse($actual); + $expected = [ + '1' => [ + 'id' => '1', + 'subfolders' => [ + '2' => [ + 'id' => '2', + ], + '3' => [ + 'id' => '3', + 'subfolders' => [ + '4' => [ + 'id' => '4', + ], + '5' => [ + 'id' => '5', + ], + ], + ], + ], + ], + ]; + static::assertEquals($expected, $treeData); + } + /** * Test `treeData` method on exception * diff --git a/tests/TestCase/View/Helper/LayoutHelperTest.php b/tests/TestCase/View/Helper/LayoutHelperTest.php index 16abb2e33..b8837114a 100644 --- a/tests/TestCase/View/Helper/LayoutHelperTest.php +++ b/tests/TestCase/View/Helper/LayoutHelperTest.php @@ -17,6 +17,7 @@ use App\View\Helper\EditorsHelper; use App\View\Helper\LayoutHelper; use App\View\Helper\PermsHelper; +use App\View\Helper\PropertyHelper; use App\View\Helper\SystemHelper; use Cake\Cache\Cache; use Cake\Core\Configure; @@ -384,7 +385,7 @@ public function moduleIndexViewTypesProvider(): array ], 'folders' => [ ['currentModule' => ['name' => 'folders']], - ['tree', 'list'], + ['tree', 'tree-compact', 'list'], ], ]; } @@ -413,6 +414,25 @@ public function testModuleIndexViewTypes(array $viewVars, array $expected): void static::assertSame($expected, $actual); } + /** + * Test `appendViewTypeButtons` method. + * + * @return void + * @covers ::appendViewTypeButtons() + */ + public function testAppendViewTypeButtons(): void + { + $response = $events = null; + $request = new ServerRequest(['query' => ['view_type' => 'other']]); + $view = new View($request, $response, $events); + $layout = new LayoutHelper($view); + $view->set('currentModule', ['name' => 'folders']); + $layout->appendViewTypeButtons(); + $actual = $view->fetch('app-module-buttons'); + $expected = 'Tree viewTree compactList view'; + static::assertEquals($expected, $actual); + } + /** * Data provider for `testTitle` test case. * @@ -578,6 +598,7 @@ public function testMetaConfig(): void ]; $view = new View($request, null, null, compact('viewVars')); $layout = new LayoutHelper($view); + $property = new PropertyHelper($view); $system = new SystemHelper($view); $conf = $layout->metaConfig(); $expected = [ @@ -599,6 +620,8 @@ public function testMetaConfig(): void 'richeditorConfig' => (array)Configure::read('Richeditor'), 'richeditorByPropertyConfig' => $layout->uiRicheditorConfig(), 'indexLists' => (array)$layout->indexLists(), + 'fastCreateFields' => (array)$property->fastCreateFieldsMap(), + 'concreteTypes' => (array)$view->get('allConcreteTypes', []), ]; static::assertSame($expected, $conf); Cache::disable(); diff --git a/tests/TestCase/View/Helper/PermsHelperTest.php b/tests/TestCase/View/Helper/PermsHelperTest.php index 095b44b7b..464d93784 100644 --- a/tests/TestCase/View/Helper/PermsHelperTest.php +++ b/tests/TestCase/View/Helper/PermsHelperTest.php @@ -176,6 +176,22 @@ public function testCanSave(): void static::assertFalse($result); } + /** + * Test `canSaveMap` method + * + * @return void + * @covers ::canSaveMap() + */ + public function testCanSaveMap(): void + { + $result = $this->Perms->canSaveMap(); + static::assertIsArray($result); + static::assertArrayHasKey('documents', $result); + static::assertTrue($result['documents']); + static::assertArrayHasKey('profiles', $result); + static::assertFalse($result['profiles']); + } + /** * Test `canDelete` method *