feat: add some formkit custom input for the system core extensions (halo-dev/console#643)

#### What type of PR is this?

/kind feature
/milestone 2.0

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

Ref https://github.com/halo-dev/halo/issues/2526#issuecomment-1273094868

FormKit 文档:https://formkit.com/advanced/custom-inputs

通过扩展 FormKit 的自定义 Input,提供系统常用资源的选择组件。

目前提供如下类型:

- menuCheckbox
- menuRadio
- menuItemSelect
- postSelect
- categorySelect
- tagSelect
- singlePageSelect
- categoryCheckbox
- tagCheckbox

FormKit 组件的使用方式:

```vue
<FormKit
        placeholder="请选择文章"
        label="文章"
        type="postSelect"
        validation="required"
/>
```

FormKit Schema 的使用方式:

```yaml
- $formkit: menuRadio
    name: menus
    label: 底部菜单组
```

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

Fixes https://github.com/halo-dev/halo/issues/2526

#### Screenshots:

<!--
如果此 PR 有 UI 的改动,最好截图说明这个 PR 的改动。
If there are UI changes to this PR, it is best to take a screenshot to illustrate the changes to this PR.
eg.
Before:
![screenshot-before](https://user-images.githubusercontent.com/screenshot.png)
After:
![screenshot-after](https://user-images.githubusercontent.com/screenshot.png)
-->

#### Special notes for your reviewer:

/cc @halo-dev/sig-halo-console 

测试方式:

1. 检查后台文章设置弹框的选择分类和标签功能是否正常。
2. 检查后台添加菜单项的功能是否正常。
3. 使用主题或者插件定义 settings.yaml,使用上述任意 input 类型,检查得到的效果和值是否正常。

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

```release-note
通过扩展 FormKit 的自定义 Input,提供系统常用资源的选择组件。
```
pull/3445/head
Ryan Wang 2022-10-18 11:32:09 +08:00 committed by GitHub
parent aa2aa26981
commit a0512e43bc
14 changed files with 310 additions and 165 deletions

View File

@ -0,0 +1,45 @@
# 自定义 FormKit 输入组件
## 原由
目前不管是在 Console 中,还是在插件 / 主题设置表单中,都有可能选择系统当中的资源,所以可以通过自定义 FormKit 组件的方式提供常用的选择器。
## 使用方式
目前已提供以下类型:
- `menuCheckbox`:选择一组菜单
- `menuRadio`:选择一个菜单
- `menuItemSelect`:选择菜单项
- `postSelect`:选择文章
- `singlePageSelect`:选择自定义页面
- `categorySelect`:选择分类
- `categoryCheckbox`:选择多个分类
- `tagSelect`:选择标签
- `tagCheckbox`:选择多个标签
在 Vue 单组件中使用:
```vue
<script lang="ts" setup>
const postName = ref("")
</script>
<template>
<FormKit
v-model="postName"
placeholder="请选择文章"
label="文章"
type="postSelect"
validation="required"
/>
</template>
```
在 FormKit Schema 中使用(插件 / 主题设置表单定义):
```yaml
- $formkit: menuRadio
name: menus
label: 底部菜单组
```

View File

@ -4,6 +4,15 @@ import { createAutoAnimatePlugin } from "@formkit/addons";
import { zh } from "@formkit/i18n";
import type { DefaultConfigOptions } from "@formkit/vue";
import { form } from "./inputs/form";
import { menuCheckbox } from "./inputs/menu-checkbox";
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 { categorySelect } from "./inputs/category-select";
import { tagSelect } from "./inputs/tag-select";
import { categoryCheckbox } from "./inputs/category-checkbox";
import { tagCheckbox } from "./inputs/tag-checkbox";
const config: DefaultConfigOptions = {
config: {
@ -12,6 +21,15 @@ const config: DefaultConfigOptions = {
plugins: [createAutoAnimatePlugin()],
inputs: {
form,
menuCheckbox,
menuRadio,
menuItemSelect,
postSelect,
categorySelect,
tagSelect,
singlePageSelect,
categoryCheckbox,
tagCheckbox,
},
locales: { zh },
locale: "zh",

View File

@ -0,0 +1,28 @@
import { apiClient } from "@/utils/api-client";
import type { FormKitNode, FormKitTypeDefinition } from "@formkit/core";
import { checkbox, checkboxes, 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 categoryCheckbox: FormKitTypeDefinition = {
...checkbox,
props: ["onValue", "offValue"],
forceTypeProp: "checkbox",
features: [
optionsHandler,
checkboxes,
defaultIcon("decorator", "checkboxDecorator"),
],
};

View File

@ -0,0 +1,24 @@
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

@ -31,6 +31,7 @@ export const form: FormKitTypeDefinition = {
"submitBehavior",
"incompleteMessage",
],
forceTypeProp: "form",
/**
* Additional features that should be added to your input
*/

View File

@ -0,0 +1,27 @@
import { apiClient } from "@/utils/api-client";
import type { FormKitNode, FormKitTypeDefinition } from "@formkit/core";
import { checkbox, checkboxes, defaultIcon } from "@formkit/inputs";
function optionsHandler(node: FormKitNode) {
node.on("created", async () => {
const { data } = await apiClient.extension.menu.listv1alpha1Menu();
node.props.options = data.items.map((menu) => {
return {
value: menu.metadata.name,
label: menu.spec.displayName,
};
});
});
}
export const menuCheckbox: FormKitTypeDefinition = {
...checkbox,
props: ["onValue", "offValue"],
forceTypeProp: "checkbox",
features: [
optionsHandler,
checkboxes,
defaultIcon("decorator", "checkboxDecorator"),
],
};

View File

@ -0,0 +1,25 @@
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.menuItem.listv1alpha1MenuItem({
fieldSelector: [`name=(${node.props.menuItems.join(",")})`],
});
node.props.options = data.items.map((menuItem) => {
return {
value: menuItem.metadata.name,
label: menuItem.status?.displayName,
};
});
});
}
export const menuItemSelect: FormKitTypeDefinition = {
...select,
props: ["placeholder", "menuItems"],
forceTypeProp: "select",
features: [optionsHandler, selects, defaultIcon("select", "select")],
};

View File

@ -0,0 +1,27 @@
import { apiClient } from "@/utils/api-client";
import type { FormKitNode, FormKitTypeDefinition } from "@formkit/core";
import { radio, radios, defaultIcon } from "@formkit/inputs";
function optionsHandler(node: FormKitNode) {
node.on("created", async () => {
const { data } = await apiClient.extension.menu.listv1alpha1Menu();
node.props.options = data.items.map((menu) => {
return {
value: menu.metadata.name,
label: menu.spec.displayName,
};
});
});
}
export const menuRadio: FormKitTypeDefinition = {
...radio,
props: ["onValue", "offValue"],
forceTypeProp: "radio",
features: [
optionsHandler,
radios,
defaultIcon("decorator", "radioDecorator"),
],
};

View File

@ -0,0 +1,24 @@
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.post.listcontentHaloRunV1alpha1Post();
node.props.options = data.items.map((post) => {
return {
value: post.metadata.name,
label: post.spec.title,
};
});
});
}
export const postSelect: FormKitTypeDefinition = {
...select,
props: ["placeholder"],
forceTypeProp: "select",
features: [optionsHandler, selects, defaultIcon("select", "select")],
};

View File

@ -0,0 +1,24 @@
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.singlePage.listcontentHaloRunV1alpha1SinglePage();
node.props.options = data.items.map((singlePage) => {
return {
value: singlePage.metadata.name,
label: singlePage.spec.title,
};
});
});
}
export const singlePageSelect: FormKitTypeDefinition = {
...select,
props: ["placeholder"],
forceTypeProp: "select",
features: [optionsHandler, selects, defaultIcon("select", "select")],
};

