Merge branch 'main' into refactor/improve-failure-message-of-plugin

pull/7429/head
Ryan Wang 2025-05-15 10:33:07 +08:00 committed by GitHub
commit a733b6f562
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 598 additions and 265 deletions

View File

@ -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 "";
}

View File

@ -1,9 +1,5 @@
<script lang="ts" setup>
// core libs
import { coreApiClient } from "@halo-dev/api-client";
import { ref } from "vue";
// components
import {
IconAddCircle,
IconBookRead,
@ -14,16 +10,13 @@ import {
VPageHeader,
VSpace,
} from "@halo-dev/components";
import { Draggable } from "@he-tree/vue";
import "@he-tree/vue/style/default.css";
import { ref } from "vue";
import CategoryEditingModal from "./components/CategoryEditingModal.vue";
import CategoryListItem from "./components/CategoryListItem.vue";
import { convertTreeToCategories, resetCategoriesTreePriority } from "./utils";
// libs
import { useDebounceFn } from "@vueuse/core";
// hooks
import { usePostCategory } from "./composables/use-post-category";
import { convertTreeToCategories, resetCategoriesTreePriority } from "./utils";
const creationModal = ref(false);
@ -31,8 +24,9 @@ const { categories, categoriesTree, isLoading, handleFetchCategories } =
usePostCategory();
const batchUpdating = ref(false);
const isDragging = ref(false);
const handleUpdateInBatch = useDebounceFn(async () => {
async function handleUpdateInBatch() {
const categoriesTreeToUpdate = resetCategoriesTreePriority(
categoriesTree.value
);
@ -62,8 +56,9 @@ const handleUpdateInBatch = useDebounceFn(async () => {
} finally {
await handleFetchCategories();
batchUpdating.value = false;
isDragging.value = false;
}
}, 300);
}
</script>
<template>
<CategoryEditingModal v-if="creationModal" @close="creationModal = false" />
@ -130,14 +125,33 @@ const handleUpdateInBatch = useDebounceFn(async () => {
</VEmpty>
</Transition>
<Transition v-else appear name="fade">
<CategoryListItem
<Draggable
v-model="categoriesTree"
:class="{
'cursor-progress opacity-60': batchUpdating,
}"
@change="handleUpdateInBatch"
/>
trigger-class="drag-element"
:indent="40"
@after-drop="handleUpdateInBatch"
@before-drag-start="isDragging = true"
>
<template #default="{ node, stat }">
<CategoryListItem
:category-tree-node="node"
:is-child-level="stat.level > 1"
:is-dragging="isDragging"
/>
</template>
</Draggable>
</Transition>
</VCard>
</div>
</template>
<style>
.vtlist-inner {
@apply divide-y divide-gray-100;
}
.he-tree-drag-placeholder {
height: 60px;
}
</style>

View File

@ -1,9 +1,4 @@
<script lang="ts" setup>
// core libs
import { coreApiClient } from "@halo-dev/api-client";
import { computed, nextTick, onMounted, ref } from "vue";
// components
import SubmitButton from "@/components/button/SubmitButton.vue";
import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import { setFocus } from "@/formkit/utils/focus";
@ -12,6 +7,7 @@ import useSlugify from "@console/composables/use-slugify";
import { useThemeCustomTemplates } from "@console/modules/interface/themes/composables/use-theme";
import { reset, submitForm } from "@formkit/core";
import type { Category } from "@halo-dev/api-client";
import { coreApiClient } from "@halo-dev/api-client";
import {
IconRefreshLine,
Toast,
@ -21,6 +17,7 @@ import {
} from "@halo-dev/components";
import { useQueryClient } from "@tanstack/vue-query";
import { cloneDeep } from "lodash-es";
import { computed, nextTick, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
const props = withDefaults(
@ -186,7 +183,13 @@ const { handleGenerateSlug } = useSlugify(
);
</script>
<template>
<VModal ref="modal" :title="modalTitle" :width="700" @close="emit('close')">
<VModal
ref="modal"
mount-to-body
:title="modalTitle"
:width="700"
@close="emit('close')"
>
<FormKit
id="category-form"
type="form"

View File

@ -1,46 +1,63 @@
<script lang="ts" setup>
<script setup lang="ts">
import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission";
import type { Category } from "@halo-dev/api-client";
import { coreApiClient } from "@halo-dev/api-client";
import { coreApiClient, type Category } from "@halo-dev/api-client";
import {
Dialog,
IconEyeOff,
IconList,
IconMore,
Toast,
VDropdown,
VDropdownItem,
VEntity,
VEntityField,
VStatusDot,
} from "@halo-dev/components";
import "@he-tree/vue/style/default.css";
import { useQueryClient } from "@tanstack/vue-query";
import type { PropType } from "vue";
import { ref } from "vue";
import { VueDraggable } from "vue-draggable-plus";
import { useI18n } from "vue-i18n";
import GridiconsLinkBreak from "~icons/gridicons/link-break";
import { convertCategoryTreeToCategory, type CategoryTree } from "../utils";
import { convertCategoryTreeToCategory, type CategoryTreeNode } from "../utils";
import CategoryEditingModal from "./CategoryEditingModal.vue";
const { currentUserHasPermission } = usePermission();
withDefaults(defineProps<{ isChildLevel?: boolean }>(), {});
const categories = defineModel({
type: Array as PropType<CategoryTree[]>,
default: [],
});
const emit = defineEmits<{
(event: "change"): void;
}>();
const queryClient = useQueryClient();
const { t } = useI18n();
const queryClient = useQueryClient();
function onChange() {
emit("change");
}
const props = withDefaults(
defineProps<{
isChildLevel?: boolean;
isDragging?: boolean;
categoryTreeNode: CategoryTreeNode;
}>(),
{
isChildLevel: false,
isDragging: false,
}
);
const handleDelete = async () => {
Dialog.warning({
title: t("core.post_category.operations.delete.title"),
description: t("core.post_category.operations.delete.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await coreApiClient.content.category.deleteCategory({
name: props.categoryTreeNode.metadata.name,
});
Toast.success(t("core.common.toast.delete_success"));
queryClient.invalidateQueries({ queryKey: ["post-categories"] });
} catch (e) {
console.error("Failed to delete category", e);
}
},
});
};
// Editing category
const editingModal = ref(false);
@ -53,49 +70,101 @@ function onEditingModalClose() {
editingModal.value = false;
}
const handleOpenEditingModal = (category: CategoryTree) => {
selectedCategory.value = convertCategoryTreeToCategory(category);
const handleOpenEditingModal = () => {
selectedCategory.value = convertCategoryTreeToCategory(
props.categoryTreeNode
);
editingModal.value = true;
};
const handleOpenCreateByParentModal = (category: CategoryTree) => {
selectedParentCategory.value = convertCategoryTreeToCategory(category);
const handleOpenCreateByParentModal = () => {
selectedParentCategory.value = convertCategoryTreeToCategory(
props.categoryTreeNode
);
editingModal.value = true;
};
const handleDelete = async (category: CategoryTree) => {
Dialog.warning({
title: t("core.post_category.operations.delete.title"),
description: t("core.post_category.operations.delete.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await coreApiClient.content.category.deleteCategory({
name: category.metadata.name,
});
Toast.success(t("core.common.toast.delete_success"));
queryClient.invalidateQueries({ queryKey: ["post-categories"] });
} catch (e) {
console.error("Failed to delete tag", e);
}
},
});
};
</script>
<template>
<VueDraggable
v-model="categories"
class="box-border h-full w-full divide-y divide-gray-100"
ghost-class="opacity-50"
group="category-item"
handle=".drag-element"
tag="ul"
@sort="onChange"
<div
class="px-4 py-3 hover:bg-gray-50 w-full group items-center flex justify-between relative"
>
<div>
<div
v-permission="['system:posts:manage']"
class="drag-element absolute inset-y-0 left-0 hidden w-3.5 cursor-move items-center bg-gray-100 transition-all hover:bg-gray-200 group-hover:flex"
:class="{
'!hidden': isDragging,
}"
>
<IconList class="h-3.5 w-3.5" />
</div>
<div class="gap-1 flex flex-col">
<div class="inline-flex items-center gap-2">
<span class="truncate text-sm font-medium text-gray-900">
{{ categoryTreeNode.spec.displayName }}
</span>
</div>
<a
v-if="categoryTreeNode.status?.permalink"
:href="categoryTreeNode.status?.permalink"
:title="categoryTreeNode.status?.permalink"
target="_blank"
class="truncate text-xs text-gray-500 group-hover:text-gray-900"
>
{{ categoryTreeNode.status.permalink }}
</a>
</div>
</div>
<div class="flex items-center gap-6">
<VStatusDot
v-if="categoryTreeNode.metadata.deletionTimestamp"
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
<IconEyeOff
v-if="categoryTreeNode.spec.hideFromList"
v-tooltip="$t('core.post_category.list.fields.hide_from_list')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
/>
<GridiconsLinkBreak
v-if="categoryTreeNode.spec.preventParentPostCascadeQuery"
v-tooltip="
$t('core.post_category.list.fields.prevent_parent_post_cascade_query')
"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
/>
<span class="truncate text-xs text-gray-500">
{{
$t("core.common.fields.post_count", {
count: categoryTreeNode.status?.postCount || 0,
})
}}
</span>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(categoryTreeNode.metadata.creationTimestamp) }}
</span>
<VDropdown v-if="currentUserHasPermission(['system:posts:manage'])">
<div
class="cursor-pointer rounded p-1 transition-all hover:text-blue-600 group-hover:bg-gray-200/60"
@click.stop
>
<IconMore />
</div>
<template #popper>
<VDropdownItem @click="handleOpenEditingModal">
{{ $t("core.common.buttons.edit") }}
</VDropdownItem>
<VDropdownItem @click="handleOpenCreateByParentModal">
{{ $t("core.post_category.operations.add_sub_category.button") }}
</VDropdownItem>
<VDropdownItem type="danger" @click="handleDelete">
{{ $t("core.common.buttons.delete") }}
</VDropdownItem>
</template>
</VDropdown>
</div>
<CategoryEditingModal
v-if="editingModal"
:is-child-level-category="isChildLevel"
@ -103,104 +172,5 @@ const handleDelete = async (category: CategoryTree) => {
:parent-category="selectedParentCategory"
@close="onEditingModalClose"
/>
<li v-for="category in categories" :key="category.metadata.name">
<VEntity>
<template #prepend>
<div
v-permission="['system:posts:manage']"
class="drag-element absolute inset-y-0 left-0 hidden w-3.5 cursor-move items-center bg-gray-100 transition-all hover:bg-gray-200 group-hover:flex"
>
<IconList class="h-3.5 w-3.5" />
</div>
</template>
<template #start>
<VEntityField :title="category.spec.displayName">
<template #description>
<a
v-if="category.status?.permalink"
:href="category.status.permalink"
:title="category.status.permalink"
target="_blank"
class="truncate text-xs text-gray-500 group-hover:text-gray-900"
>
{{ category.status.permalink }}
</a>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField v-if="category.metadata.deletionTimestamp">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField v-if="category.spec.hideFromList">
<template #description>
<IconEyeOff
v-tooltip="$t('core.post_category.list.fields.hide_from_list')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
/>
</template>
</VEntityField>
<VEntityField v-if="category.spec.preventParentPostCascadeQuery">
<template #description>
<GridiconsLinkBreak
v-tooltip="
$t(
'core.post_category.list.fields.prevent_parent_post_cascade_query'
)
"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
/>
</template>
</VEntityField>
<VEntityField
:description="
$t('core.common.fields.post_count', {
count: category.status?.postCount || 0,
})
"
/>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(category.metadata.creationTimestamp) }}
</span>
</template>
</VEntityField>
</template>
<template
v-if="currentUserHasPermission(['system:posts:manage'])"
#dropdownItems
>
<VDropdownItem
v-permission="['system:posts:manage']"
@click="handleOpenEditingModal(category)"
>
{{ $t("core.common.buttons.edit") }}
</VDropdownItem>
<VDropdownItem @click="handleOpenCreateByParentModal(category)">
{{ $t("core.post_category.operations.add_sub_category.button") }}
</VDropdownItem>
<VDropdownItem
v-permission="['system:posts:manage']"
type="danger"
@click="handleDelete(category)"
>
{{ $t("core.common.buttons.delete") }}
</VDropdownItem>
</template>
</VEntity>
<CategoryListItem
v-model="category.spec.children"
is-child-level
class="pl-10 transition-all duration-300"
@change="onChange"
/>
</li>
</VueDraggable>
</div>
</template>

