@rippling/rippling-sdk 0.2.0-alpha.37 → 0.2.0-alpha.39

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.
Files changed (202) hide show
  1. package/examples/sdui/sdui-crud-todo/sdui-crud-todo.ts +3 -3
  2. package/examples/sdui/sdui-showcase/sdui-showcase.ts +6 -11
  3. package/examples/sdui/sdui-tab-multi-view/sdui-tab-multi-view.ts +14 -24
  4. package/examples/sdui/vote/vote.ts +7 -11
  5. package/lib/manifest/app.d.mts +11 -1
  6. package/lib/manifest/app.d.mts.map +1 -1
  7. package/lib/manifest/app.d.ts +11 -1
  8. package/lib/manifest/app.d.ts.map +1 -1
  9. package/lib/manifest/app.js +42 -1
  10. package/lib/manifest/app.js.map +1 -1
  11. package/lib/manifest/app.mjs +42 -1
  12. package/lib/manifest/app.mjs.map +1 -1
  13. package/lib/manifest/category.d.mts +14 -3
  14. package/lib/manifest/category.d.mts.map +1 -1
  15. package/lib/manifest/category.d.ts +14 -3
  16. package/lib/manifest/category.d.ts.map +1 -1
  17. package/lib/manifest/category.js +25 -3
  18. package/lib/manifest/category.js.map +1 -1
  19. package/lib/manifest/category.mjs +25 -3
  20. package/lib/manifest/category.mjs.map +1 -1
  21. package/lib/manifest/custom-object-field-section.d.mts +13 -0
  22. package/lib/manifest/custom-object-field-section.d.mts.map +1 -1
  23. package/lib/manifest/custom-object-field-section.d.ts +13 -0
  24. package/lib/manifest/custom-object-field-section.d.ts.map +1 -1
  25. package/lib/manifest/custom-object-field-section.js +22 -1
  26. package/lib/manifest/custom-object-field-section.js.map +1 -1
  27. package/lib/manifest/custom-object-field-section.mjs +22 -1
  28. package/lib/manifest/custom-object-field-section.mjs.map +1 -1
  29. package/lib/manifest/custom-object-page-layout.d.mts +23 -0
  30. package/lib/manifest/custom-object-page-layout.d.mts.map +1 -1
  31. package/lib/manifest/custom-object-page-layout.d.ts +23 -0
  32. package/lib/manifest/custom-object-page-layout.d.ts.map +1 -1
  33. package/lib/manifest/custom-object-page-layout.js +45 -2
  34. package/lib/manifest/custom-object-page-layout.js.map +1 -1
  35. package/lib/manifest/custom-object-page-layout.mjs +45 -2
  36. package/lib/manifest/custom-object-page-layout.mjs.map +1 -1
  37. package/lib/manifest/custom-object.d.mts +41 -2
  38. package/lib/manifest/custom-object.d.mts.map +1 -1
  39. package/lib/manifest/custom-object.d.ts +41 -2
  40. package/lib/manifest/custom-object.d.ts.map +1 -1
  41. package/lib/manifest/custom-object.js +70 -3
  42. package/lib/manifest/custom-object.js.map +1 -1
  43. package/lib/manifest/custom-object.mjs +70 -3
  44. package/lib/manifest/custom-object.mjs.map +1 -1
  45. package/lib/manifest/existing-manifest-context.d.mts +87 -0
  46. package/lib/manifest/existing-manifest-context.d.mts.map +1 -0
  47. package/lib/manifest/existing-manifest-context.d.ts +87 -0
  48. package/lib/manifest/existing-manifest-context.d.ts.map +1 -0
  49. package/lib/manifest/existing-manifest-context.js +511 -0
  50. package/lib/manifest/existing-manifest-context.js.map +1 -0
  51. package/lib/manifest/existing-manifest-context.mjs +506 -0
  52. package/lib/manifest/existing-manifest-context.mjs.map +1 -0
  53. package/lib/manifest/field.d.mts +44 -2
  54. package/lib/manifest/field.d.mts.map +1 -1
  55. package/lib/manifest/field.d.ts +44 -2
  56. package/lib/manifest/field.d.ts.map +1 -1
  57. package/lib/manifest/field.js +134 -3
  58. package/lib/manifest/field.js.map +1 -1
  59. package/lib/manifest/field.mjs +134 -3
  60. package/lib/manifest/field.mjs.map +1 -1
  61. package/lib/manifest/index.d.mts +8 -7
  62. package/lib/manifest/index.d.mts.map +1 -1
  63. package/lib/manifest/index.d.ts +8 -7
  64. package/lib/manifest/index.d.ts.map +1 -1
  65. package/lib/manifest/index.js +3 -1
  66. package/lib/manifest/index.js.map +1 -1
  67. package/lib/manifest/index.mjs +5 -4
  68. package/lib/manifest/index.mjs.map +1 -1
  69. package/lib/manifest/list-view.d.mts +17 -0
  70. package/lib/manifest/list-view.d.mts.map +1 -1
  71. package/lib/manifest/list-view.d.ts +17 -0
  72. package/lib/manifest/list-view.d.ts.map +1 -1
  73. package/lib/manifest/list-view.js +38 -1
  74. package/lib/manifest/list-view.js.map +1 -1
  75. package/lib/manifest/list-view.mjs +38 -1
  76. package/lib/manifest/list-view.mjs.map +1 -1
  77. package/lib/manifest/manifest-builder.d.mts +60 -7
  78. package/lib/manifest/manifest-builder.d.mts.map +1 -1
  79. package/lib/manifest/manifest-builder.d.ts +60 -7
  80. package/lib/manifest/manifest-builder.d.ts.map +1 -1
  81. package/lib/manifest/manifest-builder.js +62 -4
  82. package/lib/manifest/manifest-builder.js.map +1 -1
  83. package/lib/manifest/manifest-builder.mjs +62 -4
  84. package/lib/manifest/manifest-builder.mjs.map +1 -1
  85. package/lib/manifest/manifest-function.d.mts +18 -3
  86. package/lib/manifest/manifest-function.d.mts.map +1 -1
  87. package/lib/manifest/manifest-function.d.ts +18 -3
  88. package/lib/manifest/manifest-function.d.ts.map +1 -1
  89. package/lib/manifest/manifest-function.js +44 -3
  90. package/lib/manifest/manifest-function.js.map +1 -1
  91. package/lib/manifest/manifest-function.mjs +44 -3
  92. package/lib/manifest/manifest-function.mjs.map +1 -1
  93. package/lib/manifest/rule.d.mts +17 -0
  94. package/lib/manifest/rule.d.mts.map +1 -1
  95. package/lib/manifest/rule.d.ts +17 -0
  96. package/lib/manifest/rule.d.ts.map +1 -1
  97. package/lib/manifest/rule.js +45 -1
  98. package/lib/manifest/rule.js.map +1 -1
  99. package/lib/manifest/rule.mjs +45 -1
  100. package/lib/manifest/rule.mjs.map +1 -1
  101. package/lib/manifest/sdui-page.d.mts +10 -1
  102. package/lib/manifest/sdui-page.d.mts.map +1 -1
  103. package/lib/manifest/sdui-page.d.ts +10 -1
  104. package/lib/manifest/sdui-page.d.ts.map +1 -1
  105. package/lib/manifest/sdui-page.js +28 -1
  106. package/lib/manifest/sdui-page.js.map +1 -1
  107. package/lib/manifest/sdui-page.mjs +28 -1
  108. package/lib/manifest/sdui-page.mjs.map +1 -1
  109. package/lib/manifest/validation.d.mts +17 -0
  110. package/lib/manifest/validation.d.mts.map +1 -1
  111. package/lib/manifest/validation.d.ts +17 -0
  112. package/lib/manifest/validation.d.ts.map +1 -1
  113. package/lib/manifest/validation.js +31 -1
  114. package/lib/manifest/validation.js.map +1 -1
  115. package/lib/manifest/validation.mjs +31 -1
  116. package/lib/manifest/validation.mjs.map +1 -1
  117. package/lib/sdui/actions.d.mts.map +1 -1
  118. package/lib/sdui/actions.d.ts.map +1 -1
  119. package/lib/sdui/actions.js +23 -13
  120. package/lib/sdui/actions.js.map +1 -1
  121. package/lib/sdui/actions.mjs +23 -13
  122. package/lib/sdui/actions.mjs.map +1 -1
  123. package/lib/sdui/component.d.mts +28 -11
  124. package/lib/sdui/component.d.mts.map +1 -1
  125. package/lib/sdui/component.d.ts +28 -11
  126. package/lib/sdui/component.d.ts.map +1 -1
  127. package/lib/sdui/component.js +69 -36
  128. package/lib/sdui/component.js.map +1 -1
  129. package/lib/sdui/component.mjs +69 -36
  130. package/lib/sdui/component.mjs.map +1 -1
  131. package/lib/sdui/components.d.mts +1141 -132
  132. package/lib/sdui/components.d.mts.map +1 -1
  133. package/lib/sdui/components.d.ts +1141 -132
  134. package/lib/sdui/components.d.ts.map +1 -1
  135. package/lib/sdui/components.js +834 -206
  136. package/lib/sdui/components.js.map +1 -1
  137. package/lib/sdui/components.mjs +830 -203
  138. package/lib/sdui/components.mjs.map +1 -1
  139. package/lib/sdui/directives.d.mts.map +1 -1
  140. package/lib/sdui/directives.d.ts.map +1 -1
  141. package/lib/sdui/directives.js +4 -1
  142. package/lib/sdui/directives.js.map +1 -1
  143. package/lib/sdui/directives.mjs +4 -1
  144. package/lib/sdui/directives.mjs.map +1 -1
  145. package/lib/sdui/index.d.mts +314 -261
  146. package/lib/sdui/index.d.mts.map +1 -1
  147. package/lib/sdui/index.d.ts +314 -261
  148. package/lib/sdui/index.d.ts.map +1 -1
  149. package/lib/sdui/index.js +255 -204
  150. package/lib/sdui/index.js.map +1 -1
  151. package/lib/sdui/index.mjs +128 -103
  152. package/lib/sdui/index.mjs.map +1 -1
  153. package/lib/sdui/propTypes.d.mts +6330 -0
  154. package/lib/sdui/propTypes.d.mts.map +1 -0
  155. package/lib/sdui/propTypes.d.ts +6330 -0
  156. package/lib/sdui/propTypes.d.ts.map +1 -0
  157. package/lib/sdui/propTypes.js +14 -0
  158. package/lib/sdui/propTypes.js.map +1 -0
  159. package/lib/sdui/propTypes.mjs +13 -0
  160. package/lib/sdui/propTypes.mjs.map +1 -0
  161. package/lib/sdui/render.d.mts +1 -1
  162. package/lib/sdui/render.d.mts.map +1 -1
  163. package/lib/sdui/render.d.ts +1 -1
  164. package/lib/sdui/render.d.ts.map +1 -1
  165. package/lib/sdui/render.js +11 -3
  166. package/lib/sdui/render.js.map +1 -1
  167. package/lib/sdui/render.mjs +11 -3
  168. package/lib/sdui/render.mjs.map +1 -1
  169. package/lib/sdui/state.d.mts.map +1 -1
  170. package/lib/sdui/state.d.ts.map +1 -1
  171. package/lib/sdui/state.js +3 -1
  172. package/lib/sdui/state.js.map +1 -1
  173. package/lib/sdui/state.mjs +3 -1
  174. package/lib/sdui/state.mjs.map +1 -1
  175. package/package.json +1 -1
  176. package/src/lib/manifest/app.ts +46 -2
  177. package/src/lib/manifest/category.ts +35 -7
  178. package/src/lib/manifest/custom-object-field-section.ts +37 -1
  179. package/src/lib/manifest/custom-object-page-layout.ts +67 -2
  180. package/src/lib/manifest/custom-object.ts +88 -4
  181. package/src/lib/manifest/existing-manifest-context.ts +673 -0
  182. package/src/lib/manifest/field.ts +160 -3
  183. package/src/lib/manifest/index.ts +39 -4
  184. package/src/lib/manifest/list-view.ts +50 -1
  185. package/src/lib/manifest/manifest-builder.ts +115 -5
  186. package/src/lib/manifest/manifest-function.ts +44 -4
  187. package/src/lib/manifest/rule.ts +60 -1
  188. package/src/lib/manifest/sdui-page.ts +37 -2
  189. package/src/lib/manifest/validation.ts +48 -1
  190. package/src/lib/sdui/actions.ts +23 -10
  191. package/src/lib/sdui/component.ts +84 -44
  192. package/src/lib/sdui/components.ts +1627 -212
  193. package/src/lib/sdui/directives.ts +4 -1
  194. package/src/lib/sdui/index.ts +565 -200
  195. package/src/lib/sdui/propTypes.ts +8470 -0
  196. package/src/lib/sdui/render.ts +13 -5
  197. package/src/lib/sdui/state.ts +8 -2
  198. package/src/version.ts +1 -1
  199. package/version.d.mts +1 -1
  200. package/version.d.ts +1 -1
  201. package/version.js +1 -1
  202. package/version.mjs +1 -1