View File

@ -0,0 +1,28 @@
import { apiClient } from "@/utils/api-client";
import type { FormKitNode, FormKitTypeDefinition } from "@formkit/core";
import { checkbox, checkboxes, 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 tagCheckbox: FormKitTypeDefinition = {
...checkbox,
props: ["onValue", "offValue"],
forceTypeProp: "checkbox",
features: [
optionsHandler,
checkboxes,
defaultIcon("decorator", "checkboxDecorator"),
],
};

View File

@ -0,0 +1,24 @@
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

@ -1,10 +1,8 @@
<script lang="ts" setup>
import { VButton, VModal, VSpace, VTabItem, VTabs } from "@halo-dev/components";
import { computed, ref, watch, watchEffect } from "vue";
import { computed, ref, watchEffect } from "vue";
import type { PostRequest } from "@halo-dev/api-client";
import cloneDeep from "lodash.clonedeep";
import { usePostTag } from "@/modules/contents/posts/tags/composables/use-post-tag";
import { usePostCategory } from "@/modules/contents/posts/categories/composables/use-post-category";
import { apiClient } from "@/utils/api-client";
import { v4 as uuid } from "uuid";
@ -67,26 +65,6 @@ const saving = ref(false);
const publishing = ref(false);
const publishCanceling = ref(false);
const { categories, handleFetchCategories } = usePostCategory();
const categoriesMap = computed(() => {
return categories.value.map((category) => {
return {
value: category.metadata.name,
label: category.spec.displayName,
};
});
});
const { tags, handleFetchTags } = usePostTag();
const tagsMap = computed(() => {
return tags.value.map((tag) => {
return {
value: tag.metadata.name,
label: tag.spec.displayName,
};
});
});
const isUpdateMode = computed(() => {
return !!formState.value.post.metadata.creationTimestamp;
});
@ -180,16 +158,6 @@ const handlePublishCanceling = async () => {
}
};
watch(
() => props.visible,
(visible) => {
if (visible) {
handleFetchCategories();
handleFetchTags();
}
}
);
watchEffect(() => {
if (props.post) {
formState.value = cloneDeep(props.post);
@ -233,17 +201,15 @@ watchEffect(() => {
></FormKit>
<FormKit
v-model="formState.post.spec.categories"
:options="categoriesMap"
label="分类目录"
name="categories"
type="checkbox"
type="categoryCheckbox"
/>
<FormKit
v-model="formState.post.spec.tags"
:options="tagsMap"
label="标签"
name="tags"
type="checkbox"
type="tagCheckbox"
/>
<FormKit
v-model="formState.post.spec.excerpt.autoGenerate"

View File

@ -2,15 +2,12 @@
import { VButton, VModal, VSpace } from "@halo-dev/components";
import SubmitButton from "@/components/button/SubmitButton.vue";
import { computed, ref, watch } from "vue";
import type { Menu, MenuItem, Post, SinglePage } from "@halo-dev/api-client";
import type { Menu, MenuItem } from "@halo-dev/api-client";
import { v4 as uuid } from "uuid";
import { apiClient } from "@/utils/api-client";
import { reset } from "@formkit/core";
import cloneDeep from "lodash.clonedeep";
import { usePostCategory } from "@/modules/contents/posts/categories/composables/use-post-category";
import { usePostTag } from "@/modules/contents/posts/tags/composables/use-post-tag";
import { setFocus } from "@/formkit/utils/focus";
import type { FormKitOptionsProp } from "@formkit/inputs";
const props = withDefaults(
defineProps<{
@ -47,7 +44,6 @@ const initialFormState: MenuItem = {
},
};
const menuItemMap = ref<FormKitOptionsProp>();
const selectedParentMenuItem = ref<string>("");
const formState = ref<MenuItem>(cloneDeep(initialFormState));
const saving = ref(false);
@ -56,25 +52,6 @@ const isUpdateMode = computed(() => {
return !!formState.value.metadata.creationTimestamp;
});
const handleFetchMenuItems = async () => {
try {
const { data } = await apiClient.extension.menuItem.listv1alpha1MenuItem({
fieldSelector: [`name=(${props.menu?.spec.menuItems?.join(",")})`],
});
menuItemMap.value = [
{ label: "无", value: undefined },
...data.items.map((menuItem) => {
return {
label: menuItem.status?.displayName as string,
value: menuItem.metadata.name,
};
}),
];
} catch (error) {
console.log("Failed to fetch menu items", error);
}
};
const handleSaveMenuItem = async () => {
try {
saving.value = true;
@ -244,104 +221,11 @@ const menuItemSources: MenuItemSource[] = [
const selectedMenuItemSource = ref<string>(menuItemSources[0].value);
const { categories, handleFetchCategories } = usePostCategory();
const { tags, handleFetchTags } = usePostTag();
const posts = ref<Post[]>([] as Post[]);
const singlePages = ref<SinglePage[]>([] as SinglePage[]);
const postMap = computed(() => {
return [
{ label: "请选择文章", value: undefined },
...posts.value.map((post) => {
return {
label: post.spec.title,
value: post.metadata.name,
};
}),
];
});
const singlePageMap = computed(() => {
return [
{
label: "请选择自定义页面",
value: undefined,
},
...singlePages.value.map((singlePage) => {
return {
label: singlePage.spec.title,
value: singlePage.metadata.name,
};
}),
];
});
const categoryMap = computed(() => {
return [
{
label: "请选择分类",
value: undefined,
},
...categories.value.map((category) => {
return {
label: category.spec.displayName,
value: category.metadata.name,
};
}),
];
});
const tagMap = computed(() => {
return [
{
label: "请选择标签",
value: undefined,
},
...tags.value.map((tag) => {
return {
label: tag.spec.displayName,
value: tag.metadata.name,
};
}),
];
});
const selectedRef = ref<string>("");
const handleFetchPosts = async () => {
const { data } =
await apiClient.extension.post.listcontentHaloRunV1alpha1Post({
page: 0,
size: 0,
});
posts.value = data.items;
};
const handleFetchSinglePages = async () => {
const { data } =
await apiClient.extension.singlePage.listcontentHaloRunV1alpha1SinglePage({
page: 0,
size: 0,
});
singlePages.value = data.items;
};
const onMenuItemSourceChange = () => {
selectedRef.value = "";
};
watch(
() => props.visible,
(newValue) => {
if (newValue) {
handleFetchMenuItems();
handleFetchCategories();
handleFetchTags();
handleFetchPosts();
handleFetchSinglePages();
}
}
);
</script>
<template>
<VModal
@ -359,11 +243,12 @@ watch(
@submit="handleSaveMenuItem"
>
<FormKit
v-if="!isUpdateMode && menuItemMap"
v-if="!isUpdateMode && menu"
v-model="selectedParentMenuItem"
label="上级菜单项"
type="select"
:options="menuItemMap"
placeholder="选择上级菜单项"
type="menuItemSelect"
:menu-items="menu?.spec.menuItems || []"
/>
<FormKit
@ -397,9 +282,9 @@ watch(
<FormKit
v-if="selectedMenuItemSource === 'post'"
v-model="selectedRef"
placeholder="请选择文章"
label="文章"
type="select"
:options="postMap"
type="postSelect"
validation="required"
/>
@ -407,26 +292,25 @@ watch(
v-if="selectedMenuItemSource === 'singlePage'"
v-model="selectedRef"
label="自定义页面"
type="select"
:options="singlePageMap"
type="singlePageSelect"
validation="required"
/>
<FormKit
v-if="selectedMenuItemSource === 'tag'"
v-model="selectedRef"
placeholder="请选择标签"
label="标签"
type="select"
:options="tagMap"
type="tagSelect"
validation="required"
/>
<FormKit
v-if="selectedMenuItemSource === 'category'"
v-model="selectedRef"
placeholder="请选择分类"
label="分类"
type="select"
:options="categoryMap"
type="categorySelect"
validation="required"
/>
</FormKit>