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
guqing 2022-10-19 10:46:12 +08:00 committed by GitHub
parent 638ceac5a3
commit 9f1eafddc5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 265 additions and 24 deletions

View File

@ -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);

View File

@ -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());
}
} }

View File

@ -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<>() {
});
}
} }