mirror of https://github.com/halo-dev/halo
refactor: improve drag-and-drop sorting feature for categories (#7424)
Signed-off-by: Ryan Wang <i@ryanc.cc>pull/7433/head
parent
18105cbe44
commit
6cc7cf6d22
|
@ -1,9 +1,5 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
// core libs
|
|
||||||
import { coreApiClient } from "@halo-dev/api-client";
|
import { coreApiClient } from "@halo-dev/api-client";
|
||||||
import { ref } from "vue";
|
|
||||||
|
|
||||||
// components
|
|
||||||
import {
|
import {
|
||||||
IconAddCircle,
|
IconAddCircle,
|
||||||
IconBookRead,
|
IconBookRead,
|
||||||
|
@ -14,16 +10,13 @@ import {
|
||||||
VPageHeader,
|
VPageHeader,
|
||||||
VSpace,
|
VSpace,
|
||||||
} from "@halo-dev/components";
|
} 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 CategoryEditingModal from "./components/CategoryEditingModal.vue";
|
||||||
import CategoryListItem from "./components/CategoryListItem.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 { usePostCategory } from "./composables/use-post-category";
|
||||||
|
import { convertTreeToCategories, resetCategoriesTreePriority } from "./utils";
|
||||||
|
|
||||||
const creationModal = ref(false);
|
const creationModal = ref(false);
|
||||||
|
|
||||||
|
@ -31,8 +24,9 @@ const { categories, categoriesTree, isLoading, handleFetchCategories } =
|
||||||
usePostCategory();
|
usePostCategory();
|
||||||
|
|
||||||
const batchUpdating = ref(false);
|
const batchUpdating = ref(false);
|
||||||
|
const isDragging = ref(false);
|
||||||
|
|
||||||
const handleUpdateInBatch = useDebounceFn(async () => {
|
async function handleUpdateInBatch() {
|
||||||
const categoriesTreeToUpdate = resetCategoriesTreePriority(
|
const categoriesTreeToUpdate = resetCategoriesTreePriority(
|
||||||
categoriesTree.value
|
categoriesTree.value
|
||||||
);
|
);
|
||||||
|
@ -62,8 +56,9 @@ const handleUpdateInBatch = useDebounceFn(async () => {
|
||||||
} finally {
|
} finally {
|
||||||
await handleFetchCategories();
|
await handleFetchCategories();
|
||||||
batchUpdating.value = false;
|
batchUpdating.value = false;
|
||||||
|
isDragging.value = false;
|
||||||
}
|
}
|
||||||
}, 300);
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<CategoryEditingModal v-if="creationModal" @close="creationModal = false" />
|
<CategoryEditingModal v-if="creationModal" @close="creationModal = false" />
|
||||||
|
@ -130,14 +125,33 @@ const handleUpdateInBatch = useDebounceFn(async () => {
|
||||||
</VEmpty>
|
</VEmpty>
|
||||||
</Transition>
|
</Transition>
|
||||||
<Transition v-else appear name="fade">
|
<Transition v-else appear name="fade">
|
||||||
<CategoryListItem
|
<Draggable
|
||||||
v-model="categoriesTree"
|
v-model="categoriesTree"
|
||||||
:class="{
|
:class="{
|
||||||
'cursor-progress opacity-60': batchUpdating,
|
'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>
|
</Transition>
|
||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<style>
|
||||||
|
.vtlist-inner {
|
||||||
|
@apply divide-y divide-gray-100;
|
||||||
|
}
|
||||||
|
.he-tree-drag-placeholder {
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -1,9 +1,4 @@
|
||||||
<script lang="ts" setup>
|
<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 SubmitButton from "@/components/button/SubmitButton.vue";
|
||||||
import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
|
import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
|
||||||
import { setFocus } from "@/formkit/utils/focus";
|
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 { useThemeCustomTemplates } from "@console/modules/interface/themes/composables/use-theme";
|
||||||
import { reset, submitForm } from "@formkit/core";
|
import { reset, submitForm } from "@formkit/core";
|
||||||
import type { Category } from "@halo-dev/api-client";
|
import type { Category } from "@halo-dev/api-client";
|
||||||
|
import { coreApiClient } from "@halo-dev/api-client";
|
||||||
import {
|
import {
|
||||||
IconRefreshLine,
|
IconRefreshLine,
|
||||||
Toast,
|
Toast,
|
||||||
|
@ -21,6 +17,7 @@ import {
|
||||||
} from "@halo-dev/components";
|
} from "@halo-dev/components";
|
||||||
import { useQueryClient } from "@tanstack/vue-query";
|
import { useQueryClient } from "@tanstack/vue-query";
|
||||||
import { cloneDeep } from "lodash-es";
|
import { cloneDeep } from "lodash-es";
|
||||||
|
import { computed, nextTick, onMounted, ref } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
|
@ -186,7 +183,13 @@ const { handleGenerateSlug } = useSlugify(
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<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
|
<FormKit
|
||||||
id="category-form"
|
id="category-form"
|
||||||
type="form"
|
type="form"
|
||||||
|
|
|
@ -1,46 +1,63 @@
|
||||||
<script lang="ts" setup>
|
<script setup lang="ts">
|
||||||
import { formatDatetime } from "@/utils/date";
|
import { formatDatetime } from "@/utils/date";
|
||||||
import { usePermission } from "@/utils/permission";
|
import { usePermission } from "@/utils/permission";
|
||||||
import type { Category } from "@halo-dev/api-client";
|
import { coreApiClient, type Category } from "@halo-dev/api-client";
|
||||||
import { coreApiClient } from "@halo-dev/api-client";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
IconList,
|
IconList,
|
||||||
|
IconMore,
|
||||||
Toast,
|
Toast,
|
||||||
|
VDropdown,
|
||||||
VDropdownItem,
|
VDropdownItem,
|
||||||
VEntity,
|
|
||||||
VEntityField,
|
|
||||||
VStatusDot,
|
VStatusDot,
|
||||||
} from "@halo-dev/components";
|
} from "@halo-dev/components";
|
||||||
|
import "@he-tree/vue/style/default.css";
|
||||||
import { useQueryClient } from "@tanstack/vue-query";
|
import { useQueryClient } from "@tanstack/vue-query";
|
||||||
import type { PropType } from "vue";
|
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { VueDraggable } from "vue-draggable-plus";
|
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import GridiconsLinkBreak from "~icons/gridicons/link-break";
|
import GridiconsLinkBreak from "~icons/gridicons/link-break";
|
||||||
import { convertCategoryTreeToCategory, type CategoryTree } from "../utils";
|
import { convertCategoryTreeToCategory, type CategoryTreeNode } from "../utils";
|
||||||
import CategoryEditingModal from "./CategoryEditingModal.vue";
|
import CategoryEditingModal from "./CategoryEditingModal.vue";
|
||||||
|
|
||||||
const { currentUserHasPermission } = usePermission();
|
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 { t } = useI18n();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
function onChange() {
|
const props = withDefaults(
|
||||||
emit("change");
|
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
|
// Editing category
|
||||||
const editingModal = ref(false);
|
const editingModal = ref(false);
|
||||||
|
@ -53,49 +70,101 @@ function onEditingModalClose() {
|
||||||
editingModal.value = false;
|
editingModal.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenEditingModal = (category: CategoryTree) => {
|
const handleOpenEditingModal = () => {
|
||||||
selectedCategory.value = convertCategoryTreeToCategory(category);
|
selectedCategory.value = convertCategoryTreeToCategory(
|
||||||
|
props.categoryTreeNode
|
||||||
|
);
|
||||||
editingModal.value = true;
|
editingModal.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenCreateByParentModal = (category: CategoryTree) => {
|
const handleOpenCreateByParentModal = () => {
|
||||||
selectedParentCategory.value = convertCategoryTreeToCategory(category);
|
selectedParentCategory.value = convertCategoryTreeToCategory(
|
||||||
|
props.categoryTreeNode
|
||||||
|
);
|
||||||
editingModal.value = true;
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<VueDraggable
|
<div
|
||||||
v-model="categories"
|
class="px-4 py-3 hover:bg-gray-50 w-full group items-center flex justify-between relative"
|
||||||
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>
|
||||||
|
<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
|
<CategoryEditingModal
|
||||||
v-if="editingModal"
|
v-if="editingModal"
|
||||||
:is-child-level-category="isChildLevel"
|
:is-child-level-category="isChildLevel"
|
||||||
|
@ -103,104 +172,5 @@ const handleDelete = async (category: CategoryTree) => {
|
||||||
:parent-category="selectedParentCategory"
|
:parent-category="selectedParentCategory"
|
||||||
@close="onEditingModalClose"
|
@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>
|
</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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,20 +1,10 @@
|
||||||
import type { Category } from "@halo-dev/api-client";
|
|
||||||
import { coreApiClient } from "@halo-dev/api-client";
|
import { coreApiClient } from "@halo-dev/api-client";
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
import type { Ref } from "vue";
|
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import type { CategoryTree } from "../utils";
|
import { buildCategoriesTree, type CategoryTreeNode } from "../utils";
|
||||||
import { buildCategoriesTree } from "../utils";
|
|
||||||
|
|
||||||
interface usePostCategoryReturn {
|
export function usePostCategory() {
|
||||||
categories: Ref<Category[] | undefined>;
|
const categoriesTree = ref<CategoryTreeNode[]>([] as CategoryTreeNode[]);
|
||||||
categoriesTree: Ref<CategoryTree[]>;
|
|
||||||
isLoading: Ref<boolean>;
|
|
||||||
handleFetchCategories: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePostCategory(): usePostCategoryReturn {
|
|
||||||
const categoriesTree = ref<CategoryTree[]>([] as CategoryTree[]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: categories,
|
data: categories,
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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";
|
import { cloneDeep } from "lodash-es";
|
||||||
|
|
||||||
export interface CategoryTreeSpec extends Omit<CategorySpec, "children"> {
|
export interface CategoryTreeNode extends Category {
|
||||||
children: CategoryTree[];
|
children: CategoryTreeNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CategoryTree extends Omit<Category, "spec"> {
|
export function buildCategoriesTree(
|
||||||
spec: CategoryTreeSpec;
|
categories: Category[]
|
||||||
}
|
): CategoryTreeNode[] {
|
||||||
|
|
||||||
export function buildCategoriesTree(categories: Category[]): CategoryTree[] {
|
|
||||||
const categoriesToUpdate = cloneDeep(categories);
|
const categoriesToUpdate = cloneDeep(categories);
|
||||||
|
|
||||||
const categoriesMap = {};
|
const categoriesMap: Record<string, CategoryTreeNode> = {};
|
||||||
const parentMap = {};
|
const parentMap: Record<string, string> = {};
|
||||||
|
|
||||||
categoriesToUpdate.forEach((category) => {
|
categoriesToUpdate.forEach((category) => {
|
||||||
categoriesMap[category.metadata.name] = category;
|
categoriesMap[category.metadata.name] = {
|
||||||
// @ts-ignore
|
...category,
|
||||||
|
children: [],
|
||||||
|
} as CategoryTreeNode;
|
||||||
|
|
||||||
|
if (category.spec.children) {
|
||||||
category.spec.children.forEach((child) => {
|
category.spec.children.forEach((child) => {
|
||||||
parentMap[child] = category.metadata.name;
|
parentMap[child] = category.metadata.name;
|
||||||
});
|
});
|
||||||
// @ts-ignore
|
}
|
||||||
category.spec.children = [];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
categoriesToUpdate.forEach((category) => {
|
categoriesToUpdate.forEach((category) => {
|
||||||
const parentName = parentMap[category.metadata.name];
|
const parentName = parentMap[category.metadata.name];
|
||||||
if (parentName && categoriesMap[parentName]) {
|
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
|
(node) => parentMap[node.metadata.name] === undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -40,8 +43,8 @@ export function buildCategoriesTree(categories: Category[]): CategoryTree[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortCategoriesTree(
|
export function sortCategoriesTree(
|
||||||
categoriesTree: CategoryTree[] | Category[]
|
categoriesTree: CategoryTreeNode[]
|
||||||
): CategoryTree[] {
|
): CategoryTreeNode[] {
|
||||||
return categoriesTree
|
return categoriesTree
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (a.spec.priority < b.spec.priority) {
|
if (a.spec.priority < b.spec.priority) {
|
||||||
|
@ -53,13 +56,10 @@ export function sortCategoriesTree(
|
||||||
return 0;
|
return 0;
|
||||||
})
|
})
|
||||||
.map((category) => {
|
.map((category) => {
|
||||||
if (category.spec.children.length) {
|
if (category.children && category.children.length) {
|
||||||
return {
|
return {
|
||||||
...category,
|
...category,
|
||||||
spec: {
|
children: sortCategoriesTree(category.children),
|
||||||
...category.spec,
|
|
||||||
children: sortCategoriesTree(category.spec.children),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return category;
|
return category;
|
||||||
|
@ -67,54 +67,62 @@ export function sortCategoriesTree(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetCategoriesTreePriority(
|
export function resetCategoriesTreePriority(
|
||||||
categoriesTree: CategoryTree[]
|
categoriesTree: CategoryTreeNode[]
|
||||||
): CategoryTree[] {
|
): CategoryTreeNode[] {
|
||||||
for (let i = 0; i < categoriesTree.length; i++) {
|
for (let i = 0; i < categoriesTree.length; i++) {
|
||||||
categoriesTree[i].spec.priority = i;
|
categoriesTree[i].spec.priority = i;
|
||||||
if (categoriesTree[i].spec.children) {
|
if (categoriesTree[i].children && categoriesTree[i].children.length) {
|
||||||
resetCategoriesTreePriority(categoriesTree[i].spec.children);
|
resetCategoriesTreePriority(categoriesTree[i].children);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return categoriesTree;
|
return categoriesTree;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertTreeToCategories(categoriesTree: CategoryTree[]) {
|
export function convertTreeToCategories(categoriesTree: CategoryTreeNode[]) {
|
||||||
const categories: Category[] = [];
|
const categories: Category[] = [];
|
||||||
const categoriesMap = new Map<string, Category>();
|
const categoriesMap = new Map<string, Category>();
|
||||||
const convertCategory = (node: CategoryTree | undefined) => {
|
|
||||||
|
const convertCategory = (node: CategoryTreeNode | undefined) => {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const children = node.spec.children || [];
|
|
||||||
|
const children = node.children || [];
|
||||||
|
|
||||||
categoriesMap.set(node.metadata.name, {
|
categoriesMap.set(node.metadata.name, {
|
||||||
...node,
|
...node,
|
||||||
spec: {
|
spec: {
|
||||||
...node.spec,
|
...node.spec,
|
||||||
// @ts-ignore
|
|
||||||
children: children.map((child) => child.metadata.name),
|
children: children.map((child) => child.metadata.name),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
children.forEach((child) => {
|
children.forEach((child) => {
|
||||||
convertCategory(child);
|
convertCategory(child);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
categoriesTree.forEach((node) => {
|
categoriesTree.forEach((node) => {
|
||||||
convertCategory(node);
|
convertCategory(node);
|
||||||
});
|
});
|
||||||
|
|
||||||
categoriesMap.forEach((node) => {
|
categoriesMap.forEach((node) => {
|
||||||
categories.push(node);
|
categories.push(node);
|
||||||
});
|
});
|
||||||
|
|
||||||
return categories;
|
return categories;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertCategoryTreeToCategory(
|
export function convertCategoryTreeToCategory(
|
||||||
categoryTree: CategoryTree
|
categoryTree: CategoryTreeNode
|
||||||
): Category {
|
): Category {
|
||||||
const childNames = categoryTree.spec.children.map(
|
const childNames = categoryTree.children.map((child) => child.metadata.name);
|
||||||
(child) => child.metadata.name
|
|
||||||
);
|
// eslint-disable-next-line
|
||||||
|
const { children: _, ...categoryWithoutChildren } = categoryTree;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...categoryTree,
|
...categoryWithoutChildren,
|
||||||
spec: {
|
spec: {
|
||||||
...categoryTree.spec,
|
...categoryTree.spec,
|
||||||
children: childNames,
|
children: childNames,
|
||||||
|
@ -123,18 +131,18 @@ export function convertCategoryTreeToCategory(
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getCategoryPath = (
|
export const getCategoryPath = (
|
||||||
categories: CategoryTree[],
|
categories: CategoryTreeNode[],
|
||||||
name: string,
|
name: string,
|
||||||
path: CategoryTree[] = []
|
path: CategoryTreeNode[] = []
|
||||||
): CategoryTree[] | undefined => {
|
): CategoryTreeNode[] | undefined => {
|
||||||
for (const category of categories) {
|
for (const category of categories) {
|
||||||
if (category.metadata && category.metadata.name === name) {
|
if (category.metadata && category.metadata.name === name) {
|
||||||
return path.concat([category]);
|
return path.concat([category]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (category.spec && category.spec.children) {
|
if (category.children && category.children.length) {
|
||||||
const found = getCategoryPath(
|
const found = getCategoryPath(
|
||||||
category.spec.children,
|
category.children,
|
||||||
name,
|
name,
|
||||||
path.concat([category])
|
path.concat([category])
|
||||||
);
|
);
|
||||||
|
|
|
@ -2,8 +2,10 @@
|
||||||
import HasPermission from "@/components/permission/HasPermission.vue";
|
import HasPermission from "@/components/permission/HasPermission.vue";
|
||||||
import { usePermission } from "@/utils/permission";
|
import { usePermission } from "@/utils/permission";
|
||||||
import { usePostCategory } from "@console/modules/contents/posts/categories/composables/use-post-category";
|
import { usePostCategory } from "@console/modules/contents/posts/categories/composables/use-post-category";
|
||||||
import type { CategoryTree } from "@console/modules/contents/posts/categories/utils";
|
import {
|
||||||
import { convertTreeToCategories } from "@console/modules/contents/posts/categories/utils";
|
convertTreeToCategories,
|
||||||
|
type CategoryTreeNode,
|
||||||
|
} from "@console/modules/contents/posts/categories/utils";
|
||||||
import type { FormKitFrameworkContext } from "@formkit/core";
|
import type { FormKitFrameworkContext } from "@formkit/core";
|
||||||
import type { Category } from "@halo-dev/api-client";
|
import type { Category } from "@halo-dev/api-client";
|
||||||
import { coreApiClient } from "@halo-dev/api-client";
|
import { coreApiClient } from "@halo-dev/api-client";
|
||||||
|
@ -38,11 +40,11 @@ const multiple = computed(() => {
|
||||||
|
|
||||||
const { categories, categoriesTree, handleFetchCategories } = usePostCategory();
|
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",
|
||||||
selectedCategory
|
selectedCategory
|
||||||
);
|
);
|
||||||
|
@ -109,19 +111,19 @@ const selectedCategories = computed(() => {
|
||||||
return [category].filter(Boolean) as Category[];
|
return [category].filter(Boolean) as Category[];
|
||||||
});
|
});
|
||||||
|
|
||||||
const isSelected = (category: CategoryTree | Category) => {
|
const isSelected = (category: CategoryTreeNode | Category) => {
|
||||||
if (multiple.value) {
|
if (multiple.value) {
|
||||||
return (props.context._value || []).includes(category.metadata.name);
|
return (props.context._value || []).includes(category.metadata.name);
|
||||||
}
|
}
|
||||||
return props.context._value === category.metadata.name;
|
return props.context._value === category.metadata.name;
|
||||||
};
|
};
|
||||||
|
|
||||||
provide<(category: CategoryTree | Category) => boolean>(
|
provide<(category: CategoryTreeNode | Category) => boolean>(
|
||||||
"isSelected",
|
"isSelected",
|
||||||
isSelected
|
isSelected
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelect = (category: CategoryTree | Category) => {
|
const handleSelect = (category: CategoryTreeNode | Category) => {
|
||||||
if (multiple.value) {
|
if (multiple.value) {
|
||||||
const currentValue = props.context._value || [];
|
const currentValue = props.context._value || [];
|
||||||
if (currentValue.includes(category.metadata.name)) {
|
if (currentValue.includes(category.metadata.name)) {
|
||||||
|
|
|
@ -1,27 +1,28 @@
|
||||||
<script lang="ts" setup>
|
<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 type { Category } from "@halo-dev/api-client";
|
||||||
import { IconCheckboxCircle } from "@halo-dev/components";
|
import { IconCheckboxCircle } from "@halo-dev/components";
|
||||||
import { inject, ref, type Ref } from "vue";
|
import { inject, ref, type Ref } from "vue";
|
||||||
|
|
||||||
withDefaults(
|
withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
category: CategoryTree;
|
category: CategoryTreeNode;
|
||||||
}>(),
|
}>(),
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
|
||||||
const isSelected = inject<(category: CategoryTree) => boolean>("isSelected");
|
const isSelected =
|
||||||
const selectedCategory = inject<Ref<Category | CategoryTree | undefined>>(
|
inject<(category: CategoryTreeNode) => boolean>("isSelected");
|
||||||
|
const selectedCategory = inject<Ref<Category | CategoryTreeNode | undefined>>(
|
||||||
"selectedCategory",
|
"selectedCategory",
|
||||||
ref(undefined)
|
ref(undefined)
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: "select", category: CategoryTree): void;
|
(event: "select", category: CategoryTreeNode): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const onSelect = (childCategory: CategoryTree) => {
|
const onSelect = (childCategory: CategoryTreeNode) => {
|
||||||
emit("select", childCategory);
|
emit("select", childCategory);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -54,11 +55,11 @@ const onSelect = (childCategory: CategoryTree) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul
|
<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"
|
class="my-2.5 ml-2.5 border-l pl-1.5"
|
||||||
>
|
>
|
||||||
<CategoryListItem
|
<CategoryListItem
|
||||||
v-for="(childCategory, index) in category.spec.children"
|
v-for="(childCategory, index) in category.children"
|
||||||
:key="index"
|
:key="index"
|
||||||
:category="childCategory"
|
:category="childCategory"
|
||||||
@select="onSelect"
|
@select="onSelect"
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { CategoryTree } from "@console/modules/contents/posts/categories/utils";
|
import {
|
||||||
import { getCategoryPath } from "@console/modules/contents/posts/categories/utils";
|
getCategoryPath,
|
||||||
|
type CategoryTreeNode,
|
||||||
|
} from "@console/modules/contents/posts/categories/utils";
|
||||||
import type { Category } from "@halo-dev/api-client";
|
import type { Category } from "@halo-dev/api-client";
|
||||||
import { IconClose } from "@halo-dev/components";
|
import { IconClose } from "@halo-dev/components";
|
||||||
import { computed, inject, ref, type Ref } from "vue";
|
import { computed, inject, ref, type Ref } from "vue";
|
||||||
|
@ -13,10 +15,13 @@ const props = withDefaults(
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
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 label = computed(() => {
|
||||||
const categories = getCategoryPath(
|
const categories = getCategoryPath(
|
||||||
|
@ -24,7 +29,7 @@ const label = computed(() => {
|
||||||
props.category.metadata.name
|
props.category.metadata.name
|
||||||
);
|
);
|
||||||
return categories
|
return categories
|
||||||
?.map((category: CategoryTree) => category.spec.displayName)
|
?.map((category: CategoryTreeNode) => category.spec.displayName)
|
||||||
.join(" / ");
|
.join(" / ");
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {
|
import {
|
||||||
getCategoryPath,
|
getCategoryPath,
|
||||||
type CategoryTree,
|
type CategoryTreeNode,
|
||||||
} from "@console/modules/contents/posts/categories/utils";
|
} from "@console/modules/contents/posts/categories/utils";
|
||||||
import type { Category } from "@halo-dev/api-client";
|
import type { Category } from "@halo-dev/api-client";
|
||||||
import { IconCheckboxCircle } from "@halo-dev/components";
|
import { IconCheckboxCircle } from "@halo-dev/components";
|
||||||
|
@ -14,17 +14,20 @@ const props = withDefaults(
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
|
||||||
const categoriesTree = inject<Ref<CategoryTree[]>>("categoriesTree", ref([]));
|
const categoriesTree = inject<Ref<CategoryTreeNode[]>>(
|
||||||
const selectedCategory = inject<Ref<Category | CategoryTree | undefined>>(
|
"categoriesTree",
|
||||||
|
ref([])
|
||||||
|
);
|
||||||
|
const selectedCategory = inject<Ref<Category | CategoryTreeNode | undefined>>(
|
||||||
"selectedCategory",
|
"selectedCategory",
|
||||||
ref(undefined)
|
ref(undefined)
|
||||||
);
|
);
|
||||||
|
|
||||||
const isSelected =
|
const isSelected =
|
||||||
inject<(category: Category | CategoryTree) => boolean>("isSelected");
|
inject<(category: Category | CategoryTreeNode) => boolean>("isSelected");
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: "select", category: CategoryTree | Category): void;
|
(event: "select", category: CategoryTreeNode | Category): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const label = computed(() => {
|
const label = computed(() => {
|
||||||
|
@ -33,7 +36,7 @@ const label = computed(() => {
|
||||||
props.category.metadata.name
|
props.category.metadata.name
|
||||||
);
|
);
|
||||||
return categories
|
return categories
|
||||||
?.map((category: CategoryTree) => category.spec.displayName)
|
?.map((category: CategoryTreeNode) => category.spec.displayName)
|
||||||
.join(" / ");
|
.join(" / ");
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
Loading…
Reference in New Issue