feat: add full-functional post category select component for formkit (#818)

#### What type of PR is this?

/kind feature

#### What this PR does / why we need it:

添加功能更为全面的文章分类选择器,支持以下特性:

1. 按层级展示分类
2. 支持搜索
3. 选中结果支持显示层级

todo list:

- [x] 样式整理
- [x] 支持创建新分类

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/2670
Fixes https://github.com/halo-dev/halo/issues/2485

#### Screenshots:

<img width="832" alt="image" src="https://user-images.githubusercontent.com/21301288/211768419-087d9727-1468-41a1-868a-62d90eef9cca.png">
<img width="858" alt="image" src="https://user-images.githubusercontent.com/21301288/211768478-dcc70d79-127b-42b0-ae44-e48a6a22273a.png">


#### Special notes for your reviewer:

#### Does this PR introduce a user-facing change?

```release-note
重构 Console 端的文章分类选择器。
```
pull/826/head
Ryan Wang 2023-01-16 16:02:13 +08:00 committed by GitHub
parent b132a950a8
commit 9d5529b827
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 583 additions and 26 deletions

View File

@ -20,6 +20,8 @@
- `postSelect`:选择文章
- `singlePageSelect`:选择自定义页面
- `categorySelect`:选择分类
- 参数
1. multiple: 是否多选,默认为 `false`
- `categoryCheckbox`:选择多个分类
- `tagSelect`:选择标签
- 参数

View File

@ -11,10 +11,10 @@ import { menuRadio } from "./inputs/menu-radio";
import { menuItemSelect } from "./inputs/menu-item-select";
import { postSelect } from "./inputs/post-select";
import { singlePageSelect } from "./inputs/singlePage-select";
import { tagSelect } from "./inputs/tag-select";
import { categorySelect } from "./inputs/category-select";
import { categoryCheckbox } from "./inputs/category-checkbox";
import { tagCheckbox } from "./inputs/tag-checkbox";
import { tagSelect } from "./inputs/tag-select/index";
import radioAlt from "./plugins/radio-alt";
import stopImplicitSubmission from "./plugins/stop-implicit-submission";

View File

@ -1,24 +0,0 @@
import { apiClient } from "@/utils/api-client";
import type { FormKitNode, FormKitTypeDefinition } from "@formkit/core";
import { select, selects, defaultIcon } from "@formkit/inputs";
function optionsHandler(node: FormKitNode) {
node.on("created", async () => {
const { data } =
await apiClient.extension.category.listcontentHaloRunV1alpha1Category();
node.props.options = data.items.map((category) => {
return {
value: category.metadata.name,
label: category.spec.displayName,
};
});
});
}
export const categorySelect: FormKitTypeDefinition = {
...select,
props: ["placeholder"],
forceTypeProp: "select",
features: [optionsHandler, selects, defaultIcon("select", "select")],
};

View File

@ -0,0 +1,315 @@
<script lang="ts" setup>
import type { FormKitFrameworkContext } from "@formkit/core";
import type { Category } from "@halo-dev/api-client";
import { computed, provide, ref, watch, type PropType, type Ref } from "vue";
import { IconArrowRight } from "@halo-dev/components";
import { usePostCategory } from "@/modules/contents/posts/categories/composables/use-post-category";
import type { CategoryTree } from "@/modules/contents/posts/categories/utils";
import { convertTreeToCategories } from "@/modules/contents/posts/categories/utils";
import CategoryListItem from "./components/CategoryListItem.vue";
import { onClickOutside } from "@vueuse/core";
import Fuse from "fuse.js";
import CategoryTag from "./components/CategoryTag.vue";
import SearchResultListItem from "./components/SearchResultListItem.vue";
import { apiClient } from "@/utils/api-client";
import { usePermission } from "@/utils/permission";
const { currentUserHasPermission } = usePermission();
const props = defineProps({
context: {
type: Object as PropType<FormKitFrameworkContext>,
required: true,
},
});
const multiple = computed(() => {
const { multiple } = props.context;
if (multiple === undefined) {
return false;
}
if (typeof multiple === "boolean") {
return multiple;
}
return multiple === "true";
});
const { categories, categoriesTree, handleFetchCategories } = usePostCategory({
fetchOnMounted: true,
});
provide<Ref<CategoryTree[]>>("categoriesTree", categoriesTree);
const selectedCategory = ref<Category | CategoryTree>();
provide<Ref<Category | CategoryTree | undefined>>(
"selectedCategory",
selectedCategory
);
const dropdownVisible = ref(false);
const text = ref("");
const wrapperRef = ref<HTMLElement>();
onClickOutside(wrapperRef, () => {
dropdownVisible.value = false;
});
// search
let fuse: Fuse<Category> | undefined = undefined;
const searchResults = computed(() => {
if (!text.value) {
return categories.value;
}
return fuse?.search(text.value).map((item) => item.item) || [];
});
watch(
() => searchResults.value,
(value) => {
if (value?.length > 0 && text.value) {
selectedCategory.value = value[0];
scrollToSelected();
} else {
selectedCategory.value = undefined;
}
}
);
watch(
() => categories.value,
() => {
fuse = new Fuse(categories.value, {
keys: ["spec.displayName", "spec.slug"],
useExtendedSearch: true,
threshold: 0.2,
});
}
);
const selectedCategories = computed(() => {
if (multiple.value) {
const currentValue = props.context._value || [];
return currentValue
.map((categoryName): Category | undefined => {
return categories.value.find(
(category) => category.metadata.name === categoryName
);
})
.filter(Boolean) as Category[];
}
const category = categories.value.find(
(category) => category.metadata.name === props.context._value
);
return [category].filter(Boolean) as Category[];
});
const isSelected = (category: CategoryTree | Category) => {
if (multiple.value) {
return (props.context._value || []).includes(category.metadata.name);
}
return props.context._value === category.metadata.name;
};
provide<(category: CategoryTree | Category) => boolean>(
"isSelected",
isSelected
);
const handleSelect = (category: CategoryTree | Category) => {
if (multiple.value) {
const currentValue = props.context._value || [];
if (currentValue.includes(category.metadata.name)) {
props.context.node.input(
currentValue.filter((name: string) => name !== category.metadata.name)
);
} else {
props.context.node.input([...currentValue, category.metadata.name]);
}
return;
}
props.context.node.input(
category.metadata.name === props.context._value
? ""
: category.metadata.name
);
};
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
const categoryIndices = text.value
? searchResults.value
: convertTreeToCategories(categoriesTree.value);
const index = categoryIndices.findIndex(
(category) =>
category.metadata.name === selectedCategory.value?.metadata.name
);
if (index < searchResults.value.length - 1) {
selectedCategory.value = categoryIndices[index + 1];
}
scrollToSelected();
}
if (e.key === "ArrowUp") {
e.preventDefault();
const categoryIndices = text.value
? searchResults.value
: convertTreeToCategories(categoriesTree.value);
const index = categoryIndices.findIndex(
(tag) => tag.metadata.name === selectedCategory.value?.metadata.name
);
if (index > 0) {
selectedCategory.value = categoryIndices[index - 1];
}
scrollToSelected();
}
if (e.key === "Enter") {
if (searchResults.value.length === 0 && text.value) {
handleCreateCategory();
return;
}
if (selectedCategory.value) {
handleSelect(selectedCategory.value);
text.value = "";
e.preventDefault();
}
}
};
const scrollToSelected = () => {
if (!selectedCategory.value) {
return;
}
const selectedNode = document.getElementById(
`category-${selectedCategory.value?.metadata.name}`
);
if (selectedNode) {
selectedNode.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "start",
});
}
};
const handleCreateCategory = async () => {
if (!currentUserHasPermission(["system:posts:manage"])) {
return;
}
const { data } =
await apiClient.extension.category.createcontentHaloRunV1alpha1Category({
category: {
spec: {
displayName: text.value,
slug: text.value,
description: "",
cover: "",
template: "",
priority: categories.value.length + 1,
children: [],
},
apiVersion: "content.halo.run/v1alpha1",
kind: "Category",
metadata: {
name: "",
generateName: "category-",
},
},
});
handleFetchCategories();
handleSelect(data);
text.value = "";
};
// update value immediately during IME composition
// please see https://vuejs.org//guide/essentials/forms.html#text
const onTextInput = (e: Event) => {
text.value = (e.target as HTMLInputElement).value;
};
// delete last category when text input is empty
const handleDelete = () => {
if (!text.value) {
if (multiple.value) {
const selectedTagNames = (props.context._value as string[]) || [];
props.context.node.input(selectedTagNames.slice(0, -1));
return;
}
props.context.node.input("");
}
};
</script>
<template>
<div
ref="wrapperRef"
:class="context.classes['post-categories-wrapper']"
@keydown="handleKeydown"
>
<div :class="context.classes['post-categories']">
<CategoryTag
v-for="(category, index) in selectedCategories"
:key="index"
:category="category"
@select="handleSelect"
/>
<input
:value="text"
:class="context.classes.input"
type="text"
@input="onTextInput"
@focus="dropdownVisible = true"
@keydown.delete="handleDelete"
/>
</div>
<div
:class="context.classes['post-categories-button']"
@click="dropdownVisible = !dropdownVisible"
>
<IconArrowRight class="rotate-90 text-gray-500 hover:text-gray-700" />
</div>
<div v-if="dropdownVisible" :class="context.classes['dropdown-wrapper']">
<ul class="p-1">
<li
v-if="text.trim() && searchResults.length <= 0"
v-permission="['system:posts:manage']"
class="group flex cursor-pointer items-center justify-between rounded bg-gray-100 p-2"
>
<span class="text-xs text-gray-700 group-hover:text-gray-900">
创建 {{ text }} 分类
</span>
</li>
<template v-if="text">
<SearchResultListItem
v-for="category in searchResults"
:key="category.metadata.name"
:category="category"
@select="handleSelect"
/>
</template>
<template v-else>
<CategoryListItem
v-for="category in categoriesTree"
:key="category.metadata.name"
:category="category"
@select="handleSelect"
/>
</template>
</ul>
</div>
</div>
</template>

