refactor: refactor the repeater component using the schema approach (#4702)

#### What type of PR is this?

/kind improvement

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

对 Formkit Repeater 组件使用 schema 的方式进行重构,重构后的 Repeater 组件将支持条件判断,按照下述方式定义即可。

```
const formSchema = [
  {
    $formkit: "repeater",
    name: "testing",
    min: 1,
    max: 2,
    addLabel: "添加",
    children: [
      {
        $formkit: "select",
        name: "things",
        id: "things",
        label: "Things",
        placeholder: "Select",
        options: ["Something", "Else"],
      },
      {
        $formkit: "number",
        if: "$value.things === Something",
        name: "show_1",
        id: "show_something_1",
        label: "Show something",
      },
      {
        $formkit: "number",
        if: "$value.things === Something",
        name: "show_2",
        id: "show_something_2",
        label: "Also show something",
      },
    ],
  },
];
```

同时额外增加了对 `addLabel`、`addButton`、`upControl`、`downControl`、`insertControl`、`removeControl`  属性的支持。

#### How to test it?

- 测试原有使用 `Repeater` 组件可否正常使用。
- 对 `Repeater` 条件判断功能进行测试。
- 查看保存的数据格式是否正确

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

Fixes #4603 

#### Does this PR introduce a user-facing change?
```release-note
重构 Repeater 组件,使其支持条件判断
```
pull/4717/head
Takagi 2023-10-12 16:08:33 +08:00 committed by GitHub
parent 767aa53a22
commit 9e33a81e2d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 434 additions and 163 deletions

View File

@ -19,6 +19,12 @@
- 参数
1. min: 最小数量,默认为 `0`
2. max: 最大数量,默认为 `Infinity`,即无限制。
3. addLabel: 添加按钮的文本,默认为 `添加`
4. addButton: 是否显示添加按钮,默认为 `true`
5. upControl: 是否显示上移按钮,默认为 `true`
6. downControl: 是否显示下移按钮,默认为 `true`
7. insertControl: 是否显示插入按钮,默认为 `true`
8. removeControl: 是否显示删除按钮,默认为 `true`
- `menuCheckbox`:选择一组菜单
- `menuRadio`:选择一个菜单
- `menuItemSelect`:选择菜单项
@ -37,7 +43,7 @@
```vue
<script lang="ts" setup>
const postName = ref("")
const postName = ref("");
</script>
<template>
@ -67,12 +73,15 @@ Repeater 是一个集合类型的输入组件,可以让使用者可视化的
```vue
<script lang="ts" setup>
const users = ref([])
const users = ref([]);
</script>
<template>
<FormKit
v-model="users"
:min="1"
:max="3"
addLabel="Add User"
type="repeater"
label="Users"
>
@ -98,6 +107,9 @@ const users = ref([])
- $formkit: repeater
name: users
label: Users
addLabel: Add User
min: 1
max: 3
items:
- $formkit: text
name: full_name

View File

@ -4,6 +4,7 @@ import { zh, en } from "@formkit/i18n";
import type { DefaultConfigOptions } from "@formkit/vue";
import { form } from "./inputs/form";
import { group } from "./inputs/group";
import { group as nativeGroup } from "@formkit/inputs";
import { attachment } from "./inputs/attachment";
import { code } from "./inputs/code";
import { repeater } from "./inputs/repeater";
@ -42,6 +43,7 @@ const config: DefaultConfigOptions = {
form,
password,
group,
nativeGroup,
attachment,
code,
repeater,

View File

@ -0,0 +1,27 @@
<script lang="ts" setup>
import { VButton, IconAddCircle } from "@halo-dev/components";
import type { FormKitFrameworkContext } from "@formkit/core";
import type { PropType } from "vue";
defineProps({
context: {
type: Object as PropType<FormKitFrameworkContext>,
required: true,
},
disabled: {
type: Boolean,
required: false,
},
});
</script>
<template>
<div :class="context.classes.add">
<VButton :disabled="disabled" type="secondary">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ context.addLabel || $t("core.common.buttons.add") }}
</VButton>
</div>
</template>

View File

