diff --git a/Data/CategoryTreeNode.php b/Data/CategoryTreeNode.php new file mode 100644 index 0000000..87a903f --- /dev/null +++ b/Data/CategoryTreeNode.php @@ -0,0 +1,36 @@ + $this->children !== [], + 'expanded' => $this->level === 0, + 'hovered' => false, + 'parentsPath' => null, + ] + ); + + unset($uiData['children']); + + return $uiData; + } +} diff --git a/Form/Field/FieldType/CategorySelection.php b/Form/Field/FieldType/CategorySelection.php new file mode 100644 index 0000000..21d7861 --- /dev/null +++ b/Form/Field/FieldType/CategorySelection.php @@ -0,0 +1,76 @@ +serializer->serialize($treeNode->toUiArray()); + } + + /** + * @param CategoryTreeNode[] $categoryTreeNodes + */ + public function getMaxCategoryLevel(array $categoryTreeNodes): int + { + return $this->categoryTreeBuilder->calculateMaxLevel($categoryTreeNodes); + } + + public function renderMainScript(Field $field): string + { + return $this->categoryTreeRenderer->renderMainScript($field); + } + + public function renderChildCategoryNode(CategoryTreeNode $categoryTreeNode, Field $field): string + { + return $this->categoryTreeRenderer->renderChildNode($categoryTreeNode, $field); + } + + public function renderCategoriesBreadcrumb(): string + { + return $this->categoryTreeRenderer->renderBreadcrumb(); + } + + public function renderSearchInput(): string + { + return $this->categoryTreeRenderer->renderSearchInput(); + } + + public function getHoverSequenceJson(): string + { + $categoryTree = $this->getCategoryTree(); + $hoverSequence = $this->categoryTreeBuilder->buildHoverSequence($categoryTree); + + return $this->serializer->serialize($hoverSequence); + } + + /** + * @return CategoryTreeNode[] + */ + public function getCategoryTree(int $storeId = 0, ?string $filter = null): array + { + return $this->categoryTreeBuilder->build($storeId, $filter); + } +} diff --git a/Model/CategorySelection/CategoryTreeCacheKeyGenerator.php b/Model/CategorySelection/CategoryTreeCacheKeyGenerator.php new file mode 100644 index 0000000..593fa0d --- /dev/null +++ b/Model/CategorySelection/CategoryTreeCacheKeyGenerator.php @@ -0,0 +1,36 @@ +getUserRole(), + $filter ?? '', + ]; + + return implode('_', array_filter($parts)); + } + + private function getUserRole(): string + { + $user = $this->authSession->getUser(); + + return (string)$user?->getAclRole(); + } +} diff --git a/Service/CategorySelection/CategoryFilterService.php b/Service/CategorySelection/CategoryFilterService.php new file mode 100644 index 0000000..60920ed --- /dev/null +++ b/Service/CategorySelection/CategoryFilterService.php @@ -0,0 +1,63 @@ +createFilteredCollection($storeId, $filter); + + return $this->extractCategoryIdsWithAncestors($collection); + } + + private function createFilteredCollection(int $storeId, ?string $filter): Collection + { + $collection = $this->categoryCollectionFactory->create(); + $collection->addAttributeToSelect('path') + ->addAttributeToFilter('entity_id', ['neq' => Category::TREE_ROOT_ID]) + ->setStoreId($storeId); + + if (!empty($filter)) { + $collection->addAttributeToFilter( + 'name', + ['like' => $this->dbHelper->addLikeEscape($filter, ['position' => 'any'])] + ); + } + + return $collection; + } + + /** + * @return int[] + */ + private function extractCategoryIdsWithAncestors(Collection $collection): array + { + $categoryIds = []; + + foreach ($collection as $category) { + $path = $category->getPath() ?? ''; + foreach (explode('/', $path) as $ancestorId) { + $categoryIds[(int) $ancestorId] = true; + } + } + + return array_keys($categoryIds); + } +} diff --git a/Service/CategorySelection/CategoryTreeBuilder.php b/Service/CategorySelection/CategoryTreeBuilder.php new file mode 100644 index 0000000..7eb7f94 --- /dev/null +++ b/Service/CategorySelection/CategoryTreeBuilder.php @@ -0,0 +1,240 @@ +cacheKeyGenerator->generate($storeId, $filter); + $cachedData = $this->cache->load($cacheKey); + + if ($cachedData !== false) { + return $this->deserializeCategoryTree($cachedData); + } + + $tree = $this->buildFreshTree($storeId, $filter); + + $this->cacheTree($cacheKey, $tree); + + return $tree; + } + + /** + * @param CategoryTreeNode[] $treeNodes + */ + public function calculateMaxLevel(array $treeNodes): int + { + $maxLevel = 0; + + foreach ($treeNodes as $node) { + $maxLevel = max($maxLevel, $node->level); + + if ($node->children !== []) { + $maxLevel = max($maxLevel, $this->calculateMaxLevel($node->children)); + } + } + + return $maxLevel; + } + + /** + * @param CategoryTreeNode[] $treeNodes + * @return int[] + */ + public function buildHoverSequence(array $treeNodes): array + { + $sequence = []; + + foreach ($treeNodes as $node) { + $sequence[] = $node->id; + $sequence = [ + ...$sequence, + ...$this->buildHoverSequence($node->children) + ]; + } + + return $sequence; + } + + /** + * @return CategoryTreeNode[] + */ + private function buildFreshTree(int $storeId, ?string $filter): array + { + $visibleCategoryIds = $this->categoryFilterService->getVisibleCategoryIds($storeId, $filter); + + if ($visibleCategoryIds === []) { + return []; + } + + $flatCategories = $this->loadFlatCategories($storeId, $visibleCategoryIds); + + return $this->buildTreeStructure($flatCategories); + } + + /** + * @param int[] $categoryIds + * @return array + */ + private function loadFlatCategories(int $storeId, array $categoryIds): array + { + $collection = $this->categoryCollectionFactory->create(); + $collection->addAttributeToSelect(['name', 'is_active', 'parent_id']) + ->addAttributeToFilter('entity_id', ['in' => $categoryIds]) + ->setStoreId($storeId); + + return $this->convertCollectionToFlatArray($collection); + } + + /** + * @return array + */ + private function convertCollectionToFlatArray(Collection $collection): array + { + $flat = []; + + foreach ($collection as $category) { + $flat[(int) $category->getId()] = [ + 'id' => (int) $category->getId(), + 'label' => (string) $category->getName(), + 'is_active' => (bool) $category->getIsActive(), + 'parent_id' => (int) $category->getParentId(), + ]; + } + + return $flat; + } + + /** + * @param array $flatCategories + * @return CategoryTreeNode[] + */ + private function buildTreeStructure(array $flatCategories): array + { + return $this->buildTreeRecursive( + $flatCategories, + Category::TREE_ROOT_ID, + 0, + [] + ); + } + + /** + * @param array $flatCategories + * @param int[] $ancestorIds + * @return CategoryTreeNode[] + */ + private function buildTreeRecursive( + array $flatCategories, + int $parentId, + int $level, + array $ancestorIds + ): array { + $nodes = []; + + foreach ($flatCategories as $category) { + if ($category['parent_id'] !== $parentId) { + continue; + } + + $currentAncestorIds = $this->buildAncestorIds($ancestorIds, $parentId); + + $children = $this->buildTreeRecursive( + $flatCategories, + $category['id'], + $level + 1, + $currentAncestorIds + ); + + $nodes[] = new CategoryTreeNode( + id: $category['id'], + label: $category['label'], + isActive: $category['is_active'], + level: $level, + parentId: $parentId, + parentIds: $currentAncestorIds, + children: $children + ); + } + + return $nodes; + } + + /** + * @param int[] $ancestorIds + * @return int[] + */ + private function buildAncestorIds(array $ancestorIds, int $parentId): array + { + if ($parentId === Category::TREE_ROOT_ID) { + return $ancestorIds; + } + + return [...$ancestorIds, $parentId]; + } + + /** + * @param CategoryTreeNode[] $tree + */ + private function cacheTree(string $cacheKey, array $tree): void + { + $this->cache->save( + $this->serializer->serialize($tree), + $cacheKey, + [Category::CACHE_TAG, Block::CACHE_TAG] + ); + } + + /** + * @return CategoryTreeNode[] + */ + private function deserializeCategoryTree(string $cachedData): array + { + $treeData = $this->serializer->unserialize($cachedData); + + return $this->convertArrayToTreeNodes($treeData); + } + + /** + * @return CategoryTreeNode[] + */ + private function convertArrayToTreeNodes(array $treeData): array + { + return array_map(fn (array $nodeData) => + new CategoryTreeNode( + id: $nodeData['id'], + label: $nodeData['label'], + isActive: $nodeData['isActive'], + level: $nodeData['level'], + parentId: $nodeData['parentId'], + parentIds: $nodeData['parentIds'], + children: $this->convertArrayToTreeNodes($nodeData['children'] ?? []) + ), + $treeData + ); + } +} diff --git a/Service/CategorySelection/CategoryTreeRenderer.php b/Service/CategorySelection/CategoryTreeRenderer.php new file mode 100644 index 0000000..2eeccfd --- /dev/null +++ b/Service/CategorySelection/CategoryTreeRenderer.php @@ -0,0 +1,88 @@ +layout->createBlock(Template::class, 'category_selection_main_script') + ->setData('field', $field) + ->setTemplate($this->getMainScript()) + ->toHtml(); + } + + public function renderChildNode(CategoryTreeNode $categoryTreeNode, Field $field): string + { + return $this->layout->createBlock(Template::class, 'category_selection_inner_item_' . $categoryTreeNode->id) + ->setData('category_tree_node', $categoryTreeNode) + ->setData('field', $field) + ->setTemplate($this->getChildItemTemplate()) + ->toHtml(); + } + + public function renderBreadcrumb(): string + { + return $this->layout->createBlock(Template::class, 'categories_selection_crumbs') + ->setTemplate($this->getBreadcrumbsTemplate()) + ->toHtml(); + } + + public function renderSearchInput(): string + { + return $this->layout->createBlock(Template::class, 'category_selection_search_input') + ->setTemplate($this->getSearchInputTemplate()) + ->toHtml(); + } + + /** + * Make it public to provide facility to overwrite template. + */ + public function getChildItemTemplate(): string + { + return self::TEMPLATE_CHILD_ITEM; + } + + /** + * Make it public to provide facility to overwrite template. + */ + public function getBreadcrumbsTemplate(): string + { + return self::TEMPLATE_BREADCRUMB; + } + + /** + * Make it public to provide facility to overwrite template. + */ + public function getSearchInputTemplate(): string + { + return self::TEMPLATE_SEARCH; + } + + /** + * Make it public to provide facility to overwrite template. + */ + public function getMainScript(): string + { + return self::MAIN_SCRIPT; + } +} diff --git a/etc/di.xml b/etc/di.xml index 32c8ca1..095a9c4 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -44,6 +44,7 @@ Loki\AdminComponents\Form\Field\FieldType\ProductSelect Loki\AdminComponents\Form\Field\FieldType\CustomerSelect Loki\AdminComponents\Form\Field\FieldType\EntitySelect + Loki\AdminComponents\Form\Field\FieldType\CategorySelection diff --git a/view/adminhtml/templates/form/field_type/category_selection.phtml b/view/adminhtml/templates/form/field_type/category_selection.phtml new file mode 100644 index 0000000..fd3745d --- /dev/null +++ b/view/adminhtml/templates/form/field_type/category_selection.phtml @@ -0,0 +1,112 @@ +getData('view_model'); +$field = $block->getField(); +$fieldType = $field->getFieldType(); +$categoryTreeNodes = $fieldType->getCategoryTree(); +?> +
+
+
+
+ +
+
+
+
+ renderCategoriesBreadcrumb() ?> +
+
+ renderSearchInput() ?> + +
    +
  • +
    + + + +
    + children as $childCategory): ?> + renderChildCategoryNode($childCategory, $field)?> + +
  • +
