@rippling/rippling-sdk 0.2.0-alpha.37 → 0.2.0-alpha.38
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/examples/sdui/sdui-crud-todo/sdui-crud-todo.ts +3 -3
- package/examples/sdui/sdui-showcase/sdui-showcase.ts +6 -11
- package/examples/sdui/sdui-tab-multi-view/sdui-tab-multi-view.ts +14 -24
- package/examples/sdui/vote/vote.ts +7 -11
- package/lib/manifest/category.d.mts +4 -2
- package/lib/manifest/category.d.mts.map +1 -1
- package/lib/manifest/category.d.ts +4 -2
- package/lib/manifest/category.d.ts.map +1 -1
- package/lib/manifest/category.js +2 -2
- package/lib/manifest/category.js.map +1 -1
- package/lib/manifest/category.mjs +2 -2
- package/lib/manifest/category.mjs.map +1 -1
- package/lib/manifest/custom-object-page-layout.d.mts +2 -0
- package/lib/manifest/custom-object-page-layout.d.mts.map +1 -1
- package/lib/manifest/custom-object-page-layout.d.ts +2 -0
- package/lib/manifest/custom-object-page-layout.d.ts.map +1 -1
- package/lib/manifest/custom-object-page-layout.js +4 -1
- package/lib/manifest/custom-object-page-layout.js.map +1 -1
- package/lib/manifest/custom-object-page-layout.mjs +4 -1
- package/lib/manifest/custom-object-page-layout.mjs.map +1 -1
- package/lib/manifest/custom-object.d.mts +30 -1
- package/lib/manifest/custom-object.d.mts.map +1 -1
- package/lib/manifest/custom-object.d.ts +30 -1
- package/lib/manifest/custom-object.d.ts.map +1 -1
- package/lib/manifest/custom-object.js +15 -1
- package/lib/manifest/custom-object.js.map +1 -1
- package/lib/manifest/custom-object.mjs +15 -1
- package/lib/manifest/custom-object.mjs.map +1 -1
- package/lib/manifest/field.d.mts +20 -2
- package/lib/manifest/field.d.mts.map +1 -1
- package/lib/manifest/field.d.ts +20 -2
- package/lib/manifest/field.d.ts.map +1 -1
- package/lib/manifest/field.js +20 -2
- package/lib/manifest/field.js.map +1 -1
- package/lib/manifest/field.mjs +20 -2
- package/lib/manifest/field.mjs.map +1 -1
- package/lib/manifest/manifest-builder.d.mts +1 -1
- package/lib/manifest/manifest-builder.d.ts +1 -1
- package/lib/manifest/manifest-builder.js +1 -1
- package/lib/manifest/manifest-builder.mjs +1 -1
- package/lib/manifest/manifest-function.d.mts +8 -2
- package/lib/manifest/manifest-function.d.mts.map +1 -1
- package/lib/manifest/manifest-function.d.ts +8 -2
- package/lib/manifest/manifest-function.d.ts.map +1 -1
- package/lib/manifest/manifest-function.js +8 -2
- package/lib/manifest/manifest-function.js.map +1 -1
- package/lib/manifest/manifest-function.mjs +8 -2
- package/lib/manifest/manifest-function.mjs.map +1 -1
- package/lib/sdui/actions.d.mts.map +1 -1
- package/lib/sdui/actions.d.ts.map +1 -1
- package/lib/sdui/actions.js +23 -13
- package/lib/sdui/actions.js.map +1 -1
- package/lib/sdui/actions.mjs +23 -13
- package/lib/sdui/actions.mjs.map +1 -1
- package/lib/sdui/component.d.mts +28 -11
- package/lib/sdui/component.d.mts.map +1 -1
- package/lib/sdui/component.d.ts +28 -11
- package/lib/sdui/component.d.ts.map +1 -1
- package/lib/sdui/component.js +69 -36
- package/lib/sdui/component.js.map +1 -1
- package/lib/sdui/component.mjs +69 -36
- package/lib/sdui/component.mjs.map +1 -1
- package/lib/sdui/components.d.mts +1141 -132
- package/lib/sdui/components.d.mts.map +1 -1
- package/lib/sdui/components.d.ts +1141 -132
- package/lib/sdui/components.d.ts.map +1 -1
- package/lib/sdui/components.js +834 -206
- package/lib/sdui/components.js.map +1 -1
- package/lib/sdui/components.mjs +830 -203
- package/lib/sdui/components.mjs.map +1 -1
- package/lib/sdui/directives.d.mts.map +1 -1
- package/lib/sdui/directives.d.ts.map +1 -1
- package/lib/sdui/directives.js +4 -1
- package/lib/sdui/directives.js.map +1 -1
- package/lib/sdui/directives.mjs +4 -1
- package/lib/sdui/directives.mjs.map +1 -1
- package/lib/sdui/index.d.mts +314 -261
- package/lib/sdui/index.d.mts.map +1 -1
- package/lib/sdui/index.d.ts +314 -261
- package/lib/sdui/index.d.ts.map +1 -1
- package/lib/sdui/index.js +255 -204
- package/lib/sdui/index.js.map +1 -1
- package/lib/sdui/index.mjs +128 -103
- package/lib/sdui/index.mjs.map +1 -1
- package/lib/sdui/propTypes.d.mts +6330 -0
- package/lib/sdui/propTypes.d.mts.map +1 -0
- package/lib/sdui/propTypes.d.ts +6330 -0
- package/lib/sdui/propTypes.d.ts.map +1 -0
- package/lib/sdui/propTypes.js +14 -0
- package/lib/sdui/propTypes.js.map +1 -0
- package/lib/sdui/propTypes.mjs +13 -0
- package/lib/sdui/propTypes.mjs.map +1 -0
- package/lib/sdui/render.d.mts +1 -1
- package/lib/sdui/render.d.mts.map +1 -1
- package/lib/sdui/render.d.ts +1 -1
- package/lib/sdui/render.d.ts.map +1 -1
- package/lib/sdui/render.js +11 -3
- package/lib/sdui/render.js.map +1 -1
- package/lib/sdui/render.mjs +11 -3
- package/lib/sdui/render.mjs.map +1 -1
- package/lib/sdui/state.d.mts.map +1 -1
- package/lib/sdui/state.d.ts.map +1 -1
- package/lib/sdui/state.js +3 -1
- package/lib/sdui/state.js.map +1 -1
- package/lib/sdui/state.mjs +3 -1
- package/lib/sdui/state.mjs.map +1 -1
- package/package.json +1 -1
- package/src/lib/manifest/category.ts +7 -5
- package/src/lib/manifest/custom-object-page-layout.ts +6 -1
- package/src/lib/manifest/custom-object.ts +30 -1
- package/src/lib/manifest/field.ts +20 -2
- package/src/lib/manifest/manifest-builder.ts +1 -1
- package/src/lib/manifest/manifest-function.ts +8 -2
- package/src/lib/sdui/actions.ts +23 -10
- package/src/lib/sdui/component.ts +84 -44
- package/src/lib/sdui/components.ts +1627 -212
- package/src/lib/sdui/directives.ts +4 -1
- package/src/lib/sdui/index.ts +565 -200
- package/src/lib/sdui/propTypes.ts +8470 -0
- package/src/lib/sdui/render.ts +13 -5
- package/src/lib/sdui/state.ts +8 -2
- package/src/version.ts +1 -1
- package/version.d.mts +1 -1
- package/version.d.ts +1 -1
- package/version.js +1 -1
- package/version.mjs +1 -1
|
@@ -19,9 +19,11 @@ export interface CategoryProps {
|
|
|
19
19
|
/**
|
|
20
20
|
* Human-readable description of what this category groups.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
22
|
+
* Required. The backend (`CustomCategoryConfig.description: str`) requires the
|
|
23
|
+
* key to be present and rejects `null`. Pass an empty string only if you truly
|
|
24
|
+
* have no meaningful copy.
|
|
23
25
|
*/
|
|
24
|
-
description
|
|
26
|
+
description: string;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/**
|
|
@@ -57,7 +59,7 @@ export interface CategoryProps {
|
|
|
57
59
|
export class Category {
|
|
58
60
|
private readonly _apiName: string;
|
|
59
61
|
private readonly _name: string;
|
|
60
|
-
private readonly _description: string
|
|
62
|
+
private readonly _description: string;
|
|
61
63
|
|
|
62
64
|
/**
|
|
63
65
|
* @param scope - The manifest to register this category with.
|
|
@@ -66,7 +68,7 @@ export class Category {
|
|
|
66
68
|
constructor(scope: ManifestScope, props: CategoryProps) {
|
|
67
69
|
this._apiName = props.apiName;
|
|
68
70
|
this._name = props.name;
|
|
69
|
-
this._description = props.description
|
|
71
|
+
this._description = props.description;
|
|
70
72
|
scope._register(this);
|
|
71
73
|
}
|
|
72
74
|
|
|
@@ -94,7 +96,7 @@ export class Category {
|
|
|
94
96
|
type: 'CUSTOM_CATEGORY',
|
|
95
97
|
api_name: this._apiName,
|
|
96
98
|
name: this._name,
|
|
97
|
-
description: this._description
|
|
99
|
+
description: this._description,
|
|
98
100
|
};
|
|
99
101
|
}
|
|
100
102
|
}
|
|
@@ -96,6 +96,8 @@ export interface FieldsSection {
|
|
|
96
96
|
name?: string;
|
|
97
97
|
/** Fields to display in this section. Required. */
|
|
98
98
|
fields: SectionField[];
|
|
99
|
+
/** Column layout for the fields. Optional. @default `'responsive'` (backend default). */
|
|
100
|
+
layout?: SectionFieldsLayout;
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
/**
|
|
@@ -274,6 +276,7 @@ interface SerializedFieldsSection {
|
|
|
274
276
|
key: string;
|
|
275
277
|
name: string;
|
|
276
278
|
fields: SerializedSectionField[];
|
|
279
|
+
layout?: SectionFieldsLayout;
|
|
277
280
|
}
|
|
278
281
|
|
|
279
282
|
interface SerializedTabWithSections {
|
|
@@ -380,12 +383,14 @@ function normalizeFieldsSection(
|
|
|
380
383
|
assertSectionBelongsTo(customObject, input.section);
|
|
381
384
|
const key = input.key ?? input.section.getSectionId();
|
|
382
385
|
const name = input.name ?? input.section.getName();
|
|
383
|
-
|
|
386
|
+
const normalized: SerializedFieldsSection = {
|
|
384
387
|
key,
|
|
385
388
|
name,
|
|
386
389
|
type: 'fields_section',
|
|
387
390
|
fields: input.fields.map((field) => normalizeSectionField(customObject, input.section, key, field)),
|
|
388
391
|
};
|
|
392
|
+
if (input.layout != null) normalized.layout = input.layout;
|
|
393
|
+
return normalized;
|
|
389
394
|
}
|
|
390
395
|
|
|
391
396
|
function normalizeTab(customObject: CustomObject, tab: PageLayoutTab): SerializedPageLayoutTab {
|
|
@@ -215,6 +215,21 @@ export interface CustomObjectProps {
|
|
|
215
215
|
* Overrides for the auto-number name field — controls the format string and starting number.
|
|
216
216
|
* Leave `null` to use the default name field behavior.
|
|
217
217
|
*
|
|
218
|
+
* Example:
|
|
219
|
+
* ```ts
|
|
220
|
+
* nameFieldDetails: {
|
|
221
|
+
* is_autonumber_field: true,
|
|
222
|
+
* starting_number: 1,
|
|
223
|
+
* autonumber_format: 'EXP-{0000}',
|
|
224
|
+
* }
|
|
225
|
+
* ```
|
|
226
|
+
*
|
|
227
|
+
* @remarks
|
|
228
|
+
* The backend auto-appends `{0000}` to `autonumber_format` if the format string
|
|
229
|
+
* doesn't already contain a sequence token — so `'EXP-'` becomes `'EXP-{0000}'`
|
|
230
|
+
* on the wire. Include the token explicitly when you want a different width
|
|
231
|
+
* (e.g. `'EXP-{00000}'`).
|
|
232
|
+
*
|
|
218
233
|
* Optional. @default null
|
|
219
234
|
*/
|
|
220
235
|
nameFieldDetails?: Record<string, any> | null;
|
|
@@ -241,7 +256,7 @@ export interface CustomObjectProps {
|
|
|
241
256
|
* name: 'Gym Membership Management',
|
|
242
257
|
* });
|
|
243
258
|
*
|
|
244
|
-
* const gymCategory = new Category(manifest, { apiName: 'gym__c', name: 'Gym' });
|
|
259
|
+
* const gymCategory = new Category(manifest, { apiName: 'gym__c', name: 'Gym', description: 'Gym domain' });
|
|
245
260
|
*
|
|
246
261
|
* const memberObj = new CustomObject(manifest, {
|
|
247
262
|
* apiName: 'gym_member__c',
|
|
@@ -266,6 +281,20 @@ export interface CustomObjectProps {
|
|
|
266
281
|
* with `DEPENDENCY_ERROR: Page layout config for CO (api_name=...) not found in package
|
|
267
282
|
* configs. CO requires at least one page layout in the package.`
|
|
268
283
|
* Use {@link CustomObjectPageLayout.basic} for a minimal single-tab layout.
|
|
284
|
+
*
|
|
285
|
+
* **Consider defining at least one {@link ListViewDef}.** Without a list view,
|
|
286
|
+
* the records page has nothing to display by default — users land on an empty grid.
|
|
287
|
+
* Consider shipping a "default" view with the columns most users want to see
|
|
288
|
+
* (typically `name`, the status / category field, owner, and `created_at`), sorted
|
|
289
|
+
* however the records are most commonly browsed.
|
|
290
|
+
*
|
|
291
|
+
* **Consider system fields before authoring new ones.** Every CO ships with
|
|
292
|
+
* `owner_role`, `created_by`, and `last_modified_by` (all `Employee` references),
|
|
293
|
+
* plus `name`, `created_at`, `updated_at`, `external_id`, and `id`. For any
|
|
294
|
+
* "the employee responsible for this record" semantics, consider using
|
|
295
|
+
* `owner_role` rather than adding a new {@link LookupField} targeting `'role'`.
|
|
296
|
+
* `owner_role` drives Rippling's record-level permissions, so a parallel field
|
|
297
|
+
* can fragment ownership and complicate the permission story.
|
|
269
298
|
*/
|
|
270
299
|
export class CustomObject implements ManifestScope {
|
|
271
300
|
private readonly _scope: ManifestScope;
|
|
@@ -446,7 +446,12 @@ export interface SelectFieldProps extends CommonFieldProps {
|
|
|
446
446
|
/** Whether a value is required on save. Optional. */
|
|
447
447
|
required?: boolean;
|
|
448
448
|
}
|
|
449
|
-
/**
|
|
449
|
+
/**
|
|
450
|
+
* Multi-select dropdown field. Renders as a dropdown that allows multiple
|
|
451
|
+
* options to be selected at once.
|
|
452
|
+
*
|
|
453
|
+
* Use {@link RadioField} instead when only one option should be selectable.
|
|
454
|
+
*/
|
|
450
455
|
export class SelectField extends Field {
|
|
451
456
|
constructor(customObject: CustomObject, props: SelectFieldProps) {
|
|
452
457
|
super(customObject, { ...props, fieldType: 'SELECT' });
|
|
@@ -464,7 +469,11 @@ export interface RadioFieldProps extends CommonFieldProps {
|
|
|
464
469
|
/** Whether a value is required on save. Optional. */
|
|
465
470
|
required?: boolean;
|
|
466
471
|
}
|
|
467
|
-
/**
|
|
472
|
+
/**
|
|
473
|
+
* Single-select radio button group field. Exactly one option can be selected.
|
|
474
|
+
*
|
|
475
|
+
* Use {@link SelectField} instead when multiple options should be selectable.
|
|
476
|
+
*/
|
|
468
477
|
export class RadioField extends Field {
|
|
469
478
|
constructor(customObject: CustomObject, props: RadioFieldProps) {
|
|
470
479
|
super(customObject, { ...props, fieldType: 'RADIO' });
|
|
@@ -538,6 +547,15 @@ export interface LookupFieldProps extends CommonFieldProps {
|
|
|
538
547
|
*
|
|
539
548
|
* Use {@link ParentChildField} instead when the relationship should create a
|
|
540
549
|
* parent-child hierarchy with rollup support.
|
|
550
|
+
*
|
|
551
|
+
* @remarks
|
|
552
|
+
* **For "the employee responsible for this record" semantics, consider the built-in
|
|
553
|
+
* `owner_role` standard field instead of a new `LookupField` to `'role'`.** Every
|
|
554
|
+
* `CustomObject` ships with `owner_role` (an Employee reference) plus `created_by`
|
|
555
|
+
* and `last_modified_by`. `owner_role` drives Rippling's record-level permissions,
|
|
556
|
+
* so a parallel custom employee field can fragment ownership and complicate the
|
|
557
|
+
* permission story. Consider a new `LookupField` to `'role'` when you need a
|
|
558
|
+
* *second*, non-owner employee reference (e.g. an "approver", a "manager on record").
|
|
541
559
|
*/
|
|
542
560
|
export class LookupField extends Field {
|
|
543
561
|
constructor(customObject: CustomObject, props: LookupFieldProps) {
|
|
@@ -63,7 +63,7 @@ export interface ManifestBuilderProps {
|
|
|
63
63
|
* description: 'Members, trainers, schedules, and check-in',
|
|
64
64
|
* });
|
|
65
65
|
*
|
|
66
|
-
* const category = new Category(manifest, { apiName: 'gym__c', name: 'Gym' });
|
|
66
|
+
* const category = new Category(manifest, { apiName: 'gym__c', name: 'Gym', description: 'Gym domain' });
|
|
67
67
|
* const memberObj = new CustomObject(manifest, { apiName: 'gym_member__c', name: 'Member', category });
|
|
68
68
|
* // ... define fields, rules, list views, etc. ...
|
|
69
69
|
*
|
|
@@ -68,8 +68,14 @@ export interface ManifestFunctionProps {
|
|
|
68
68
|
* Defines a serverless function and registers it with the manifest.
|
|
69
69
|
*
|
|
70
70
|
* A `ManifestFunction` packages TypeScript handler code that runs on Rippling's
|
|
71
|
-
* serverless infrastructure.
|
|
72
|
-
*
|
|
71
|
+
* serverless infrastructure. The handler must export `onRipplingEvent`.
|
|
72
|
+
*
|
|
73
|
+
* Functions have two primary uses:
|
|
74
|
+
* 1. **Authoring SDUI for Custom Apps** — the function returns an SDUI spec
|
|
75
|
+
* (built with `@rippling/sdui-builder`) that renders as a page tab. Wrap the
|
|
76
|
+
* function in an {@link SduiPage} and reference it from an {@link App}.
|
|
77
|
+
* 2. **Workflow / event handlers** — functions invoked from rules, automations,
|
|
78
|
+
* or callbacks that do not render UI.
|
|
73
79
|
*
|
|
74
80
|
* This class is named `ManifestFunction` to avoid collision with the JavaScript
|
|
75
81
|
* built-in `Function`. It is also re-exported as `Function` from the barrel for
|
package/src/lib/sdui/actions.ts
CHANGED
|
@@ -12,15 +12,19 @@ export function set(ref: unknown, value: unknown): ActionBinding {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export function push(ref: unknown, value: unknown, opts?: { clear?: unknown }): ActionBinding {
|
|
15
|
-
const params: Record<string, unknown> = {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
const params: Record<string, unknown> = {
|
|
16
|
+
statePath: extractPath(ref),
|
|
17
|
+
value,
|
|
18
|
+
...(opts?.clear ? { clearStatePath: extractPath(opts.clear) } : {}),
|
|
19
|
+
};
|
|
19
20
|
return { action: 'pushState', params };
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export function remove(ref: unknown, index: unknown): ActionBinding {
|
|
23
|
-
return {
|
|
24
|
+
return {
|
|
25
|
+
action: 'removeState',
|
|
26
|
+
params: { statePath: extractPath(ref), index },
|
|
27
|
+
};
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
export function callFunction(
|
|
@@ -29,11 +33,20 @@ export function callFunction(
|
|
|
29
33
|
opts?: Record<string, unknown>,
|
|
30
34
|
): ActionBinding {
|
|
31
35
|
const { responsePath, onSuccess, onError, ...rest } = opts || {};
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
// Spread rest FIRST so explicit functionId/action always win if opts
|
|
37
|
+
// happens to carry colliding keys.
|
|
38
|
+
const params: Record<string, unknown> = {
|
|
39
|
+
...rest,
|
|
40
|
+
functionId,
|
|
41
|
+
action,
|
|
42
|
+
...(responsePath !== undefined ? { responsePath } : {}),
|
|
43
|
+
};
|
|
44
|
+
const binding: ActionBinding = {
|
|
45
|
+
action: 'triggerFunction',
|
|
46
|
+
params,
|
|
47
|
+
...(onSuccess ? { onSuccess: onSuccess as ActionBinding } : {}),
|
|
48
|
+
...(onError ? { onError: onError as ActionBinding } : {}),
|
|
49
|
+
};
|
|
37
50
|
return binding;
|
|
38
51
|
}
|
|
39
52
|
|
|
@@ -1,25 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* component() — creates a stateful component boundary.
|
|
3
3
|
*
|
|
4
|
-
* Provides ctx.
|
|
5
|
-
*
|
|
4
|
+
* Provides ctx.localState (scoped local state slot) and ctx.onMount (an
|
|
5
|
+
* action that fires once when the component mounts on the client). The
|
|
6
|
+
* runtime handles state setup and mount firing; consumers don't need to
|
|
7
|
+
* know how the wrapping is implemented.
|
|
8
|
+
*
|
|
9
|
+
* NOTE: these are NOT React hooks. The factory runs ONCE at spec-build
|
|
10
|
+
* time, producing a JSON description of the UI. `localState` returns a
|
|
11
|
+
* path-builder proxy (not a [value, setter] tuple); `onMount` registers
|
|
12
|
+
* a JSON action that fires on first client render (not an effect that
|
|
13
|
+
* re-runs).
|
|
6
14
|
*
|
|
7
15
|
* Usage:
|
|
8
16
|
* function MembersGrid(props) {
|
|
9
17
|
* return component((ctx) => {
|
|
10
|
-
* const local = ctx.
|
|
11
|
-
* ctx.
|
|
12
|
-
* return { element: DataGrid({ ... })
|
|
18
|
+
* const local = ctx.localState({ data: [], loading: true })
|
|
19
|
+
* ctx.onMount(fetchAction)
|
|
20
|
+
* return { element: DataGrid({ ... }) }
|
|
13
21
|
* })
|
|
14
22
|
* }
|
|
15
23
|
*/
|
|
16
24
|
|
|
17
|
-
import type { SpecNode } from './components';
|
|
18
25
|
import type { ActionBinding } from './actions';
|
|
26
|
+
import type { SpecNode } from './components';
|
|
19
27
|
|
|
20
28
|
let _idCounter = 0;
|
|
29
|
+
// Counter-only IDs (no Date.now()) so the same factory body produces the
|
|
30
|
+
// same paths across runs. The counter is monotonic per process; calling
|
|
31
|
+
// `_resetIdCounter()` (tests only) brings it back to 0 so snapshot tests
|
|
32
|
+
// stay stable.
|
|
21
33
|
function generateId(): string {
|
|
22
|
-
|
|
34
|
+
_idCounter += 1;
|
|
35
|
+
return `auto_${_idCounter.toString(36)}`;
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
// Reset counter (for testing)
|
|
@@ -37,15 +50,25 @@ export interface StateScope {
|
|
|
37
50
|
}
|
|
38
51
|
|
|
39
52
|
interface ComponentContext {
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Declare a scoped local state slot. Returns a path-builder proxy whose
|
|
55
|
+
* property accesses produce `{ $state: '/path' }` references for reading
|
|
56
|
+
* and `.$bind` for two-way binding. Not a React hook — runs once when
|
|
57
|
+
* the factory is invoked, never returns `[value, setter]`.
|
|
58
|
+
*/
|
|
59
|
+
localState<T extends Record<string, unknown>>(initial: T): StateScope;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Register a JSON action to fire once when the component mounts on the
|
|
63
|
+
* client. Not a React effect — does not re-run, has no dependency
|
|
64
|
+
* tracking. Pass an `ActionBinding` (e.g. from `set(...)`, `callFunction(...)`)
|
|
65
|
+
* or an array of them.
|
|
66
|
+
*/
|
|
67
|
+
onMount(action: ActionBinding | ActionBinding[]): void;
|
|
44
68
|
}
|
|
45
69
|
|
|
46
70
|
interface ComponentResult {
|
|
47
71
|
element: SpecNode;
|
|
48
|
-
[key: string]: any; // exposed actions like refresh, reset, etc.
|
|
49
72
|
}
|
|
50
73
|
|
|
51
74
|
function createScopeProxy(basePath: string): StateScope {
|
|
@@ -65,58 +88,75 @@ function createScopeProxy(basePath: string): StateScope {
|
|
|
65
88
|
}
|
|
66
89
|
|
|
67
90
|
export function component(factory: (ctx: ComponentContext) => ComponentResult): ComponentResult {
|
|
68
|
-
|
|
69
|
-
|
|
91
|
+
const states: { path: string; initial: unknown }[] = [];
|
|
92
|
+
const mountActions: (ActionBinding | ActionBinding[])[] = [];
|
|
70
93
|
|
|
71
94
|
const ctx: ComponentContext = {
|
|
72
|
-
|
|
95
|
+
localState<T extends Record<string, unknown>>(initial: T): StateScope {
|
|
73
96
|
const path = `/${generateId()}`;
|
|
74
|
-
|
|
97
|
+
states.push({ path, initial });
|
|
75
98
|
return createScopeProxy(path);
|
|
76
99
|
},
|
|
77
|
-
|
|
78
|
-
|
|
100
|
+
onMount(action: ActionBinding | ActionBinding[]) {
|
|
101
|
+
mountActions.push(action);
|
|
79
102
|
},
|
|
80
103
|
};
|
|
81
104
|
|
|
82
105
|
const result = factory(ctx);
|
|
83
106
|
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
const capturedMount = mountAction;
|
|
87
|
-
|
|
88
|
-
// If no local state and no effects, return as-is
|
|
89
|
-
if (!capturedState && !capturedMount) {
|
|
107
|
+
// No state, no effects → return as-is.
|
|
108
|
+
if (states.length === 0 && mountActions.length === 0) {
|
|
90
109
|
return result;
|
|
91
110
|
}
|
|
92
111
|
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
providerProps['onMountAction'] = capturedMount;
|
|
112
|
+
// Collapse zero/one/many mount actions for the single onMountAction slot
|
|
113
|
+
// on the innermost wrapper.
|
|
114
|
+
let combinedMount: ActionBinding | ActionBinding[] | undefined;
|
|
115
|
+
if (mountActions.length === 1) {
|
|
116
|
+
combinedMount = mountActions[0];
|
|
117
|
+
} else if (mountActions.length > 1) {
|
|
118
|
+
combinedMount = mountActions.flatMap((a) => (Array.isArray(a) ? a : [a]));
|
|
101
119
|
}
|
|
102
120
|
|
|
103
|
-
|
|
104
|
-
type: '_LocalStateProvider',
|
|
105
|
-
props: providerProps,
|
|
106
|
-
children: [result.element],
|
|
107
|
-
};
|
|
121
|
+
let wrapped: SpecNode = result.element;
|
|
108
122
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
123
|
+
if (states.length === 0) {
|
|
124
|
+
// Mount-only: a single state-provider wrapper carries onMountAction.
|
|
125
|
+
wrapped = {
|
|
126
|
+
type: '_LocalStateProvider',
|
|
127
|
+
props: combinedMount ? { onMountAction: combinedMount } : {},
|
|
128
|
+
children: [wrapped],
|
|
115
129
|
};
|
|
130
|
+
} else {
|
|
131
|
+
// One state-provider wrapper per localState() call, nested
|
|
132
|
+
// innermost-first. Mount actions attach to the innermost wrapper so
|
|
133
|
+
// every state slot is initialized before any action fires.
|
|
134
|
+
for (let i = states.length - 1; i >= 0; i--) {
|
|
135
|
+
const state = states[i];
|
|
136
|
+
if (state) {
|
|
137
|
+
const isInnermost = i === states.length - 1;
|
|
138
|
+
const props: Record<string, unknown> = {
|
|
139
|
+
statePath: state.path,
|
|
140
|
+
initialValue: state.initial,
|
|
141
|
+
...(isInnermost && combinedMount ? { onMountAction: combinedMount } : {}),
|
|
142
|
+
};
|
|
143
|
+
const wrapper: SpecNode = {
|
|
144
|
+
type: '_LocalStateProvider',
|
|
145
|
+
props,
|
|
146
|
+
children: [wrapped],
|
|
147
|
+
};
|
|
148
|
+
// render() picks up this marker to populate spec.state.
|
|
149
|
+
(wrapper as any)._localStateRegistration = {
|
|
150
|
+
path: state.path,
|
|
151
|
+
initial: state.initial,
|
|
152
|
+
};
|
|
153
|
+
wrapped = wrapper;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
116
156
|
}
|
|
117
157
|
|
|
118
158
|
return {
|
|
119
159
|
...result,
|
|
120
|
-
element:
|
|
160
|
+
element: wrapped,
|
|
121
161
|
};
|
|
122
162
|
}
|