@ -1,147 +0,0 @@
<script lang="ts" setup>
import {
VButton,
IconAddCircle,
IconCloseCircle,
IconArrowUpCircleLine,
IconArrowDownCircleLine,
} from "@halo-dev/components";
import type { FormKitFrameworkContext } from "@formkit/core";
import { group } from "@formkit/inputs";
import type { PropType } from "vue";
import { onMounted } from "vue";
import cloneDeep from "lodash.clonedeep";
const props = defineProps({
context: {
type: Object as PropType<FormKitFrameworkContext>,
required: true,
},
});
const min = Number(props.context.min) || 0;
const max = Number(props.context.max) || Infinity;
const handleAppend = (index: number) => {
const value = cloneDeep(props.context._value);
value.splice(index + 1, 0, {});
props.context.node.input(value);
};
const handleRemove = (index: number) => {
if (props.context._value?.length <= min) {
return;
}
const value = cloneDeep(props.context._value);
value.splice(index, 1);
props.context.node.input(value);
};
const handleMoveUp = (index: number) => {
if (index === 0) {
return;
}
const value = cloneDeep(props.context._value);
value.splice(index - 1, 0, value.splice(index, 1)[0]);
props.context.node.input(value);
};
const handleMoveDown = (index: number) => {
if (index === props.context._value.length - 1) {
return;
}
const value = cloneDeep(props.context._value);
value.splice(index + 1, 0, value.splice(index, 1)[0]);
props.context.node.input(value);
};
onMounted(() => {
const value = cloneDeep(props.context._value);
const differenceCount = min - value?.length || 0;
if (differenceCount > 0) {
for (let i = 0; i < differenceCount; i++) {
value.push({});
}
props.context.node.input(value);
}
});
</script>
<template>
<ul :class="context.classes.items">
<li
v-for="(item, index) in context._value"
:key="index"
:class="context.classes.item"
>
<div :class="context.classes.content">
<FormKit
:id="`${context.node.name}-group-${index}`"
:key="`${context.node.name}-group-${index}`"
:model-value="item"
:type="group"
>
<slot />
</FormKit>
</div>
<div :class="context.classes.controls">
<ul class="flex flex-col items-center justify-center gap-1.5 py-2">
<li
class="cursor-pointer text-gray-500 transition-all hover:text-primary"
:class="{
'!cursor-not-allowed opacity-50 hover:!text-gray-500':
index === 0,
}"
>
<IconArrowUpCircleLine
class="h-5 w-5"
@click="handleMoveUp(index)"
/>
</li>
<li
class="cursor-pointer text-gray-500 transition-all hover:text-primary"
:class="{
'!cursor-not-allowed opacity-50 hover:!text-gray-500':
context._value?.length <= min,
}"
>
<IconCloseCircle class="h-5 w-5" @click="handleRemove(index)" />
</li>
<li
class="cursor-pointer text-gray-500 transition-all hover:text-primary"
>
<IconAddCircle class="h-5 w-5" @click="handleAppend(index)" />
</li>
<li
class="cursor-pointer text-gray-500 transition-all hover:text-primary"
:class="{
'!cursor-not-allowed opacity-50 hover:!text-gray-500':
index === context._value.length - 1,
}"
>
<IconArrowDownCircleLine
class="h-5 w-5"
@click="handleMoveDown(index)"
/>
</li>
</ul>
</div>
</li>
</ul>
<div :class="context.classes.add">
<VButton
:disabled="context._value?.length >= max"
type="secondary"
@click="handleAppend(context._value.length)"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.common.buttons.add") }}
</VButton>
</div>
</template>

View File

@ -0,0 +1,69 @@
import type { FormKitNode } from "@formkit/core";
import { undefine } from "@formkit/utils";
export const repeats = function (node: FormKitNode) {
node._c.sync = true;
node.on("created", repeaterFeature.bind(null, node));
};
type FnType = (index: number) => object;
function createValue(num: number, fn: FnType) {
return new Array(num).fill("").map((value, index) => fn(index));
}
function repeaterFeature(node: FormKitNode) {
node.props.removeControl = node.props.removeControl ?? true;
node.props.upControl = node.props.upControl ?? true;
node.props.downControl = node.props.downControl ?? true;
node.props.insertControl = node.props.insertControl ?? true;
node.props.addButton = node.props.addButton ?? true;
node.props.addLabel = node.props.addLabel ?? false;
node.props.addAttrs = node.props.addAttrs ?? {};
node.props.min = node.props.min ? Number(node.props.min) : 0;
node.props.max = node.props.max ? Number(node.props.max) : 1 / 0;
if (node.props.min > node.props.max) {
throw Error("Repeater: min must be less than max");
}
if ("disabled" in node.props) {
node.props.disabled = undefine(node.props.disabled);
}
if (Array.isArray(node.value)) {
if (node.value.length < node.props.min) {
const value = createValue(node.props.min - node.value.length, () => ({}));
node.input(node.value.concat(value), false);
} else {
if (node.value.length > node.props.max) {
node.input(node.value.slice(0, node.props.max), false);
}
}
} else {
node.input(
createValue(node.props.min, () => ({})),
false
);
}
if (node.context) {
const fns = node.context.fns;
fns.createShift = (index: number, offset: number) => () => {
const value = node._value as unknown[];
value.splice(index + offset, 0, value.splice(index, 1)[0]),
node.input(value, false);
};
fns.createInsert = (index: number) => () => {
const value = node._value as unknown[];
value.splice(index + 1, 0, {}), node.input(value, false);
};
fns.createAppend = () => () => {
const value = node._value as unknown[];
value.push({}), node.input(value, false);
};
fns.createRemover = (index: number) => () => {
const value = node._value as unknown[];
value.splice(index, 1), node.input(value, false);
};
}
}

