feat: add default selection option to formkit select component (#6538)

#### What type of PR is this?

/kind improvement
/area ui
/milestone 2.19.x

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

为 formkit select 增加属性 `autoSelect`。

这个属性仅会作用于单选,且目的是为了初始化数据所用。当 value 或者 placeholder 存在时,此属性无效。

会查找第一个 option 不为 disabled 的数据。

#### Does this PR introduce a user-facing change?
```release-note
None
```
pull/6548/head
Takagi 2024-08-29 12:15:24 +08:00 committed by GitHub
parent ad267ebed7
commit 19049c1302
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 87 additions and 37 deletions

View File

@ -71,6 +71,7 @@
10. `maxCount`:多选时最大可选数量,默认为 `Infinity`。仅在多选时有效。 10. `maxCount`:多选时最大可选数量,默认为 `Infinity`。仅在多选时有效。
11. `sortable`:是否支持拖动排序,默认为 `false`。仅在多选时有效。 11. `sortable`:是否支持拖动排序,默认为 `false`。仅在多选时有效。
12. `searchable`:是否支持搜索内容,默认为 `false` 12. `searchable`:是否支持搜索内容,默认为 `false`
13. `autoSelect`:当 value 不存在时,是否自动选择第一个选项,默认为 `true`。仅在单选时有效。
在 Vue 单组件中使用: 在 Vue 单组件中使用:
@ -120,16 +121,16 @@ select 是一个选择器类型的输入组件,使用者可以从一批待选
multiple multiple
searchable searchable
:options="[ :options="[
{ label: "China", value: "China" }, { label: 'China', value: 'China' },
{ label: "USA", value: "USA" }, { label: 'USA', value: 'USA' },
{ label: "Japan", value: "Japan" }, { label: 'Japan', value: 'Japan' },
{ label: "Korea", value: "Korea" }, { label: 'Korea', value: 'Korea' },
{ label: "France", value: "France" }, { label: 'France', value: 'France' },
{ label: "Italy", value: "Italy" }, { label: 'Italy', value: 'Italy' },
{ label: "Germany", value: "Germany" }, { label: 'Germany', value: 'Germany' },
{ label: "UK", value: "UK" }, { label: 'UK', value: 'UK' },
{ label: "Canada", value: "Canada" }, { label: 'Canada', value: 'Canada' },
{ label: "Australia", value: "Australia" }, { label: 'Australia', value: 'Australia' },
]" ]"
help="Dont worry, you cant get this one wrong." help="Dont worry, you cant get this one wrong."
/> />
@ -196,6 +197,8 @@ const handleSelectPostAuthorRemote = {
clearable: true clearable: true
placeholder: Select a country placeholder: Select a country
options: options:
- label: China
value: cn
- label: France - label: France
value: fr value: fr
- label: Germany - label: Germany
@ -231,7 +234,7 @@ const handleSelectPostAuthorRemote = {
``` ```
> [!NOTE] > [!NOTE]
> 当远程数据具有分页时,可能会出现默认选项不在第一页的情况,此时 Select 组件将会发送另一个查询请求,以获取默认选项的数据。此接口会携带如下参数 `labelSelector: ${requestOption.valueField}=in(value1,value2,value3)`。 > 当远程数据具有分页时,可能会出现默认选项不在第一页的情况,此时 Select 组件将会发送另一个查询请求,以获取默认选项的数据。此接口会携带如下参数 `fieldSelector: ${requestOption.valueField}=(value1,value2,value3)`。
> 其中value1, value2, value3 为默认选项的值。返回值与查询一致,通过 `requestOption` 解析。 > 其中value1, value2, value3 为默认选项的值。返回值与查询一致,通过 `requestOption` 解析。

View File

@ -22,7 +22,6 @@ function optionsHandler(node: FormKitNode) {
export const attachmentGroupSelect: FormKitTypeDefinition = { export const attachmentGroupSelect: FormKitTypeDefinition = {
...select, ...select,
props: ["placeholder"],
forceTypeProp: "select", forceTypeProp: "select",
features: [optionsHandler], features: [optionsHandler],
}; };

View File

@ -19,7 +19,6 @@ function optionsHandler(node: FormKitNode) {
export const attachmentPolicySelect: FormKitTypeDefinition = { export const attachmentPolicySelect: FormKitTypeDefinition = {
...select, ...select,
props: ["placeholder"],
forceTypeProp: "select", forceTypeProp: "select",
features: [optionsHandler], features: [optionsHandler],
}; };

View File

@ -21,7 +21,7 @@ function optionsHandler(node: FormKitNode) {
export const menuItemSelect: FormKitTypeDefinition = { export const menuItemSelect: FormKitTypeDefinition = {
...select, ...select,
props: ["placeholder", "menuItems"], props: ["menuItems", ...(select.props as string[])],
forceTypeProp: "select", forceTypeProp: "select",
features: [optionsHandler], features: [optionsHandler],
}; };

View File

@ -53,13 +53,13 @@ function optionsHandler(node: FormKitNode) {
search, search,
findOptionsByValues, findOptionsByValues,
}, },
searchable: true,
}; };
}); });
} }
export const postSelect: FormKitTypeDefinition = { export const postSelect: FormKitTypeDefinition = {
...select, ...select,
props: ["placeholder"],
forceTypeProp: "select", forceTypeProp: "select",
features: [optionsHandler], features: [optionsHandler],
}; };

