diff --git a/biome.json b/biome.json index e57c465..b1552c8 100644 --- a/biome.json +++ b/biome.json @@ -17,7 +17,8 @@ "noExplicitAny": "off" }, "complexity": { - "noForEach": "off" + "noForEach": "off", + "noStaticOnlyClass": "off" }, "a11y": { "useKeyWithClickEvents": "off", diff --git a/docs/components/demos/Linkage.tsx b/docs/components/demos/CascaderPlus.tsx similarity index 96% rename from docs/components/demos/Linkage.tsx rename to docs/components/demos/CascaderPlus.tsx index 116ebfd..5e4cdec 100644 --- a/docs/components/demos/Linkage.tsx +++ b/docs/components/demos/CascaderPlus.tsx @@ -1,7 +1,7 @@ import { Button, Divider, Space } from "antd"; -import { Linkage } from "@pro.formily/antd"; -import list from "china-location/dist/location.json"; +import { CascaderPlus } from "@pro.formily/antd"; import React, { useEffect, useState } from "react"; +import localtionList from "china-location/dist/location.json"; export interface OptionData { label?: string; @@ -82,10 +82,10 @@ const buildTree = (parent: ReturnType["tree"]) => { return tree; }; -const fake = (): Promise => { +const fake = (): Promise => { return new Promise((resolve) => { setTimeout(() => { - resolve(list); + resolve(localtionList); }, 500); }); }; @@ -182,7 +182,7 @@ export const LinkageDemo1 = () => {
- { loadData={all ? (loadAll as any) : loadData} multiple={mul} labelInValue={labelInValue} - > + >
); @@ -238,13 +238,13 @@ export const LinkageDemo2 = () => {
- + >
); @@ -332,14 +332,14 @@ export const LinkageDemo3 = () => {
- + >
); diff --git a/docs/components/demos/DictDemo.tsx b/docs/components/demos/DictDemo.tsx deleted file mode 100644 index d185672..0000000 --- a/docs/components/demos/DictDemo.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { - Checkbox, - FormGrid, - FormItem, - FormLayout, - Input, - Radio, - Select, - Space, -} from "@formily/antd"; -import { createForm } from "@formily/core"; -import { FormProvider, createSchemaField } from "@formily/react"; -import { Dict, dict, dictEffects, registerDictLoader } from "@pro.formily/antd"; -import React, { useMemo } from "react"; - -const loaders = { - bool: () => { - registerDictLoader("bool", () => { - return Promise.resolve([ - { label: "是", value: 1, color: "success" }, - { label: "否", value: 0, color: "error" }, - ]); - }); - }, - status: () => { - registerDictLoader("status", () => { - return Promise.resolve([ - { label: "已上线", value: 0, color: "success" }, - { label: "运行中", value: 1, color: "processing" }, - { label: "关闭", value: 2, color: "default" }, - { label: "已宕机", value: 3, color: "error" }, - { label: "已超载", value: 4, color: "warning" }, - ]); - }); - }, - classify: () => { - registerDictLoader("classify", () => { - return Promise.resolve([ - { label: "文艺", value: 0 }, - { label: "喜剧", value: 1 }, - { label: "爱情", value: 2 }, - { label: "动画", value: 3 }, - { label: "悬疑", value: 4 }, - { label: "科幻", value: 5 }, - ]); - }); - }, -}; - -const SchemaField = createSchemaField({ - components: { - FormItem, - Input, - Select, - Radio, - Checkbox, - FormGrid, - FormLayout, - Dict, - Space, - }, - scope: { - dict, - }, -}); - -loaders.bool(); -loaders.status(); -loaders.classify(); - -type SchemaShape = React.ComponentProps["schema"]; - -const schema: SchemaShape = { - type: "object", - properties: { - layout: { - type: "void", - "x-decorator": "FormLayout", - "x-decorator-props": { - layout: "vertical", - }, - "x-component": "FormGrid", - "x-component-props": { - maxColumns: 2, - minColumns: 2, - }, - properties: { - dict: { - title: "DICT 只读组件", - type: "string", - "x-decorator": "FormItem", - "x-component": "Dict", - "x-data": { - dict: "classify", - }, - "x-value": [1, 3], - }, - dict2: { - title: "DICT 只读组件 TAG 形态", - type: "string", - "x-decorator": "FormItem", - "x-component": "Dict", - "x-component-props": { - type: "tag", - }, - "x-data": { - dict: "classify", - }, - "x-value": [1, 3], - }, - select1: { - title: "多选 SELECT", - type: "string", - "x-decorator": "FormItem", - "x-component": "Select", - "x-component-props": { - mode: "multiple", - }, - "x-data": { - dict: "classify", - }, - }, - select1badge: { - title: "多选 SELECT READ PRETTY 徽章形态", - type: "string", - "x-read-pretty": true, - "x-reactions": { - dependencies: [".select1"], - fulfill: { - schema: { - "x-value": "{{$deps[0]}}", - }, - }, - }, - "x-decorator": "FormItem", - "x-component": "Select", - "x-component-props": { - type: "badge", - }, - "x-data": { - dict: "classify", - }, - }, - select2: { - title: "单选 SELECT", - type: "string", - "x-decorator": "FormItem", - "x-component": "Select", - "x-data": { - dict: "classify", - }, - }, - slect2tag: { - title: "单选 SELECT pretty 标签形态", - type: "string", - "x-read-pretty": true, - "x-reactions": { - dependencies: [".select2"], - fulfill: { - schema: { - "x-value": "{{$deps[0]}}", - }, - }, - }, - "x-decorator": "FormItem", - "x-component": "Select", - "x-component-props": { - type: "tag", - }, - "x-data": { - dict: "classify", - }, - }, - radio: { - title: "Radio 单选", - type: "string", - "x-decorator": "FormItem", - "x-component": "Radio.Group", - "x-data": { - dict: "status", - }, - }, - radiobadge: { - title: "Radio 单选 pretty 徽章形态", - type: "string", - "x-read-pretty": true, - "x-reactions": { - dependencies: [".radio"], - fulfill: { - schema: { - "x-value": "{{$deps[0]}}", - }, - }, - }, - "x-decorator": "FormItem", - "x-component": "Radio.Group", - "x-component-props": { - type: "badge", - }, - "x-data": { - dict: "status", - }, - }, - checkbox: { - title: "Checkbox 多选", - type: "string", - "x-decorator": "FormItem", - "x-component": "Checkbox.Group", - "x-data": { - dict: "status", - }, - }, - checkboxtag: { - title: "Checkbox 多选 pretty 标签形态", - type: "string", - "x-read-pretty": true, - "x-reactions": { - dependencies: [".checkbox"], - fulfill: { - schema: { - "x-value": "{{$deps[0]}}", - }, - }, - }, - "x-decorator": "FormItem", - "x-component": "Checkbox.Group", - "x-component-props": { - type: "tag", - }, - "x-data": { - dict: "status", - }, - }, - }, - }, - }, -}; - -const DictDemo = () => { - const form = useMemo(() => { - return createForm({ - effects(fform) { - dictEffects(fform); - }, - }); - }, []); - return ( - - - - ); -}; - -export default DictDemo; diff --git a/docs/components/demos/DictDemo2.tsx b/docs/components/demos/DictDemo2.tsx deleted file mode 100644 index bf3c37a..0000000 --- a/docs/components/demos/DictDemo2.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { observer } from "@formily/react"; -import { dict, registerDictLoader } from "@pro.formily/antd"; -import { Button, Space } from "antd"; - -const loader = registerDictLoader("framework", () => { - return Promise.resolve([ - { label: "vue", value: 0 }, - { label: "react", value: 1 }, - { label: "solid", value: 2 }, - ]); -}); - -const LiveDict = observer(() => { - const emap = dict.framework?.emap; - // console.log("🚀 ~ LiveDict ~ dict:", dict.framework); - return ( -
-
{ - // console.log("🚀 ~ LOG dict:", toJS(dict.framework)); - }} - > - LOG -
-
    -
  • - value: {emap?.vue} , label: {emap?.[0]} -
  • -
  • - value: {emap?.react} , label: {emap?.[1]} -
  • -
  • - value: {emap?.solid} , label: {emap?.[2]} -
  • -
-
- ); -}); - -const Loader = () => { - return ( - - ); -}; - -export default () => { - return ( - - -
- -
-
- ); -}; diff --git a/docs/components/demos/ProEnum.tsx b/docs/components/demos/ProEnum.tsx new file mode 100644 index 0000000..77605c3 --- /dev/null +++ b/docs/components/demos/ProEnum.tsx @@ -0,0 +1,360 @@ +import qs from "qs"; +import jsonp from "fetch-jsonp"; +import { + Checkbox, + FormGrid, + FormItem, + FormLayout, + Input, + Radio, + Select, + Space, +} from "@formily/antd"; +import { createForm } from "@formily/core"; +import { FormProvider, createSchemaField } from "@formily/react"; +import { + CascaderPlus, + Suggestion, + ProEnum, + ProEnumPretty, + useProEnumEffects, +} from "@pro.formily/antd"; +import React, { useMemo } from "react"; +import localtionList from "china-location/dist/location.json"; +import { Button } from "antd"; + +export interface OptionData { + label: string; + value: string | number; + isLeaf?: boolean; + children?: OptionData[]; + loading?: boolean; +} + +export const flat = ( + json: Record< + string, + { + name: string; + code: string; + children?: { + name: string; + code: string; + children?: { name: string; code: string }[]; + }[]; + cities: Record< + string, + { + name: string; + code: string; + children?: { + name: string; + code: string; + }[]; + districts: Record; + } + >; + } + >, +) => { + const flatten: { parent?: string; code: string; name: string }[] = []; + + const tree = Object.values(json).map((province) => { + flatten.push({ code: province.code, name: province.name }); + province.children = Object.values(province.cities).map((city) => { + // 拍平的结构要求 parentId 不能重复, 这个数据里面直辖市是一样的, 搞一下 + const cityCode = + city.code === province.code ? `${city.code}00` : city.code; + + flatten.push({ + code: cityCode, + name: city.name, + parent: province.code, + }); + city.code = cityCode; + city.children = Object.entries(city.districts).map(([code, name]) => { + const distCode = + code === cityCode || code === province.code ? `${code}0000` : code; + flatten.push({ code: distCode, name, parent: cityCode }); + return { code, name } as any; + }); + return city; + }); + return province; + }); + return { flatten, tree }; +}; + +const buildTree = (parent: ReturnType["tree"]) => { + const tree = parent.reduce((root, item) => { + // item.children = + const node: OptionData = { + label: item.name, + value: item.code, + isLeaf: !(Array.isArray(item.children) && item.children.length > 0), + }; + if (!node.isLeaf) { + node.children = buildTree(item.children as any); + } + root.push(node); + return root; + }, [] as OptionData[]); + return tree; +}; + +const fake = (): Promise => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(localtionList); + }, 500); + }); +}; + +export const getById = (parent?: React.Key) => { + return fake() + .then((origin) => flat(origin)) + .then(({ flatten }) => { + return flatten.filter((x) => x.parent === parent); + }); +}; +export const loadData = (options: OptionData[]): Promise => { + const keys = [undefined, ...options.map((x) => x.value)]; + const last = options[options.length - 1]; + return getById(last?.value).then((opts) => + opts.map((item) => { + return { + value: item.code, + label: item.name, + // 需要给出叶子条件, 这里我们是省市区3级, 所以keys长度是3的时候就到最后一级别了 + isLeaf: keys.length === 3, + }; + }), + ); +}; +const list = [ + { + label: "啊", + value: "a", + }, + { + label: "哦", + value: "o", + }, + { + label: "呃", + value: "e", + }, +]; +const suggest = (params: object & { kw: string }) => { + console.log("search params", params); + const str = qs.stringify({ + code: "utf-8", + q: params?.kw, + }); + return jsonp(`https://suggest.taobao.com/sug?${str}`) + .then((response: any) => response.json()) + .then((d: any) => { + const { result } = d; + const data: { label: string; value: string }[] = result.map( + (item: any) => { + return { + value: item[0] as string, + label: item[0] as string, + }; + }, + ); + return data; + }); +}; + +const enums = { + list: ProEnum.from(list), + lazyList: ProEnum.from(() => { + return new Promise((resolve) => { + console.log(`🚀 ~ request list...:`); + setTimeout(() => { + resolve(list); + }, 1000); + }); + }), + suggest: ProEnum.from(suggest, { mapToProp: "suggest" }), + linkage: ProEnum.from(loadData, { mapToProp: "loadData" }), +}; + +const SchemaField = createSchemaField({ + components: { + FormItem, + Input, + Select, + Radio, + Checkbox, + FormGrid, + FormLayout, + Space, + CascaderPlus, + Suggestion, + ProEnumPretty, + }, + scope: { + enums, + }, +}); + +type SchemaShape = React.ComponentProps["schema"]; + +const schema: SchemaShape = { + type: "object", + properties: { + layout: { + type: "void", + "x-decorator": "FormLayout", + "x-decorator-props": { + layout: "vertical", + }, + "x-component": "FormGrid", + "x-component-props": { + maxColumns: 2, + minColumns: 2, + }, + properties: { + select: { + title: "Select", + type: "string", + "x-decorator": "FormItem", + "x-component": "Select", + enum: "{{enums.list}}", + "x-component-props": { + showType: "badge", + }, + }, + lazySelect: { + title: "Lazy Select", + type: "string", + "x-decorator": "FormItem", + "x-component": "Select", + enum: "{{enums.lazyList}}", + "x-component-props": { + mode: "multiple", + showType: "tag", + }, + }, + radio: { + title: "Radio", + type: "string", + "x-decorator": "FormItem", + "x-component": "Radio.Group", + enum: "{{enums.list}}", + }, + lazyRadio: { + title: "Lazy Radio", + type: "string", + "x-decorator": "FormItem", + "x-component": "Radio.Group", + enum: "{{enums.lazyList}}", + }, + checkbox: { + title: "Checkbox", + type: "string", + "x-decorator": "FormItem", + "x-component": "Checkbox.Group", + enum: "{{enums.list}}", + }, + lazyCheckbox: { + title: "Lazy Checkbox", + type: "string", + "x-decorator": "FormItem", + "x-component": "Checkbox.Group", + enum: "{{enums.lazyList}}", + }, + pretty: { + title: "ProEnumPretty", + type: "string", + "x-decorator": "FormItem", + "x-component": "ProEnumPretty", + default: ["a", "o"], + enum: "{{enums.list}}", + }, + prettybadge: { + title: "ProEnumPretty Badge", + type: "string", + "x-decorator": "FormItem", + "x-component": "ProEnumPretty", + default: ["a", "o"], + enum: "{{enums.list}}", + "x-component-props": { + showType: "badge", + }, + }, + prettytag: { + title: "ProEnumPretty Tag", + type: "string", + "x-decorator": "FormItem", + "x-component": "ProEnumPretty", + default: ["a", "e"], + enum: "{{enums.list}}", + "x-component-props": { + showType: "tag", + }, + }, + suggest: { + title: "Suggestion", + type: "string", + "x-decorator": "FormItem", + "x-component": "Suggestion", + "x-component-props": { + placeholder: "查询淘宝商品..", + multiple: true, + }, + enum: "{{enums.suggest}}", + // "x-data": { + // proEnum: { + // mapToProp: "suggest" + // } + // }, + }, + linkage: { + title: "Linkage", + type: "string", + "x-decorator": "FormItem", + "x-component": "CascaderPlus", + enum: "{{enums.linkage}}", + }, + }, + }, + }, +}; + +const DictDemo = () => { + const form = useMemo(() => { + return createForm({ + effects() { + useProEnumEffects(); + }, + }); + }, []); + return ( + + + + + + + + + + ); +}; + +export default DictDemo; diff --git a/docs/components/demos/Suggestion.tsx b/docs/components/demos/Suggestion.tsx index 4bf589d..dcaa02d 100644 --- a/docs/components/demos/Suggestion.tsx +++ b/docs/components/demos/Suggestion.tsx @@ -10,7 +10,7 @@ export const suggest = (params: object & { kw: string }) => { console.log("search params", params); const str = qs.stringify({ code: "utf-8", - q: params.kw, + q: params?.kw, }); return jsonp(`https://suggest.taobao.com/sug?${str}`) .then((response: any) => response.json()) diff --git a/docs/components/plus/linkage.mdx b/docs/components/plus/cascader-plus.mdx similarity index 89% rename from docs/components/plus/linkage.mdx rename to docs/components/plus/cascader-plus.mdx index f7ed04a..d210926 100644 --- a/docs/components/plus/linkage.mdx +++ b/docs/components/plus/cascader-plus.mdx @@ -1,4 +1,4 @@ -# 🎹 Linkage - 级联查询 +# 🎹 CascaderPlus - 级联查询 > 主要为了解决一个很蛋疼的问题, 比如省市区, 返回值如果只有 `code` 的话, 加载不出来 `label` @@ -11,5 +11,5 @@ - 增强: 初始值只有 `value` 的时候, 自动调用接口补全 `label` 展示, 请刷新注意下面两个反显的示意 ## 代码案例 - + diff --git a/docs/components/plus/dict.mdx b/docs/components/plus/dict.mdx index e541fc6..a3a9d3d 100644 --- a/docs/components/plus/dict.mdx +++ b/docs/components/plus/dict.mdx @@ -1,4 +1,6 @@ -# 📕 Dict - 远程词典 +# [废弃⚠️] 请使用 ProEnum ~~📕 Dict - 远程词典~~ + +> 已废弃!! > Formily 模型中的 `enum` 字段能够满足本地的枚举词典的需求, 但远程的其实更常用一些; @@ -72,7 +74,6 @@ dict.bool?.emap ### 代码案例: 结合 `observer` 中使用 - 在 Formily 中使用的话, 我们可以结合 `effect` 和 `schema` 配置, ```tsx | pure @@ -101,5 +102,4 @@ const schema = { ## 代码案例: 在 Formily 中使用 - diff --git a/docs/components/pro/pro-enum.mdx b/docs/components/pro/pro-enum.mdx new file mode 100644 index 0000000..265d921 --- /dev/null +++ b/docs/components/pro/pro-enum.mdx @@ -0,0 +1,105 @@ +# 🪂 Pro Enum + +> ProEnum 是为了解决常见的 枚举/Select/Checkbox.Group/Radio.Group 甚至Suggestion/Cascader 的异步数据源的解决方案 + + +## 设计思路 +Formily 模型中的 `enum` 字段能够满足本地的枚举词典的需求, 但远程的其实更常用一些; 基于这个事实,使用[归纳演绎法](https://mp.weixin.qq.com/s/9gCJudLQTjVMcm4DzMbgvA), 可以发现,上面提到的几个组件,所用到的数据源, 可以归纳为下面这种格式; + +```tsx | pure +type Value = string | number; +export type ProEnumItem = { + label: string; + value: Value; + disabled?: boolean; + /** tree like */ + isLeaf?: boolean; + children?: ProEnumItem[]; + /** lazy */ + loading?: boolean; + /** read prettery */ + color?: keyof typeof BUILTIN_COLOR | (string & {}); +}; +``` + +因此, 设计了一个专用的 `ProEnum` 组件, 来增强原有的 `enum` 属性的表现力,当然, 这需要我们使用 `useProEnumEffects` 使用 `FieldEffects` 对 Field 改进劫持。 + +### 针对 Select/Radio.Group/Checkbox.Group + +来讲, 劫持动作包含两部分 + +- `enum` 属性支持 `ProEnum` 实例, 而这, 是一个支持远程加载的枚举, 并在组件 init 钩子中进行自动加并注入的 field.dataSource 属性, 也就是 enum 原来对应的属性值 + +- 劫持 `readPretty` 形态下的 field.component, 优化枚举值的展示 + +### 针对 Suggestion/CascaderPlus 这种 + +更有用的是 异步数据的加载器, 而不是 `dataSource`, 因此如果 loader 被 mapToProp 到 field.componentProps 上的话, 我们认为加载流程会被托管到组件, 那么上述的自动加载和劫持都是不会生效的。 + +取而代之的是, 你需要在组件内部处理这个异步请求; + + + + +## 代码案例 + +如此,我们就可以通过固定的形式, 方便的在 Formliy 中使用异步的枚举值 + + + +## API + +### ProEnum + +```ts | pure + +type ProEnumOption = { + /** loader 映射到 componentProps 上的名称 */ + mapToProp: string; + /** 是否进行缓存, 默认开启, 显示设置 false 关闭缓存 */ + cache?: boolean; +}; + +type TItemsOrLoader = + | ProEnumItem[] + | ((params?: any) => Promise); + +export class ProEnum { + static from(optionsOrLoader: TItemsOrLoader, opt?: ProEnumOption) { + return new ProEnum(optionsOrLoader, opt); + } + + static is(x: any): x is ProEnum { + return x instanceof ProEnum; + } + + isAsync = false; + remapPropName: string | null = null; + options: ProEnumItem[] = []; + + loader(params?: any): Promise { + return Promise.resolve(this.options); + } +} +``` + +### Schema 属性 + +在 Schema 表达式中, 使用 x-data#enum 这个字段可以添加一些属性 + +```json +{ + + "proEnum": { + "type": "string", + "x-data": { + "enum": { + "mapToProp": "loadData", + // TODO: test compile + "params": "{{object or expression}}", + } + } + } +} +``` + diff --git a/src/linkage/index.tsx b/src/cascader-plus/index.tsx similarity index 73% rename from src/linkage/index.tsx rename to src/cascader-plus/index.tsx index 2d29149..f2b7ab4 100644 --- a/src/linkage/index.tsx +++ b/src/cascader-plus/index.tsx @@ -1,13 +1,14 @@ -import { observer } from "@formily/react"; +import { connect, mapProps, observer } from "@formily/react"; import { model } from "@formily/reactive"; import React, { useEffect, useMemo, useRef } from "react"; -import { Cascader } from "../adaptor"; +import { Cascader as BaseCascader } from "../adaptor"; +import { prettyEnum } from "../shared"; -export interface LinkageOption { - label?: string; - value?: Value; +export interface CascaderPlusOption { + label: string; + value: Value; isLeaf?: boolean; - children?: LinkageOption[]; + children?: CascaderPlusOption[]; disabled?: boolean; loading?: boolean; } @@ -21,13 +22,13 @@ type LabelValueType = { label: string; value: ValueType }; export type LinkageValueType = LabelValueType[] | ValueType[]; -type CascaderProps = React.ComponentProps; +type CascaderProps = React.ComponentProps; const display: CascaderProps["displayRender"] = (label) => { return label.join("/"); }; -const mapProps = (props: React.ComponentProps) => { +const remapProps = (props: React.ComponentProps) => { // type ChangeFnParams = Parameters['onChange']>; const onChange = (values: any, options: any) => { let next = values; @@ -70,7 +71,7 @@ const mapProps = (props: React.ComponentProps) => { }; const fill = < - Option extends LinkageOption = LinkageOption, + Option extends CascaderPlusOption = CascaderPlusOption, >( $options: Option[], loader: (opts: Option[]) => Promise, @@ -121,7 +122,32 @@ const fill = < : Promise.resolve([]); }; -export const Linkage = observer( +// export const pretty = ( +// value: ValueType[] | LabelValueType[], +// options: CascaderPlusOption[], +// ) => { +// let labels = []; +// if ((value?.[0] as LabelValueType)?.label) { +// labels = (value as LabelValueType[]).map((item) => item.label); +// } else { +// const found = value.reduce( +// (info, val) => { +// // biome-ignore lint/suspicious/noDoubleEquals: +// const me = info.parent.find((x) => x.value == val); +// if (me) { +// info.chain.push(me.label!); +// info.parent = me.children!; +// } +// return info; +// }, +// { parent: options, chain: [] as string[] }, +// ); +// labels = found.chain; +// } +// return labels; +// }; + +const Cascader = observer( ( props: Omit & { value: TValueType; @@ -132,21 +158,29 @@ export const Linkage = observer( /** 懒加载, 与整棵树加载不能共存 */ loadData?: ( selectOptions: LabelValueType[], - ) => Promise[]>; + ) => Promise[]>; /** loadData 是否返回整棵树加载, 与懒加载不能共存 */ all?: boolean; + readPretty?: boolean; }, ) => { - const { loadData, all, labelInValue, disabled, multiple, ...others } = - props; + const { + loadData, + all, + readPretty, + labelInValue, + disabled, + multiple, + ...others + } = props; const state = useMemo(() => { return model({ loading: false, - options: [] as LinkageOption[], + options: [] as CascaderPlusOption[], }); }, []); - const { onChange, value } = mapProps(props as any); + const { onChange, value } = remapProps(props as any); const loaderCache = useRef({}); useEffect(() => { @@ -178,7 +212,7 @@ export const Linkage = observer( const _loadData = all || !loadData ? undefined - : (options: LinkageOption[]) => { + : (options: CascaderPlusOption[]) => { const last = options[options.length - 1]; if (last.children) return; last.loading = true; @@ -198,8 +232,13 @@ export const Linkage = observer( state.loading = false; }); }; - return ( - + + {display(prettyEnum(value, state.options))} + + ) : ( + = T extends (infer P)[] ? P : unknown; - -const statusColors = { - // status - success: "green", - processing: "blue", - error: "red", - warning: "gold", - default: "#f1f1f1", -} as const; - -export const colors = { - length: 11, - 0: "magenta", - 1: "green", - 2: "cyan", - 3: "dodgerblue", - 4: "purple", - 5: "red", - 6: "orange", - 7: "lime", - 8: "blue", - 9: "volcano", - 10: "gold", - magenta: "magenta", - green: "green", - cyan: "cyan", - dodgerblue: "dodgerblue", - purple: "purple", - red: "red", - orange: "orange", - lime: "lime", - blue: "blue", - volcano: "volcano", - gold: "gold", - ...statusColors, -} as const; - -export const colorByStatus = (a?: ColorsKey) => (statusColors as any)[a!] ?? a; - -export const isColorStatus = (x = ""): x is keyof typeof statusColors => { - return Boolean((statusColors as any)[x]); -}; - -type RGB = `rgb(${number}, ${number}, ${number})`; -type RGBA = `rgba(${number}, ${number}, ${number}, ${number})`; -type HEX = `#${string}`; - -type CSSColor = RGB | RGBA | HEX; - -export type ColorsKey = K extends number - ? never - : K extends "length" - ? never - : K | CSSColor; diff --git a/src/dict/helper.ts b/src/dict/helper.ts deleted file mode 100644 index a652a4e..0000000 --- a/src/dict/helper.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { ColorsKey, colorByStatus, colors } from "./colors"; - -export type TDictShape = { - emap: { - [x: string]: string | number; - [x: number]: string | number; - }; - colors: { - [x: string]: string; - [x: number]: string; - }; - options: { - key?: string | number; - label: string; - value: string | number; - color?: ColorsKey; - }[]; -}; - -export type TDictItem = Omit; - -const getColorByIdx = (idx: number) => - colors[(idx % colors.length) as keyof typeof colors]; - -export const listToDict = (list: TDictItem[] = []): TDictShape => { - const dict = { - emap: list.reduce((ret: any, cur: any) => { - ret[cur.value] = cur.label; - ret[cur.label] = cur.value; - return ret; - }, {}), - colors: list.reduce((ret: any, cur: any, idx) => { - const color = cur.color || getColorByIdx(idx); - ret[cur.value] = color; - ret[cur.label] = color; - return ret; - }, {}), - options: list.map((x, idx) => ({ - ...x, - key: x.value, - color: x.color || (getColorByIdx(idx) as ColorsKey), - })), - }; - return dict; -}; -import type { Form } from "@formily/core"; -import { onFieldMount, onFieldReact } from "@formily/core"; -import { observable } from "@formily/reactive"; -import type React from "react"; -import { Dict } from "./index"; - -export type TDictLoaderFactory = () => Promise; - -export const memo: Record = observable({}); - -export const dict = memo as Record; - -const loaders: Record Promise> = {}; - -const pendings: Record | undefined> = {}; - -export const registerDictLoader = ( - name: string, - loaderFactory: TDictLoaderFactory, -) => { - loaders[name] = () => { - return loaderFactory().then((list) => { - const mydict = listToDict(list); - memo[name] = mydict; - return memo[name]; - }); - }; - return loaders[name]; -}; - -export const dictEffects = (form: Form, hijack?: boolean) => { - onFieldMount("*", (field) => { - const maybe = field.data?.dict; - if (!maybe) return; - const noCache = field.data.dictNoCache; - - if (!memo[maybe] && !loaders[maybe]) { - throw new Error(`词典 ${maybe} 的加载器不存在!`); - } - - if (noCache !== true && memo[maybe]) { - field.setState((s) => { - s.dataSource = memo[maybe].options; - if (s.componentProps) { - s.componentProps.options = s.dataSource; - } - }); - } else if (maybe && loaders[maybe]) { - field.setState((s) => { - s.loading = true; - }); - - const task = pendings[maybe] || loaders[maybe](); - - pendings[maybe] = task - .then((mydict) => { - field.setState((s) => { - s.dataSource = mydict.options; - if (s.componentProps) { - s.componentProps.options = s.dataSource; - } - s.loading = false; - }); - return mydict; - }) - .catch((e) => { - field.setState((s) => { - s.loading = false; - }); - throw e; - }); - } - }); - - onFieldReact("*", (field) => { - if (!hijack) return; - const maybe = field.data?.dict; - if (!maybe) return; - - const same = ( - origin: typeof field.component, - comp: string | React.FunctionComponent | typeof field.component, - ) => { - const ocomp = Array.isArray(origin) ? origin[0] : origin; - const dcomp = Array.isArray(comp) ? comp[0] : comp; - return ocomp === dcomp; - }; - - const readPretty = field.readPretty || field.readOnly; - if (readPretty) { - if (!same(field.component, Dict)) { - field.data.__origin_component = field.component; - field.setComponent(Dict); - } - } else if ( - field.data?.__origin_component && - !same(field.data.__origin_component, field.component) - ) { - field.setComponent(field.data.__origin_component); - } - }); -}; diff --git a/src/dict/index.ts b/src/dict/index.ts deleted file mode 100644 index ec466a4..0000000 --- a/src/dict/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -export * from "./dict"; -export { - dict, - dictEffects, - listToDict, - registerDictLoader, -} from "./helper"; - -export type { - TDictItem, - TDictLoaderFactory, - TDictShape, -} from "./helper"; diff --git a/src/index.ts b/src/index.ts index 6c6f13d..23d637b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,10 +2,10 @@ export * from "./pro-array-table"; export * from "./query-list"; export * from "./query-form"; export * from "./query-table"; -export * from "./dict"; export * from "./image-view"; export * from "./long-text"; export * from "./suggestion"; export * from "./shared"; -export * from "./linkage"; +export * from "./cascader-plus"; export * from "./shadow-form"; +export * from "./pro-enum"; diff --git a/src/pro-enum/color.ts b/src/pro-enum/color.ts new file mode 100644 index 0000000..5e32fc2 --- /dev/null +++ b/src/pro-enum/color.ts @@ -0,0 +1,39 @@ +import type { ProEnumItem } from "./pro-enum"; + +export const BUILTIN_COLOR = { + success: "green", + processing: "blue", + error: "red", + warning: "gold", + normal: "#f1f1f1", + magenta: "magenta", + green: "green", + cyan: "cyan", + dodgerblue: "dodgerblue", + purple: "purple", + red: "red", + orange: "orange", + lime: "lime", + blue: "blue", + volcano: "volcano", + gold: "gold", +} as const; + +export const fillColors = (options: ProEnumItem[]) => { + let acc = 0; + const withColors = options.map((item) => { + if (item.color) { + const color = + BUILTIN_COLOR[item.color as keyof typeof BUILTIN_COLOR] ?? item.color; + item.color = color; + } else { + const keys = Object.keys(BUILTIN_COLOR); + acc++; + const idx = acc % keys.length; + const colorKey = keys[idx] as keyof typeof BUILTIN_COLOR; + item.color = BUILTIN_COLOR[colorKey]; + } + return item; + }); + return withColors; +}; diff --git a/src/pro-enum/index.ts b/src/pro-enum/index.ts new file mode 100644 index 0000000..44896f8 --- /dev/null +++ b/src/pro-enum/index.ts @@ -0,0 +1,2 @@ +export { ProEnum, useProEnumEffects } from "./pro-enum"; +export { ProEnumPretty } from "./pretty"; diff --git a/src/dict/dict.tsx b/src/pro-enum/pretty.tsx similarity index 61% rename from src/dict/dict.tsx rename to src/pro-enum/pretty.tsx index 0e42680..0f52605 100644 --- a/src/dict/dict.tsx +++ b/src/pro-enum/pretty.tsx @@ -1,29 +1,25 @@ -import { useMemo } from "react"; +import type { Value, ProEnumItem } from "./pro-enum"; import { Badge, Space, Tag } from "../adaptor/index"; -import { colorByStatus, isColorStatus } from "./colors"; -import type { TDictShape } from "./helper"; - -type Input = string | number | Input[]; - -export const Dict = (props: { +import { mapProps, connect } from "@formily/react"; +import { useMemo } from "react"; +export const EnumReadPretty: React.FC<{ /** * 对应枚举值 - * @type Input = string | number | Input[]; + * @type Value = string | number | Value[]; */ - value?: Input; + value?: Value | Value[]; /** 渲染形式 Badeg | Tag */ - type?: "badge" | "tag"; + showType?: "badge" | "tag"; /** 选项 */ - options?: TDictShape["options"]; + options?: ProEnumItem[]; /** * 严格模式, 将使用 === 来对比 value * 非严格模式下采用 == 对比 * @default false */ strict?: boolean; -}) => { - const { strict, type, value, options } = props; - +}> = (props) => { + const { strict, showType: type, value, options } = props; const items = useMemo(() => { if (!Array.isArray(options)) return []; const ret = Array.isArray(value) @@ -40,16 +36,9 @@ export const Dict = (props: { {items.map((item) => { return type === "badge" ? ( - + ) : type === "tag" ? ( - + {item?.label} ) : ( @@ -59,3 +48,8 @@ export const Dict = (props: { ); }; + +export const ProEnumPretty = connect( + EnumReadPretty, + mapProps({ dataSource: "options" }), +); diff --git a/src/pro-enum/pro-enum.ts b/src/pro-enum/pro-enum.ts new file mode 100644 index 0000000..e71212a --- /dev/null +++ b/src/pro-enum/pro-enum.ts @@ -0,0 +1,168 @@ +import { Field, isDataField, onFieldInit, onFieldReact } from "@formily/core"; +import { BUILTIN_COLOR, fillColors } from "./color"; +import { ProEnumPretty } from "./pretty"; + +export type Value = string | number; + +const safeStringify = (x: any) => { + try { + return JSON.stringify(x); + } catch (error) { + return `ERROR_STRING_ON${x?.toString()}`; + } +}; + +export type ProEnumItem = { + label: string; + value: Value; + disabled?: boolean; + /** tree like */ + isLeaf?: boolean; + children?: ProEnumItem[]; + /** lazy */ + loading?: boolean; + /** read prettery */ + color?: keyof typeof BUILTIN_COLOR | (string & {}); +}; + +type TItemsOrLoader = + | ProEnumItem[] + | ((params?: any) => Promise); + +type ProEnumOption = { + mapToProp: string; + cache?: boolean; +}; + +export class ProEnum { + static from(optionsOrLoader: TItemsOrLoader, opt?: ProEnumOption) { + return new ProEnum(optionsOrLoader, opt); + } + + static is(x: any): x is ProEnum { + return x instanceof ProEnum; + } + + private pendings: Record> = {}; + private cached: Record = {}; + + isAsync = false; + remapPropName: string | null = null; + options: ProEnumItem[] = []; + + constructor(optionsOrLoader: TItemsOrLoader, opt?: ProEnumOption) { + this.remapPropName = opt?.mapToProp ?? null; + if (typeof optionsOrLoader === "function") { + this.isAsync = true; + this.loader = (params?: any) => { + const cacheKey = safeStringify(params); + // 缓存 + if (opt?.cache !== false && this.cached[cacheKey]) { + return Promise.resolve(this.cached[cacheKey]); + } + // 并发 + if (this.pendings[cacheKey] as any) return this.pendings[cacheKey]; + + this.pendings[cacheKey] = optionsOrLoader(params).then((options) => { + this.options = fillColors(options); + this.cached[cacheKey] = this.options; + return options; + }); + return this.pendings[cacheKey]; + }; + } else { + this.options = fillColors(optionsOrLoader); + } + } + + get emap() { + return this.options.reduce>((em, item) => { + em.set(item.value, item.label); + em.set(item.label, item.value); + return em; + }, new Map()); + } + + loader(params?: any): Promise { + return Promise.resolve(this.options); + } +} + +const PRO_ENUM_KEY = "__pro_enum__"; +const PRO_ENUM_HIJACK_ORIGIN_COMP_KEY = "__pro_enum_hijack_origin_comp__"; +const PRO_ENUM_IN_DATA_KEY = "enum"; +const LOADER_REMAP_KEY = "mapToProp"; +const LOADER_PARAMS_KEY = "params"; + +const sameComp = ( + origin: Field["component"], + comp: string | React.FunctionComponent | Field["component"], +) => { + const ocomp = Array.isArray(origin) ? origin[0] : origin; + const dcomp = Array.isArray(comp) ? comp[0] : comp; + return ocomp === dcomp; +}; + +export const useProEnumEffects = (opts?: { + // 劫持 read pretty + hijackReadPretty: boolean; +}) => { + onFieldInit("*", (field) => { + if (!isDataField(field)) return; + if (!ProEnum.is(field.dataSource)) return; + // Immediately, case by dataSource will be consumer by + // componet, function maybe error; + const proEnum = field.dataSource; + field.dataSource = undefined!; + + field.setState((s) => { + s.data = s.data ?? {}; + s.data[PRO_ENUM_KEY] = proEnum; + + const remapToProp = proEnum.remapPropName + ? proEnum.remapPropName + : s.data[PRO_ENUM_IN_DATA_KEY]?.[LOADER_REMAP_KEY]; + + if (proEnum.isAsync && remapToProp) { + s.componentProps = s.componentProps ?? {}; + s.componentProps[remapToProp] = proEnum.loader; + } else if (proEnum.isAsync) { + // auto load, + // TODO: 需要在 fieldReact 的时候重新 autoload 吗, 我暂时认为不需要 + const params = field.data[PRO_ENUM_IN_DATA_KEY]?.[LOADER_PARAMS_KEY]; + field.loading = true; + proEnum + .loader(params) + .then((options) => { + field.dataSource = options; + }) + .finally(() => { + field.loading = false; + }); + } else { + s.dataSource = proEnum.options; + } + }); + }); + + if (opts?.hijackReadPretty !== false) { + onFieldReact("*", (field) => { + if (!isDataField(field)) return; + if (!ProEnum.is(field.data[PRO_ENUM_KEY])) return; + if (!field.dataSource) return; + const readPretty = field.readPretty || field.readOnly; + if (readPretty) { + if (!sameComp(field.component, ProEnumPretty)) { + field.data[PRO_ENUM_HIJACK_ORIGIN_COMP_KEY] = field.component; + field.data[PRO_ENUM_HIJACK_ORIGIN_COMP_KEY] = field.component; + field.setComponent(ProEnumPretty); + } + } else if ( + field.data?.[PRO_ENUM_HIJACK_ORIGIN_COMP_KEY] && + !sameComp(field.data[PRO_ENUM_HIJACK_ORIGIN_COMP_KEY], field.component) + ) { + field.setComponent(field.data?.[PRO_ENUM_HIJACK_ORIGIN_COMP_KEY][0]); + } + }); + } +}; diff --git a/src/shared/index.ts b/src/shared/index.ts index 0f3fd54..916fcd9 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1,3 +1,4 @@ export * from "./useGrid"; export * from "./useRecord"; export * from "./withLayoutGrid"; +export * from "./prettyEnum"; diff --git a/src/shared/prettyEnum.ts b/src/shared/prettyEnum.ts new file mode 100644 index 0000000..6397fb3 --- /dev/null +++ b/src/shared/prettyEnum.ts @@ -0,0 +1,44 @@ +type Value = string | number; + +type OptionLike = { + label: string; + value: string | number; + children?: OptionLike[]; +}; + +const isLabelInValue = (x: any): x is OptionLike => x?.label != null; +const isLabelInValueList = (x: any[]): x is OptionLike[] => + isLabelInValue(x?.[0]); + +export const prettyEnum = ( + value: Value | OptionLike | Value[] | OptionLike[], + options: OptionLike[], +): string[] => { + let labels: string[] = []; + if (Array.isArray(value)) { + if (isLabelInValueList(value)) { + return value.map((item) => item.label); + } else { + const found = value.reduce( + (info, val) => { + // biome-ignore lint/suspicious/noDoubleEquals: + const me = info.parent.find((x) => x.value == val); + if (me) { + info.chain.push(me.label!); + info.parent = me.children!; + } + return info; + }, + { parent: options, chain: [] as string[] }, + ); + labels = found.chain; + } + return labels; + } else { + const label = isLabelInValue(value) + ? value.label + : // biome-ignore lint/suspicious/noDoubleEquals: + options.find((x) => x.value == value)?.label; + return label ? [label] : []; + } +}; diff --git a/src/suggestion/index.tsx b/src/suggestion/index.tsx index 5b64fbd..69ddbb2 100644 --- a/src/suggestion/index.tsx +++ b/src/suggestion/index.tsx @@ -1,13 +1,15 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { Select } from "../adaptor"; +import { connect, mapProps } from "@formily/react"; +import { prettyEnum } from ".."; -type Input = +type Value = | string | number | { label: string; value: string | number } - | Input[]; + | Value[]; -const getInit = (multiple?: boolean, value?: Input): Input => { +const getInit = (multiple?: boolean, value?: Value): Value => { let ret = value; if (multiple) { ret = Array.isArray(value) ? value : []; @@ -15,15 +17,16 @@ const getInit = (multiple?: boolean, value?: Input): Input => { return ret as any; }; -export const Suggestion: React.FC<{ +export const BaseSuggestion: React.FC<{ placeholder?: string; style?: React.CSSProperties; labelInValue?: boolean; params?: object; multiple?: boolean; - value?: Input; - onChange?: (neo: Input) => void; + value?: Value; + onChange?: (neo: Value) => void; disabled?: boolean; + readPretty?: boolean; suggest?: ( parmas: object & { kw: string }, ) => Promise<{ label: string; value: string | number }[]>; @@ -31,7 +34,7 @@ export const Suggestion: React.FC<{ const [data, setData] = useState<{ label: string; value: string | number }[]>( [], ); - const [value, setValue] = useState( + const [value, setValue] = useState( getInit(props.multiple, props.value), ); @@ -79,12 +82,15 @@ export const Suggestion: React.FC<{ } }; - const handleChange = (newValue: Input) => { + const handleChange = (newValue: Value) => { setValue(newValue); props.onChange?.(newValue); }; - return ( + return props.readPretty ? ( + // biome-ignore lint/complexity/noUselessFragments: + {prettyEnum(value as any, data)} + ) : (