View File

@ -1,18 +1,47 @@
import type { FormKitTypeDefinition } from "@formkit/core";
import {
outer,
fieldset,
legend,
help,
inner,
legend,
message,
messages,
outer,
prefix,
$if,
suffix,
messages,
message,
} from "@formkit/inputs";
import { repeaterItems } from "./sections";
import Repeater from "./Repeater.vue";
import type { FormKitInputs } from "@formkit/inputs";
import { repeats } from "./features/repeats";
import {
addButton,
content,
controls,
down,
downControl,
downIcon,
empty,
group,
insert,
insertControl,
insertIcon,
item,
items,
remove,
removeControl,
removeIcon,
up,
upControl,
upIcon,
} from "./sections";
import {
IconAddCircle,
IconCloseCircle,
IconArrowUpCircleLine,
IconArrowDownCircleLine,
} from "@halo-dev/components";
import AddButton from "./AddButton.vue";
import { i18n } from "@/locales";
declare module "@formkit/inputs" {
interface FormKitInputProps<Props extends FormKitInputs<Props>> {
@ -23,18 +52,71 @@ declare module "@formkit/inputs" {
}
}
/**
* Input definition for a repeater input.
* @public
*/
export const repeater: FormKitTypeDefinition = {
/**
* The actual schema of the input, or a function that returns the schema.
*/
schema: outer(
fieldset(
legend("$label"),
help("$help"),
inner(prefix(), repeaterItems("$slots.default"), suffix())
inner(
prefix(),
$if(
"$value.length === 0",
$if("$slots.empty", empty()),
$if(
"$slots.default",
items(
item(
content(group("$slots.default")),
controls(
up(upControl(upIcon())),
remove(removeControl(removeIcon())),
insert(insertControl(insertIcon())),
down(downControl(downIcon()))
)
)
),
suffix()
)
),
addButton(`$addLabel || (${i18n.global.t("core.common.buttons.add")})`)
)
),
messages(message("$message.value"))
),
/**
* The type of node, can be a list, group, or input.
*/
type: "list",
props: ["min", "max"],
/**
* An array of extra props to accept for this input.
*/
props: [
"min",
"max",
"upControl",
"downControl",
"removeControl",
"insertControl",
"addLabel",
"addButton",
],
/**
* Additional features that make this input work.
*/
features: [repeats],
library: {
Repeater: Repeater,
IconAddCircle,
IconCloseCircle,
IconArrowUpCircleLine,
IconArrowDownCircleLine,
AddButton,
},
};

View File

@ -0,0 +1,97 @@
import {
isComponent,
isDOM,
type FormKitSchemaNode,
type FormKitExtendableSchemaRoot,
type FormKitSchemaCondition,
} from "@formkit/core";
import {
extendSchema,
type FormKitSchemaExtendableSection,
type FormKitSection,
} from "@formkit/inputs";
export function createRepeaterSection() {
return (
section: string,
el: string | null | (() => FormKitSchemaNode),
fragment = false
) => {
return createSection(
section,
el,
fragment
) as FormKitSection<FormKitSchemaExtendableSection>;
};
}
function createSection(
section: string,
el: string | null | (() => FormKitSchemaNode),
fragment = false
): FormKitSection<
FormKitExtendableSchemaRoot | FormKitSchemaExtendableSection
> {
return (
...children: Array<
FormKitSchemaExtendableSection | string | FormKitSchemaCondition
>
) => {
const extendable = (
extensions: Record<string, Partial<FormKitSchemaNode>>
) => {
const node = !el || typeof el === "string" ? { $el: el } : el();
if ("string" != typeof node) {
if (isDOM(node) || isComponent(node) || "$formkit" in node) {
if (children.length && !node.children) {
node.children = [
...children.map((child) =>
typeof child === "function" ? child(extensions) : child
),
];
}
if (!node.meta) {
node.meta = { section };
}
if (isDOM(node)) {
node.attrs = {
class: `$classes.${section}`,
...(node.attrs || {}),
};
}
if ("$formkit" in node) {
node.outerClass = `$classes.${section}`;
}
}
}
return {
if: `$slots.${section}`,
then: `$slots.${section}`,
else:
section in extensions
? extendSchema(node as FormKitSchemaNode, extensions[section])
: node,
} as FormKitSchemaCondition;
};
extendable._s = section;
return fragment ? createRoot(extendable) : extendable;
};
}
/**
*
* Returns an extendable schema root node.
*
* @param rootSection - Creates the root node.
*
* @returns {@link @formkit/core#FormKitExtendableSchemaRoot | FormKitExtendableSchemaRoot}
*
* @internal
*/
export function createRoot(
rootSection: FormKitSchemaExtendableSection
): FormKitExtendableSchemaRoot {
return (extensions: Record<string, Partial<FormKitSchemaNode>>) => {
return [rootSection(extensions)];
};
}

View File

@ -1,8 +1,134 @@
import { createSection } from "@formkit/inputs";
import { createRepeaterSection } from "../repeaterSection";
export const repeaterItems = createSection("repeaterItems", () => ({
$cmp: "Repeater",
const repeaterSection = createRepeaterSection();
export const addButton = repeaterSection("addButton", () => ({
$cmp: "AddButton",
props: {
onClick: "$fns.createAppend()",
disabled: "$value.length >= $max",
context: "$node.context",
},
bind: "$addAttrs",
if: "$addButton",
type: "button",
}));
export const content = repeaterSection("content", "div");
export const controlLabel = repeaterSection("controlLabel", "span");
export const controls = repeaterSection("controls", () => ({
$el: "ul",
if: "$removeControl || $insertControl || $upControl || $downControl",
}));
export const down = repeaterSection("down", () => ({
$el: "li",
if: "$downControl",
}));
export const downControl = repeaterSection("downControl", () => ({
$el: "button",
attrs: {
disabled: "$index >= $value.length - 1",
onClick: "$fns.createShift($index, 1)",
type: "button",
class: `$classes.control`,
},
}));
export const downIcon = repeaterSection("downIcon", () => ({
$cmp: "IconArrowDownCircleLine",
}));
export const empty = repeaterSection("empty", () => ({
$el: "div",
}));
export const fieldset = repeaterSection("fieldset", () => ({
$el: "fieldset",
attrs: {
id: "$id",
disabled: "$disabled",
},
}));
export const group = repeaterSection("group", () => ({
$formkit: "nativeGroup",
index: "$index",
}));
export const insert = repeaterSection("insert", () => ({
$el: "li",
if: "$insertControl",
}));
export const insertControl = repeaterSection("insertControl", () => ({
$el: "button",
attrs: {
disabled: "$value.length >= $max",
onClick: "$fns.createInsert($index)",
type: "button",
class: `$classes.control`,
},
}));
export const insertIcon = repeaterSection("insertIcon", () => ({
$cmp: "IconAddCircle",
}));
export const item = repeaterSection("item", () => ({
$el: "li",
for: ["item", "index", "$items"],
attrs: {
role: "listitem",
key: "$item",
"data-index": "$index",
},
}));
export const items = repeaterSection("items", () => ({
$el: "ul",
attrs: {
role: "list",
},
}));
export const remove = repeaterSection("remove", () => ({
$el: "li",
if: "$removeControl",
}));
export const removeControl = repeaterSection("removeControl", () => ({
$el: "button",
attrs: {
disabled: "$value.length <= $min",
onClick: "$fns.createRemover($index)",
type: "button",
class: `$classes.control`,
},
}));
export const removeIcon = repeaterSection("removeIcon", () => ({
$cmp: "IconCloseCircle",
}));
export const up = repeaterSection("up", () => ({
$el: "li",
if: "$upControl",
}));
export const upControl = repeaterSection("upControl", () => ({
$el: "button",
attrs: {
disabled: "$index <= 0",
onClick: "$fns.createShift($index, -1)",
type: "button",
class: `$classes.control`,
},
}));
export const upIcon = repeaterSection("upIcon", () => ({
$cmp: "IconArrowUpCircleLine",
}));

View File

@ -89,7 +89,10 @@ const theme: Record<string, Record<string, string>> = {
items: "flex flex-col w-full gap-2 rounded-base",
item: "border rounded-base grid grid-cols-12 focus-within:border-primary transition-all overflow-visible focus-within:shadow-sm",
content: "flex-1 p-2 col-span-11 divide-y divide-gray-100",
controls: "bg-gray-200 col-span-1 flex items-center justify-center",
controls:
"flex flex-col items-center justify-center gap-1.5 py-2 bg-gray-200 col-span-1 flex items-center justify-center",
control:
"cursor-pointer text-gray-500 transition-all hover:text-primary disabled:!cursor-not-allowed disabled:opacity-50 disabled:hover:!text-gray-500",
},
group: {
label: textClassification.label,