mirror of https://github.com/halo-dev/halo
feat: add method to find path of a specified node in a category tree (#6135)
#### What type of PR is this? /kind feature /area core /milestone 2.17.x #### What this PR does / why we need it: 为分类 Finder 提供获取指定节点的面包屑路径方法 #### Which issue(s) this PR fixes: Fixes #3374 #### Does this PR introduce a user-facing change? ```release-note 为分类 Finder 提供获取指定节点的面包屑路径方法 ```pull/6185/head
parent
bb0a3bc467
commit
0cdd043d1f
|
@ -30,4 +30,6 @@ public interface CategoryFinder {
|
||||||
Flux<CategoryTreeVo> listAsTree(String name);
|
Flux<CategoryTreeVo> listAsTree(String name);
|
||||||
|
|
||||||
Mono<CategoryVo> getParentByName(String name);
|
Mono<CategoryVo> getParentByName(String name);
|
||||||
|
|
||||||
|
Flux<CategoryVo> getBreadcrumbs(String name);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ package run.halo.app.theme.finders.impl;
|
||||||
import static run.halo.app.extension.index.query.QueryFactory.notEqual;
|
import static run.halo.app.extension.index.query.QueryFactory.notEqual;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -16,6 +17,7 @@ import org.apache.commons.lang3.BooleanUtils;
|
||||||
import org.apache.commons.lang3.ObjectUtils;
|
import org.apache.commons.lang3.ObjectUtils;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import run.halo.app.content.CategoryService;
|
import run.halo.app.content.CategoryService;
|
||||||
|
@ -101,8 +103,17 @@ public class CategoryFinderImpl implements CategoryFinder {
|
||||||
.map(CategoryVo::from);
|
.map(CategoryVo::from);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Flux<CategoryVo> listAllFor(String parentName) {
|
||||||
|
return categoryService.isCategoryHidden(parentName)
|
||||||
|
.flatMapMany(
|
||||||
|
isHidden -> client.listAll(Category.class, new ListOptions(), defaultSort())
|
||||||
|
.filter(category -> isHidden || !category.getSpec().isHideFromList())
|
||||||
|
.map(CategoryVo::from)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Flux<CategoryTreeVo> toCategoryTreeVoFlux(String name) {
|
Flux<CategoryTreeVo> toCategoryTreeVoFlux(String name) {
|
||||||
return listAll()
|
return listAllFor(name)
|
||||||
.collectList()
|
.collectList()
|
||||||
.flatMapIterable(categoryVos -> {
|
.flatMapIterable(categoryVos -> {
|
||||||
Map<String, CategoryTreeVo> nameIdentityMap = categoryVos.stream()
|
Map<String, CategoryTreeVo> nameIdentityMap = categoryVos.stream()
|
||||||
|
@ -153,6 +164,7 @@ public class CategoryFinderImpl implements CategoryFinder {
|
||||||
Category.CategorySpec categorySpec = new Category.CategorySpec();
|
Category.CategorySpec categorySpec = new Category.CategorySpec();
|
||||||
categorySpec.setSlug("/");
|
categorySpec.setSlug("/");
|
||||||
return CategoryTreeVo.builder()
|
return CategoryTreeVo.builder()
|
||||||
|
.metadata(new Metadata())
|
||||||
.spec(categorySpec)
|
.spec(categorySpec)
|
||||||
.postCount(0)
|
.postCount(0)
|
||||||
.children(treeNodes)
|
.children(treeNodes)
|
||||||
|
@ -214,6 +226,48 @@ public class CategoryFinderImpl implements CategoryFinder {
|
||||||
.map(CategoryVo::from);
|
.map(CategoryVo::from);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<CategoryVo> getBreadcrumbs(String name) {
|
||||||
|
return listAsTree()
|
||||||
|
.collectList()
|
||||||
|
.flatMapMany(treeNodes -> {
|
||||||
|
var rootNode = dummyVirtualRoot(treeNodes);
|
||||||
|
var paths = new ArrayList<CategoryVo>();
|
||||||
|
findPathHelper(rootNode, name, paths);
|
||||||
|
return Flux.fromIterable(paths);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean findPathHelper(CategoryTreeVo node, String targetName,
|
||||||
|
List<CategoryVo> path) {
|
||||||
|
Assert.notNull(targetName, "Target name must not be null");
|
||||||
|
if (node == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// null name is just a virtual root
|
||||||
|
if (node.getMetadata().getName() != null) {
|
||||||
|
path.add(CategoryTreeVo.toCategoryVo(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
// node maybe a virtual root node so it may have null name
|
||||||
|
if (targetName.equals(node.getMetadata().getName())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (CategoryTreeVo child : node.getChildren()) {
|
||||||
|
if (findPathHelper(child, targetName, path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the target node is not in the current subtree, remove the current node to roll back
|
||||||
|
if (!path.isEmpty()) {
|
||||||
|
path.remove(path.size() - 1);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
int pageNullSafe(Integer page) {
|
int pageNullSafe(Integer page) {
|
||||||
return ObjectUtils.defaultIfNull(page, 1);
|
return ObjectUtils.defaultIfNull(page, 1);
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,6 +51,19 @@ public class CategoryTreeVo implements VisualizableTreeNode<CategoryTreeVo>, Ext
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert {@link CategoryTreeVo} to {@link CategoryVo}.
|
||||||
|
*/
|
||||||
|
public static CategoryVo toCategoryVo(CategoryTreeVo categoryTreeVo) {
|
||||||
|
Assert.notNull(categoryTreeVo, "The category tree vo must not be null");
|
||||||
|
return CategoryVo.builder()
|
||||||
|
.metadata(categoryTreeVo.getMetadata())
|
||||||
|
.spec(categoryTreeVo.getSpec())
|
||||||
|
.status(categoryTreeVo.getStatus())
|
||||||
|
.postCount(categoryTreeVo.getPostCount())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String nodeText() {
|
public String nodeText() {
|
||||||
return String.format("%s (%s)%s", getSpec().getDisplayName(), getPostCount(),
|
return String.format("%s (%s)%s", getSpec().getDisplayName(), getPostCount(),
|
||||||
|
|
|
@ -3,6 +3,7 @@ package run.halo.app.theme.finders.impl;
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
@ -55,6 +56,7 @@ class CategoryFinderImplTest {
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
categoryFinder = new CategoryFinderImpl(client, categoryService);
|
categoryFinder = new CategoryFinderImpl(client, categoryService);
|
||||||
|
lenient().when(categoryService.isCategoryHidden(any())).thenReturn(Mono.just(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -227,6 +229,44 @@ class CategoryFinderImplTest {
|
||||||
└── IndependentChild4 (3)
|
└── IndependentChild4 (3)
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getBreadcrumbsTest() {
|
||||||
|
// first level
|
||||||
|
var breadcrumbs = categoryFinder.getBreadcrumbs("全部").collectList().block();
|
||||||
|
assertThat(toNames(breadcrumbs)).containsSequence("全部");
|
||||||
|
|
||||||
|
// second level
|
||||||
|
breadcrumbs = categoryFinder.getBreadcrumbs("AnotherRootChild").collectList().block();
|
||||||
|
assertThat(toNames(breadcrumbs)).containsSequence("全部", "AnotherRootChild");
|
||||||
|
|
||||||
|
// more levels
|
||||||
|
breadcrumbs = categoryFinder.getBreadcrumbs("DeepNode5").collectList().block();
|
||||||
|
assertThat(toNames(breadcrumbs)).containsSequence("全部", "AnotherRootChild", "Child1",
|
||||||
|
"SubChild2", "DeepNode3", "DeepNode5");
|
||||||
|
|
||||||
|
breadcrumbs = categoryFinder.getBreadcrumbs("IndependentChild4").collectList().block();
|
||||||
|
assertThat(toNames(breadcrumbs)).containsSequence("全部", "FIT2CLOUD",
|
||||||
|
"IndependentNode",
|
||||||
|
"IndependentChild4");
|
||||||
|
|
||||||
|
breadcrumbs = categoryFinder.getBreadcrumbs("SubNode4").collectList().block();
|
||||||
|
assertThat(toNames(breadcrumbs)).containsSequence("全部", "AnotherRootChild", "Child2",
|
||||||
|
"IndependentSubNode", "SubNode4");
|
||||||
|
|
||||||
|
// not exist
|
||||||
|
breadcrumbs = categoryFinder.getBreadcrumbs("not-exist").collectList().block();
|
||||||
|
assertThat(toNames(breadcrumbs)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> toNames(List<CategoryVo> categories) {
|
||||||
|
if (categories == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return categories.stream()
|
||||||
|
.map(category -> category.getMetadata().getName())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Category> categoriesForTree() {
|
private List<Category> categoriesForTree() {
|
||||||
|
|
Loading…
Reference in New Issue