akanjs 3.0.0-alpha.35 → 3.0.0-alpha.37
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/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/store/action.ts +5 -0
- package/store/agentic/formFields.ts +12 -0
- package/store/agentic/index.ts +1 -0
- package/store/agentic/useFieldTool.ts +105 -14
- package/store/agentic/useRelationFieldTool.ts +114 -0
- package/store/formSetterNames.ts +9 -0
- package/store/storeRegistry.ts +23 -0
- package/types/store/agentic/formFields.d.ts +9 -0
- package/types/store/agentic/index.d.ts +1 -0
- package/types/store/agentic/useFieldTool.d.ts +1 -1
- package/types/store/agentic/useRelationFieldTool.d.ts +27 -0
- package/types/store/formSetterNames.d.ts +9 -0
- package/ui/Field.tsx +42 -22
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/store/action.ts
CHANGED
|
@@ -316,6 +316,11 @@ export const makeFormSetter = (refName: string, fetch: FetchProxy<any>) => {
|
|
|
316
316
|
: (value as object);
|
|
317
317
|
(state[names.modelForm] as { [key: string]: any })[namesOfField.field] = setValue;
|
|
318
318
|
});
|
|
319
|
+
|
|
320
|
+
const postSet = (this as unknown as { [key: string]: ((value: unknown) => unknown) | undefined })[
|
|
321
|
+
namesOfField.postSetField
|
|
322
|
+
];
|
|
323
|
+
if (postSet) void postSet.call(this, value);
|
|
319
324
|
},
|
|
320
325
|
...(field.isArray
|
|
321
326
|
? {
|
|
@@ -174,6 +174,18 @@ export class FormFields {
|
|
|
174
174
|
return field.arrDepth > 0 || (field.modelRef as unknown) === Map || !!FormFields.#scalarModel(field);
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The embedded model an array field's rows are, or null for every other field.
|
|
179
|
+
*
|
|
180
|
+
* This is the one shape where writing the whole array is a hazard rather than an inconvenience: the agent has to
|
|
181
|
+
* echo every row it is *not* changing, `checked` validates types and not values, so one mistyped row it was never
|
|
182
|
+
* asked to touch is written silently. A relation array is excluded — its rows travel as ids, which are the payload
|
|
183
|
+
* and are refused by name when wrong — and so is an array of primitives, for the same reason.
|
|
184
|
+
*/
|
|
185
|
+
static rowModelOf(field: ConstantField) {
|
|
186
|
+
return field.arrDepth > 0 ? FormFields.#scalarModel(field) : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
177
189
|
static #scalarModel(field: ConstantField) {
|
|
178
190
|
const modelRef = field.modelRef as unknown as Cls;
|
|
179
191
|
if (field.enum || PrimitiveRegistry.has(modelRef)) return null;
|
package/store/agentic/index.ts
CHANGED
|
@@ -1,9 +1,93 @@
|
|
|
1
1
|
"use client";
|
|
2
|
+
import { capitalize } from "akanjs/common";
|
|
3
|
+
import type { JsonSchema, ToolEntry } from "../../vendor/use-agentic";
|
|
2
4
|
import { useScopePath, useSurface } from "../../vendor/use-agentic";
|
|
3
5
|
import { actionTagOf } from "../actionTag";
|
|
6
|
+
import { formSetterNames } from "../formSetterNames";
|
|
4
7
|
|
|
5
8
|
import { useEffect, useRef } from "../hooks";
|
|
6
|
-
import {
|
|
9
|
+
import { StoreRegistry } from "../storeRegistry";
|
|
10
|
+
import { type FormFieldRef, FormFields } from "./formFields";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A control's `transform` is what it does to every value a person types, so an agent's write goes through it too —
|
|
14
|
+
* otherwise a `Field.Phone` stores `010-1234-5678` for the person and the raw digits for the agent. It normalizes
|
|
15
|
+
* one scalar, so an array-valued control (`TextList`, `Tags`, `DoubleNumber`) applies it per element. A cleared
|
|
16
|
+
* nullable field stays null: a normalizer written for a value would turn it into one.
|
|
17
|
+
*/
|
|
18
|
+
const normalized = (value: unknown, transform: unknown): unknown => {
|
|
19
|
+
if (typeof transform !== "function" || value === null) return value;
|
|
20
|
+
const apply = transform as (input: unknown) => unknown;
|
|
21
|
+
return Array.isArray(value) ? value.map((item) => apply(item)) : apply(value);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const dispatcherOf = (action: string) =>
|
|
25
|
+
StoreRegistry.instance.do[action] as ((...args: unknown[]) => unknown) | undefined;
|
|
26
|
+
|
|
27
|
+
const rowsOf = (ref: FormFieldRef): unknown[] => {
|
|
28
|
+
const form = StoreRegistry.instance.get()[`${ref.refName}Form`] as { [key: string]: unknown } | undefined;
|
|
29
|
+
const rows = form?.[ref.key];
|
|
30
|
+
return Array.isArray(rows) ? rows : [];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Append and remove-by-index for an array of embedded rows, beside the whole-array setter.
|
|
35
|
+
*
|
|
36
|
+
* Not new authority: the setter this control already published can produce any array these two can, so they are
|
|
37
|
+
* strictly weaker — which is what makes deriving them from the same field sound. What they add is that neither can
|
|
38
|
+
* touch a row it was not given, so the agent stops having to retype the rows it is leaving alone.
|
|
39
|
+
*
|
|
40
|
+
* Both take a list and act atomically. Removing indices one call at a time would shift the ones not yet removed, so
|
|
41
|
+
* `sub` filters the whole set at once, the way the generated action already does.
|
|
42
|
+
*
|
|
43
|
+
* `add` appends and publishes no insert position: the `+` a person presses always appends, and the framework cannot
|
|
44
|
+
* see the `limit` an app may pass from its own `onAdd`. `addOrSub` is never published — it matches by `indexOf`, so
|
|
45
|
+
* on rows it compares by reference and every toggle would append.
|
|
46
|
+
*/
|
|
47
|
+
const rowEntries = (ref: FormFieldRef, arraySchema: JsonSchema): ToolEntry[] => {
|
|
48
|
+
if (!FormFields.rowModelOf(ref.field)) return [];
|
|
49
|
+
const names = formSetterNames(capitalize(ref.refName), ref.key);
|
|
50
|
+
if (!dispatcherOf(names.addFieldOnModel) || !dispatcherOf(names.subFieldOnModel)) return [];
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
name: names.addFieldOnModel,
|
|
54
|
+
description: `Append rows to ${ref.key} on the ${ref.refName} form. Leaves every existing row untouched.`,
|
|
55
|
+
parameters: {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: { values: arraySchema },
|
|
58
|
+
required: ["values"],
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
},
|
|
61
|
+
effect: "state",
|
|
62
|
+
run: (args) => {
|
|
63
|
+
const checked = FormFields.checked(names.addFieldOnModel, "values", ref.field, args.values);
|
|
64
|
+
return dispatcherOf(names.addFieldOnModel)?.(checked);
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: names.subFieldOnModel,
|
|
69
|
+
description: `Remove rows of ${ref.key} from the ${ref.refName} form by their positions, counting from 0.`,
|
|
70
|
+
parameters: {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: { idxs: { type: "array", items: { type: "integer" } } },
|
|
73
|
+
required: ["idxs"],
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
},
|
|
76
|
+
effect: "state",
|
|
77
|
+
guard: (args) => {
|
|
78
|
+
const idxs = args.idxs;
|
|
79
|
+
if (!Array.isArray(idxs) || !idxs.length) return `"idxs" of ${names.subFieldOnModel} takes at least one index.`;
|
|
80
|
+
const length = rowsOf(ref).length;
|
|
81
|
+
const outside = idxs.filter(
|
|
82
|
+
(idx) => typeof idx !== "number" || !Number.isInteger(idx) || idx < 0 || idx >= length,
|
|
83
|
+
);
|
|
84
|
+
if (!outside.length) return true;
|
|
85
|
+
return `${ref.key} has ${length} ${length === 1 ? "row" : "rows"}, so ${outside.join(", ")} is out of range.`;
|
|
86
|
+
},
|
|
87
|
+
run: (args) => dispatcherOf(names.subFieldOnModel)?.(args.idxs),
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
};
|
|
7
91
|
|
|
8
92
|
/**
|
|
9
93
|
* Publishes the setter a form control is already holding, for exactly as long as the control is on screen.
|
|
@@ -14,27 +98,34 @@ import { FormFields } from "./formFields";
|
|
|
14
98
|
* consequences. Publishing from the form's subscription instead would offer every field of the model, including
|
|
15
99
|
* the ones this template draws no control for.
|
|
16
100
|
*/
|
|
17
|
-
export const useFieldTool = (onChange: unknown) => {
|
|
101
|
+
export const useFieldTool = (onChange: unknown, transform?: unknown) => {
|
|
18
102
|
const surface = useSurface();
|
|
19
103
|
const scope = useScopePath();
|
|
20
104
|
const action = actionTagOf(onChange)?.action ?? null;
|
|
21
|
-
const live = useRef(onChange);
|
|
22
|
-
live.current = onChange;
|
|
105
|
+
const live = useRef({ onChange, transform });
|
|
106
|
+
live.current = { onChange, transform };
|
|
23
107
|
const scopeKey = scope.join(".");
|
|
24
108
|
useEffect(() => {
|
|
25
109
|
if (!action) return;
|
|
26
110
|
const ref = FormFields.ref(action);
|
|
27
111
|
const schema = ref && FormFields.schema(ref.field);
|
|
28
112
|
if (!ref || !schema) return;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
FormFields.checked(action, "value", ref.field, args.value === undefined ? null : args.value)
|
|
37
|
-
|
|
38
|
-
|
|
113
|
+
const entries: ToolEntry[] = [
|
|
114
|
+
{
|
|
115
|
+
name: action,
|
|
116
|
+
description: `Set ${ref.key} on the ${ref.refName} form.`,
|
|
117
|
+
parameters: { type: "object", properties: { value: schema }, required: ["value"], additionalProperties: false },
|
|
118
|
+
effect: "state",
|
|
119
|
+
run: (args) => {
|
|
120
|
+
const checked = FormFields.checked(action, "value", ref.field, args.value === undefined ? null : args.value);
|
|
121
|
+
return (live.current.onChange as (value: unknown) => unknown)(normalized(checked, live.current.transform));
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
...rowEntries(ref, schema),
|
|
125
|
+
];
|
|
126
|
+
const registered = entries.map((entry) => surface.registerTool(scope, entry));
|
|
127
|
+
return () => {
|
|
128
|
+
for (const unregister of registered) unregister();
|
|
129
|
+
};
|
|
39
130
|
}, [surface, scopeKey, action]);
|
|
40
131
|
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { type Cls, type DataList, PrimitiveRegistry } from "akanjs/base";
|
|
3
|
+
import { capitalize } from "akanjs/common";
|
|
4
|
+
import { type ConstantField, ConstantRegistry } from "akanjs/constant";
|
|
5
|
+
import type { JsonSchema } from "../../vendor/use-agentic";
|
|
6
|
+
import { useScopePath, useSurface } from "../../vendor/use-agentic";
|
|
7
|
+
import { actionTagOf } from "../actionTag";
|
|
8
|
+
|
|
9
|
+
import { useEffect, useRef } from "../hooks";
|
|
10
|
+
import { FormFields } from "./formFields";
|
|
11
|
+
|
|
12
|
+
export interface RelationFieldSource<T extends { id: string }> {
|
|
13
|
+
/** Read live from the store rather than closed over: `load` below changes the list mid-call. */
|
|
14
|
+
read: () => DataList<T>;
|
|
15
|
+
/** Loads the options an agent never opened the dropdown to fetch. */
|
|
16
|
+
load: () => Promise<unknown> | unknown;
|
|
17
|
+
/** How one option reads to a person, so the agent can match what it sees on screen. */
|
|
18
|
+
label: (model: T) => string;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The database model a relation field points at, or null for anything else — a primitive, an enum, a scalar. */
|
|
23
|
+
const relationOf = (field: ConstantField): string | null => {
|
|
24
|
+
const modelRef = field.modelRef as unknown as Cls;
|
|
25
|
+
if (field.fieldType !== "property" || field.enum || !modelRef || PrimitiveRegistry.has(modelRef)) return null;
|
|
26
|
+
const refName = ConstantRegistry.getRefName(modelRef, { allowEmpty: true });
|
|
27
|
+
return refName && ConstantRegistry.database.has(refName) ? refName : null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The two tools a relation picker owes an agent: list the documents it can pick, then pick by id.
|
|
32
|
+
*
|
|
33
|
+
* `FormFields` publishes no schema for a relation, and it is right not to — the form holds the whole related
|
|
34
|
+
* document, so an id would need a lookup the store does not do. The picker is where that lookup lives: it holds
|
|
35
|
+
* the slice list, the loader, and the label each option renders with. So the field reaches an agent from the one
|
|
36
|
+
* component that can resolve it, which is the rule every other control follows — the control is the declaration.
|
|
37
|
+
*
|
|
38
|
+
* Listing is its own tool because loading is its own step for a person too: the options arrive when the dropdown
|
|
39
|
+
* opens, and an agent never opens it. Folding the load into the setter would leave an agent guessing ids in order
|
|
40
|
+
* to learn them from the refusal.
|
|
41
|
+
*/
|
|
42
|
+
export const useRelationFieldTool = <T extends { id: string }>(
|
|
43
|
+
onChange: unknown,
|
|
44
|
+
{ read, load, label, disabled }: RelationFieldSource<T>,
|
|
45
|
+
) => {
|
|
46
|
+
const surface = useSurface();
|
|
47
|
+
const scope = useScopePath();
|
|
48
|
+
const action = actionTagOf(onChange)?.action ?? null;
|
|
49
|
+
const live = useRef({ onChange, read, load, label });
|
|
50
|
+
live.current = { onChange, read, load, label };
|
|
51
|
+
const scopeKey = scope.join(".");
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!action || disabled) return;
|
|
54
|
+
const ref = FormFields.ref(action);
|
|
55
|
+
const target = ref && relationOf(ref.field);
|
|
56
|
+
|
|
57
|
+
if (!ref || !target || FormFields.schema(ref.field)) return;
|
|
58
|
+
const many = ref.field.arrDepth > 0;
|
|
59
|
+
const nullable = !!ref.field.nullable && !many;
|
|
60
|
+
const listName = `load${capitalize(ref.key)}OptionsOn${capitalize(ref.refName)}`;
|
|
61
|
+
const argName = many ? `${ref.key}Ids` : `${ref.key}Id`;
|
|
62
|
+
const id: JsonSchema = { type: "string" };
|
|
63
|
+
const options = () => live.current.read().map((model) => ({ id: model.id, label: live.current.label(model) }));
|
|
64
|
+
const idsIn = (args: Record<string, unknown>): unknown => {
|
|
65
|
+
const value = args[argName];
|
|
66
|
+
if (nullable && (value === null || value === undefined)) return [];
|
|
67
|
+
return many ? value : [value];
|
|
68
|
+
};
|
|
69
|
+
const offList = surface.registerTool(scope, {
|
|
70
|
+
name: listName,
|
|
71
|
+
description: `List the ${target}s the ${ref.refName} form can pick for ${ref.key}, loading them first. Pass an id from it to ${action}.`,
|
|
72
|
+
effect: "query",
|
|
73
|
+
run: async () => {
|
|
74
|
+
await live.current.load();
|
|
75
|
+
return options();
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const offSet = surface.registerTool(scope, {
|
|
79
|
+
name: action,
|
|
80
|
+
description: `Set ${ref.key} on the ${ref.refName} form to ${many ? `${target}s` : `one ${target}`}, by id. Call ${listName} first for the ids.`,
|
|
81
|
+
parameters: {
|
|
82
|
+
type: "object",
|
|
83
|
+
properties: { [argName]: many ? { type: "array", items: id } : id },
|
|
84
|
+
...(nullable ? {} : { required: [argName] }),
|
|
85
|
+
additionalProperties: false,
|
|
86
|
+
},
|
|
87
|
+
effect: "state",
|
|
88
|
+
guard: (args) => {
|
|
89
|
+
const ids = idsIn(args);
|
|
90
|
+
if (!Array.isArray(ids)) return `"${argName}" of ${action} must be an array of ids.`;
|
|
91
|
+
const list = live.current.read();
|
|
92
|
+
const missing = ids.filter((value) => typeof value !== "string" || !list.get(value));
|
|
93
|
+
if (!missing.length) return true;
|
|
94
|
+
if (!list.length) return `No ${target} is loaded yet. Call ${listName} first for the ids.`;
|
|
95
|
+
return `The ${ref.refName} form offers no ${target} ${missing.join(", ")}. It offers: ${list
|
|
96
|
+
.map((model) => `${model.id} (${live.current.label(model)})`)
|
|
97
|
+
.join(", ")}.`;
|
|
98
|
+
},
|
|
99
|
+
run: (args) => {
|
|
100
|
+
const list = live.current.read();
|
|
101
|
+
const setter = live.current.onChange as (value: unknown) => unknown;
|
|
102
|
+
const picked = (idsIn(args) as string[]).flatMap((value) => {
|
|
103
|
+
const model = list.get(value);
|
|
104
|
+
return model ? [model] : [];
|
|
105
|
+
});
|
|
106
|
+
return setter(many ? picked : (picked[0] ?? null));
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
return () => {
|
|
110
|
+
offList();
|
|
111
|
+
offSet();
|
|
112
|
+
};
|
|
113
|
+
}, [surface, scopeKey, action, disabled]);
|
|
114
|
+
};
|
package/store/formSetterNames.ts
CHANGED
|
@@ -17,5 +17,14 @@ export const formSetterNames = (className: string, key: string) => {
|
|
|
17
17
|
subFieldOnModel: `sub${classKeyName}On${className}`,
|
|
18
18
|
addOrSubFieldOnModel: `addOrSub${classKeyName}On${className}`,
|
|
19
19
|
uploadFieldOnModel: `upload${classKeyName}On${className}`,
|
|
20
|
+
/**
|
|
21
|
+
* The optional hook a store declares to run after this field is written.
|
|
22
|
+
*
|
|
23
|
+
* It carries no model suffix and a leading `_`, so it can never collide with a generated action name — which is
|
|
24
|
+
* the whole point: every generated action lives in a mapped type, and a mapped type produces *properties*, so a
|
|
25
|
+
* subclass method of the same name is a TS2425 error and there is no legal way to override one. A hook under a
|
|
26
|
+
* name the base type does not declare is the only shape TypeScript permits.
|
|
27
|
+
*/
|
|
28
|
+
postSetField: `_postSet${classKeyName}`,
|
|
20
29
|
};
|
|
21
30
|
};
|
package/store/storeRegistry.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ACTION_META,
|
|
3
3
|
ACTION_OWNER_META,
|
|
4
|
+
FIELD_META,
|
|
4
5
|
type MergeAllKeyOfObjects,
|
|
5
6
|
type MergeAllKeyOfTypes,
|
|
6
7
|
type MergeAllTypes,
|
|
@@ -9,7 +10,9 @@ import {
|
|
|
9
10
|
STATE_META,
|
|
10
11
|
} from "akanjs/base";
|
|
11
12
|
import { applyMixins } from "akanjs/common";
|
|
13
|
+
import { ConstantRegistry } from "akanjs/constant";
|
|
12
14
|
import { attachAgentic } from "./agentic";
|
|
15
|
+
import { formSetterNames } from "./formSetterNames";
|
|
13
16
|
import type { RootStoreCls } from "./rootStore";
|
|
14
17
|
import type { StoreCls } from "./store";
|
|
15
18
|
import { StoreInstance } from "./storeInstance";
|
|
@@ -57,8 +60,28 @@ export class StoreRegistry {
|
|
|
57
60
|
store[ACTION_META] = actions;
|
|
58
61
|
store[ACTION_OWNER_META] = owners;
|
|
59
62
|
StoreRegistry.#state.store.set(store.refName, store);
|
|
63
|
+
StoreRegistry.#warnUnknownPostSetHooks(store);
|
|
60
64
|
return store;
|
|
61
65
|
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A `_postSet<Field>` hook cannot be typed — the generated setters live in a mapped type, so every name the base
|
|
69
|
+
* declares is a property a subclass method may not redeclare (TS2425), and the hook only compiles because the base
|
|
70
|
+
* declares nothing under it. A misspelled field would therefore never fire and never complain, so say so here.
|
|
71
|
+
*/
|
|
72
|
+
static #warnUnknownPostSetHooks(store: StoreCls) {
|
|
73
|
+
const hooks = Object.keys(Object.getOwnPropertyDescriptors(store.prototype)).filter((key) =>
|
|
74
|
+
key.startsWith("_postSet"),
|
|
75
|
+
);
|
|
76
|
+
if (!hooks.length) return;
|
|
77
|
+
const model = ConstantRegistry.getDatabase(store.refName, { allowEmpty: true });
|
|
78
|
+
if (!model) return;
|
|
79
|
+
const known = new Set(
|
|
80
|
+
Object.keys(model.full[FIELD_META] as object).map((key) => formSetterNames("", key).postSetField),
|
|
81
|
+
);
|
|
82
|
+
for (const hook of hooks.filter((key) => !known.has(key)))
|
|
83
|
+
console.warn(`[${store.refName}Store] ${hook} matches no field of ${store.refName}, so it will never run.`);
|
|
84
|
+
}
|
|
62
85
|
static get(refName: string) {
|
|
63
86
|
return StoreRegistry.#state.store.get(refName);
|
|
64
87
|
}
|
|
@@ -41,4 +41,13 @@ export declare class FormFields {
|
|
|
41
41
|
static checked(action: string, path: string, field: ConstantField, value: unknown, depth?: number): unknown;
|
|
42
42
|
/** A field no annotated control can ever name: its rows are written through `writeOn<Model>(path, value)`. */
|
|
43
43
|
static isComposite(field: ConstantField): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* The embedded model an array field's rows are, or null for every other field.
|
|
46
|
+
*
|
|
47
|
+
* This is the one shape where writing the whole array is a hazard rather than an inconvenience: the agent has to
|
|
48
|
+
* echo every row it is *not* changing, `checked` validates types and not values, so one mistyped row it was never
|
|
49
|
+
* asked to touch is written silently. A relation array is excluded — its rows travel as ids, which are the payload
|
|
50
|
+
* and are refused by name when wrong — and so is an array of primitives, for the same reason.
|
|
51
|
+
*/
|
|
52
|
+
static rowModelOf(field: ConstantField): (new (...args: never[]) => unknown) | null;
|
|
44
53
|
}
|
|
@@ -4,5 +4,6 @@ export * from "./readableValue.d.ts";
|
|
|
4
4
|
export * from "./StToolBuilder.d.ts";
|
|
5
5
|
export * from "./useFieldTool.d.ts";
|
|
6
6
|
export * from "./useFormTools.d.ts";
|
|
7
|
+
export * from "./useRelationFieldTool.d.ts";
|
|
7
8
|
export * from "./useStExpose.d.ts";
|
|
8
9
|
export * from "./useStState.d.ts";
|
|
@@ -7,4 +7,4 @@
|
|
|
7
7
|
* consequences. Publishing from the form's subscription instead would offer every field of the model, including
|
|
8
8
|
* the ones this template draws no control for.
|
|
9
9
|
*/
|
|
10
|
-
export declare const useFieldTool: (onChange: unknown) => void;
|
|
10
|
+
export declare const useFieldTool: (onChange: unknown, transform?: unknown) => void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type DataList } from "akanjs/base";
|
|
2
|
+
export interface RelationFieldSource<T extends {
|
|
3
|
+
id: string;
|
|
4
|
+
}> {
|
|
5
|
+
/** Read live from the store rather than closed over: `load` below changes the list mid-call. */
|
|
6
|
+
read: () => DataList<T>;
|
|
7
|
+
/** Loads the options an agent never opened the dropdown to fetch. */
|
|
8
|
+
load: () => Promise<unknown> | unknown;
|
|
9
|
+
/** How one option reads to a person, so the agent can match what it sees on screen. */
|
|
10
|
+
label: (model: T) => string;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The two tools a relation picker owes an agent: list the documents it can pick, then pick by id.
|
|
15
|
+
*
|
|
16
|
+
* `FormFields` publishes no schema for a relation, and it is right not to — the form holds the whole related
|
|
17
|
+
* document, so an id would need a lookup the store does not do. The picker is where that lookup lives: it holds
|
|
18
|
+
* the slice list, the loader, and the label each option renders with. So the field reaches an agent from the one
|
|
19
|
+
* component that can resolve it, which is the rule every other control follows — the control is the declaration.
|
|
20
|
+
*
|
|
21
|
+
* Listing is its own tool because loading is its own step for a person too: the options arrive when the dropdown
|
|
22
|
+
* opens, and an agent never opens it. Folding the load into the setter would leave an agent guessing ids in order
|
|
23
|
+
* to learn them from the refusal.
|
|
24
|
+
*/
|
|
25
|
+
export declare const useRelationFieldTool: <T extends {
|
|
26
|
+
id: string;
|
|
27
|
+
}>(onChange: unknown, { read, load, label, disabled }: RelationFieldSource<T>) => void;
|
|
@@ -13,4 +13,13 @@ export declare const formSetterNames: (className: string, key: string) => {
|
|
|
13
13
|
subFieldOnModel: string;
|
|
14
14
|
addOrSubFieldOnModel: string;
|
|
15
15
|
uploadFieldOnModel: string;
|
|
16
|
+
/**
|
|
17
|
+
* The optional hook a store declares to run after this field is written.
|
|
18
|
+
*
|
|
19
|
+
* It carries no model suffix and a leading `_`, so it can never collide with a generated action name — which is
|
|
20
|
+
* the whole point: every generated action lives in a mapped type, and a mapped type produces *properties*, so a
|
|
21
|
+
* subclass method of the same name is a TS2425 error and there is no legal way to override one. A hook under a
|
|
22
|
+
* name the base type does not declare is the only shape TypeScript permits.
|
|
23
|
+
*/
|
|
24
|
+
postSetField: string;
|
|
16
25
|
};
|
package/ui/Field.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { type DataList, type Dayjs, dayjs, type EnumInstance, isEnum } from "aka
|
|
|
3
3
|
import { cn, usePage } from "akanjs/client";
|
|
4
4
|
import { capitalize, formatPhone, isPhoneNumber, lowerlize } from "akanjs/common";
|
|
5
5
|
import type { SliceMeta } from "akanjs/fetch";
|
|
6
|
-
import { st, useFieldTool } from "akanjs/store";
|
|
6
|
+
import { st, useFieldTool, useRelationFieldTool } from "akanjs/store";
|
|
7
7
|
import { memo, type ReactNode, useState } from "react";
|
|
8
8
|
import { AiOutlinePlus } from "react-icons/ai";
|
|
9
9
|
import { BiHelpCircle, BiTrash, BiX } from "react-icons/bi";
|
|
@@ -104,8 +104,9 @@ const List = <Item,>({
|
|
|
104
104
|
}: ListProps<Item>) => {
|
|
105
105
|
const { l } = usePage();
|
|
106
106
|
const recipe = useUiRecipe("button") ?? buttonRecipe;
|
|
107
|
+
useFieldTool(onChange);
|
|
107
108
|
return (
|
|
108
|
-
<div className={cn("flex w-full flex-col", className)}>
|
|
109
|
+
<div {...agentAttrs(onChange)} className={cn("flex w-full flex-col", className)}>
|
|
109
110
|
{label ? <Label className={labelClassName} nullable={nullable} label={label} desc={desc} /> : null}
|
|
110
111
|
<div className="mb-2 flex w-full flex-col gap-2 rounded-box border border-border p-2">
|
|
111
112
|
{value.map((item, idx) => (
|
|
@@ -181,7 +182,7 @@ const Text = ({
|
|
|
181
182
|
inputClassName,
|
|
182
183
|
inputStyleType = "bordered",
|
|
183
184
|
}: TextProps) => {
|
|
184
|
-
useFieldTool(onChange);
|
|
185
|
+
useFieldTool(onChange, transform);
|
|
185
186
|
const { l } = usePage();
|
|
186
187
|
return (
|
|
187
188
|
<div className={cn("flex flex-col", className)}>
|
|
@@ -247,7 +248,7 @@ const Price = ({
|
|
|
247
248
|
inputClassName,
|
|
248
249
|
inputStyleType = "bordered",
|
|
249
250
|
}: PriceProps) => {
|
|
250
|
-
useFieldTool(onChange);
|
|
251
|
+
useFieldTool(onChange, transform);
|
|
251
252
|
const { l } = usePage();
|
|
252
253
|
return (
|
|
253
254
|
<div className={cn("flex flex-col", className)}>
|
|
@@ -315,7 +316,7 @@ const TextArea = ({
|
|
|
315
316
|
cache,
|
|
316
317
|
inputClassName,
|
|
317
318
|
}: TextAreaProps) => {
|
|
318
|
-
useFieldTool(onChange);
|
|
319
|
+
useFieldTool(onChange, transform);
|
|
319
320
|
const { l } = usePage();
|
|
320
321
|
return (
|
|
321
322
|
<div className={cn("flex flex-col", className)}>
|
|
@@ -544,7 +545,7 @@ const TextList = ({
|
|
|
544
545
|
validate,
|
|
545
546
|
inputClassName,
|
|
546
547
|
}: TextListProps) => {
|
|
547
|
-
useFieldTool(onChange);
|
|
548
|
+
useFieldTool(onChange, transform);
|
|
548
549
|
const { l } = usePage();
|
|
549
550
|
const recipe = useUiRecipe("button") ?? buttonRecipe;
|
|
550
551
|
return (
|
|
@@ -651,7 +652,7 @@ const Tags = ({
|
|
|
651
652
|
validate,
|
|
652
653
|
inputClassName,
|
|
653
654
|
}: TagsProps) => {
|
|
654
|
-
useFieldTool(onChange);
|
|
655
|
+
useFieldTool(onChange, transform);
|
|
655
656
|
const { l } = usePage();
|
|
656
657
|
const badge = useUiRecipe("badge") ?? badgeRecipe;
|
|
657
658
|
const [inputVisible, setInputVisible] = useState(false);
|
|
@@ -814,9 +815,7 @@ const DateRange = <Nullable extends boolean>({
|
|
|
814
815
|
value={from}
|
|
815
816
|
max={max}
|
|
816
817
|
min={min}
|
|
817
|
-
onChange={
|
|
818
|
-
onChangeFrom(value);
|
|
819
|
-
}}
|
|
818
|
+
onChange={onChangeFrom}
|
|
820
819
|
/>
|
|
821
820
|
</div>
|
|
822
821
|
<div className="relative flex w-full flex-col items-start gap-2 text-center md:flex-row md:items-center">
|
|
@@ -828,9 +827,7 @@ const DateRange = <Nullable extends boolean>({
|
|
|
828
827
|
value={to}
|
|
829
828
|
max={max}
|
|
830
829
|
min={min}
|
|
831
|
-
onChange={
|
|
832
|
-
onChangeTo(value);
|
|
833
|
-
}}
|
|
830
|
+
onChange={onChangeTo}
|
|
834
831
|
/>
|
|
835
832
|
</div>
|
|
836
833
|
</div>
|
|
@@ -881,7 +878,7 @@ const Number = ({
|
|
|
881
878
|
formatter,
|
|
882
879
|
parser,
|
|
883
880
|
}: NumberProps) => {
|
|
884
|
-
useFieldTool(onChange);
|
|
881
|
+
useFieldTool(onChange, transform);
|
|
885
882
|
const { l } = usePage();
|
|
886
883
|
return (
|
|
887
884
|
<div className={cn("flex flex-col", className)}>
|
|
@@ -953,7 +950,7 @@ const DoubleNumber = ({
|
|
|
953
950
|
validate,
|
|
954
951
|
onPressEnter,
|
|
955
952
|
}: DoubleNumberProps) => {
|
|
956
|
-
useFieldTool(onChange);
|
|
953
|
+
useFieldTool(onChange, transform);
|
|
957
954
|
const { l } = usePage();
|
|
958
955
|
return (
|
|
959
956
|
<div {...agentAttrs(onChange)} className={cn("flex flex-col", className)}>
|
|
@@ -1041,7 +1038,7 @@ const Email = ({
|
|
|
1041
1038
|
inputClassName,
|
|
1042
1039
|
inputStyleType,
|
|
1043
1040
|
}: EmailProps) => {
|
|
1044
|
-
useFieldTool(onChange);
|
|
1041
|
+
useFieldTool(onChange, transform);
|
|
1045
1042
|
const { l } = usePage();
|
|
1046
1043
|
return (
|
|
1047
1044
|
<div className={cn("flex flex-col", className)}>
|
|
@@ -1106,7 +1103,7 @@ const Phone = ({
|
|
|
1106
1103
|
onPressEnter,
|
|
1107
1104
|
inputClassName,
|
|
1108
1105
|
}: PhoneProps) => {
|
|
1109
|
-
useFieldTool(onChange);
|
|
1106
|
+
useFieldTool(onChange, transform);
|
|
1110
1107
|
const { l } = usePage();
|
|
1111
1108
|
|
|
1112
1109
|
return (
|
|
@@ -1178,7 +1175,7 @@ const Password = ({
|
|
|
1178
1175
|
inputClassName,
|
|
1179
1176
|
showConfirm,
|
|
1180
1177
|
}: PasswordProps) => {
|
|
1181
|
-
useFieldTool(onChange);
|
|
1178
|
+
useFieldTool(onChange, transform);
|
|
1182
1179
|
const { l } = usePage();
|
|
1183
1180
|
return (
|
|
1184
1181
|
<div className={cn("flex flex-col", className)}>
|
|
@@ -1242,6 +1239,12 @@ interface ParentProps<T extends string, State, Input, Full, Light> {
|
|
|
1242
1239
|
renderOption: (model: Light) => ReactNode;
|
|
1243
1240
|
renderSelected?: (value: Light) => ReactNode;
|
|
1244
1241
|
}
|
|
1242
|
+
/** The one line an option renders as, so an agent can match an id against what it reads on screen. */
|
|
1243
|
+
const optionLabel = <Light extends { id: string }>(model: Light, render: (model: Light) => ReactNode) => {
|
|
1244
|
+
const rendered = render(model);
|
|
1245
|
+
return typeof rendered === "string" ? rendered : model.id;
|
|
1246
|
+
};
|
|
1247
|
+
|
|
1245
1248
|
const Parent = <T extends string, State, Input, Full extends { id: string }, Light extends { id: string }>({
|
|
1246
1249
|
label,
|
|
1247
1250
|
desc,
|
|
@@ -1263,6 +1266,7 @@ const Parent = <T extends string, State, Input, Full extends { id: string }, Lig
|
|
|
1263
1266
|
const [modelName, ModelName] = [lowerlize(refName), capitalize(refName)];
|
|
1264
1267
|
const storeUse = st.use as { [key: string]: () => unknown };
|
|
1265
1268
|
const storeDo = st.do as unknown as { [key: string]: (...args: any[]) => Promise<void> };
|
|
1269
|
+
const storeGet = st.get as unknown as <V>() => { [key: string]: V };
|
|
1266
1270
|
|
|
1267
1271
|
const names = {
|
|
1268
1272
|
model: modelName,
|
|
@@ -1279,9 +1283,15 @@ const Parent = <T extends string, State, Input, Full extends { id: string }, Lig
|
|
|
1279
1283
|
|
|
1280
1284
|
const modelList = storeUse[namesOfSlice.modelList]() as DataList<Light>;
|
|
1281
1285
|
const modelListLoading = storeUse[namesOfSlice.modelListLoading]() as string | boolean;
|
|
1286
|
+
useRelationFieldTool(onChange, {
|
|
1287
|
+
read: () => storeGet<DataList<Light>>()[namesOfSlice.modelList],
|
|
1288
|
+
load: () => storeDo[namesOfSlice.refreshModel]({ invalidate: true, queryArgs: initArgs }),
|
|
1289
|
+
label: (model) => optionLabel(model, renderOption),
|
|
1290
|
+
disabled,
|
|
1291
|
+
});
|
|
1282
1292
|
|
|
1283
1293
|
return (
|
|
1284
|
-
<div className={cn("flex flex-col", className)}>
|
|
1294
|
+
<div {...agentAttrs(onChange)} className={cn("flex flex-col", className)}>
|
|
1285
1295
|
{label ? <Label className={labelClassName} nullable={nullable} label={label} desc={desc} /> : null}
|
|
1286
1296
|
<Select<string | null, false, true>
|
|
1287
1297
|
label={label}
|
|
@@ -1373,9 +1383,11 @@ const ParentId = <T extends string, State, Input, Full extends { id: string }, L
|
|
|
1373
1383
|
};
|
|
1374
1384
|
const modelList = storeUse[namesOfSlice.modelList]() as DataList<Light>;
|
|
1375
1385
|
const modelListLoading = storeUse[namesOfSlice.modelListLoading]() as string | boolean;
|
|
1386
|
+
|
|
1387
|
+
useFieldTool(onChange);
|
|
1376
1388
|
|
|
1377
1389
|
return (
|
|
1378
|
-
<div className={cn("flex flex-col", className)}>
|
|
1390
|
+
<div {...agentAttrs(onChange)} className={cn("flex flex-col", className)}>
|
|
1379
1391
|
{label ? <Label className={labelClassName} nullable={nullable} label={label} desc={desc} /> : null}
|
|
1380
1392
|
<Select<string | null, false, true>
|
|
1381
1393
|
searchable
|
|
@@ -1464,9 +1476,15 @@ const Children = <T extends string, State, Input, Full extends { id: string }, L
|
|
|
1464
1476
|
};
|
|
1465
1477
|
const modelList = storeUse[namesOfSlice.modelList]() as DataList<Light>;
|
|
1466
1478
|
const modelListLoading = storeUse[namesOfSlice.modelListLoading]() as string | boolean;
|
|
1479
|
+
useRelationFieldTool(onChange, {
|
|
1480
|
+
read: () => storeGet<DataList<Light>>()[namesOfSlice.modelList],
|
|
1481
|
+
load: () => storeDo[namesOfSlice.refreshModel]({ invalidate: true, queryArgs: initArgs }),
|
|
1482
|
+
label: (model) => optionLabel(model, renderOption),
|
|
1483
|
+
disabled,
|
|
1484
|
+
});
|
|
1467
1485
|
|
|
1468
1486
|
return (
|
|
1469
|
-
<div className={cn("flex flex-col", className)}>
|
|
1487
|
+
<div {...agentAttrs(onChange)} className={cn("flex flex-col", className)}>
|
|
1470
1488
|
{label ? <Label className={labelClassName} nullable={nullable} label={label} desc={desc} /> : null}
|
|
1471
1489
|
<Select
|
|
1472
1490
|
searchable
|
|
@@ -1554,9 +1572,11 @@ const ChildrenId = <T extends string, State, Input, Full extends { id: string },
|
|
|
1554
1572
|
};
|
|
1555
1573
|
const modelList = storeUse[namesOfSlice.modelList]() as DataList<Light>;
|
|
1556
1574
|
const modelListLoading = storeUse[namesOfSlice.modelListLoading]() as string | boolean;
|
|
1575
|
+
|
|
1576
|
+
useFieldTool(onChange);
|
|
1557
1577
|
|
|
1558
1578
|
return (
|
|
1559
|
-
<div className={cn("flex flex-col", className)}>
|
|
1579
|
+
<div {...agentAttrs(onChange)} className={cn("flex flex-col", className)}>
|
|
1560
1580
|
{label ? <Label className={labelClassName} nullable={nullable} label={label} desc={desc} /> : null}
|
|
1561
1581
|
<Select
|
|
1562
1582
|
searchable
|