diff --git a/api/src/main/java/run/halo/app/theme/finders/Finder.java b/api/src/main/java/run/halo/app/theme/finders/Finder.java index 6a24311a6..7948059ef 100644 --- a/api/src/main/java/run/halo/app/theme/finders/Finder.java +++ b/api/src/main/java/run/halo/app/theme/finders/Finder.java @@ -4,6 +4,7 @@ import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.springframework.core.annotation.AliasFor; import org.springframework.stereotype.Service; /** @@ -22,5 +23,6 @@ public @interface Finder { * * @return variable name, class simple name if not specified */ + @AliasFor(annotation = Service.class) String value() default ""; } \ No newline at end of file diff --git a/ui/console-src/modules/contents/posts/categories/CategoryList.vue b/ui/console-src/modules/contents/posts/categories/CategoryList.vue index 4acc22c86..4118a942b 100644 --- a/ui/console-src/modules/contents/posts/categories/CategoryList.vue +++ b/ui/console-src/modules/contents/posts/categories/CategoryList.vue @@ -1,9 +1,5 @@ @@ -130,14 +125,33 @@ const handleUpdateInBatch = useDebounceFn(async () => { - + trigger-class="drag-element" + :indent="40" + @after-drop="handleUpdateInBatch" + @before-drag-start="isDragging = true" + > + + + + + diff --git a/ui/console-src/modules/contents/posts/categories/components/CategoryEditingModal.vue b/ui/console-src/modules/contents/posts/categories/components/CategoryEditingModal.vue index d82382419..9144eda85 100644 --- a/ui/console-src/modules/contents/posts/categories/components/CategoryEditingModal.vue +++ b/ui/console-src/modules/contents/posts/categories/components/CategoryEditingModal.vue @@ -1,9 +1,4 @@ - + + - + + + + + + + + {{ categoryTreeNode.spec.displayName }} + + + + {{ categoryTreeNode.status.permalink }} + + + + + + + + + {{ + $t("core.common.fields.post_count", { + count: categoryTreeNode.status?.postCount || 0, + }) + }} + + + {{ formatDatetime(categoryTreeNode.metadata.creationTimestamp) }} + + + + + + + + {{ $t("core.common.buttons.edit") }} + + + {{ $t("core.post_category.operations.add_sub_category.button") }} + + + {{ $t("core.common.buttons.delete") }} + + + + + { :parent-category="selectedParentCategory" @close="onEditingModalClose" /> - - - - - - - - - - - - {{ category.status.permalink }} - - - - - - - - - - - - - - - - - - - - - - - - - {{ formatDatetime(category.metadata.creationTimestamp) }} - - - - - - - {{ $t("core.common.buttons.edit") }} - - - {{ $t("core.post_category.operations.add_sub_category.button") }} - - - {{ $t("core.common.buttons.delete") }} - - - - - - + diff --git a/ui/console-src/modules/contents/posts/categories/composables/use-post-category.ts b/ui/console-src/modules/contents/posts/categories/composables/use-post-category.ts index eaf6a6d5a..e2c0f55c7 100644 --- a/ui/console-src/modules/contents/posts/categories/composables/use-post-category.ts +++ b/ui/console-src/modules/contents/posts/categories/composables/use-post-category.ts @@ -1,20 +1,10 @@ -import type { Category } from "@halo-dev/api-client"; import { coreApiClient } from "@halo-dev/api-client"; import { useQuery } from "@tanstack/vue-query"; -import type { Ref } from "vue"; import { ref } from "vue"; -import type { CategoryTree } from "../utils"; -import { buildCategoriesTree } from "../utils"; +import { buildCategoriesTree, type CategoryTreeNode } from "../utils"; -interface usePostCategoryReturn { - categories: Ref; - categoriesTree: Ref; - isLoading: Ref; - handleFetchCategories: () => void; -} - -export function usePostCategory(): usePostCategoryReturn { - const categoriesTree = ref([] as CategoryTree[]); +export function usePostCategory() { + const categoriesTree = ref([] as CategoryTreeNode[]); const { data: categories, diff --git a/ui/console-src/modules/contents/posts/categories/utils/__tests__/index.spec.ts b/ui/console-src/modules/contents/posts/categories/utils/__tests__/index.spec.ts new file mode 100644 index 000000000..9187addb7 --- /dev/null +++ b/ui/console-src/modules/contents/posts/categories/utils/__tests__/index.spec.ts @@ -0,0 +1,335 @@ +import type { Category } from "@halo-dev/api-client"; +import { describe, expect, it } from "vitest"; +import { + buildCategoriesTree, + convertCategoryTreeToCategory, + convertTreeToCategories, + getCategoryPath, + resetCategoriesTreePriority, + sortCategoriesTree, + type CategoryTreeNode, +} from "../index"; + +function createMockCategory( + name: string, + priority = 0, + children: string[] = [] +): Category { + return { + metadata: { + name, + annotations: {}, + labels: {}, + version: 1, + creationTimestamp: new Date().toISOString(), + }, + apiVersion: "content.halo.run/v1alpha1", + kind: "Category", + spec: { + displayName: `Category ${name}`, + slug: name, + description: `Description for ${name}`, + cover: "", + template: "", + priority: priority, + children: children, + }, + }; +} + +function createMockCategoryTreeNode( + name: string, + priority = 0, + children: CategoryTreeNode[] = [] +): CategoryTreeNode { + return { + metadata: { + name, + annotations: {}, + labels: {}, + version: 1, + creationTimestamp: new Date().toISOString(), + }, + apiVersion: "content.halo.run/v1alpha1", + kind: "Category", + spec: { + displayName: `Category ${name}`, + slug: name, + description: `Description for ${name}`, + cover: "", + template: "", + priority: priority, + children: children.map((child) => child.metadata.name), + }, + children: children, + }; +} + +describe("buildCategoriesTree", () => { + it("should convert flat category array to tree structure", () => { + // Prepare test data + const categories: Category[] = [ + createMockCategory("parent1", 0, ["child1", "child2"]), + createMockCategory("child1", 0), + createMockCategory("child2", 1), + createMockCategory("parent2", 1, ["child3"]), + createMockCategory("child3", 0), + ]; + + // Execute test + const result = buildCategoriesTree(categories); + + // Verify results + expect(result.length).toBe(2); // Should have two root nodes + expect(result[0].metadata.name).toBe("parent1"); // First root node should be parent1 + expect(result[1].metadata.name).toBe("parent2"); // Second root node should be parent2 + expect(result[0].children.length).toBe(2); // parent1 should have two children + expect(result[1].children.length).toBe(1); // parent2 should have one child + expect(result[0].children[0].metadata.name).toBe("child1"); + expect(result[0].children[1].metadata.name).toBe("child2"); + expect(result[1].children[0].metadata.name).toBe("child3"); + }); + + it("should handle empty array input", () => { + const result = buildCategoriesTree([]); + expect(result).toEqual([]); + }); + + it("should handle categories without parent-child relationships", () => { + const categories: Category[] = [ + createMockCategory("category1", 0), + createMockCategory("category2", 1), + createMockCategory("category3", 2), + ]; + + const result = buildCategoriesTree(categories); + + expect(result.length).toBe(3); + expect(result.every((node) => node.children.length === 0)).toBe(true); + }); + + it("should handle multi-level nested category structure", () => { + const categories: Category[] = [ + createMockCategory("root", 0, ["level1"]), + createMockCategory("level1", 0, ["level2"]), + createMockCategory("level2", 0, ["level3"]), + createMockCategory("level3", 0), + ]; + + const result = buildCategoriesTree(categories); + + expect(result.length).toBe(1); + expect(result[0].metadata.name).toBe("root"); + expect(result[0].children[0].metadata.name).toBe("level1"); + expect(result[0].children[0].children[0].metadata.name).toBe("level2"); + expect(result[0].children[0].children[0].children[0].metadata.name).toBe( + "level3" + ); + }); +}); + +describe("sortCategoriesTree", () => { + it("should sort category tree by priority", () => { + const categoriesTree: CategoryTreeNode[] = [ + createMockCategoryTreeNode("node3", 2), + createMockCategoryTreeNode("node1", 0), + createMockCategoryTreeNode("node2", 1), + ]; + + const result = sortCategoriesTree(categoriesTree); + + expect(result[0].metadata.name).toBe("node1"); + expect(result[1].metadata.name).toBe("node2"); + expect(result[2].metadata.name).toBe("node3"); + }); + + it("should recursively sort child nodes", () => { + const categoriesTree: CategoryTreeNode[] = [ + createMockCategoryTreeNode("parent1", 0, [ + createMockCategoryTreeNode("child3", 2), + createMockCategoryTreeNode("child1", 0), + createMockCategoryTreeNode("child2", 1), + ]), + ]; + + const result = sortCategoriesTree(categoriesTree); + + expect(result[0].children[0].metadata.name).toBe("child1"); + expect(result[0].children[1].metadata.name).toBe("child2"); + expect(result[0].children[2].metadata.name).toBe("child3"); + }); + + it("should handle empty array input", () => { + const result = sortCategoriesTree([]); + expect(result).toEqual([]); + }); +}); + +describe("resetCategoriesTreePriority", () => { + it("should reset priority values of all nodes in the tree", () => { + const categoriesTree: CategoryTreeNode[] = [ + createMockCategoryTreeNode("node1", 5), + createMockCategoryTreeNode("node2", 10), + createMockCategoryTreeNode("node3", 15), + ]; + + const result = resetCategoriesTreePriority(categoriesTree); + + expect(result[0].spec.priority).toBe(0); + expect(result[1].spec.priority).toBe(1); + expect(result[2].spec.priority).toBe(2); + }); + + it("should recursively reset child node priorities", () => { + const categoriesTree: CategoryTreeNode[] = [ + createMockCategoryTreeNode("parent", 5, [ + createMockCategoryTreeNode("child1", 10), + createMockCategoryTreeNode("child2", 15), + ]), + ]; + + const result = resetCategoriesTreePriority(categoriesTree); + + expect(result[0].spec.priority).toBe(0); + expect(result[0].children[0].spec.priority).toBe(0); + expect(result[0].children[1].spec.priority).toBe(1); + }); + + it("should handle empty array input", () => { + const result = resetCategoriesTreePriority([]); + expect(result).toEqual([]); + }); +}); + +describe("convertTreeToCategories", () => { + it("should convert tree structure back to flat category array", () => { + const child1 = createMockCategoryTreeNode("child1", 0); + const child2 = createMockCategoryTreeNode("child2", 1); + const parent = createMockCategoryTreeNode("parent", 0, [child1, child2]); + const categoriesTree: CategoryTreeNode[] = [parent]; + + const result = convertTreeToCategories(categoriesTree); + + expect(result.length).toBe(3); + + // Verify parent node + const parentCategory = result.find((c) => c.metadata.name === "parent"); + expect(parentCategory).toBeDefined(); + expect(parentCategory?.spec.children).toContain("child1"); + expect(parentCategory?.spec.children).toContain("child2"); + + // Verify child nodes + const child1Category = result.find((c) => c.metadata.name === "child1"); + const child2Category = result.find((c) => c.metadata.name === "child2"); + expect(child1Category).toBeDefined(); + expect(child2Category).toBeDefined(); + expect(child1Category?.spec.children).toEqual([]); + expect(child2Category?.spec.children).toEqual([]); + }); + + it("should handle multi-level nested structure", () => { + const level3 = createMockCategoryTreeNode("level3", 0); + const level2 = createMockCategoryTreeNode("level2", 0, [level3]); + const level1 = createMockCategoryTreeNode("level1", 0, [level2]); + const root = createMockCategoryTreeNode("root", 0, [level1]); + const categoriesTree: CategoryTreeNode[] = [root]; + + const result = convertTreeToCategories(categoriesTree); + + expect(result.length).toBe(4); + + const rootCategory = result.find((c) => c.metadata.name === "root"); + const level1Category = result.find((c) => c.metadata.name === "level1"); + const level2Category = result.find((c) => c.metadata.name === "level2"); + const level3Category = result.find((c) => c.metadata.name === "level3"); + + expect(rootCategory?.spec.children).toContain("level1"); + expect(level1Category?.spec.children).toContain("level2"); + expect(level2Category?.spec.children).toContain("level3"); + expect(level3Category?.spec.children).toEqual([]); + }); + + it("should handle empty array input", () => { + const result = convertTreeToCategories([]); + expect(result).toEqual([]); + }); +}); + +describe("convertCategoryTreeToCategory", () => { + it("should convert a single tree node to a category object", () => { + const child1 = createMockCategoryTreeNode("child1", 0); + const child2 = createMockCategoryTreeNode("child2", 1); + const parent = createMockCategoryTreeNode("parent", 0, [child1, child2]); + + const result = convertCategoryTreeToCategory(parent); + + expect(result.metadata.name).toBe("parent"); + expect(result.spec.children).toContain("child1"); + expect(result.spec.children).toContain("child2"); + expect(result.spec.children?.length).toBe(2); + // eslint-disable-next-line + expect((result as any).children).toBeUndefined(); + }); + + it("should handle nodes without children", () => { + const node = createMockCategoryTreeNode("node", 0); + + const result = convertCategoryTreeToCategory(node); + + expect(result.metadata.name).toBe("node"); + expect(result.spec.children).toEqual([]); + }); +}); + +describe("getCategoryPath", () => { + it("should return path from root to specified category", () => { + const level3 = createMockCategoryTreeNode("level3", 0); + const level2 = createMockCategoryTreeNode("level2", 0, [level3]); + const level1 = createMockCategoryTreeNode("level1", 0, [level2]); + const root = createMockCategoryTreeNode("root", 0, [level1]); + const categoriesTree: CategoryTreeNode[] = [root]; + + const result = getCategoryPath(categoriesTree, "level3"); + + expect(result).toBeDefined(); + expect(result?.length).toBe(4); + expect(result?.[0].metadata.name).toBe("root"); + expect(result?.[1].metadata.name).toBe("level1"); + expect(result?.[2].metadata.name).toBe("level2"); + expect(result?.[3].metadata.name).toBe("level3"); + }); + + it("should handle case when category is not found", () => { + const categoriesTree: CategoryTreeNode[] = [ + createMockCategoryTreeNode("node1", 0), + createMockCategoryTreeNode("node2", 1), + ]; + + const result = getCategoryPath(categoriesTree, "nonexistent"); + + expect(result).toBeUndefined(); + }); + + it("should handle multiple branches", () => { + const child1 = createMockCategoryTreeNode("child1", 0); + const child2 = createMockCategoryTreeNode("child2", 1); + const target = createMockCategoryTreeNode("target", 0); + const branch1 = createMockCategoryTreeNode("branch1", 0, [child1, child2]); + const branch2 = createMockCategoryTreeNode("branch2", 1, [target]); + const root = createMockCategoryTreeNode("root", 0, [branch1, branch2]); + const categoriesTree: CategoryTreeNode[] = [root]; + + const result = getCategoryPath(categoriesTree, "target"); + + expect(result).toBeDefined(); + expect(result?.length).toBe(3); + expect(result?.[0].metadata.name).toBe("root"); + expect(result?.[1].metadata.name).toBe("branch2"); + expect(result?.[2].metadata.name).toBe("target"); + }); + + it("should handle empty array input", () => { + const result = getCategoryPath([], "any"); + expect(result).toBeUndefined(); + }); +}); diff --git a/ui/console-src/modules/contents/posts/categories/utils/index.ts b/ui/console-src/modules/contents/posts/categories/utils/index.ts index dc5863d1f..0e298eecb 100644 --- a/ui/console-src/modules/contents/posts/categories/utils/index.ts +++ b/ui/console-src/modules/contents/posts/categories/utils/index.ts @@ -1,38 +1,41 @@ -import type { Category, CategorySpec } from "@halo-dev/api-client"; +import type { Category } from "@halo-dev/api-client"; import { cloneDeep } from "lodash-es"; -export interface CategoryTreeSpec extends Omit { - children: CategoryTree[]; +export interface CategoryTreeNode extends Category { + children: CategoryTreeNode[]; } -export interface CategoryTree extends Omit { - spec: CategoryTreeSpec; -} - -export function buildCategoriesTree(categories: Category[]): CategoryTree[] { +export function buildCategoriesTree( + categories: Category[] +): CategoryTreeNode[] { const categoriesToUpdate = cloneDeep(categories); - const categoriesMap = {}; - const parentMap = {}; + const categoriesMap: Record = {}; + const parentMap: Record = {}; categoriesToUpdate.forEach((category) => { - categoriesMap[category.metadata.name] = category; - // @ts-ignore - category.spec.children.forEach((child) => { - parentMap[child] = category.metadata.name; - }); - // @ts-ignore - category.spec.children = []; + categoriesMap[category.metadata.name] = { + ...category, + children: [], + } as CategoryTreeNode; + + if (category.spec.children) { + category.spec.children.forEach((child) => { + parentMap[child] = category.metadata.name; + }); + } }); categoriesToUpdate.forEach((category) => { const parentName = parentMap[category.metadata.name]; if (parentName && categoriesMap[parentName]) { - categoriesMap[parentName].spec.children.push(category); + categoriesMap[parentName].children.push( + categoriesMap[category.metadata.name] + ); } }); - const categoriesTree = categoriesToUpdate.filter( + const categoriesTree = Object.values(categoriesMap).filter( (node) => parentMap[node.metadata.name] === undefined ); @@ -40,8 +43,8 @@ export function buildCategoriesTree(categories: Category[]): CategoryTree[] { } export function sortCategoriesTree( - categoriesTree: CategoryTree[] | Category[] -): CategoryTree[] { + categoriesTree: CategoryTreeNode[] +): CategoryTreeNode[] { return categoriesTree .sort((a, b) => { if (a.spec.priority < b.spec.priority) { @@ -53,13 +56,10 @@ export function sortCategoriesTree( return 0; }) .map((category) => { - if (category.spec.children.length) { + if (category.children && category.children.length) { return { ...category, - spec: { - ...category.spec, - children: sortCategoriesTree(category.spec.children), - }, + children: sortCategoriesTree(category.children), }; } return category; @@ -67,54 +67,62 @@ export function sortCategoriesTree( } export function resetCategoriesTreePriority( - categoriesTree: CategoryTree[] -): CategoryTree[] { + categoriesTree: CategoryTreeNode[] +): CategoryTreeNode[] { for (let i = 0; i < categoriesTree.length; i++) { categoriesTree[i].spec.priority = i; - if (categoriesTree[i].spec.children) { - resetCategoriesTreePriority(categoriesTree[i].spec.children); + if (categoriesTree[i].children && categoriesTree[i].children.length) { + resetCategoriesTreePriority(categoriesTree[i].children); } } return categoriesTree; } -export function convertTreeToCategories(categoriesTree: CategoryTree[]) { +export function convertTreeToCategories(categoriesTree: CategoryTreeNode[]) { const categories: Category[] = []; const categoriesMap = new Map(); - const convertCategory = (node: CategoryTree | undefined) => { + + const convertCategory = (node: CategoryTreeNode | undefined) => { if (!node) { return; } - const children = node.spec.children || []; + + const children = node.children || []; + categoriesMap.set(node.metadata.name, { ...node, spec: { ...node.spec, - // @ts-ignore children: children.map((child) => child.metadata.name), }, }); + children.forEach((child) => { convertCategory(child); }); }; + categoriesTree.forEach((node) => { convertCategory(node); }); + categoriesMap.forEach((node) => { categories.push(node); }); + return categories; } export function convertCategoryTreeToCategory( - categoryTree: CategoryTree + categoryTree: CategoryTreeNode ): Category { - const childNames = categoryTree.spec.children.map( - (child) => child.metadata.name - ); + const childNames = categoryTree.children.map((child) => child.metadata.name); + + // eslint-disable-next-line + const { children: _, ...categoryWithoutChildren } = categoryTree; + return { - ...categoryTree, + ...categoryWithoutChildren, spec: { ...categoryTree.spec, children: childNames, @@ -123,18 +131,18 @@ export function convertCategoryTreeToCategory( } export const getCategoryPath = ( - categories: CategoryTree[], + categories: CategoryTreeNode[], name: string, - path: CategoryTree[] = [] -): CategoryTree[] | undefined => { + path: CategoryTreeNode[] = [] +): CategoryTreeNode[] | undefined => { for (const category of categories) { if (category.metadata && category.metadata.name === name) { return path.concat([category]); } - if (category.spec && category.spec.children) { + if (category.children && category.children.length) { const found = getCategoryPath( - category.spec.children, + category.children, name, path.concat([category]) ); diff --git a/ui/src/formkit/inputs/category-select/CategorySelect.vue b/ui/src/formkit/inputs/category-select/CategorySelect.vue index 5f68c403f..56e256a6b 100644 --- a/ui/src/formkit/inputs/category-select/CategorySelect.vue +++ b/ui/src/formkit/inputs/category-select/CategorySelect.vue @@ -2,8 +2,10 @@ import HasPermission from "@/components/permission/HasPermission.vue"; import { usePermission } from "@/utils/permission"; import { usePostCategory } from "@console/modules/contents/posts/categories/composables/use-post-category"; -import type { CategoryTree } from "@console/modules/contents/posts/categories/utils"; -import { convertTreeToCategories } from "@console/modules/contents/posts/categories/utils"; +import { + convertTreeToCategories, + type CategoryTreeNode, +} from "@console/modules/contents/posts/categories/utils"; import type { FormKitFrameworkContext } from "@formkit/core"; import type { Category } from "@halo-dev/api-client"; import { coreApiClient } from "@halo-dev/api-client"; @@ -38,11 +40,11 @@ const multiple = computed(() => { const { categories, categoriesTree, handleFetchCategories } = usePostCategory(); -provide>("categoriesTree", categoriesTree); +provide>("categoriesTree", categoriesTree); -const selectedCategory = ref(); +const selectedCategory = ref(); -provide>( +provide>( "selectedCategory", selectedCategory ); @@ -109,19 +111,19 @@ const selectedCategories = computed(() => { return [category].filter(Boolean) as Category[]; }); -const isSelected = (category: CategoryTree | Category) => { +const isSelected = (category: CategoryTreeNode | Category) => { if (multiple.value) { return (props.context._value || []).includes(category.metadata.name); } return props.context._value === category.metadata.name; }; -provide<(category: CategoryTree | Category) => boolean>( +provide<(category: CategoryTreeNode | Category) => boolean>( "isSelected", isSelected ); -const handleSelect = (category: CategoryTree | Category) => { +const handleSelect = (category: CategoryTreeNode | Category) => { if (multiple.value) { const currentValue = props.context._value || []; if (currentValue.includes(category.metadata.name)) { diff --git a/ui/src/formkit/inputs/category-select/components/CategoryListItem.vue b/ui/src/formkit/inputs/category-select/components/CategoryListItem.vue index 951f9b5fc..3176ff4f0 100644 --- a/ui/src/formkit/inputs/category-select/components/CategoryListItem.vue +++ b/ui/src/formkit/inputs/category-select/components/CategoryListItem.vue @@ -1,27 +1,28 @@ @@ -54,11 +55,11 @@ const onSelect = (childCategory: CategoryTree) => { -import type { CategoryTree } from "@console/modules/contents/posts/categories/utils"; -import { getCategoryPath } from "@console/modules/contents/posts/categories/utils"; +import { + getCategoryPath, + type CategoryTreeNode, +} from "@console/modules/contents/posts/categories/utils"; import type { Category } from "@halo-dev/api-client"; import { IconClose } from "@halo-dev/components"; import { computed, inject, ref, type Ref } from "vue"; @@ -13,10 +15,13 @@ const props = withDefaults( ); const emit = defineEmits<{ - (event: "select", category: CategoryTree | Category): void; + (event: "select", category: CategoryTreeNode | Category): void; }>(); -const categoriesTree = inject>("categoriesTree", ref([])); +const categoriesTree = inject>( + "categoriesTree", + ref([]) +); const label = computed(() => { const categories = getCategoryPath( @@ -24,7 +29,7 @@ const label = computed(() => { props.category.metadata.name ); return categories - ?.map((category: CategoryTree) => category.spec.displayName) + ?.map((category: CategoryTreeNode) => category.spec.displayName) .join(" / "); }); diff --git a/ui/src/formkit/inputs/category-select/components/SearchResultListItem.vue b/ui/src/formkit/inputs/category-select/components/SearchResultListItem.vue index ed053cee8..d47b49bee 100644 --- a/ui/src/formkit/inputs/category-select/components/SearchResultListItem.vue +++ b/ui/src/formkit/inputs/category-select/components/SearchResultListItem.vue @@ -1,7 +1,7 @@