From 1ffb699385731bcd5096cd9ea605cc3cbd66d00d Mon Sep 17 00:00:00 2001 From: dante di domenico Date: Mon, 26 May 2025 11:53:52 +0200 Subject: [PATCH 01/24] refactor: add new tree compact view --- config/routes.php | 5 + resources/js/app/app.js | 1 + .../components/tree-compact/tree-compact.vue | 69 ++++++ .../components/tree-compact/tree-folder.vue | 206 ++++++++++++++++++ resources/js/app/pages/modules/index.js | 1 + src/Controller/TreeController.php | 112 ++++++++++ src/View/Helper/LayoutHelper.php | 55 ++++- templates/Element/Modules/sidebar.twig | 15 +- templates/Element/Modules/tree-compact.twig | 2 + templates/Pages/Modules/index.twig | 4 +- .../TestCase/View/Helper/LayoutHelperTest.php | 2 +- 11 files changed, 455 insertions(+), 17 deletions(-) create mode 100644 resources/js/app/components/tree-compact/tree-compact.vue create mode 100644 resources/js/app/components/tree-compact/tree-folder.vue create mode 100644 templates/Element/Modules/tree-compact.twig diff --git a/config/routes.php b/config/routes.php index 1e9015c71..85b0c5710 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'], diff --git a/resources/js/app/app.js b/resources/js/app/app.js index 87f4983f0..3bd7d1ed9 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/tree-compact/tree-compact.vue b/resources/js/app/components/tree-compact/tree-compact.vue new file mode 100644 index 000000000..19e49e031 --- /dev/null +++ b/resources/js/app/components/tree-compact/tree-compact.vue @@ -0,0 +1,69 @@ + + 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..9aaf6a028 --- /dev/null +++ b/resources/js/app/components/tree-compact/tree-folder.vue @@ -0,0 +1,206 @@ + + + diff --git a/resources/js/app/pages/modules/index.js b/resources/js/app/pages/modules/index.js index 97e2e0db2..f29d0d735 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/TreeController.php b/src/Controller/TreeController.php index fbf6053ac..b1c344d26 100644 --- a/src/Controller/TreeController.php +++ b/src/Controller/TreeController.php @@ -53,6 +53,21 @@ 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 node by ID. * Use cache to store data. @@ -104,6 +119,8 @@ public function parents(string $type, string $id): void /** * Saves the current slug + * + * @return \Cake\Http\Response|null */ public function slug(): ?Response { @@ -143,6 +160,26 @@ public function slug(): ?Response return null; } + public function compactTreeData(): array + { + $objectType = $this->getRequest()->getParam('object_type'); + $key = CacheTools::cacheKey(sprintf('compact-tree-%s', $objectType)); + $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 +318,81 @@ function () use ($id, $type) { return $data; } + 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)) { + continue; + } + $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. diff --git a/src/View/Helper/LayoutHelper.php b/src/View/Helper/LayoutHelper.php index 882cab281..640a5b34f 100644 --- a/src/View/Helper/LayoutHelper.php +++ b/src/View/Helper/LayoutHelper.php @@ -303,7 +303,60 @@ 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) { + 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 = $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); + } + } + } + } } /** 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..5d3ef0ece --- /dev/null +++ b/templates/Element/Modules/tree-compact.twig @@ -0,0 +1,2 @@ + + diff --git a/templates/Pages/Modules/index.twig b/templates/Pages/Modules/index.twig index 4cff0d8d4..5c8d73218 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/View/Helper/LayoutHelperTest.php b/tests/TestCase/View/Helper/LayoutHelperTest.php index cd77a26a4..be6c7fcc3 100644 --- a/tests/TestCase/View/Helper/LayoutHelperTest.php +++ b/tests/TestCase/View/Helper/LayoutHelperTest.php @@ -384,7 +384,7 @@ public function moduleIndexViewTypesProvider(): array ], 'folders' => [ ['currentModule' => ['name' => 'folders']], - ['tree', 'list'], + ['tree', 'tree-compact', 'list'], ], ]; } From 75e594451c1e22e672ae6955a0bc303da5731a60 Mon Sep 17 00:00:00 2001 From: dante di domenico Date: Mon, 26 May 2025 17:54:57 +0200 Subject: [PATCH 02/24] fix: view as filesystem --- .../components/tree-compact/tree-content.vue | 31 ++++ .../components/tree-compact/tree-folder.vue | 142 +++++++++--------- src/Controller/TreeController.php | 1 + 3 files changed, 106 insertions(+), 68 deletions(-) create mode 100644 resources/js/app/components/tree-compact/tree-content.vue 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..904687bde --- /dev/null +++ b/resources/js/app/components/tree-compact/tree-content.vue @@ -0,0 +1,31 @@ + + + diff --git a/resources/js/app/components/tree-compact/tree-folder.vue b/resources/js/app/components/tree-compact/tree-folder.vue index 9aaf6a028..73fdae2f4 100644 --- a/resources/js/app/components/tree-compact/tree-folder.vue +++ b/resources/js/app/components/tree-compact/tree-folder.vue @@ -6,21 +6,21 @@ :open="open" @click.prevent.stop="toggle" > -