@@ -1,6 +1,13 @@
1
1
  import { generateId, type TriggerType, type UpdateTriggerType } from './_helpers';
2
2
  import type { CustomObject } from './custom-object';
3
+ import { getExistingManifestContext, type ExistingManifestContextOptions } from './existing-manifest-context';
3
4
  import type { Field } from './field';
5
+ import type { ManifestComponentDeletion } from './manifest-builder';
6
+
7
+ export interface RuleDeletionIdentifier {
8
+ flowId: string;
9
+ modelRqlName: string;
10
+ }
4
11
 
5
12
  /**
6
13
  * A single action performed when a {@link Rule} fires.
@@ -79,6 +86,10 @@ export interface RuleProps {
79
86
  actions?: RuleAction[];
80
87
  }
81
88
 
89
+ export interface RuleLoadFromExistingOptions extends ExistingManifestContextOptions {
90
+ customObjectApiName?: string;
91
+ }
92
+
82
93
  /** SELECT / RADIO rule actions use list `fixed_value` (Rippling rule builder). */
83
94
  function fixedValueToWire(targetField: Field, value: any): any {
84
95
  const ft = targetField.getFieldType();
@@ -103,6 +114,10 @@ function actionToDict(action: RuleAction): Record<string, any> {
103
114
  return out;
104
115
  }
105
116
 
117
+ function hasOwn(value: Record<string, any>, key: string): boolean {
118
+ return Object.prototype.hasOwnProperty.call(value, key);
119
+ }
120
+
106
121
  /**
107
122
  * Defines a record-triggered automation rule and registers it with the manifest.
108
123
  *
@@ -122,6 +137,16 @@ function actionToDict(action: RuleAction): Record<string, any> {
122
137
  * ```
123
138
  */
124
139
  export class Rule {
140
+ static readonly componentType = 'CUSTOM_OBJECT_RECORD_TRIGGERED_FLOW' as const;
141
+
142
+ static toDeletionIdentifier(identifier: RuleDeletionIdentifier): ManifestComponentDeletion {
143
+ return {
144
+ type: Rule.componentType,
145
+ flow_id: identifier.flowId,
146
+ model_rql_name: identifier.modelRqlName,
147
+ };
148
+ }
149
+
125
150
  private readonly _flowId: string;
126
151
  private readonly _customObjectApiName: string;
127
152
  private readonly _flowName: string;
@@ -154,6 +179,40 @@ export class Rule {
154
179
  customObject._register(this);
155
180
  }
156
181
 
182
+ /**
183
+ * Loads this record-triggered rule from the active existing-manifest JSON context.
184
+ */
185
+ static loadFromExisting(flowId: string, options: RuleLoadFromExistingOptions = {}): Rule {
186
+ return getExistingManifestContext(options).loadRule(flowId, options);
187
+ }
188
+
189
+ /** @internal Hydrates a record-triggered rule from existing manifest wire JSON. */
190
+ static _fromExistingComponent(
191
+ customObject: CustomObject,
192
+ component: Record<string, any>,
193
+ resolveField: (apiName: string) => Field,
194
+ ): Rule {
195
+ const props: RuleProps = {
196
+ flowId: component['flow_id'],
197
+ flowName: component['flow_name'] ?? component['flow_id'],
198
+ triggerType: component['trigger_type'],
199
+ actions: (component['actions'] ?? []).map((action: Record<string, any>): RuleAction => {
200
+ const out: RuleAction = {
201
+ targetField: resolveField(action['target_field']),
202
+ };
203
+ if (hasOwn(action, 'fixed_value')) out.fixedValue = action['fixed_value'];
204
+ if (hasOwn(action, 'rql_formula')) out.rqlFormula = action['rql_formula'];
205
+ return out;
206
+ }),
207
+ };
208
+ if (component['description'] !== undefined) props.description = component['description'];
209
+ if (component['rql_condition'] !== undefined) props.rqlCondition = component['rql_condition'];
210
+ if (component['update_trigger_type'] !== undefined) {
211
+ props.updateTriggerType = component['update_trigger_type'];
212
+ }
213
+ return new Rule(customObject, props);
214
+ }
215
+
157
216
  /**
158
217
  * Returns the stable identifier for this rule (e.g. `'gym_default_enrollment_status'`).
159
218
  */
@@ -176,7 +235,7 @@ export class Rule {
176
235
  */
177
236
  toDict(): Record<string, any> {
178
237
  const component: Record<string, any> = {
179
- type: 'CUSTOM_OBJECT_RECORD_TRIGGERED_FLOW',
238
+ type: Rule.componentType,
180
239
  model_rql_name: this._customObjectApiName,
181
240
  flow_id: this._flowId,
182
241
  flow_name: this._flowName,
@@ -1,5 +1,6 @@
1
+ import { getExistingManifestContext, type ExistingManifestContextOptions } from './existing-manifest-context';
1
2
  import type { ManifestFunction } from './manifest-function';
2
- import type { ManifestScope } from './manifest-builder';
3
+ import type { ManifestComponentDeletion, ManifestScope } from './manifest-builder';
3
4
 
4
5
  /**
5
6
  * Initialization properties for {@link SduiPage}.
@@ -59,6 +60,15 @@ export interface SduiPageProps {
59
60
  * @see {@link App} — hosts this page as a tab via `pages`.
60
61
  */
61
62
  export class SduiPage {
63
+ static readonly componentType = 'SDUI_PAGE' as const;
64
+
65
+ static toDeletionIdentifier(sduiPageId: string): ManifestComponentDeletion {
66
+ return {
67
+ type: SduiPage.componentType,
68
+ sdui_page_id: sduiPageId,
69
+ };
70
+ }
71
+
62
72
  private readonly _sduiPageId: string;
63
73
  private readonly _name: string;
64
74
  private readonly _functionApiName: string;
@@ -80,6 +90,31 @@ export class SduiPage {
80
90
  scope._register(this);
81
91
  }
82
92
 
93
+ /**
94
+ * Loads this SDUI page from the active existing-manifest JSON context.
95
+ */
96
+ static loadFromExisting(sduiPageId: string, options: ExistingManifestContextOptions = {}): SduiPage {
97
+ return getExistingManifestContext(options).loadSduiPage(sduiPageId);
98
+ }
99
+
100
+ /** @internal Hydrates an SDUI page from existing manifest wire JSON. */
101
+ static _fromExistingComponent(
102
+ scope: ManifestScope,
103
+ component: Record<string, any>,
104
+ resolveFunction: (apiName: string) => ManifestFunction | string,
105
+ ): SduiPage {
106
+ const functionApiName = component['function_api_name'];
107
+ const props: SduiPageProps = {
108
+ sduiPageId: component['sdui_page_id'],
109
+ name: component['name'] ?? component['sdui_page_id'],
110
+ function: typeof functionApiName === 'string' ? resolveFunction(functionApiName) : functionApiName,
111
+ };
112
+ if (component['description'] !== undefined) {
113
+ props.description = component['description'];
114
+ }
115
+ return new SduiPage(scope, props);
116
+ }
117
+
83
118
  /**
84
119
  * Returns the unique page identifier (e.g. `'gym_today_schedule'`).
85
120
  */
@@ -108,7 +143,7 @@ export class SduiPage {
108
143
  */
109
144
  toDict(): Record<string, any> {
110
145
  return {
111
- type: 'SDUI_PAGE',
146
+ type: SduiPage.componentType,
112
147
  name: this._name,
113
148
  sdui_page_id: this._sduiPageId,
114
149
  function_api_name: this._functionApiName,
@@ -1,6 +1,13 @@
1
1
  import { generateId, type ValidationLevel, type ValidationState } from './_helpers';
2
2
  import type { CustomObject } from './custom-object';
3
+ import { getExistingManifestContext, type ExistingManifestContextOptions } from './existing-manifest-context';
3
4
  import type { Field } from './field';
5
+ import type { ManifestComponentDeletion } from './manifest-builder';
6
+
7
+ export interface ValidationDeletionIdentifier {
8
+ ruleId: string;
9
+ modelRqlName: string;
10
+ }
4
11
 
5
12
  /**
6
13
  * Initialization properties for {@link Validation}.
@@ -59,6 +66,10 @@ export interface ValidationProps {
59
66
  fieldRef?: Field;
60
67
  }
61
68
 
69
+ export interface ValidationLoadFromExistingOptions extends ExistingManifestContextOptions {
70
+ customObjectApiName?: string;
71
+ }
72
+
62
73
  /**
63
74
  * Defines a validation rule and registers it with the manifest.
64
75
  *
@@ -85,6 +96,16 @@ export interface ValidationProps {
85
96
  * ```
86
97
  */
87
98
  export class Validation {
99
+ static readonly componentType = 'CUSTOM_OBJECT_VALIDATION_RULE' as const;
100
+
101
+ static toDeletionIdentifier(identifier: ValidationDeletionIdentifier): ManifestComponentDeletion {
102
+ return {
103
+ type: Validation.componentType,
104
+ rule_id: identifier.ruleId,
105
+ model_rql_name: identifier.modelRqlName,
106
+ };
107
+ }
108
+
88
109
  private readonly _ruleId: string;
89
110
  private readonly _customObjectApiName: string;
90
111
  private readonly _displayName: string;
@@ -112,6 +133,32 @@ export class Validation {
112
133
  customObject._register(this);
113
134
  }
114
135
 
136
+ /**
137
+ * Loads this validation rule from the active existing-manifest JSON context.
138
+ */
139
+ static loadFromExisting(ruleId: string, options: ValidationLoadFromExistingOptions = {}): Validation {
140
+ return getExistingManifestContext(options).loadValidation(ruleId, options);
141
+ }
142
+
143
+ /** @internal Hydrates a validation rule from existing manifest wire JSON. */
144
+ static _fromExistingComponent(
145
+ customObject: CustomObject,
146
+ component: Record<string, any>,
147
+ resolveField: (apiName: string) => Field,
148
+ ): Validation {
149
+ const props: ValidationProps = {
150
+ ruleId: component['rule_id'],
151
+ displayName: component['display_name'] ?? component['rule_id'],
152
+ rqlFormula: component['rql_formula'] ?? '',
153
+ errorMessage: component['custom_error_message'] ?? '',
154
+ state: component['state'],
155
+ level: component['level'],
156
+ ruleDescription: component['rule_description'] ?? '',
157
+ };
158
+ if (component['field_rql_name'] != null) props.fieldRef = resolveField(component['field_rql_name']);
159
+ return new Validation(customObject, props);
160
+ }
161
+
115
162
  /**
116
163
  * Returns the stable identifier for this validation rule (e.g. `'gym_session_end_after_start'`).
117
164
  */
@@ -134,7 +181,7 @@ export class Validation {
134
181
  */
135
182
  toDict(): Record<string, any> {
136
183
  const component: Record<string, any> = {
137
- type: 'CUSTOM_OBJECT_VALIDATION_RULE',
184
+ type: Validation.componentType,
138
185
  model_rql_name: this._customObjectApiName,
139
186
  rule_id: this._ruleId,
140
187
  state: this._state,
@@ -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> = { statePath: extractPath(ref), value };
16
- if (opts?.clear) {
17
- params['clearStatePath'] = extractPath(opts.clear);
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 { action: 'removeState', params: { statePath: extractPath(ref), index } };
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
- const params: Record<string, unknown> = { functionId, action, ...rest };
33
- if (responsePath !== undefined) params['responsePath'] = responsePath;
34
- const binding: ActionBinding = { action: 'triggerFunction', params };
35
- if (onSuccess) binding.onSuccess = onSuccess as ActionBinding;
36
- if (onError) binding.onError = onError as ActionBinding;
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.useState (scoped local state) and ctx.useEffect (onMount actions).
5
- * The returned element is automatically wrapped with _LocalStateProvider in the rendered spec.
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.useState({ data: [], loading: true })
11
- * ctx.useEffect(fetchAction)
12
- * return { element: DataGrid({ ... }), refresh: fetchAction }
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
- return `auto_${(++_idCounter).toString(36)}_${Date.now().toString(36)}`;
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
- /** Declare scoped local state. Returns a proxy for reading/binding. */
41
- useState<T extends Record<string, unknown>>(initial: T): StateScope;
42
- /** Register an action to fire on mount (when element becomes visible). */
43
- useEffect(action: ActionBinding | ActionBinding[]): void;
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
- let localState: { path: string; initial: unknown } | null = null;
69
- let mountAction: ActionBinding | ActionBinding[] | null = null;
91
+ const states: { path: string; initial: unknown }[] = [];
92
+ const mountActions: (ActionBinding | ActionBinding[])[] = [];
70
93
 
71
94
  const ctx: ComponentContext = {
72
- useState<T extends Record<string, unknown>>(initial: T): StateScope {
95
+ localState<T extends Record<string, unknown>>(initial: T): StateScope {
73
96
  const path = `/${generateId()}`;
74
- localState = { path, initial };
97
+ states.push({ path, initial });
75
98
  return createScopeProxy(path);
76
99
  },
77
- useEffect(action: ActionBinding | ActionBinding[]) {
78
- mountAction = action;
100
+ onMount(action: ActionBinding | ActionBinding[]) {
101
+ mountActions.push(action);
79
102
  },
80
103
  };
81
104
 
82
105
  const result = factory(ctx);
83
106
 
84
- // Capture state after factory execution (TS can't track closure mutations)
85
- const capturedState = localState as { path: string; initial: unknown } | null;
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
- // Wrap element with _LocalStateProvider
94
- const providerProps: Record<string, unknown> = {};
95
- if (capturedState) {
96
- providerProps['statePath'] = capturedState.path;
97
- providerProps['initialValue'] = capturedState.initial;
98
- }
99
- if (capturedMount) {
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
- const wrapper: SpecNode = {
104
- type: '_LocalStateProvider',
105
- props: providerProps,
106
- children: [result.element],
107
- };
121
+ let wrapped: SpecNode = result.element;
108
122
 
109
- // Attach the local state to the spec's state tree
110
- // The render() function will collect this via a special marker
111
- if (capturedState) {
112
- (wrapper as any)._localStateRegistration = {
113
- path: capturedState.path,
114
- initial: capturedState.initial,
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: wrapper,
160
+ element: wrapped,
121
161
  };
122
162
  }