feat: add full-functional post tag select component for formkit (halo-dev/console#817)

#### 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

#### Screenshots:

<img width="796" alt="image" src="https://user-images.githubusercontent.com/21301288/211768172-b8dbf1c2-5f7c-4411-b8cc-f0070320649d.png">
<img width="725" alt="image" src="https://user-images.githubusercontent.com/21301288/211768235-8d587374-abad-40c9-b9e0-ea2b27e58495.png">


#### Special notes for your reviewer:

1. 创建若干文章,并进行标签设置的操作。
2. 测试标签选择,搜索,创建,删除,取消选择等操作。

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


```release-note
重构 Console 端的文章标签选择器。
```
pull/3445/head
Ryan Wang 2023-01-16 10:58:13 +08:00 committed by GitHub
parent c06affb46b
commit e0df9ade6e
9 changed files with 378 additions and 26 deletions

View File

@ -22,6 +22,8 @@
- `categorySelect`:选择分类
- `categoryCheckbox`:选择多个分类
- `tagSelect`:选择标签
- 参数
1. multiple: 是否多选,默认为 `false`
- `tagCheckbox`:选择多个标签
在 Vue 单组件中使用:

View File

@ -12,9 +12,9 @@ import { menuItemSelect } from "./inputs/menu-item-select";
import { postSelect } from "./inputs/post-select";
import { singlePageSelect } from "./inputs/singlePage-select";
import { categorySelect } from "./inputs/category-select";
import { tagSelect } from "./inputs/tag-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.tag.listcontentHaloRunV1alpha1Tag();
node.props.options = data.items.map((tag) => {
return {
value: tag.metadata.name,
label: tag.spec.displayName,
};
});
});
}
export const tagSelect: FormKitTypeDefinition = {
...select,
props: ["placeholder"],
forceTypeProp: "select",
features: [optionsHandler, selects, defaultIcon("select", "select")],
};

View File