View File

@ -0,0 +1,68 @@
<script lang="ts" setup>
import type { CategoryTree } from "@/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;
}>(),
{}
);
const isSelected = inject<(category: CategoryTree) => boolean>("isSelected");
const selectedCategory = inject<Ref<Category | CategoryTree | undefined>>(
"selectedCategory",
ref(undefined)
);
const emit = defineEmits<{
(event: "select", category: CategoryTree): void;
}>();
const onSelect = (childCategory: CategoryTree) => {
emit("select", childCategory);
};
</script>
<template>
<li :id="`category-${category.metadata.name}`">
<div
class="flex cursor-pointer items-center justify-between rounded p-2 hover:bg-gray-100"
:class="{
'bg-gray-100':
selectedCategory?.metadata.name === category.metadata.name,
}"
@click="emit('select', category)"
>
<span
class="flex-1 truncate text-xs text-gray-700 group-hover:text-gray-900"
:class="{
'text-gray-900':
isSelected?.(category) &&
selectedCategory?.metadata.name === category.metadata.name,
}"
>
{{ category.spec.displayName }}
</span>
<IconCheckboxCircle
class="text-primary opacity-0"
:class="{ 'opacity-100': isSelected?.(category) }"
/>
</div>
<ul
v-if="category.spec.children.length > 0"
class="my-2.5 ml-2.5 border-l pl-1.5"
>
<CategoryListItem
v-for="(childCategory, index) in category.spec.children"
:key="index"
:category="childCategory"
@select="onSelect"
/>
</ul>
</li>
</template>