+ + +
+ +
+
+
+
+
+
+
+renderMainScript($field) ?> diff --git a/view/adminhtml/templates/form/field_type/category_selection/crumbs.phtml b/view/adminhtml/templates/form/field_type/category_selection/crumbs.phtml new file mode 100644 index 0000000..0b5f7fc --- /dev/null +++ b/view/adminhtml/templates/form/field_type/category_selection/crumbs.phtml @@ -0,0 +1,57 @@ + + + diff --git a/view/adminhtml/templates/form/field_type/category_selection/main_js.phtml b/view/adminhtml/templates/form/field_type/category_selection/main_js.phtml new file mode 100644 index 0000000..0715400 --- /dev/null +++ b/view/adminhtml/templates/form/field_type/category_selection/main_js.phtml @@ -0,0 +1,275 @@ +getData('field'); +$fieldType = $field->getFieldType(); +$maxTreeLevel = $fieldType->getMaxCategoryLevel($fieldType->getCategoryTree()); +?> + diff --git a/view/adminhtml/templates/form/field_type/category_selection/search_input.phtml b/view/adminhtml/templates/form/field_type/category_selection/search_input.phtml new file mode 100644 index 0000000..ac20d03 --- /dev/null +++ b/view/adminhtml/templates/form/field_type/category_selection/search_input.phtml @@ -0,0 +1,163 @@ + +
+ + + +
+ + + + diff --git a/view/adminhtml/templates/form/field_type/category_selection/select_inner_item.phtml b/view/adminhtml/templates/form/field_type/category_selection/select_inner_item.phtml new file mode 100644 index 0000000..e8dd51c --- /dev/null +++ b/view/adminhtml/templates/form/field_type/category_selection/select_inner_item.phtml @@ -0,0 +1,55 @@ +getData('field'); +$categoryTreeNode = $block->getData('category_tree_node'); +$fieldType = $field->getFieldType(); +?> +
    +
  • +
    + + + +
    + children as $childCategory): ?> + renderChildCategoryNode($childCategory, $field)?> + +
  • +