@ -0,0 +1,308 @@
<script lang="ts" setup>
import { apiClient } from "@/utils/api-client";
import type { FormKitFrameworkContext } from "@formkit/core";
import type { Tag } from "@halo-dev/api-client";
import { computed, onMounted, ref, watch, type PropType } from "vue";
import PostTag from "@/modules/contents/posts/tags/components/PostTag.vue";
import {
IconCheckboxCircle,
IconArrowRight,
IconClose,
} from "@halo-dev/components";
import { onClickOutside } from "@vueuse/core";
import Fuse from "fuse.js";
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 postTags = ref<Tag[]>([] as Tag[]);
const selectedTag = ref<Tag>();
const dropdownVisible = ref(false);
const text = ref("");
const wrapperRef = ref<HTMLElement>();
onClickOutside(wrapperRef, () => {
dropdownVisible.value = false;
});
// search
let fuse: Fuse<Tag> | undefined = undefined;
const searchResults = computed(() => {
if (!fuse || !text.value) {
return postTags.value;
}
return fuse?.search(text.value).map((item) => item.item);
});
watch(
() => searchResults.value,
(value) => {
if (value?.length > 0 && text.value) {
selectedTag.value = value[0];
scrollToSelected();
} else {
selectedTag.value = undefined;
}
}
);
const handleFetchTags = async () => {
const { data } = await apiClient.extension.tag.listcontentHaloRunV1alpha1Tag({
page: 0,
size: 0,
});
postTags.value = data.items;
fuse = new Fuse(data.items, {
keys: ["spec.displayName", "spec.slug"],
useExtendedSearch: true,
threshold: 0.2,
});
};
const selectedTags = computed(() => {
if (multiple.value) {
const selectedTagNames = (props.context._value as string[]) || [];
return selectedTagNames
.map((tagName): Tag | undefined => {
return postTags.value.find((tag) => tag.metadata.name === tagName);
})
.filter(Boolean) as Tag[];
}
const tag = postTags.value.find(
(tag) => tag.metadata.name === props.context._value
);
return [tag].filter(Boolean) as Tag[];
});
const isSelected = (tag: Tag) => {
if (multiple.value) {
return (props.context._value || []).includes(tag.metadata.name);
}
return props.context._value === tag.metadata.name;
};
const handleSelect = (tag: Tag) => {
if (multiple.value) {
const currentValue = props.context._value || [];
if (currentValue.includes(tag.metadata.name)) {
props.context.node.input(
currentValue.filter((t) => t !== tag.metadata.name)
);
} else {
props.context.node.input([...currentValue, tag.metadata.name]);
}
return;
}
props.context.node.input(
tag.metadata.name === props.context._value ? "" : tag.metadata.name
);
};
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
const index = searchResults.value.findIndex(
(tag) => tag.metadata.name === selectedTag.value?.metadata.name
);
if (index < searchResults.value.length - 1) {
selectedTag.value = searchResults.value[index + 1];
}
scrollToSelected();
}
if (e.key === "ArrowUp") {
e.preventDefault();
const index = searchResults.value.findIndex(
(tag) => tag.metadata.name === selectedTag.value?.metadata.name
);
if (index > 0) {
selectedTag.value = searchResults.value[index - 1];
}
scrollToSelected();
}
if (e.key === "Enter") {
if (searchResults.value.length === 0 && text.value) {
handleCreateTag();
return;
}
if (selectedTag.value) {
handleSelect(selectedTag.value);
text.value = "";
e.preventDefault();
}
}
};
const scrollToSelected = () => {
if (!selectedTag.value) {
return;
}
const selectedNode = document.getElementById(
selectedTag.value?.metadata.name
);
if (selectedNode) {
selectedNode.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "start",
});
}
};
const handleCreateTag = async () => {
if (!currentUserHasPermission(["system:posts:manage"])) {
return;
}
const { data } =
await apiClient.extension.tag.createcontentHaloRunV1alpha1Tag({
tag: {
spec: {
displayName: text.value,
slug: text.value,
color: "#ffffff",
cover: "",
},
apiVersion: "content.halo.run/v1alpha1",
kind: "Tag",
metadata: {
name: "",
generateName: "tag-",
},
},
});
handleFetchTags();
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 tag 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("");
}
};
onMounted(handleFetchTags);
</script>
<template>
<div
ref="wrapperRef"
:class="context.classes['post-tags-wrapper']"
@keydown="handleKeydown"
>
<div :class="context.classes['post-tags']">
<div
v-for="(tag, index) in selectedTags"
:key="index"
:class="context.classes['post-tag-wrapper']"
>
<PostTag :tag="tag" rounded>
<template #rightIcon>
<IconClose
:class="context.classes['post-tag-close']"
@click="handleSelect(tag)"
/>
</template>
</PostTag>
</div>
<input
:value="text"
:class="context.classes.input"
type="text"
@input="onTextInput"
@focus="dropdownVisible = true"
@keydown.delete="handleDelete"
/>
</div>
<div
:class="context.classes['post-tags-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"
@click="handleCreateTag"
>
<span class="text-xs text-gray-700 group-hover:text-gray-900">
创建 {{ text }} 标签
</span>
</li>
<li
v-for="tag in searchResults"
:id="tag.metadata.name"
:key="tag.metadata.name"
class="group flex cursor-pointer items-center justify-between rounded p-2 hover:bg-gray-100"
:class="{
'bg-gray-100': selectedTag?.metadata.name === tag.metadata.name,
}"
@click="handleSelect(tag)"
>
<div class="inline-flex items-center overflow-hidden">
<PostTag :tag="tag" />
</div>
<IconCheckboxCircle v-if="isSelected(tag)" class="text-primary" />
</li>
</ul>
</div>
</div>
</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 TagSelect from "./TagSelect.vue";
import { TagSelectSection } from "./sections";
export const tagSelect: FormKitTypeDefinition = {
schema: outer(
wrapper(
label("$label"),
inner(
icon("prefix"),
prefix(),
TagSelectSection(),
suffix(),
icon("suffix")
)
),
help("$help"),
messages(message("$message.value"))
),
type: "input",
props: ["multiple"],
library: {
TagSelect: TagSelect,
},
};

View File

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

View File

@ -91,6 +91,19 @@ const theme: Record<string, Record<string, string>> = {
content: "flex-1 p-2 col-span-11",
controls: "bg-gray-200 col-span-1 flex items-center justify-center",
},
tagSelect: {
...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-tags-wrapper": "flex w-full items-center",
"post-tags": "flex w-full flex-wrap items-center",
"post-tag-wrapper": "inline-flex items-center p-1",
"post-tag-close":
"h-4 w-4 cursor-pointer text-gray-600 hover:text-gray-900",
"post-tags-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

@ -276,7 +276,8 @@ const annotationsFormRef = ref<InstanceType<typeof AnnotationsForm>>();
v-model="formState.spec.tags"
label="标签"
name="tags"
type="tagCheckbox"
type="tagSelect"
:multiple="true"
/>
<FormKit
v-model="formState.spec.excerpt.autoGenerate"

View File

@ -8,9 +8,11 @@ import Color from "colorjs.io";
const props = withDefaults(
defineProps<{
tag: Tag;
rounded?: boolean;
route?: boolean;
}>(),
{
rounded: false,
route: false,
}
);
@ -43,8 +45,13 @@ const handleRouteToDetail = () => {
background: tag.spec.color,
color: labelColor,
}"
:rounded="rounded"
@click="handleRouteToDetail"
>
{{ tag.spec.displayName }}
<template #rightIcon>
<slot name="rightIcon" />
</template>
</VTag>
</template>