View File

@ -0,0 +1,46 @@
<script lang="ts" setup>
import type { CategoryTree } from "@/modules/contents/posts/categories/utils";
import { getCategoryPath } from "@/modules/contents/posts/categories/utils";
import type { Category } from "@halo-dev/api-client";
import { computed, inject, ref, type Ref } from "vue";
import { IconClose } from "@halo-dev/components";
const props = withDefaults(
defineProps<{
category: Category;
}>(),
{}
);
const emit = defineEmits<{
(event: "select", category: CategoryTree | Category): void;
}>();
const categoriesTree = inject<Ref<CategoryTree[]>>("categoriesTree", ref([]));
const label = computed(() => {
const categories = getCategoryPath(
categoriesTree.value,
props.category.metadata.name
);
return categories
.map((category: CategoryTree) => category.spec.displayName)
.join(" / ");
});
</script>
<template>
<div class="inline-flex items-center p-1">
<div
class="box-border inline-flex min-h-[1.25rem] items-center gap-1 rounded-full border border-solid border-[#d9d9d9] bg-white px-1 align-middle"
>
<span class="flex-1 text-xs">
{{ label }}
</span>
<IconClose
class="h-4 w-4 cursor-pointer text-gray-600 hover:text-gray-900"
@click="emit('select', category)"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,67 @@
<script lang="ts" setup>
import {
getCategoryPath,
type CategoryTree,
} from "@/modules/contents/posts/categories/utils";
import type { Category } from "@halo-dev/api-client";
import { IconCheckboxCircle } from "@halo-dev/components";
import { computed, inject, ref, type Ref } from "vue";
const props = withDefaults(
defineProps<{
category: Category;
}>(),
{}
);
const categoriesTree = inject<Ref<CategoryTree[]>>("categoriesTree", ref([]));
const selectedCategory = inject<Ref<Category | CategoryTree | undefined>>(
"selectedCategory",
ref(undefined)
);
const isSelected =
inject<(category: Category | CategoryTree) => boolean>("isSelected");
const emit = defineEmits<{
(event: "select", category: CategoryTree | Category): void;
}>();
const label = computed(() => {
const categories = getCategoryPath(
categoriesTree.value,
props.category.metadata.name
);
return categories
.map((category: CategoryTree) => category.spec.displayName)
.join(" / ");
});
</script>
<template>
<li :id="`category-${category.metadata.name}`">
<div
class="flex cursor-pointer items-center justify-between rounded p-2 hover:bg-gray-100"
:class="{
'bg-gray-100':
selectedCategory?.metadata.name === category.metadata.name,
}"
@click="emit('select', category)"
>
<span
class="flex-1 truncate text-xs text-gray-700 group-hover:text-gray-900"
:class="{
'text-gray-900':
isSelected?.(category) &&
selectedCategory?.metadata.name === category.metadata.name,
}"
>
{{ label }}
</span>
<IconCheckboxCircle
class="h-5 w-5 text-primary opacity-0"
:class="{ 'opacity-100': isSelected?.(category) }"
/>
</div>
</li>
</template>