- - - + > + + - - {{ totalChildren }} + + + {{ totalChildren }} {{ msgObjects }} + {{ $helpers.formatDate(folder?.meta?.modified) }}

@@ -109,6 +129,7 @@ export default { msgFolders: t`Folders`, msgObjects: t`objects`, msgSave: t`Save`, + msgUndo: t`Undo`, open: false, show: 'subfolders', title: '', @@ -151,6 +172,11 @@ export default { this.hoverTitle = false; this.folder.attributes.title = this.title; }, + async undoTitle() { + this.editField = null; + this.hoverTitle = false; + this.title = this.folder?.attributes?.title || ''; + }, toggle() { this.open = !this.open; }, @@ -179,20 +205,9 @@ div.tree-folder > header > h2 > span.editable { div.tree-folder > header > h2 > span.modified { font-size: 0.7rem; } -div.tree-folder div.children, div.tree-folder div.subfolders { - background-color: #121c21; -} -div.tree-folder div.children { - border: solid blue 1px; - display: flex; - flex-direction: column; - flex-wrap: wrap; - gap: 0.2rem; -} -div.tree-folder div.children > div { - padding: 0.2rem; - margin: 0.2rem; - border-bottom: dotted 1px orange; +div.tree-folder div.contents { + border-bottom: dashed aqua 0.5px; + margin-bottom: 1rem; } div.tree-folder .editable:hover { cursor: pointer; From 4c92fd9d927b543431c150ffed2cc333ae08915f Mon Sep 17 00:00:00 2001 From: dante di domenico Date: Tue, 27 May 2025 15:37:32 +0200 Subject: [PATCH 04/24] fix: children api get --- config/routes.php | 5 + .../components/folder-picker/folder-picker.js | 2 +- .../components/object-info/object-info.vue | 13 ++ .../components/tree-compact/tree-compact.vue | 7 +- .../components/tree-compact/tree-content.vue | 179 ++++++++++++----- .../components/tree-compact/tree-folder.vue | 188 +++++++++++++----- src/Controller/TreeController.php | 23 +++ src/View/Helper/PermsHelper.php | 22 +- templates/Element/Modules/tree-compact.twig | 4 +- 9 files changed, 340 insertions(+), 103 deletions(-) diff --git a/config/routes.php b/config/routes.php index 85b0c5710..582fa0623 100644 --- a/config/routes.php +++ b/config/routes.php @@ -127,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/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/object-info/object-info.vue b/resources/js/app/components/object-info/object-info.vue index ab22fc10d..e7ee21b9a 100644 --- a/resources/js/app/components/object-info/object-info.vue +++ b/resources/js/app/components/object-info/object-info.vue @@ -6,6 +6,7 @@ @@ -17,6 +18,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 @@ -27,6 +36,10 @@ export default { fields: ['title', 'description'], labelsMap: new Map(), msgShowObjectInfo: t`Show object info`, + styles: { + borderColor: this.borderColor, + color: this.color, + }, values: {}, }; }, diff --git a/resources/js/app/components/tree-compact/tree-compact.vue b/resources/js/app/components/tree-compact/tree-compact.vue index 19e49e031..1f28e0060 100644 --- a/resources/js/app/components/tree-compact/tree-compact.vue +++ b/resources/js/app/components/tree-compact/tree-compact.vue @@ -5,6 +5,7 @@ :key="rootId" > import('./tree-folder.vue'), }, props: { - objectType: { - type: String, + canSaveMap: { + type: Object, required: true }, }, @@ -51,7 +52,7 @@ export default { try { this.loading = true; this.tree = []; - const response = await fetch(`${API_URL}tree/loadAll?objectType=${this.objectType}`, API_OPTIONS); + const response = await fetch(`${API_URL}tree/loadAll?objectType=folders`, API_OPTIONS); const json = await response.json(); if (json.error) { throw new Error(json.error); diff --git a/resources/js/app/components/tree-compact/tree-content.vue b/resources/js/app/components/tree-compact/tree-content.vue index 7d866e577..52cbc396b 100644 --- a/resources/js/app/components/tree-compact/tree-content.vue +++ b/resources/js/app/components/tree-compact/tree-content.vue @@ -1,46 +1,94 @@ + + + +
+ +
+ - {{ truncate(title, 80) }} - - + + {{ $helpers.formatDate(obj?.meta?.modified) }} @@ -51,7 +99,14 @@ import { t } from 'ttag'; export default { name: 'TreeContent', + components: { + ObjectInfo: () => import(/* webpackChunkName: "object-info" */'app/components/object-info/object-info'), + }, props: { + canSaveMap: { + type: Object, + required: true + }, obj: { type: Object, required: true @@ -59,25 +114,29 @@ export default { }, data() { return { - editField: null, + createNew: false, + editMode: false, hoverTitle: false, title: this.obj?.attributes?.title || '', + msgClose: t`Close`, + msgCreateNew: t`Create new`, + msgEdit: t`Edit`, + msgOpenInNewTab: t`Open in new tab`, msgSave: t`Save`, msgUndo: t`Undo`, } }, methods: { - saveTitle() { - console.log('Saving title:', this.title); - this.editField = null; + canSave() { + return this.canSaveMap?.[this.obj?.type] || false; + }, + closePanel() { + this.createNew = false; + this.editMode = false; }, truncate(str, len) { return this.$helpers.truncate(str, len); }, - undoTitle() { - console.log('Undoing title change'); - this.editField = null; - }, }, } @@ -85,6 +144,34 @@ export default { div.tree-content { padding-left: 0.5rem; } +div.tree-content aside.main-panel-container { + z-index: 9999; +} +div.tree-content aside.main-panel { + margin: 1rem; + padding: 1rem; +} +div.tree-content button.close { + border: solid transparent 0px; + min-width: 36px; + max-width: 36px; +} +div.tree-content .container { + padding: 1rem; + margin: auto; + display: flex; + flex-direction: column; + gap: 1rem; +} +div.tree-content .container > div { + display: flex; + flex-direction: column; +} +div.tree-content div.buttons { + display: flex; + flex-direction: row; + gap: 1rem; +} div.tree-content > header > h2 { display: flex; flex-direction: row; @@ -93,10 +180,10 @@ div.tree-content > header > h2 { justify-content: start; border-bottom: dotted 0.1px silver; } -div.tree-content > header > h2 > span.editable { +div.tree-content > header > h2 > span.editable, div.tree-content > header > h2 > span.not-editable { font-size: 0.875rem; } -div.tree-content > header > h2 > span.modified { +div.tree-content > header > h2 > span.modified, div.tree-content > header > h2 > a { font-size: 0.7rem; } div.tree-content .editable:hover { @@ -104,7 +191,7 @@ div.tree-content .editable:hover { color: #00aaff; text-decoration: underline; } -div.tree-content span.modified { +div.tree-content div.object-info-container { margin-left: auto; } diff --git a/resources/js/app/components/tree-compact/tree-folder.vue b/resources/js/app/components/tree-compact/tree-folder.vue index 48f97e821..34dc348c9 100644 --- a/resources/js/app/components/tree-compact/tree-folder.vue +++ b/resources/js/app/components/tree-compact/tree-folder.vue @@ -1,5 +1,56 @@