mirror of https://github.com/halo-dev/halo
fix: list as tree in category finder (#2537)
#### What type of PR is this? /kind bug /area core /milestone 2.0 #### What this PR does / why we need it: 修复分类树查询 #### Which issue(s) this PR fixes: Fixes #2532 #### Special notes for your reviewer: how to test it? 1. 新建分类并,并拖动构建一个树形 2. 在主题端通过 `categoryFinder.listAsTree()` 查看结果是否正确 /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? ```release-note 修复分类树状数据查询 ```pull/2604/head
parent
638ceac5a3
commit
9f1eafddc5
|
@ -3,14 +3,13 @@ package run.halo.app.theme.finders.impl;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.commons.lang3.ObjectUtils;
|
import org.apache.commons.lang3.ObjectUtils;
|
||||||
import org.springframework.util.CollectionUtils;
|
|
||||||
import run.halo.app.core.extension.Category;
|
import run.halo.app.core.extension.Category;
|
||||||
import run.halo.app.extension.ListResult;
|
import run.halo.app.extension.ListResult;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
|
@ -25,6 +24,7 @@ import run.halo.app.theme.finders.vo.CategoryVo;
|
||||||
* @author guqing
|
* @author guqing
|
||||||
* @since 2.0.0
|
* @since 2.0.0
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
@Finder("categoryFinder")
|
@Finder("categoryFinder")
|
||||||
public class CategoryFinderImpl implements CategoryFinder {
|
public class CategoryFinderImpl implements CategoryFinder {
|
||||||
private final ReactiveExtensionClient client;
|
private final ReactiveExtensionClient client;
|
||||||
|
@ -75,40 +75,55 @@ public class CategoryFinderImpl implements CategoryFinder {
|
||||||
@Override
|
@Override
|
||||||
public List<CategoryTreeVo> listAsTree() {
|
public List<CategoryTreeVo> listAsTree() {
|
||||||
List<CategoryVo> categoryVos = listAll();
|
List<CategoryVo> categoryVos = listAll();
|
||||||
Map<String, CategoryVo> nameIdentityMap = categoryVos.stream()
|
Map<String, CategoryTreeVo> nameIdentityMap = categoryVos.stream()
|
||||||
|
.map(CategoryTreeVo::from)
|
||||||
.collect(Collectors.toMap(categoryVo -> categoryVo.getMetadata().getName(),
|
.collect(Collectors.toMap(categoryVo -> categoryVo.getMetadata().getName(),
|
||||||
Function.identity()));
|
Function.identity()));
|
||||||
|
|
||||||
Map<String, CategoryTreeVo> treeVoMap = new HashMap<>();
|
nameIdentityMap.forEach((name, value) -> {
|
||||||
// populate parentName
|
List<String> children = value.getSpec().getChildren();
|
||||||
categoryVos.forEach(categoryVo -> {
|
if (children != null) {
|
||||||
final String parentName = categoryVo.getMetadata().getName();
|
for (String child : children) {
|
||||||
treeVoMap.putIfAbsent(parentName, CategoryTreeVo.from(categoryVo));
|
CategoryTreeVo childNode = nameIdentityMap.get(child);
|
||||||
List<String> children = categoryVo.getSpec().getChildren();
|
childNode.setParentName(name);
|
||||||
if (CollectionUtils.isEmpty(children)) {
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
children.forEach(childrenName -> {
|
|
||||||
CategoryVo childrenVo = nameIdentityMap.get(childrenName);
|
|
||||||
CategoryTreeVo treeVo = CategoryTreeVo.from(childrenVo);
|
|
||||||
treeVo.setParentName(parentName);
|
|
||||||
treeVoMap.putIfAbsent(treeVo.getMetadata().getName(), treeVo);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
nameIdentityMap.clear();
|
return listToTree(nameIdentityMap.values());
|
||||||
return listToTree(treeVoMap.values());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<CategoryTreeVo> listToTree(Collection<CategoryTreeVo> list) {
|
static List<CategoryTreeVo> listToTree(Collection<CategoryTreeVo> list) {
|
||||||
Map<String, List<CategoryTreeVo>> nameIdentityMap = list.stream()
|
Map<String, List<CategoryTreeVo>> parentNameIdentityMap = list.stream()
|
||||||
.filter(item -> item.getParentName() != null)
|
.filter(categoryTreeVo -> categoryTreeVo.getParentName() != null)
|
||||||
.collect(Collectors.groupingBy(CategoryTreeVo::getParentName));
|
.collect(Collectors.groupingBy(CategoryTreeVo::getParentName));
|
||||||
list.forEach(node -> node.setChildren(nameIdentityMap.get(node.getMetadata().getName())));
|
|
||||||
|
list.forEach(node -> {
|
||||||
|
// sort children
|
||||||
|
List<CategoryTreeVo> children =
|
||||||
|
parentNameIdentityMap.getOrDefault(node.getMetadata().getName(), List.of())
|
||||||
|
.stream()
|
||||||
|
.sorted(defaultTreeNodeComparator())
|
||||||
|
.toList();
|
||||||
|
node.setChildren(children);
|
||||||
|
});
|
||||||
return list.stream()
|
return list.stream()
|
||||||
.filter(v -> v.getParentName() == null)
|
.filter(v -> v.getParentName() == null)
|
||||||
|
.sorted(defaultTreeNodeComparator())
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Comparator<CategoryTreeVo> defaultTreeNodeComparator() {
|
||||||
|
Function<CategoryTreeVo, Integer> priority =
|
||||||
|
category -> Objects.requireNonNullElse(category.getSpec().getPriority(), 0);
|
||||||
|
Function<CategoryTreeVo, Instant> creationTimestamp =
|
||||||
|
category -> category.getMetadata().getCreationTimestamp();
|
||||||
|
Function<CategoryTreeVo, String> name =
|
||||||
|
category -> category.getMetadata().getName();
|
||||||
|
return Comparator.comparing(priority)
|
||||||
|
.thenComparing(creationTimestamp)
|
||||||
|
.thenComparing(name);
|
||||||
|
}
|
||||||
|
|
||||||
static Comparator<Category> defaultComparator() {
|
static Comparator<Category> defaultComparator() {
|
||||||
Function<Category, Integer> priority =
|
Function<Category, Integer> priority =
|
||||||
category -> Objects.requireNonNullElse(category.getSpec().getPriority(), 0);
|
category -> Objects.requireNonNullElse(category.getSpec().getPriority(), 0);
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package run.halo.app.theme.finders.vo;
|
package run.halo.app.theme.finders.vo;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.EqualsAndHashCode;
|
import lombok.EqualsAndHashCode;
|
||||||
|
@ -19,7 +20,7 @@ import run.halo.app.extension.MetadataOperator;
|
||||||
@Builder
|
@Builder
|
||||||
@ToString
|
@ToString
|
||||||
@EqualsAndHashCode
|
@EqualsAndHashCode
|
||||||
public class CategoryTreeVo {
|
public class CategoryTreeVo implements VisualizableTreeNode<CategoryTreeVo> {
|
||||||
|
|
||||||
private MetadataOperator metadata;
|
private MetadataOperator metadata;
|
||||||
|
|
||||||
|
@ -46,7 +47,12 @@ public class CategoryTreeVo {
|
||||||
.spec(category.getSpec())
|
.spec(category.getSpec())
|
||||||
.status(category.getStatus())
|
.status(category.getStatus())
|
||||||
.children(List.of())
|
.children(List.of())
|
||||||
.postCount(category.getPostCount())
|
.postCount(Objects.requireNonNullElse(category.getPostCount(), 0))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String nodeText() {
|
||||||
|
return String.format("%s (%s)", getSpec().getDisplayName(), getPostCount());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,9 +6,11 @@ import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import org.json.JSONException;
|
import org.json.JSONException;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
@ -99,6 +101,28 @@ class CategoryFinderImplTest {
|
||||||
assertThat(treeVos).hasSize(1);
|
assertThat(treeVos).hasSize(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test for {@link CategoryFinderImpl#listAsTree()}.
|
||||||
|
*
|
||||||
|
* @see <a href="https://github.com/halo-dev/halo/issues/2532">Fix #2532</a>
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void listAsTreeMore() {
|
||||||
|
when(client.list(eq(Category.class), eq(null), any()))
|
||||||
|
.thenReturn(Flux.fromIterable(moreCategories()));
|
||||||
|
List<CategoryTreeVo> treeVos = categoryFinder.listAsTree();
|
||||||
|
String s = visualizeTree(treeVos);
|
||||||
|
assertThat(s).isEqualTo("""
|
||||||
|
全部 (5)
|
||||||
|
├── FIT2CLOUD (2)
|
||||||
|
│ ├── DataEase (0)
|
||||||
|
│ ├── Halo (2)
|
||||||
|
│ ├── MeterSphere (0)
|
||||||
|
│ └── JumpServer (0)
|
||||||
|
└── 默认分类 (3)
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
private List<Category> categoriesForTree() {
|
private List<Category> categoriesForTree() {
|
||||||
/*
|
/*
|
||||||
* D
|
* D
|
||||||
|
@ -144,6 +168,30 @@ class CategoryFinderImplTest {
|
||||||
return List.of(d, e, a, b, c, g, f, h);
|
return List.of(d, e, a, b, c, g, f, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visualize a tree.
|
||||||
|
*/
|
||||||
|
String visualizeTree(List<CategoryTreeVo> categoryTreeVos) {
|
||||||
|
Category.CategorySpec categorySpec = new Category.CategorySpec();
|
||||||
|
categorySpec.setSlug("/");
|
||||||
|
categorySpec.setDisplayName("全部");
|
||||||
|
Integer postCount = categoryTreeVos.stream()
|
||||||
|
.map(CategoryTreeVo::getPostCount)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.reduce(Integer::sum)
|
||||||
|
.orElse(0);
|
||||||
|
CategoryTreeVo root = CategoryTreeVo.builder()
|
||||||
|
.spec(categorySpec)
|
||||||
|
.postCount(postCount)
|
||||||
|
.children(categoryTreeVos)
|
||||||
|
.metadata(new Metadata())
|
||||||
|
.build();
|
||||||
|
StringBuilder stringBuilder = new StringBuilder();
|
||||||
|
root.print(stringBuilder, "", "");
|
||||||
|
return stringBuilder.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private List<Category> categories() {
|
private List<Category> categories() {
|
||||||
Category category2 = JsonUtils.deepCopy(category());
|
Category category2 = JsonUtils.deepCopy(category());
|
||||||
category2.getMetadata().setName("c2");
|
category2.getMetadata().setName("c2");
|
||||||
|
@ -176,4 +224,176 @@ class CategoryFinderImplTest {
|
||||||
category.setSpec(categorySpec);
|
category.setSpec(categorySpec);
|
||||||
return category;
|
return category;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<Category> moreCategories() {
|
||||||
|
String s = """
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"spec":{
|
||||||
|
"displayName":"默认分类",
|
||||||
|
"slug":"default",
|
||||||
|
"description":"这是你的默认分类,如不需要,删除即可。",
|
||||||
|
"cover":"",
|
||||||
|
"template":"",
|
||||||
|
"priority":1,
|
||||||
|
"children":[
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"status":{
|
||||||
|
"permalink":"/categories/default",
|
||||||
|
"postCount":3,
|
||||||
|
"visiblePostCount":3
|
||||||
|
},
|
||||||
|
"apiVersion":"content.halo.run/v1alpha1",
|
||||||
|
"kind":"Category",
|
||||||
|
"metadata":{
|
||||||
|
"name":"76514a40-6ef1-4ed9-b58a-e26945bde3ca",
|
||||||
|
"version":16,
|
||||||
|
"creationTimestamp":"2022-10-08T06:17:47.589181Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"spec":{
|
||||||
|
"displayName":"MeterSphere",
|
||||||
|
"slug":"metersphere",
|
||||||
|
"description":"",
|
||||||
|
"cover":"",
|
||||||
|
"template":"",
|
||||||
|
"priority":2,
|
||||||
|
"children":[
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"status":{
|
||||||
|
"permalink":"/categories/metersphere",
|
||||||
|
"postCount":0,
|
||||||
|
"visiblePostCount":0
|
||||||
|
},
|
||||||
|
"apiVersion":"content.halo.run/v1alpha1",
|
||||||
|
"kind":"Category",
|
||||||
|
"metadata":{
|
||||||
|
"finalizers":[
|
||||||
|
"category-protection"
|
||||||
|
],
|
||||||
|
"name":"acf09686-d5a7-4227-ba8c-3aeff063f12f",
|
||||||
|
"version":13,
|
||||||
|
"creationTimestamp":"2022-10-08T06:32:36.650974Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"spec":{
|
||||||
|
"displayName":"DataEase",
|
||||||
|
"slug":"dataease",
|
||||||
|
"description":"",
|
||||||
|
"cover":"",
|
||||||
|
"template":"",
|
||||||
|
"priority":0,
|
||||||
|
"children":[
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"status":{
|
||||||
|
"permalink":"/categories/dataease",
|
||||||
|
"postCount":0,
|
||||||
|
"visiblePostCount":0
|
||||||
|
},
|
||||||
|
"apiVersion":"content.halo.run/v1alpha1",
|
||||||
|
"kind":"Category",
|
||||||
|
"metadata":{
|
||||||
|
"finalizers":[
|
||||||
|
"category-protection"
|
||||||
|
],
|
||||||
|
"name":"bd95f914-22fc-4de5-afcc-a9ffba2f6401",
|
||||||
|
"version":13,
|
||||||
|
"creationTimestamp":"2022-10-08T06:32:53.353838Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"spec":{
|
||||||
|
"displayName":"FIT2CLOUD",
|
||||||
|
"slug":"fit2cloud",
|
||||||
|
"description":"",
|
||||||
|
"cover":"",
|
||||||
|
"template":"",
|
||||||
|
"priority":0,
|
||||||
|
"children":[
|
||||||
|
"bd95f914-22fc-4de5-afcc-a9ffba2f6401",
|
||||||
|
"e1150fd9-4512-453c-9186-f8de9c156c3d",
|
||||||
|
"acf09686-d5a7-4227-ba8c-3aeff063f12f",
|
||||||
|
"ed064d5e-2b6f-4123-8114-78d0c6f2c4e2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"status":{
|
||||||
|
"permalink":"/categories/fit2cloud",
|
||||||
|
"postCount":2,
|
||||||
|
"visiblePostCount":2
|
||||||
|
},
|
||||||
|
"apiVersion":"content.halo.run/v1alpha1",
|
||||||
|
"kind":"Category",
|
||||||
|
"metadata":{
|
||||||
|
"finalizers":[
|
||||||
|
"category-protection"
|
||||||
|
],
|
||||||
|
"name":"c25c17ae-4a7b-43c5-a424-76950b9622cd",
|
||||||
|
"version":14,
|
||||||
|
"creationTimestamp":"2022-10-08T06:32:27.802025Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"spec":{
|
||||||
|
"displayName":"Halo",
|
||||||
|
"slug":"halo",
|
||||||
|
"description":"",
|
||||||
|
"cover":"",
|
||||||
|
"template":"",
|
||||||
|
"priority":1,
|
||||||
|
"children":[
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"status":{
|
||||||
|
"permalink":"/categories/halo",
|
||||||
|
"postCount":2,
|
||||||
|
"visiblePostCount":2
|
||||||
|
},
|
||||||
|
"apiVersion":"content.halo.run/v1alpha1",
|
||||||
|
"kind":"Category",
|
||||||
|
"metadata":{
|
||||||
|
"finalizers":[
|
||||||
|
"category-protection"
|
||||||
|
],
|
||||||
|
"name":"e1150fd9-4512-453c-9186-f8de9c156c3d",
|
||||||
|
"version":15,
|
||||||
|
"creationTimestamp":"2022-10-08T06:32:42.991788Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"spec":{
|
||||||
|
"displayName":"JumpServer",
|
||||||
|
"slug":"jumpserver",
|
||||||
|
"description":"",
|
||||||
|
"cover":"",
|
||||||
|
"template":"",
|
||||||
|
"priority":3,
|
||||||
|
"children":[
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"status":{
|
||||||
|
"permalink":"/categories/jumpserver",
|
||||||
|
"postCount":0,
|
||||||
|
"visiblePostCount":0
|
||||||
|
},
|
||||||
|
"apiVersion":"content.halo.run/v1alpha1",
|
||||||
|
"kind":"Category",
|
||||||
|
"metadata":{
|
||||||
|
"finalizers":[
|
||||||
|
"category-protection"
|
||||||
|
],
|
||||||
|
"name":"ed064d5e-2b6f-4123-8114-78d0c6f2c4e2",
|
||||||
|
"version":13,
|
||||||
|
"creationTimestamp":"2022-10-08T06:33:00.557435Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
""";
|
||||||
|
return JsonUtils.jsonToObject(s, new TypeReference<>() {
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
Loading…
Reference in New Issue