@77-zhike/app-sdk 0.2.0-d7b1.6 → 0.2.0-d7b1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +192 -0
- package/package.json +1 -1
- package/src/cli/project-init.js +1 -0
- package/src/client/destinations.d.ts +25 -0
- package/src/client/destinations.js +70 -0
- package/src/client/external-url.js +9 -0
- package/src/client/index.d.ts +186 -3
- package/src/client/index.js +45 -3
- package/src/client/interactions.d.ts +7 -3
- package/src/client/interactions.js +19 -2
- package/src/client/points.generated.js +48 -0
- package/src/forms/components.generated.js +2 -2
- package/src/forms/factory.js +14 -5
- package/src/forms/index.d.ts +63 -3
- package/src/forms/index.js +2 -0
- package/src/forms/options.d.ts +7 -2
- package/src/forms/options.js +8 -1
- package/src/forms/record-options.d.ts +28 -0
- package/src/forms/record-options.js +92 -0
- package/src/forms/schema.d.ts +50 -5
- package/src/forms/schema.js +105 -0
- package/src/forms/state.d.ts +28 -0
- package/src/forms/state.js +92 -0
- package/src/forms/validation.d.ts +9 -0
- package/src/forms/validation.js +83 -0
- package/src/forms/values.d.ts +5 -0
- package/src/forms/values.js +38 -0
- package/src/server/index.d.ts +15 -0
- package/src/server/index.js +20 -0
- package/src/toolchain/real-build.js +29 -3
- package/src/toolchain/source-discovery.js +202 -16
- package/src/ui/components.generated.js +3 -1
- package/src/ui/factory.js +50 -3
- package/src/ui/index.d.ts +157 -8
- package/src/ui/index.js +1 -1
- package/src/ui/validation.js +38 -0
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { assertExternalUrl } from './external-url.js';
|
|
2
|
+
import { assertAppDestination } from './destinations.js';
|
|
3
|
+
|
|
1
4
|
const fail = () => { throw new Error('runtime_interaction_invalid'); };
|
|
2
5
|
const text = (value, max, required = false) => value === undefined ? !required : typeof value === 'string' && value.length <= max && (!required || value.length > 0);
|
|
3
6
|
const externalUrl = value => {
|
|
@@ -9,13 +12,19 @@ const externalUrl = value => {
|
|
|
9
12
|
};
|
|
10
13
|
export function assertHostInteraction(operation, input) {
|
|
11
14
|
if (!input || typeof input !== 'object' || Array.isArray(input)) fail();
|
|
12
|
-
const allowed = operation === 'showToast' ? ['message','tone'] : operation === 'confirm' ? ['title','description','confirmLabel','cancelLabel'] : operation === 'openExternalUrl' ? ['url'] : [];
|
|
15
|
+
const allowed = operation === 'showToast' ? ['message','tone'] : operation === 'confirm' ? ['title','description','confirmLabel','cancelLabel'] : operation === 'navigate' ? ['destination'] : operation === 'openExternal' ? ['href'] : operation === 'openExternalUrl' ? ['url'] : [];
|
|
13
16
|
if (!allowed.length || Object.keys(input).some(key=>!allowed.includes(key))) fail();
|
|
14
17
|
if (operation === 'showToast') {
|
|
15
18
|
if (!text(input.message,500,true) || (input.tone !== undefined && !['info','success','error'].includes(input.tone))) fail();
|
|
16
19
|
} else if (operation === 'confirm') {
|
|
17
20
|
if (!text(input.title,200,true) || !text(input.description,2000) || !text(input.confirmLabel,60) || !text(input.cancelLabel,60)) fail();
|
|
18
|
-
} else if (
|
|
21
|
+
} else if (operation === 'openExternalUrl') {
|
|
22
|
+
if (!externalUrl(input.url)) fail();
|
|
23
|
+
} else if (operation === 'openExternal') {
|
|
24
|
+
try { return Object.freeze({ href: assertExternalUrl(input.href) }); } catch { fail(); }
|
|
25
|
+
} else {
|
|
26
|
+
try { assertAppDestination(input.destination); } catch { fail(); }
|
|
27
|
+
}
|
|
19
28
|
return Object.freeze(Object.fromEntries(Object.entries(input).filter(([,value])=>value!==undefined)));
|
|
20
29
|
}
|
|
21
30
|
export function createClientInteractions(invoke) {
|
|
@@ -32,5 +41,13 @@ export function createClientInteractions(invoke) {
|
|
|
32
41
|
const request = assertHostInteraction('openExternalUrl',{url});
|
|
33
42
|
return invoke('openExternalUrl',request).then(value=>{if(value!==null)fail();});
|
|
34
43
|
},
|
|
44
|
+
openExternal(href) {
|
|
45
|
+
const request = assertHostInteraction('openExternal', {href});
|
|
46
|
+
return invoke('openExternal',request).then(value=>{if(typeof value!=='boolean')fail();return value;});
|
|
47
|
+
},
|
|
48
|
+
navigate(destination) {
|
|
49
|
+
const request = assertHostInteraction('navigate',{destination});
|
|
50
|
+
return invoke('navigate',request).then(value=>{if(value!==null)fail();});
|
|
51
|
+
},
|
|
35
52
|
});
|
|
36
53
|
}
|
|
@@ -14,6 +14,54 @@ export const frontendPoints = Object.freeze([
|
|
|
14
14
|
"definitionName": "RecordActionDefinition",
|
|
15
15
|
"callbackName": "onTrigger",
|
|
16
16
|
"maxPerApp": 100
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"key": "app.object-actions",
|
|
20
|
+
"kind": "action",
|
|
21
|
+
"legacyType": "object-action",
|
|
22
|
+
"definitionName": "ObjectActionDefinition",
|
|
23
|
+
"callbackName": "onTrigger",
|
|
24
|
+
"maxPerApp": 100
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"key": "app.bulk-record-actions",
|
|
28
|
+
"kind": "action",
|
|
29
|
+
"legacyType": "bulk-record-action",
|
|
30
|
+
"definitionName": "BulkRecordActionDefinition",
|
|
31
|
+
"callbackName": "onTrigger",
|
|
32
|
+
"maxPerApp": 100
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"key": "app.record-page-widgets",
|
|
36
|
+
"kind": "view",
|
|
37
|
+
"legacyType": "record-widget",
|
|
38
|
+
"definitionName": "RecordWidgetDefinition",
|
|
39
|
+
"componentName": "Widget",
|
|
40
|
+
"maxPerApp": 100
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"key": "app.record-sections",
|
|
44
|
+
"kind": "view",
|
|
45
|
+
"legacyType": "record-section",
|
|
46
|
+
"definitionName": "RecordSectionDefinition",
|
|
47
|
+
"componentName": "Section",
|
|
48
|
+
"maxPerApp": 100
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"key": "app.record-page-tabs",
|
|
52
|
+
"kind": "view",
|
|
53
|
+
"legacyType": "record-tab",
|
|
54
|
+
"definitionName": "RecordTabDefinition",
|
|
55
|
+
"componentName": "Tab",
|
|
56
|
+
"maxPerApp": 100
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"key": "app.workspace-pages",
|
|
60
|
+
"kind": "view",
|
|
61
|
+
"legacyType": "page",
|
|
62
|
+
"definitionName": "PageDefinition",
|
|
63
|
+
"componentName": "Page",
|
|
64
|
+
"maxPerApp": 100
|
|
17
65
|
}
|
|
18
66
|
].map(Object.freeze));
|
|
19
67
|
export const frontendPoint = type => frontendPoints.find(point => point.legacyType === type);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// 此文件由 scripts/generate-components.mjs 生成,请修改产品能力声明。
|
|
2
|
-
export const fieldComponents = Object.freeze(["TextInput","Checkbox","Select","Combobox","MemberCombobox","TeamCombobox"]);
|
|
3
|
-
export const boundComponents = Object.freeze(["TextInput","Checkbox","Select","Combobox","MemberCombobox","TeamCombobox","SubmitButton"]);
|
|
2
|
+
export const fieldComponents = Object.freeze(["TextInput","Checkbox","Select","Combobox","MemberCombobox","TeamCombobox","TextArea","NumberInput","PlainDateInput","DateTimeInput","Toggle","RadioGroup","RecordCombobox"]);
|
|
3
|
+
export const boundComponents = Object.freeze(["TextInput","Checkbox","Select","Combobox","MemberCombobox","TeamCombobox","SubmitButton","TextArea","NumberInput","PlainDateInput","DateTimeInput","Toggle","RadioGroup","RecordCombobox","InputGroup","Fieldset"]);
|
|
4
4
|
export const formComponent = "Form";
|
package/src/forms/factory.js
CHANGED
|
@@ -1,23 +1,32 @@
|
|
|
1
|
+
import { compileForm } from './schema.js';
|
|
2
|
+
import { createWithState } from './state.js';
|
|
3
|
+
import { useNormalizedRecordOptions, recordObjectsForField } from './record-options.js';
|
|
1
4
|
import { boundComponents, formComponent } from './components.generated.js';
|
|
2
5
|
import { useNormalizedOptions } from './normalize-options.js';
|
|
3
|
-
export function createFormHook({
|
|
6
|
+
export function createFormHook(React, { useBindCallback = () => callback => callback } = {}) {
|
|
7
|
+
const { createElement, useId, useMemo, useRef } = React;
|
|
4
8
|
|
|
5
9
|
return function useForm(options) {
|
|
6
10
|
const formId = useId();
|
|
11
|
+
const bindCallback = useBindCallback();
|
|
7
12
|
const initial = useRef(options);
|
|
8
13
|
return useMemo(() => {
|
|
9
|
-
const {
|
|
14
|
+
const { initialValues } = initial.current;
|
|
15
|
+
const { schema, ...validation } = compileForm(initial.current, formId, bindCallback);
|
|
10
16
|
const field = type => function Field({ children, ...props }) {
|
|
11
|
-
if (type === 'Combobox') {
|
|
17
|
+
if (type === 'Combobox' || type === 'RecordCombobox') {
|
|
12
18
|
const { options, ...rest } = props;
|
|
13
|
-
const normalized =
|
|
19
|
+
const normalized = type === 'RecordCombobox'
|
|
20
|
+
? useNormalizedRecordOptions({ useRef, useMemo }, options, recordObjectsForField(schema.fields[props.name]))
|
|
21
|
+
: useNormalizedOptions({ useRef, useMemo }, options);
|
|
14
22
|
return createElement(type, { ...rest, ...normalized, formId });
|
|
15
23
|
}
|
|
16
24
|
return createElement(type, { ...props, formId }, children);
|
|
17
25
|
};
|
|
18
26
|
return Object.freeze({
|
|
27
|
+
WithState: createWithState(React, formId, schema, initialValues),
|
|
19
28
|
Form: function Form({ children, onSubmit }) {
|
|
20
|
-
return createElement(formComponent, { formId, schema, initialValues, onSubmit }, children);
|
|
29
|
+
return createElement(formComponent, { formId, schema, initialValues, onSubmit, ...validation }, children);
|
|
21
30
|
},
|
|
22
31
|
...Object.fromEntries(boundComponents.map(type => [type, field(type)])),
|
|
23
32
|
});
|
package/src/forms/index.d.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
// 此文件由 scripts/generate-components.mjs 生成,请修改产品能力声明。
|
|
2
2
|
import type { ComponentType, ReactNode } from "react";
|
|
3
|
-
import type { FormSchema, FormValues, FieldName, FormSubmitResult } from "./schema.js";
|
|
3
|
+
import type { FormSchema, FormValues, FieldName, FormSubmitResult, FormOptions } from "./schema.js";
|
|
4
4
|
import type { Option, OptionsPage, OptionsProvider } from "./options.js";
|
|
5
|
+
import type { RecordOptionsProvider } from "./record-options.js";
|
|
6
|
+
import type { WithStateComponent } from "./state.js";
|
|
7
|
+
export type { FormStateSelection, SelectedFormState } from "./state.js";
|
|
8
|
+
export type { RecordRef, RecordObjectOption, RecordOption, RecordOptionsPage, RecordOptionsProvider } from "./record-options.js";
|
|
5
9
|
export type { Option, OptionsPage, OptionsProvider } from "./options.js";
|
|
6
|
-
export
|
|
10
|
+
export { Forms } from "./schema.js";
|
|
11
|
+
export type { FormField, FormSchema, FormValues, FieldName, FormSubmitResult, FormOptions, FormValidationResult } from "./schema.js";
|
|
7
12
|
export interface FormHandle<S extends FormSchema> {
|
|
13
|
+
WithState: WithStateComponent<S>;
|
|
8
14
|
Form: ComponentType<{
|
|
9
15
|
children?: ReactNode;
|
|
10
16
|
onSubmit: (values: FormValues<S>) => FormSubmitResult<S> | Promise<FormSubmitResult<S>>;
|
|
@@ -14,11 +20,13 @@ export interface FormHandle<S extends FormSchema> {
|
|
|
14
20
|
description?: string;
|
|
15
21
|
placeholder?: string;
|
|
16
22
|
disabled?: boolean;
|
|
23
|
+
type?: "text" | "email" | "password" | "tel" | "url";
|
|
17
24
|
}>;
|
|
18
25
|
Checkbox: ComponentType<{
|
|
19
26
|
name: FieldName<S, "boolean">;
|
|
20
27
|
description?: string;
|
|
21
28
|
disabled?: boolean;
|
|
29
|
+
labelHidden?: boolean;
|
|
22
30
|
}>;
|
|
23
31
|
Select: ComponentType<{
|
|
24
32
|
name: FieldName<S, "enum">;
|
|
@@ -49,6 +57,58 @@ export interface FormHandle<S extends FormSchema> {
|
|
|
49
57
|
children?: string;
|
|
50
58
|
disabled?: boolean;
|
|
51
59
|
}>;
|
|
60
|
+
TextArea: ComponentType<{
|
|
61
|
+
name: FieldName<S, "string">;
|
|
62
|
+
description?: string;
|
|
63
|
+
placeholder?: string;
|
|
64
|
+
disabled?: boolean;
|
|
65
|
+
resizable?: boolean;
|
|
66
|
+
}>;
|
|
67
|
+
NumberInput: ComponentType<{
|
|
68
|
+
name: FieldName<S, "number">;
|
|
69
|
+
description?: string;
|
|
70
|
+
placeholder?: string;
|
|
71
|
+
disabled?: boolean;
|
|
72
|
+
}>;
|
|
73
|
+
PlainDateInput: ComponentType<{
|
|
74
|
+
name: FieldName<S, "plainDate">;
|
|
75
|
+
description?: string;
|
|
76
|
+
placeholder?: string;
|
|
77
|
+
disabled?: boolean;
|
|
78
|
+
}>;
|
|
79
|
+
DateTimeInput: ComponentType<{
|
|
80
|
+
name: FieldName<S, "dateTime">;
|
|
81
|
+
description?: string;
|
|
82
|
+
placeholder?: string;
|
|
83
|
+
disabled?: boolean;
|
|
84
|
+
}>;
|
|
85
|
+
Toggle: ComponentType<{
|
|
86
|
+
name: FieldName<S, "boolean">;
|
|
87
|
+
description?: string;
|
|
88
|
+
placeholder?: string;
|
|
89
|
+
disabled?: boolean;
|
|
90
|
+
}>;
|
|
91
|
+
RadioGroup: ComponentType<{
|
|
92
|
+
name: FieldName<S, "enum">;
|
|
93
|
+
description?: string;
|
|
94
|
+
placeholder?: string;
|
|
95
|
+
disabled?: boolean;
|
|
96
|
+
variant?: "default" | "cards";
|
|
97
|
+
}>;
|
|
98
|
+
RecordCombobox: ComponentType<{
|
|
99
|
+
name: FieldName<S, "record">;
|
|
100
|
+
description?: string;
|
|
101
|
+
placeholder?: string;
|
|
102
|
+
disabled?: boolean;
|
|
103
|
+
options?: RecordOptionsProvider;
|
|
104
|
+
}>;
|
|
105
|
+
InputGroup: ComponentType<{
|
|
106
|
+
children?: ReactNode;
|
|
107
|
+
}>;
|
|
108
|
+
Fieldset: ComponentType<{
|
|
109
|
+
legend: string;
|
|
110
|
+
children?: ReactNode;
|
|
111
|
+
}>;
|
|
52
112
|
}
|
|
53
113
|
/** schema 和 initialValues 仅在首次挂载使用;重新渲染不会重置输入。 */
|
|
54
|
-
export function useForm<const S extends FormSchema>(options:
|
|
114
|
+
export function useForm<const S extends FormSchema>(options: FormOptions<S>): FormHandle<S>;
|
package/src/forms/index.js
CHANGED
package/src/forms/options.d.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
+
import type { AppIconName, BadgeProps } from '../ui/index.js';
|
|
1
2
|
/** value 是持久化标识,label 只负责显示。无法回显不能清除原 value。 */
|
|
2
|
-
export
|
|
3
|
+
export type Option = {
|
|
3
4
|
value: string;
|
|
4
5
|
label: string;
|
|
5
6
|
description?: string;
|
|
6
7
|
disabled?: boolean;
|
|
7
8
|
disabledReason?: string;
|
|
9
|
+
categoryLabel?: string;
|
|
10
|
+
avatarUrl?: string;
|
|
11
|
+
icon?: AppIconName;
|
|
12
|
+
color?: NonNullable<BadgeProps['color']>;
|
|
8
13
|
}
|
|
9
|
-
export
|
|
14
|
+
export type OptionsPage = {
|
|
10
15
|
items: Option[];
|
|
11
16
|
/** null 表示末页;游标只能在当前搜索下使用。 */
|
|
12
17
|
nextCursor: string | null;
|
package/src/forms/options.js
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
|
+
import { sdkIconNames } from '../ui/icons.generated.js';
|
|
2
|
+
import { badgeColors } from '../ui/components.generated.js';
|
|
3
|
+
import { assertExternalUrl } from '../client/external-url.js';
|
|
1
4
|
// SDK 回调与 Host 共用有界 DTO 验证;函数留在 Guest,不传递 Provider 对象。
|
|
2
5
|
const fail = () => { throw new Error('runtime_options_invalid'); };
|
|
3
6
|
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
4
7
|
const only = (value, keys) => { if (!object(value) || Object.keys(value).some(key => !keys.includes(key))) fail(); };
|
|
5
8
|
export const OPTIONS_PAGE_LIMIT = 100;
|
|
6
9
|
export function assertOption(option) {
|
|
7
|
-
only(option, ['value', 'label', 'description', 'disabled', 'disabledReason']);
|
|
10
|
+
only(option, ['value', 'label', 'description', 'disabled', 'disabledReason', 'categoryLabel', 'avatarUrl', 'icon', 'color']);
|
|
8
11
|
assertOptionValue(option.value);
|
|
9
12
|
if (typeof option.label !== 'string' || !option.label || option.label.length > 200) fail();
|
|
10
13
|
for (const key of ['description', 'disabledReason']) {
|
|
11
14
|
if (option[key] !== undefined && (typeof option[key] !== 'string' || option[key].length > 4096)) fail();
|
|
12
15
|
}
|
|
13
16
|
if (option.disabled !== undefined && typeof option.disabled !== 'boolean') fail();
|
|
17
|
+
if (option.categoryLabel !== undefined && (typeof option.categoryLabel !== 'string' || !option.categoryLabel.trim() || option.categoryLabel.length > 200)) fail();
|
|
18
|
+
if (option.avatarUrl !== undefined) { try { assertExternalUrl(option.avatarUrl); } catch { fail(); } }
|
|
19
|
+
if (option.icon !== undefined && !sdkIconNames.includes(option.icon)) fail();
|
|
20
|
+
if (option.color !== undefined && !badgeColors.includes(option.color)) fail();
|
|
14
21
|
return option;
|
|
15
22
|
}
|
|
16
23
|
export function assertOptionValue(value) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type RecordRef = {
|
|
2
|
+
/** 数据源范围内稳定的公开对象键,不是内部模型名或授权证明。 */
|
|
3
|
+
objectSlug: string;
|
|
4
|
+
recordId: string;
|
|
5
|
+
};
|
|
6
|
+
export type RecordObjectOption = { objectSlug: string; label: string; }
|
|
7
|
+
export type RecordOption = {
|
|
8
|
+
value: RecordRef;
|
|
9
|
+
label: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
disabledReason?: string;
|
|
13
|
+
}
|
|
14
|
+
export type RecordOptionsPage = { items: RecordOption[]; nextCursor: string | null; }
|
|
15
|
+
export interface RecordOptionsProvider {
|
|
16
|
+
search(input: { objectSlug: string; query: string; cursor?: string }): Promise<RecordOptionsPage>;
|
|
17
|
+
/** null 表示未解析;查询错误必须拒绝 Promise,不能静默清空引用。 */
|
|
18
|
+
getOption(value: RecordRef): Promise<RecordOption | null>;
|
|
19
|
+
}
|
|
20
|
+
export function recordKey(value: RecordRef): string;
|
|
21
|
+
export function assertRecordRef(value: unknown, objects?: readonly RecordObjectOption[]): RecordRef;
|
|
22
|
+
export function assertRecordObjects(value: unknown): RecordObjectOption[];
|
|
23
|
+
export function assertRecordSearch(value: unknown, objects?: readonly RecordObjectOption[]): { objectSlug: string; query: string; cursor?: string };
|
|
24
|
+
export function assertRecordOption(value: unknown): RecordOption;
|
|
25
|
+
export function assertRecordPage(value: unknown, objectSlug: string): RecordOptionsPage;
|
|
26
|
+
export function assertSelectedRecordOption(value: unknown, ref: RecordRef): RecordOption | null;
|
|
27
|
+
|
|
28
|
+
export function recordObjectsForField(field: { objectSlug?: string; objects?: readonly RecordObjectOption[]; label: string }): readonly RecordObjectOption[];
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { assertOptionValue, assertOptionsSearch } from './options.js';
|
|
2
|
+
const fail = () => { throw new Error('runtime_record_options_invalid'); };
|
|
3
|
+
const only = (value, keys) => {
|
|
4
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !keys.includes(key))) fail();
|
|
5
|
+
};
|
|
6
|
+
/** 仅用于 Host 选择器 identity,不作为公开保存值或传输编码。 */
|
|
7
|
+
export const recordKey = value => JSON.stringify([value.objectSlug, value.recordId]);
|
|
8
|
+
export function assertRecordRef(value, objects) {
|
|
9
|
+
only(value, ['objectSlug', 'recordId']);
|
|
10
|
+
assertOptionValue(value.objectSlug); assertOptionValue(value.recordId);
|
|
11
|
+
if (objects && !objects.some(object => object.objectSlug === value.objectSlug)) fail();
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
export function assertRecordObjects(objects) {
|
|
15
|
+
if (!Array.isArray(objects) || objects.length !== 1) fail();
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
for (const object of objects) {
|
|
18
|
+
only(object, ['objectSlug', 'label']);
|
|
19
|
+
assertOptionValue(object.objectSlug);
|
|
20
|
+
if (seen.has(object.objectSlug) || typeof object.label !== 'string' || !object.label.trim() || object.label.length > 200) fail();
|
|
21
|
+
seen.add(object.objectSlug);
|
|
22
|
+
}
|
|
23
|
+
return objects;
|
|
24
|
+
}
|
|
25
|
+
/** 新配置绑定唯一对象;旧数组仅允许恰好一项。 */
|
|
26
|
+
export function recordObjectsForField(field) {
|
|
27
|
+
if (field.objectSlug !== undefined) {
|
|
28
|
+
if (field.objects !== undefined) fail();
|
|
29
|
+
assertOptionValue(field.objectSlug);
|
|
30
|
+
return [{ objectSlug: field.objectSlug, label: field.label }];
|
|
31
|
+
}
|
|
32
|
+
return assertRecordObjects(field.objects);
|
|
33
|
+
}
|
|
34
|
+
export function assertRecordSearch(input, objects) {
|
|
35
|
+
only(input, ['objectSlug', 'query', 'cursor']);
|
|
36
|
+
assertOptionValue(input.objectSlug);
|
|
37
|
+
if (objects && !objects.some(object => object.objectSlug === input.objectSlug)) fail();
|
|
38
|
+
assertOptionsSearch({ query: input.query, ...(input.cursor === undefined ? {} : { cursor: input.cursor }) });
|
|
39
|
+
return input;
|
|
40
|
+
}
|
|
41
|
+
export function assertRecordOption(option) {
|
|
42
|
+
only(option, ['value', 'label', 'description', 'disabled', 'disabledReason']);
|
|
43
|
+
assertRecordRef(option.value);
|
|
44
|
+
if (typeof option.label !== 'string' || !option.label || option.label.length > 200) fail();
|
|
45
|
+
for (const key of ['description', 'disabledReason']) if (option[key] !== undefined && (typeof option[key] !== 'string' || option[key].length > 4096)) fail();
|
|
46
|
+
if (option.disabled !== undefined && typeof option.disabled !== 'boolean') fail();
|
|
47
|
+
return option;
|
|
48
|
+
}
|
|
49
|
+
export function assertRecordPage(page, objectSlug) {
|
|
50
|
+
only(page, ['items', 'nextCursor']);
|
|
51
|
+
if (!Array.isArray(page.items) || page.items.length > 100) fail();
|
|
52
|
+
if (page.nextCursor !== null && (typeof page.nextCursor !== 'string' || !page.nextCursor || page.nextCursor.length > 4096)) fail();
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
for (const item of page.items) {
|
|
55
|
+
assertRecordOption(item);
|
|
56
|
+
const key = recordKey(item.value);
|
|
57
|
+
if (item.value.objectSlug !== objectSlug || seen.has(key)) fail();
|
|
58
|
+
seen.add(key);
|
|
59
|
+
}
|
|
60
|
+
return page;
|
|
61
|
+
}
|
|
62
|
+
export function assertSelectedRecordOption(option, ref) {
|
|
63
|
+
assertRecordRef(ref);
|
|
64
|
+
if (option === null) return null;
|
|
65
|
+
assertRecordOption(option);
|
|
66
|
+
if (recordKey(option.value) !== recordKey(ref)) fail();
|
|
67
|
+
return option;
|
|
68
|
+
}
|
|
69
|
+
export function useNormalizedRecordOptions({ useRef, useMemo }, options, objects) {
|
|
70
|
+
assertRecordObjects(objects);
|
|
71
|
+
const source = useRef({ options: null, search: null, getOption: null, revision: 0 });
|
|
72
|
+
const search = options?.search, getOption = options?.getOption;
|
|
73
|
+
if (source.current.options !== options || source.current.search !== search || source.current.getOption !== getOption) {
|
|
74
|
+
source.current = { options, search, getOption, revision: source.current.revision + 1 };
|
|
75
|
+
}
|
|
76
|
+
const revision = source.current.revision;
|
|
77
|
+
return useMemo(() => {
|
|
78
|
+
if (options === undefined) return {};
|
|
79
|
+
if (!options || typeof search !== 'function' || typeof getOption !== 'function') fail();
|
|
80
|
+
return {
|
|
81
|
+
sourceRevision: String(revision),
|
|
82
|
+
onSearch: input => {
|
|
83
|
+
assertRecordSearch(input, objects);
|
|
84
|
+
return Promise.resolve(search.call(options, input)).then(page => assertRecordPage(page, input.objectSlug));
|
|
85
|
+
},
|
|
86
|
+
onGetOption: value => {
|
|
87
|
+
assertRecordRef(value, objects);
|
|
88
|
+
return Promise.resolve(getOption.call(options, value)).then(option => assertSelectedRecordOption(option, value));
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}, [revision]);
|
|
92
|
+
}
|
package/src/forms/schema.d.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import type { RecordRef, RecordObjectOption } from "./record-options.js";
|
|
2
|
+
export interface RuleMessages { required?: string; min?: string; max?: string; format?: string; }
|
|
3
|
+
export type FormField = (
|
|
4
|
+
| { type: "string"; label: string; required?: boolean; minLength?: number; maxLength?: number; format?: "email" | "url" }
|
|
3
5
|
| { type: "boolean"; label: string; required?: boolean }
|
|
4
|
-
| { type: "enum"; label: string; required?: boolean; options: readonly { value: string; label: string }[] }
|
|
5
|
-
|
|
6
|
+
| { type: "enum"; label: string; required?: boolean; options: readonly { value: string; label: string; description?: string }[] }
|
|
7
|
+
| { type: "number"; label: string; required?: boolean; min?: number; max?: number }
|
|
8
|
+
| { type: "plainDate" | "dateTime"; label: string; required?: boolean; min?: string; max?: string }
|
|
9
|
+
| ({ type: "record"; label: string; required?: boolean } & ({ objectSlug: string; objects?: never } | { objectSlug?: never; objects: readonly [RecordObjectOption] }))) & { messages?: RuleMessages };
|
|
10
|
+
export interface FormSchema { fields: Readonly<Record<string, FormField | { type: FormField['type']; label: (text: string) => unknown }>>; }
|
|
6
11
|
export type FormValues<S extends FormSchema> = {
|
|
7
|
-
[K in keyof S["fields"]]: S["fields"][K] extends { type: "boolean" } ? boolean
|
|
12
|
+
[K in keyof S["fields"]]: S["fields"][K] extends { type: "boolean" } ? boolean
|
|
13
|
+
: S["fields"][K] extends { type: "record" } ? (S["fields"][K] extends { objectSlug: infer O extends string } ? RecordRef & { objectSlug: O } : RecordRef) | null
|
|
14
|
+
: S["fields"][K] extends { type: "number" } ? number | null
|
|
15
|
+
: S["fields"][K] extends { type: "plainDate" | "dateTime" } ? string | null : string;
|
|
8
16
|
};
|
|
9
17
|
export type FieldName<S extends FormSchema, T extends FormField['type']> = {
|
|
10
18
|
[K in keyof S['fields']]: S['fields'][K]['type'] extends T ? K : never;
|
|
@@ -16,3 +24,40 @@ export type FormSubmitResult<S extends FormSchema> = void | { ok: true } | {
|
|
|
16
24
|
message: string;
|
|
17
25
|
fieldErrors?: Partial<Record<Extract<keyof S['fields'], string>, string>>;
|
|
18
26
|
};
|
|
27
|
+
|
|
28
|
+
export type FormValidationResult<S extends FormSchema> = void | {
|
|
29
|
+
fieldErrors?: Partial<Record<Extract<keyof S['fields'], string>, string>>;
|
|
30
|
+
formError?: string;
|
|
31
|
+
};
|
|
32
|
+
type FieldValue<F extends FormField> = FormValues<{ fields: { value: F } }>['value'];
|
|
33
|
+
type ReadonlyValue<T> = T extends object ? { readonly [K in keyof T]: ReadonlyValue<T[K]> } : T;
|
|
34
|
+
export type FormOptions<S extends FormSchema> = {
|
|
35
|
+
schema: S;
|
|
36
|
+
initialValues: FormValues<S>;
|
|
37
|
+
validationMode?: 'onBlur' | 'onChange' | 'onSubmit';
|
|
38
|
+
validate?: (values: ReadonlyValue<FormValues<S>>) => FormValidationResult<S> | Promise<FormValidationResult<S>>;
|
|
39
|
+
};
|
|
40
|
+
type MessageOption = { message: string };
|
|
41
|
+
export type FieldBuilder<F extends FormField> = Omit<F, 'label'> & {
|
|
42
|
+
label(text: string): FieldBuilder<F>;
|
|
43
|
+
required(message?: string): FieldBuilder<F>;
|
|
44
|
+
optional(): FieldBuilder<F>;
|
|
45
|
+
custom(validator: (value: ReadonlyValue<FieldValue<F>>) => void | string | Promise<void | string>): FieldBuilder<F>;
|
|
46
|
+
} & (F['type'] extends 'string' | 'number' | 'plainDate' | 'dateTime' ? {
|
|
47
|
+
min(value: F['type'] extends 'string' | 'number' ? number : string, options?: MessageOption): FieldBuilder<F>;
|
|
48
|
+
max(value: F['type'] extends 'string' | 'number' ? number : string, options?: MessageOption): FieldBuilder<F>;
|
|
49
|
+
} : {}) & (F['type'] extends 'string' ? {
|
|
50
|
+
email(options?: MessageOption): FieldBuilder<F>;
|
|
51
|
+
url(options?: MessageOption): FieldBuilder<F>;
|
|
52
|
+
} : {});
|
|
53
|
+
// builder 的 label 是方法,wire 的 label 是字符串;只有编译后才跨 Worker 边界。
|
|
54
|
+
export const Forms: {
|
|
55
|
+
schema<const F extends Record<string, { type: FormField['type']; label: (text: string) => unknown }>>(fields: F): { readonly fields: F };
|
|
56
|
+
string(): FieldBuilder<Extract<FormField, { type: 'string' }>>;
|
|
57
|
+
number(): FieldBuilder<Extract<FormField, { type: 'number' }>>;
|
|
58
|
+
boolean(): FieldBuilder<Extract<FormField, { type: 'boolean' }>>;
|
|
59
|
+
enum(options: readonly { value: string; label: string; description?: string }[]): FieldBuilder<Extract<FormField, { type: 'enum' }>>;
|
|
60
|
+
plainDate(): FieldBuilder<{ type: 'plainDate'; label: string }>;
|
|
61
|
+
dateTime(): FieldBuilder<{ type: 'dateTime'; label: string }>;
|
|
62
|
+
record<const O extends string>(objectSlug: O): FieldBuilder<{ type: 'record'; label: string; objectSlug: O }>;
|
|
63
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { isPlainDate, isDateTime } from './values.js';
|
|
2
|
+
import { recordObjectsForField } from './record-options.js';
|
|
3
|
+
import { freezeFormState } from './state.js';
|
|
4
|
+
import { createValidationCallback } from './validation.js';
|
|
5
|
+
|
|
6
|
+
const descriptors = new WeakMap();
|
|
7
|
+
const fail = () => { throw new Error('runtime_form_schema_invalid'); };
|
|
8
|
+
const message = value => {
|
|
9
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 300) fail();
|
|
10
|
+
return value;
|
|
11
|
+
};
|
|
12
|
+
function builder(field, validators = []) {
|
|
13
|
+
field = freezeFormState(field);
|
|
14
|
+
const next = (patch, checks = validators) => builder({ ...field, ...patch }, checks);
|
|
15
|
+
const rule = (key, value, options) => {
|
|
16
|
+
const messages = { ...field.messages };
|
|
17
|
+
if (options !== undefined) {
|
|
18
|
+
if (!options || Object.keys(options).some(key => key !== 'message')) fail();
|
|
19
|
+
messages[key] = message(options.message);
|
|
20
|
+
} else delete messages[key];
|
|
21
|
+
return next({ [field.type === 'string' ? `${key}Length` : key]: value, messages });
|
|
22
|
+
};
|
|
23
|
+
const methods = {
|
|
24
|
+
label(value) { return next({ label: value }); },
|
|
25
|
+
required(value) { return next({ required: true, messages: { ...field.messages, ...(value === undefined ? {} : { required: message(value) }) } }); },
|
|
26
|
+
optional() { return next({ required: false }); },
|
|
27
|
+
custom(validator) {
|
|
28
|
+
if (typeof validator !== 'function' || validators.length >= 4) fail();
|
|
29
|
+
return next({}, [...validators, validator]);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
if (['string', 'number', 'plainDate', 'dateTime'].includes(field.type)) {
|
|
33
|
+
methods.min = (value, options) => rule('min', value, options);
|
|
34
|
+
methods.max = (value, options) => rule('max', value, options);
|
|
35
|
+
}
|
|
36
|
+
if (field.type === 'string') for (const format of ['email', 'url']) methods[format] = options => {
|
|
37
|
+
const messages = { ...field.messages };
|
|
38
|
+
if (options === undefined) delete messages.format;
|
|
39
|
+
else { if (!options || Object.keys(options).some(key => key !== 'message')) fail(); messages.format = message(options.message); }
|
|
40
|
+
return next({ format, messages });
|
|
41
|
+
};
|
|
42
|
+
const result = Object.freeze({ ...field, ...methods });
|
|
43
|
+
descriptors.set(result, { field, validators });
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
export const Forms = Object.freeze({
|
|
47
|
+
schema(fields) {
|
|
48
|
+
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) fail();
|
|
49
|
+
if (Object.values(fields).some(field => !descriptors.has(field))) fail();
|
|
50
|
+
return Object.freeze({ fields: Object.freeze({ ...fields }) });
|
|
51
|
+
},
|
|
52
|
+
...Object.fromEntries(['string', 'number', 'boolean', 'plainDate', 'dateTime'].map(type => [type, () => builder({ type })])),
|
|
53
|
+
enum: options => builder({ type: 'enum', options: Array.isArray(options) ? options.map(option => Object.freeze({ ...option })) : options }),
|
|
54
|
+
record: objectSlug => builder({ type: 'record', objectSlug }),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** 只编译描述与回调;输入规则由 Host 单一校验器执行。 */
|
|
58
|
+
export function compileForm(options, formId, bindCallback = callback => callback) {
|
|
59
|
+
const { schema, validate, validationMode = 'onBlur' } = options;
|
|
60
|
+
if (!schema || !schema.fields || typeof schema.fields !== 'object' || Array.isArray(schema.fields) || Object.keys(schema).some(key => key !== 'fields')
|
|
61
|
+
|| Object.keys(schema.fields).length > 30 || !['onBlur', 'onChange', 'onSubmit'].includes(validationMode)
|
|
62
|
+
|| (validate !== undefined && typeof validate !== 'function')) fail();
|
|
63
|
+
const fields = {}, validators = {};
|
|
64
|
+
for (const [name, input] of Object.entries(schema.fields)) {
|
|
65
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(name) || ['constructor', 'prototype'].includes(name)) fail();
|
|
66
|
+
const descriptor = descriptors.get(input);
|
|
67
|
+
const field = structuredClone(descriptor?.field ?? input);
|
|
68
|
+
if (!field || !['string', 'boolean', 'enum', 'number', 'plainDate', 'dateTime', 'record'].includes(field.type)
|
|
69
|
+
|| typeof field.label !== 'string' || descriptor && !field.label.trim() || field.label.length > 200) fail();
|
|
70
|
+
const allowed = ['type', 'label', 'required', 'messages', ...(field.type === 'string' ? ['minLength', 'maxLength', 'format'] : field.type === 'enum' ? ['options'] : field.type === 'record' ? ['objectSlug', 'objects'] : ['number', 'plainDate', 'dateTime'].includes(field.type) ? ['min', 'max'] : [])];
|
|
71
|
+
if (Object.keys(field).some(key => !allowed.includes(key)) || (field.required !== undefined && typeof field.required !== 'boolean')) fail();
|
|
72
|
+
if (field.messages !== undefined) {
|
|
73
|
+
if (!field.messages || typeof field.messages !== 'object' || Array.isArray(field.messages)
|
|
74
|
+
|| Object.keys(field.messages).some(key => !['required', 'min', 'max', 'format'].includes(key))) fail();
|
|
75
|
+
Object.values(field.messages).forEach(message);
|
|
76
|
+
}
|
|
77
|
+
if (field.type === 'record') recordObjectsForField(field);
|
|
78
|
+
if (field.type === 'enum') {
|
|
79
|
+
if (!Array.isArray(field.options) || !field.options.length || field.options.length > 100) fail();
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
for (const choice of field.options) {
|
|
82
|
+
if (!choice || Object.keys(choice).some(key => !['value', 'label', 'description'].includes(key)) || typeof choice.value !== 'string' || !choice.value || choice.value.length > 200 || seen.has(choice.value) || typeof choice.label !== 'string' || choice.label.length > 200) fail();
|
|
83
|
+
if (choice.description !== undefined && (typeof choice.description !== 'string' || choice.description.length > 300)) fail();
|
|
84
|
+
seen.add(choice.value);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const low = field.type === 'string' ? 'minLength' : 'min', high = field.type === 'string' ? 'maxLength' : 'max';
|
|
88
|
+
const valid = value => field.type === 'string' ? Number.isSafeInteger(value) && value >= 0 && value <= 10000
|
|
89
|
+
: field.type === 'number' ? typeof value === 'number' && Number.isFinite(value)
|
|
90
|
+
: field.type === 'plainDate' ? isPlainDate(value) : isDateTime(value);
|
|
91
|
+
for (const key of [low, high]) if (field[key] !== undefined && (!valid(field[key]) || key === 'maxLength' && field[key] === 0)) fail();
|
|
92
|
+
const comparable = value => field.type === 'dateTime' ? Date.parse(value) : value;
|
|
93
|
+
if (field[low] !== undefined && field[high] !== undefined && comparable(field[low]) > comparable(field[high])) fail();
|
|
94
|
+
if (field.format !== undefined && !['email', 'url'].includes(field.format)) fail();
|
|
95
|
+
fields[name] = field;
|
|
96
|
+
if (descriptor?.validators.length) validators[name] = descriptor.validators.map(bindCallback);
|
|
97
|
+
}
|
|
98
|
+
const wire = freezeFormState({ fields });
|
|
99
|
+
const validation = { fields: Object.keys(validators), form: Boolean(validate), mode: validationMode };
|
|
100
|
+
const remote = Boolean(validation.fields.length || validate);
|
|
101
|
+
const configured = remote || options.validationMode !== undefined || Object.values(schema.fields).some(field => descriptors.has(field));
|
|
102
|
+
return { schema: wire, ...(configured ? { validation } : {}), ...(remote ? {
|
|
103
|
+
onValidate: createValidationCallback(formId, fields, validators, validate ? bindCallback(validate) : undefined),
|
|
104
|
+
} : {}) };
|
|
105
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { FormSchema, FormValues } from './schema.js';
|
|
3
|
+
export type FormStateSelection<S extends FormSchema> = {
|
|
4
|
+
values?: true | readonly (keyof S['fields'] & string)[];
|
|
5
|
+
errors?: true | readonly (keyof S['fields'] & string)[];
|
|
6
|
+
submitting?: boolean;
|
|
7
|
+
validating?: boolean;
|
|
8
|
+
validatingFields?: true | readonly (keyof S['fields'] & string)[];
|
|
9
|
+
formError?: boolean;
|
|
10
|
+
};
|
|
11
|
+
type Names<S extends FormSchema, T> = T extends true ? keyof FormValues<S> : T extends readonly (infer K)[] ? Extract<K, keyof FormValues<S>> : never;
|
|
12
|
+
type ReadonlyValue<T> = T extends object ? { readonly [K in keyof T]: ReadonlyValue<T[K]> } : T;
|
|
13
|
+
export type SelectedFormState<S extends FormSchema, Selection extends FormStateSelection<S>> =
|
|
14
|
+
(Selection extends { values: infer T } ? { readonly values: ReadonlyValue<Pick<FormValues<S>, Names<S, T>>> } : {})
|
|
15
|
+
& (Selection extends { errors: infer T } ? { readonly errors: Readonly<Partial<Record<Names<S, T>, string>>> } : {})
|
|
16
|
+
& (Selection extends { submitting: true } ? { readonly submitting: boolean } : {})
|
|
17
|
+
& (Selection extends { validating: true } ? { readonly validating: boolean } : {})
|
|
18
|
+
& (Selection extends { formError: true } ? { readonly formError: string | null } : {})
|
|
19
|
+
& (Selection extends { validatingFields: infer T } ? { readonly validatingFields: Readonly<Partial<Record<Names<S, T>, boolean>>> } : {});
|
|
20
|
+
export type WithStateComponent<S extends FormSchema> = <const Selection extends FormStateSelection<S>>(props: {
|
|
21
|
+
selection: Selection;
|
|
22
|
+
children: (state: SelectedFormState<S, Selection>) => ReactNode;
|
|
23
|
+
}) => ReactNode;
|
|
24
|
+
|
|
25
|
+
export type StateSelection = { values?: true | readonly string[]; errors?: true | readonly string[]; submitting?: boolean; validating?: boolean; validatingFields?: true | readonly string[]; formError?: boolean };
|
|
26
|
+
export function selectedNames(selection: true | readonly string[] | undefined, fields: Record<string, unknown>): string[];
|
|
27
|
+
export function assertFormStateSelection(selection: unknown, fields: Record<string, unknown>): StateSelection;
|
|
28
|
+
export function assertFormStateSnapshot(snapshot: unknown, formId: string, fields: Record<string, unknown>, selection: StateSelection): unknown;
|