View File

@ -0,0 +1,37 @@
import type { FormKitTypeDefinition } from "@formkit/core";
import {
help,
icon,
inner,
label,
message,
messages,
outer,
prefix,
suffix,
wrapper,
} from "@formkit/inputs";
import CategorySelect from "./CategorySelect.vue";
import { CategorySelectSection } from "./sections";
export const categorySelect: FormKitTypeDefinition = {
schema: outer(
wrapper(
label("$label"),
inner(
icon("prefix"),
prefix(),
CategorySelectSection(),
suffix(),
icon("suffix")
)
),
help("$help"),
messages(message("$message.value"))
),
type: "input",
props: ["multiple"],
library: {
CategorySelect: CategorySelect,
},
};

View File

@ -0,0 +1,11 @@
import { createSection } from "@formkit/inputs";
export const CategorySelectSection = createSection(
"CategorySelectSection",
() => ({
$cmp: "CategorySelect",
props: {
context: "$node.context",
},
})
);

View File

@ -104,6 +104,17 @@ const theme: Record<string, Record<string, string>> = {
"dropdown-wrapper":
"absolute ring-1 ring-white top-full bottom-auto right-0 z-10 mt-1 max-h-96 w-full overflow-auto rounded bg-white shadow-lg",
},
categorySelect: {
...textClassification,
inner: `${textClassification.inner} !overflow-visible !h-auto min-h-[2.25rem]`,
input: `w-0 flex-grow outline-0 bg-transparent py-1 px-3 block transition-all appearance-none text-sm antialiased`,
"post-categories-wrapper": "flex w-full items-center",
"post-categories": "flex w-full flex-wrap items-center",
"post-categories-button":
"inline-flex h-full cursor-pointer items-center px-1",
"dropdown-wrapper":
"absolute ring-1 ring-white top-full bottom-auto right-0 z-10 mt-1 max-h-96 w-full overflow-auto rounded bg-white shadow-lg",
},
};
export default theme;

View File

@ -121,3 +121,26 @@ export function convertCategoryTreeToCategory(
},
};
}
export const getCategoryPath = (
categories: CategoryTree[],
name: string,
path: CategoryTree[] = []
) => {
for (const category of categories) {
if (category.metadata && category.metadata.name === name) {
return path.concat([category]);
}
if (category.spec && category.spec.children) {
const found = getCategoryPath(
category.spec.children,
name,
path.concat([category])
);
if (found) {
return found;
}
}
}
};

View File

@ -270,7 +270,8 @@ const annotationsFormRef = ref<InstanceType<typeof AnnotationsForm>>();
v-model="formState.spec.categories"
label="分类目录"
name="categories"
type="categoryCheckbox"
type="categorySelect"
:multiple="true"
/>
<FormKit
v-model="formState.spec.tags"