View File

@ -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<Category[] | undefined>;
categoriesTree: Ref<CategoryTree[]>;
isLoading: Ref<boolean>;
handleFetchCategories: () => void;
}
export function usePostCategory(): usePostCategoryReturn {
const categoriesTree = ref<CategoryTree[]>([] as CategoryTree[]);
export function usePostCategory() {
const categoriesTree = ref<CategoryTreeNode[]>([] as CategoryTreeNode[]);
const {
data: categories,

View File

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

View File

@ -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<CategorySpec, "children"> {
children: CategoryTree[];
export interface CategoryTreeNode extends Category {
children: CategoryTreeNode[];
}
export interface CategoryTree extends Omit<Category, "spec"> {
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<string, CategoryTreeNode> = {};
const parentMap: Record<string, string> = {};
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<string, Category>();
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])
);

View File

@ -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<Ref<CategoryTree[]>>("categoriesTree", categoriesTree);
provide<Ref<CategoryTreeNode[]>>("categoriesTree", categoriesTree);
const selectedCategory = ref<Category | CategoryTree>();
const selectedCategory = ref<Category | CategoryTreeNode>();
provide<Ref<Category | CategoryTree | undefined>>(
provide<Ref<Category | CategoryTreeNode | undefined>>(
"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)) {

View File

@ -1,27 +1,28 @@
<script lang="ts" setup>
import type { CategoryTree } from "@console/modules/contents/posts/categories/utils";
import type { CategoryTreeNode } from "@console/modules/contents/posts/categories/utils";
import type { Category } from "@halo-dev/api-client";
import { IconCheckboxCircle } from "@halo-dev/components";
import { inject, ref, type Ref } from "vue";
withDefaults(
defineProps<{
category: CategoryTree;
category: CategoryTreeNode;
}>(),
{}
);
const isSelected = inject<(category: CategoryTree) => boolean>("isSelected");
const selectedCategory = inject<Ref<Category | CategoryTree | undefined>>(
const isSelected =
inject<(category: CategoryTreeNode) => boolean>("isSelected");
const selectedCategory = inject<Ref<Category | CategoryTreeNode | undefined>>(
"selectedCategory",
ref(undefined)
);
const emit = defineEmits<{
(event: "select", category: CategoryTree): void;
(event: "select", category: CategoryTreeNode): void;
}>();
const onSelect = (childCategory: CategoryTree) => {
const onSelect = (childCategory: CategoryTreeNode) => {
emit("select", childCategory);
};
</script>
@ -54,11 +55,11 @@ const onSelect = (childCategory: CategoryTree) => {
</div>
<ul
v-if="category.spec.children.length > 0"
v-if="category.children.length > 0"
class="my-2.5 ml-2.5 border-l pl-1.5"
>
<CategoryListItem
v-for="(childCategory, index) in category.spec.children"
v-for="(childCategory, index) in category.children"
:key="index"
:category="childCategory"
@select="onSelect"

View File

@ -1,6 +1,8 @@
<script lang="ts" setup>
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<Ref<CategoryTree[]>>("categoriesTree", ref([]));
const categoriesTree = inject<Ref<CategoryTreeNode[]>>(
"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(" / ");
});
</script>

View File

@ -1,7 +1,7 @@
<script lang="ts" setup>
import {
getCategoryPath,
type CategoryTree,
type CategoryTreeNode,
} from "@console/modules/contents/posts/categories/utils";
import type { Category } from "@halo-dev/api-client";
import { IconCheckboxCircle } from "@halo-dev/components";
@ -14,17 +14,20 @@ const props = withDefaults(
{}
);
const categoriesTree = inject<Ref<CategoryTree[]>>("categoriesTree", ref([]));
const selectedCategory = inject<Ref<Category | CategoryTree | undefined>>(
const categoriesTree = inject<Ref<CategoryTreeNode[]>>(
"categoriesTree",
ref([])
);
const selectedCategory = inject<Ref<Category | CategoryTreeNode | undefined>>(
"selectedCategory",
ref(undefined)
);
const isSelected =
inject<(category: Category | CategoryTree) => boolean>("isSelected");
inject<(category: Category | CategoryTreeNode) => boolean>("isSelected");
const emit = defineEmits<{
(event: "select", category: CategoryTree | Category): void;
(event: "select", category: CategoryTreeNode | Category): void;
}>();
const label = computed(() => {
@ -33,7 +36,7 @@ const label = computed(() => {
props.category.metadata.name
);
return categories
?.map((category: CategoryTree) => category.spec.displayName)
?.map((category: CategoryTreeNode) => category.spec.displayName)
.join(" / ");
});
</script>