View File

@ -37,7 +37,6 @@ function optionsHandler(node: FormKitNode) {
export const roleSelect: FormKitTypeDefinition = { export const roleSelect: FormKitTypeDefinition = {
...select, ...select,
props: ["placeholder"], forceTypeProp: "select",
forceTypeProp: "nativeSelect",
features: [optionsHandler], features: [optionsHandler],
}; };

View File

@ -78,6 +78,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
autoSelect: {
type: Boolean,
default: true,
},
}); });
const emit = defineEmits<{ const emit = defineEmits<{
@ -221,6 +225,7 @@ const clearAllSelectedOptions = () => {
if (!hasClearable.value) { if (!hasClearable.value) {
return; return;
} }
selectedOptions.value = []; selectedOptions.value = [];
clearInputValue(); clearInputValue();
}; };

View File

@ -77,6 +77,13 @@ export interface SelectProps {
* Whether to enable search, default is false. * Whether to enable search, default is false.
*/ */
searchable?: boolean; searchable?: boolean;
/**
* Whether to automatically select the first option. default is true.
*
* Only valid when `multiple` is false.
*/
autoSelect?: boolean;
} }
export interface SelectResponse { export interface SelectResponse {
@ -214,6 +221,7 @@ const initSelectProps = () => {
selectProps.allowCreate = !isFalse(nodeProps.allowCreate); selectProps.allowCreate = !isFalse(nodeProps.allowCreate);
selectProps.clearable = !isFalse(nodeProps.clearable); selectProps.clearable = !isFalse(nodeProps.clearable);
selectProps.searchable = !isFalse(nodeProps.searchable); selectProps.searchable = !isFalse(nodeProps.searchable);
selectProps.autoSelect = !isFalse(nodeProps.autoSelect) || true;
if (selectProps.remote) { if (selectProps.remote) {
if (!nodeProps.remoteOption) { if (!nodeProps.remoteOption) {
throw new Error("remoteOption is required when remote is true."); throw new Error("remoteOption is required when remote is true.");
@ -341,37 +349,42 @@ const fetchSelectedOptions = async (): Promise<
> => { > => {
const node = props.context.node; const node = props.context.node;
const value = node.value; const value = node.value;
if (!value) {
return undefined;
}
const selectedValues: string[] = []; const selectedValues: Array<unknown> = [];
if (Array.isArray(value)) { if (Array.isArray(value)) {
selectedValues.push(...value); selectedValues.push(...value);
} else if ( } else if (
typeof value === "string" || typeof value === "string" ||
typeof value === "number" || typeof value === "number" ||
typeof value === "boolean" typeof value === "boolean" ||
value === void 0
) { ) {
selectedValues.push(value.toString()); selectedValues.push(value);
} }
const currentOptions = options.value?.filter((option) => const currentOptions = options.value?.filter((option) =>
selectedValues.includes(option.value.toString()) selectedValues.includes(option.value)
); );
// Get options that are not yet mapped. // Get options that are not yet mapped.
const unmappedSelectValues = selectedValues.filter( const unmappedSelectValues = selectedValues
.filter(
(value) => !currentOptions?.find((option) => option.value === value) (value) => !currentOptions?.find((option) => option.value === value)
); )
.filter(Boolean);
if (unmappedSelectValues.length === 0) { if (unmappedSelectValues.length === 0) {
if (!currentOptions || currentOptions.length === 0) {
return;
}
return currentOptions?.sort((a, b) => return currentOptions?.sort((a, b) =>
selectedValues.indexOf(a.value) > selectedValues.indexOf(b.value) ? 1 : -1 selectedValues.indexOf(a.value) > selectedValues.indexOf(b.value) ? 1 : -1
); );
} }
// Map the unresolved options to label and value format. // Map the unresolved options to label and value format.
const mappedSelectOptions = await mapUnresolvedOptions(unmappedSelectValues); const mappedSelectOptions = await mapUnresolvedOptions(
unmappedSelectValues.map(String)
);
// Merge currentOptions and mappedSelectOptions, then sort them according to selectValues order. // Merge currentOptions and mappedSelectOptions, then sort them according to selectValues order.
return [...(currentOptions || []), ...mappedSelectOptions].sort((a, b) => return [...(currentOptions || []), ...mappedSelectOptions].sort((a, b) =>
selectedValues.indexOf(a.value) > selectedValues.indexOf(b.value) ? 1 : -1 selectedValues.indexOf(a.value) > selectedValues.indexOf(b.value) ? 1 : -1
@ -508,16 +521,47 @@ onMounted(async () => {
} }
}); });
const getAutoSelectedOption = ():
| {
label: string;
value: string;
}
| undefined => {
if (!options.value || options.value.length === 0) {
return;
}
// Find the first option that is not disabled.
return options.value.find((option) => {
const attrs = option.attrs as Record<string, unknown>;
return isFalse(attrs?.disabled as string | boolean | undefined);
});
};
const stopSelectedWatch = watch( const stopSelectedWatch = watch(
() => [options.value, props.context.value], () => [options.value, props.context.value],
async () => { async () => {
if (options.value) { if (options.value) {
const selectedOption = await fetchSelectedOptions(); const selectedOption = await fetchSelectedOptions();
if (selectedOption) {
selectOptions.value = selectedOption; selectOptions.value = selectedOption;
return;
}
const isAutoSelect =
selectProps.autoSelect &&
!selectProps.multiple &&
!selectProps.placeholder &&
!props.context.node.value;
if (isAutoSelect) {
// Automatically select the first option when the selected value is empty.
const autoSelectedOption = getAutoSelectedOption();
if (autoSelectedOption) {
selectOptions.value = [autoSelectedOption];
handleUpdate(selectOptions.value);
}
}
} }
},
{
immediate: true,
} }
); );
@ -669,6 +713,7 @@ const handleNextPage = async () => {
:remote="isRemote" :remote="isRemote"
:clearable="selectProps.clearable" :clearable="selectProps.clearable"
:searchable="selectProps.searchable" :searchable="selectProps.searchable"
:auto-select="selectProps.autoSelect"
@update="handleUpdate" @update="handleUpdate"
@search="handleSearch" @search="handleSearch"
@load-more="handleNextPage" @load-more="handleNextPage"

View File

@ -52,6 +52,7 @@ export const select: FormKitTypeDefinition = {
"allowCreate", "allowCreate",
"remoteOptimize", "remoteOptimize",
"searchable", "searchable",
"autoSelect",
], ],
library: { library: {

View File

@ -1,7 +1,7 @@
import { singlePageLabels } from "@/constants/labels"; import { singlePageLabels } from "@/constants/labels";
import type { FormKitNode, FormKitTypeDefinition } from "@formkit/core"; import type { FormKitNode, FormKitTypeDefinition } from "@formkit/core";
import { defaultIcon, select, selects } from "@formkit/inputs";
import { consoleApiClient } from "@halo-dev/api-client"; import { consoleApiClient } from "@halo-dev/api-client";
import { select } from "./select";
async function search({ page, size, keyword }) { async function search({ page, size, keyword }) {
const { data } = await consoleApiClient.content.singlePage.listSinglePages({ const { data } = await consoleApiClient.content.singlePage.listSinglePages({
@ -53,13 +53,13 @@ function optionsHandler(node: FormKitNode) {
search, search,
findOptionsByValues, findOptionsByValues,
}, },
searchable: true,
}; };
}); });
} }
export const singlePageSelect: FormKitTypeDefinition = { export const singlePageSelect: FormKitTypeDefinition = {
...select, ...select,
props: ["placeholder"], forceTypeProp: "select",
forceTypeProp: "nativeSelect", features: [optionsHandler],
features: [optionsHandler, selects, defaultIcon("select", "select")],
}; };

View File

@ -57,7 +57,6 @@ function optionsHandler(node: FormKitNode) {
export const userSelect: FormKitTypeDefinition = { export const userSelect: FormKitTypeDefinition = {
...select, ...select,
props: ["placeholder"],
forceTypeProp: "select", forceTypeProp: "select",
features: [optionsHandler], features: [optionsHandler],
}; };