mxrb 0.1.4 → 0.1.5

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'digest'
4
4
  require 'fileutils'
5
+ require 'find'
5
6
  require 'json'
6
7
 
7
8
  module Mxrb
@@ -37,6 +38,7 @@ module Mxrb
37
38
  @project = project
38
39
  @coverage = []
39
40
  @nanoflow_entries = []
41
+ @page_entries = []
40
42
  modules = project.modules.map { export_module(_1) }
41
43
  @module_manifests = modules
42
44
  write_support_files
@@ -221,12 +223,7 @@ module Mxrb
221
223
  def export_nanoflow(flow, mod, root)
222
224
  qualified = "#{mod.name}.#{flow.name}"
223
225
  relative = File.join('frontend', 'src', 'nanoflows', root, "#{underscore(flow.name)}.ts")
224
- plan = nanoflow_plan(flow, qualified)
225
- write(
226
- relative,
227
- "import type { NanoflowPlan } from '../../types';\n\n" \
228
- "export default #{JSON.pretty_generate(plan)} satisfies NanoflowPlan;\n"
229
- )
226
+ write(relative, nanoflow_typescript(flow, qualified))
230
227
  entry = {
231
228
  'name' => qualified, 'id' => flow.id, 'path' => relative,
232
229
  'kind' => 'nanoflow', 'runtime' => 'frontend'
@@ -236,6 +233,143 @@ module Mxrb
236
233
  entry
237
234
  end
238
235
 
236
+ def nanoflow_typescript(flow, qualified)
237
+ plan = nanoflow_plan(flow, qualified)
238
+ parameters = nanoflow_parameter_type(flow.parameters)
239
+ result_type = nanoflow_result_type(flow.respond_to?(:return_type) ? flow.return_type : nil)
240
+ start = plan.fetch('objects').find { _1['type'] == 'StartEvent' }
241
+ cases = plan.fetch('objects').filter_map do |object|
242
+ nanoflow_typescript_case(object, plan.fetch('flows'), result_type)
243
+ end
244
+ <<~TS
245
+ import { defineNanoflow } from '../../runtime/nanoflow';
246
+ import type { EntityTypeMap, NanoflowParameters, RuntimeValue } from '../../types';
247
+
248
+ type Parameters = NanoflowParameters & #{parameters};
249
+
250
+ export default defineNanoflow<Parameters, #{result_type}>({
251
+ name: #{JSON.generate(qualified)},
252
+ id: #{JSON.generate(flow.id.to_s)},
253
+ parameters: #{JSON.generate(plan.fetch('parameters'))}
254
+ }, runtime => {
255
+ let current = #{JSON.generate(start && start['id'])};
256
+ for (let step = 0; step < 10_000; step += 1) {
257
+ switch (current) {
258
+ #{cases.join("\n")}
259
+ default:
260
+ throw runtime.missing(current);
261
+ }
262
+ }
263
+ throw runtime.exceeded();
264
+ });
265
+ TS
266
+ end
267
+
268
+ def nanoflow_parameter_type(parameters)
269
+ fields = parameters.filter_map do |parameter|
270
+ next unless parameter.is_a?(Hash)
271
+
272
+ " #{JSON.generate(parameter['Name'].to_s)}: #{typescript_flow_type(parameter['VariableType'])};"
273
+ end
274
+ "{\n#{fields.join("\n")}\n}"
275
+ end
276
+
277
+ def nanoflow_result_type(type)
278
+ typescript_flow_type('$Type' => type.to_s).delete_suffix(' | null')
279
+ end
280
+
281
+ def typescript_flow_type(type)
282
+ type = {} unless type.is_a?(Hash)
283
+ kind = (type['$Type'] || type['Type']).to_s
284
+ entity = type['Entity'].to_s
285
+ return "EntityTypeMap[#{JSON.generate(entity)}] | null" if kind.end_with?('ObjectType') && !entity.empty?
286
+ return "Array<EntityTypeMap[#{JSON.generate(entity)}]>" if kind.end_with?('ListType') && !entity.empty?
287
+ return 'boolean' if kind.match?(/Boolean/)
288
+ return 'number' if kind.match?(/Integer|Long|Decimal|Float/)
289
+ return 'string' if kind.match?(/String|DateTime|Enumeration/)
290
+ return 'undefined' if kind.empty? || kind.match?(/Void|MicroflowReturnType/)
291
+
292
+ 'RuntimeValue | undefined'
293
+ end
294
+
295
+ def nanoflow_typescript_case(object, flows, result_type)
296
+ return if object['type'] == 'MicroflowParameter'
297
+
298
+ outgoing = flows.select { _1['origin'] == object['id'] }
299
+ body = case object['type']
300
+ when 'EndEvent' then nanoflow_end_source(object, result_type)
301
+ when 'ExclusiveSplit' then nanoflow_split_source(object, outgoing)
302
+ when 'ActionActivity' then nanoflow_action_source(object['action']) + nanoflow_next_source(outgoing)
303
+ else nanoflow_next_source(outgoing)
304
+ end
305
+ <<~TS.chomp
306
+ case #{JSON.generate(object['id'])}: {
307
+ #{indent(body, 10)}
308
+ }
309
+ TS
310
+ end
311
+
312
+ def nanoflow_end_source(object, result_type)
313
+ expression = JSON.generate(object['return'].to_s)
314
+ value = case result_type
315
+ when 'boolean' then "runtime.boolean(#{expression})"
316
+ when 'number' then "runtime.number(#{expression})"
317
+ when 'string' then "runtime.string(#{expression})"
318
+ when 'undefined' then 'undefined'
319
+ else "runtime.value(#{expression}) as #{result_type}"
320
+ end
321
+ "return runtime.complete(#{value});"
322
+ end
323
+
324
+ def nanoflow_split_source(object, flows)
325
+ cases = flows.reject { _1['case'].to_s.empty? }.map do |edge|
326
+ "case #{JSON.generate(edge['case'].to_s)}: current = #{JSON.generate(edge['destination'])}; break;"
327
+ end
328
+ fallback = flows.find { _1['case'].to_s.empty? }
329
+ default = if fallback
330
+ "current = #{JSON.generate(fallback['destination'])}; break;"
331
+ else
332
+ "throw runtime.stopped(#{JSON.generate(object['type'])});"
333
+ end
334
+ <<~TS.chomp
335
+ switch (String(runtime.condition(#{JSON.generate(object['condition'].to_s)}))) {
336
+ #{indent(cases.join("\n"), 2)}
337
+ default: #{default}
338
+ }
339
+ break;
340
+ TS
341
+ end
342
+
343
+ def nanoflow_action_source(action)
344
+ action ||= {}
345
+ case action['type']
346
+ when 'LogMessage'
347
+ "runtime.log(#{JSON.generate(action['message'].to_s)});\n"
348
+ when 'CreateVariable', 'ChangeVariable'
349
+ "runtime.set(#{JSON.generate(action['variable'].to_s)}, " \
350
+ "runtime.value(#{JSON.generate(action['value'].to_s)}));\n"
351
+ when 'Change'
352
+ changes = action.fetch('changes', []).to_h do |change|
353
+ [change['member'].to_s, change['value'].to_s]
354
+ end
355
+ "runtime.change(#{JSON.generate(action['variable'].to_s)}, #{JSON.generate(changes)});\n"
356
+ else
357
+ "throw runtime.unsupported(#{JSON.generate(action['type'].to_s)});\n"
358
+ end
359
+ end
360
+
361
+ def nanoflow_next_source(flows)
362
+ edge = flows.first
363
+ return "throw runtime.stopped('node');" unless edge
364
+
365
+ "current = #{JSON.generate(edge['destination'])};\nbreak;"
366
+ end
367
+
368
+ def indent(source, spaces)
369
+ prefix = ' ' * spaces
370
+ source.to_s.lines.map { "#{prefix}#{_1}" }.join.chomp
371
+ end
372
+
239
373
  def nanoflow_plan(flow, qualified)
240
374
  {
241
375
  'name' => qualified, 'id' => flow.id,
@@ -312,7 +446,7 @@ module Mxrb
312
446
  data_source: page.data_source)
313
447
  )
314
448
  add_coverage(page.id, qualified, 'page', relative, 'native_projection_source_preserved')
315
- {
449
+ manifest = {
316
450
  'name' => qualified, 'id' => page.id, 'title' => page.title,
317
451
  'ruby_class' => "#{namespace}::#{class_name}", 'path' => relative,
318
452
  'appearance_class' => page.appearance_class,
@@ -321,6 +455,67 @@ module Mxrb
321
455
  'allowed_module_roles' => page.allowed_module_roles.map(&:to_s),
322
456
  'widgets' => widgets
323
457
  }
458
+ export_frontend_page(manifest, root, page.name)
459
+ manifest
460
+ end
461
+
462
+ def export_frontend_page(manifest, root, page_name)
463
+ relative = File.join('frontend', 'src', 'pages', root, "#{underscore(page_name)}.tsx")
464
+ component = "#{typescript_identifier(manifest.fetch('name'))}Page"
465
+ definition = manifest.slice('name', 'title', 'appearance_class', 'appearance_style', 'widgets')
466
+ declarations = []
467
+ compiled_widgets = definition.fetch('widgets').each_with_index.map do |widget, index|
468
+ frontend_widget_jsx(widget, [index], declarations, 6)
469
+ end
470
+ widget_tree = compiled_widgets.map(&:first).join("\n")
471
+ definition_source = frontend_page_definition_source(definition, compiled_widgets.map(&:last))
472
+ write(relative, <<~TS)
473
+ import type { PageComponentProps, PageDefinition, WidgetDefinition } from '../../types';
474
+
475
+ #{declarations.join("\n\n")}
476
+
477
+ export const definition = #{definition_source} satisfies PageDefinition;
478
+
479
+ export default function #{component}({ busy, Widget: PageWidget }: PageComponentProps) {
480
+ return <main className="mxrb-page region-content mx-scrollcontainer-wrapper"
481
+ aria-busy={busy} data-page={definition.name}>
482
+ #{widget_tree}
483
+ </main>;
484
+ }
485
+ TS
486
+ @page_entries << {
487
+ 'name' => manifest.fetch('name'), 'path' => relative,
488
+ 'import_name' => component
489
+ }
490
+ end
491
+
492
+ def frontend_widget_jsx(widget, path, declarations, indent)
493
+ identifier = "widget#{path.join('_')}"
494
+ children = Array(widget['children'])
495
+ compiled = widget.reject { |key, _value| key == 'children' }
496
+ nested_widgets = children.each_with_index.map do |child, index|
497
+ frontend_widget_jsx(child, path + [index], declarations, indent + 2)
498
+ end
499
+ source = JSON.pretty_generate(compiled)
500
+ unless nested_widgets.empty?
501
+ source = source.sub(/\n}\z/, ",\n \"children\": [#{nested_widgets.map(&:last).join(', ')}]\n}")
502
+ end
503
+ declarations << "const #{identifier} = #{source} satisfies WidgetDefinition;"
504
+ padding = ' ' * indent
505
+ return ["#{padding}<PageWidget widget={#{identifier}} index={#{path.last}} />", identifier] if children.empty?
506
+
507
+ nested = nested_widgets.map(&:first).join("\n")
508
+ jsx = <<~TS.chomp
509
+ #{padding}<PageWidget widget={#{identifier}} index={#{path.last}}>
510
+ #{nested}
511
+ #{padding}</PageWidget>
512
+ TS
513
+ [jsx, identifier]
514
+ end
515
+
516
+ def frontend_page_definition_source(definition, widget_identifiers)
517
+ source = JSON.pretty_generate(definition.reject { |key, _value| key == 'widgets' })
518
+ source.sub(/\n}\z/, ",\n \"widgets\": [#{widget_identifiers.join(', ')}]\n}")
324
519
  end
325
520
 
326
521
  def attribute_manifest(attribute)
@@ -465,8 +660,13 @@ module Mxrb
465
660
  write(File.join('frontend', 'src', 'vite-env.d.ts'), "/// <reference types=\"vite/client\" />\n")
466
661
  write(File.join('frontend', 'src', 'main.tsx'), frontend_main)
467
662
  write(File.join('frontend', 'src', 'types.ts'), frontend_types)
663
+ write(File.join('frontend', 'src', 'api', 'client.ts'), frontend_api_client)
664
+ write(File.join('frontend', 'src', 'runtime', 'nanoflow.ts'), frontend_nanoflow_runtime)
665
+ write(File.join('frontend', 'src', 'runtime', 'marketplace.tsx'), frontend_marketplace_runtime)
468
666
  write(File.join('frontend', 'src', 'nanoflows.ts'), frontend_nanoflows)
469
- write(File.join('frontend', 'src', 'App.tsx'), frontend_app)
667
+ write(File.join('frontend', 'src', 'pages.ts'), frontend_pages)
668
+ write(File.join('frontend', 'src', 'app', 'App.tsx'), frontend_app)
669
+ write(File.join('frontend', 'src', 'App.tsx'), "export { default } from './app/App';\n")
470
670
  write(File.join('frontend', 'src', 'app.css'), frontend_css)
471
671
  write('README.md', readme)
472
672
  end
@@ -478,12 +678,30 @@ module Mxrb
478
678
  source = File.join(@mendix_sidecar, directory)
479
679
  next unless File.directory?(source)
480
680
 
481
- FileUtils.cp_r(source, File.join(root, directory), remove_destination: true)
681
+ copy_frontend_web_assets(source, File.join(root, directory))
482
682
  end
483
683
  fallback = File.join(root, 'theme', 'web', 'main.scss')
484
684
  write(relative(fallback), '') unless File.file?(fallback)
485
685
  end
486
686
 
687
+ def copy_frontend_web_assets(source, destination)
688
+ FileUtils.rm_rf(destination)
689
+ Find.find(source) do |path|
690
+ relative_path = path.delete_prefix("#{source}/")
691
+ Find.prune if File.directory?(path) && relative_path.split(File::SEPARATOR).include?('native')
692
+ next if path == source
693
+ next if File.file?(path) && %w[.js .jsx].include?(File.extname(path).downcase)
694
+
695
+ target = File.join(destination, relative_path)
696
+ if File.directory?(path)
697
+ FileUtils.mkdir_p(target)
698
+ else
699
+ FileUtils.mkdir_p(File.dirname(target))
700
+ FileUtils.cp(path, target)
701
+ end
702
+ end
703
+ end
704
+
487
705
  def write_manifest(project, modules, runtime_mpr)
488
706
  native_coverage(project)
489
707
  payload = {
@@ -694,7 +912,7 @@ module Mxrb
694
912
  'lib' => %w[ES2022 DOM DOM.Iterable], 'allowJs' => false,
695
913
  'skipLibCheck' => true, 'esModuleInterop' => true,
696
914
  'allowSyntheticDefaultImports' => true, 'strict' => true,
697
- 'noImplicitAny' => false, 'useUnknownInCatchVariables' => false,
915
+ 'noImplicitAny' => true, 'useUnknownInCatchVariables' => true,
698
916
  'forceConsistentCasingInFileNames' => true, 'module' => 'ESNext',
699
917
  'moduleResolution' => 'Bundler', 'resolveJsonModule' => true,
700
918
  'isolatedModules' => true, 'noEmit' => true, 'jsx' => 'react-jsx'
@@ -712,6 +930,14 @@ module Mxrb
712
930
 
713
931
  export default defineConfig({
714
932
  plugins: [react()],
933
+ css: {
934
+ preprocessorOptions: {
935
+ scss: {
936
+ // Mendix Atlas still depends on the legacy global Sass module model.
937
+ silenceDeprecations: ['import', 'global-builtin']
938
+ }
939
+ }
940
+ },
715
941
  server: { proxy: { '/api': `http://127.0.0.1:${apiPort}` } }
716
942
  });
717
943
  JS
@@ -738,7 +964,7 @@ module Mxrb
738
964
  <<~JS
739
965
  import React from 'react';
740
966
  import { createRoot } from 'react-dom/client';
741
- import App from './App';
967
+ import App from './app/App';
742
968
  import './app.css';
743
969
  import './mendix/theme/web/main.scss';
744
970
 
@@ -760,18 +986,347 @@ module Mxrb
760
986
  " #{entry.fetch('name').inspect}: #{entry.fetch('import_name')}"
761
987
  end
762
988
  <<~TS
763
- import type { NanoflowPlan } from './types';
989
+ import type { RegisteredNanoflow } from './types';
764
990
 
765
991
  #{imports.join("\n")}
766
992
 
767
- const nanoflows = {
993
+ const nanoflows: Record<string, RegisteredNanoflow> = {
768
994
  #{mappings.join(",\n")}
769
- } satisfies Record<string, NanoflowPlan>;
995
+ };
770
996
 
771
997
  export default nanoflows;
772
998
  TS
773
999
  end
774
1000
 
1001
+ def frontend_pages
1002
+ imports = @page_entries.map do |entry|
1003
+ relative = entry.fetch('path').delete_prefix('frontend/src/')
1004
+ "import #{entry.fetch('import_name')} from './#{relative.delete_suffix('.tsx')}';"
1005
+ end
1006
+ mappings = @page_entries.map do |entry|
1007
+ " #{JSON.generate(entry.fetch('name'))}: #{entry.fetch('import_name')}"
1008
+ end
1009
+ <<~TS
1010
+ import type { ComponentType } from 'react';
1011
+ import type { PageComponentProps } from './types';
1012
+
1013
+ #{imports.join("\n")}
1014
+
1015
+ const pages: Record<string, ComponentType<PageComponentProps>> = {
1016
+ #{mappings.join(",\n")}
1017
+ };
1018
+
1019
+ export default pages;
1020
+ TS
1021
+ end
1022
+
1023
+ def frontend_api_client
1024
+ <<~'TS'
1025
+ import type { ApiFailure, RuntimeValue } from '../types';
1026
+
1027
+ const errorMessage = (payload: unknown, status: number): string => {
1028
+ if (payload && typeof payload === 'object' && 'error' in payload) {
1029
+ const error = payload.error;
1030
+ if (error && typeof error === 'object' && 'message' in error) return String(error.message);
1031
+ }
1032
+ return `HTTP ${status}`;
1033
+ };
1034
+
1035
+ export const api = async <T = RuntimeValue | undefined>(
1036
+ path: string, options: RequestInit = {}, token: string | null = null
1037
+ ): Promise<T> => {
1038
+ const headers = new Headers(options.headers);
1039
+ headers.set('Content-Type', 'application/json');
1040
+ if (token) headers.set('Authorization', `Bearer ${token}`);
1041
+ const response = await fetch(path, { ...options, headers });
1042
+ const payload: unknown = await response.json();
1043
+ if (!response.ok) {
1044
+ const error: ApiFailure = new Error(errorMessage(payload, response.status));
1045
+ error.status = response.status;
1046
+ throw error;
1047
+ }
1048
+ return payload as T;
1049
+ };
1050
+ TS
1051
+ end
1052
+
1053
+ def frontend_nanoflow_runtime
1054
+ <<~'TS'
1055
+ import type {
1056
+ EntityRecord, NanoflowExecution, NanoflowMetadata, NanoflowParameters,
1057
+ RegisteredNanoflow, RuntimeValue
1058
+ } from '../types';
1059
+
1060
+ type ChangeExpressions = Record<string, string>;
1061
+ type Comparable = string | number;
1062
+
1063
+ const isRecord = (value: RuntimeValue | undefined): value is EntityRecord => {
1064
+ return Boolean(value && typeof value === 'object'
1065
+ && 'id' in value && 'type' in value && 'attributes' in value);
1066
+ };
1067
+
1068
+ const attributes = (value: RuntimeValue | undefined): Record<string, RuntimeValue | undefined> => {
1069
+ return isRecord(value) ? value.attributes : {};
1070
+ };
1071
+
1072
+ const memberName = (value: string): string => value.split(/[./]/).at(-1) || value;
1073
+
1074
+ export class NanoflowRuntime<P extends NanoflowParameters = NanoflowParameters> {
1075
+ readonly variables: NanoflowParameters;
1076
+ readonly #changes = new Map<string, EntityRecord>();
1077
+
1078
+ constructor(parameters: P, readonly metadata: NanoflowMetadata) {
1079
+ this.variables = structuredClone(parameters);
1080
+ }
1081
+
1082
+ value(source: string | undefined, context: EntityRecord | null = null): RuntimeValue | undefined {
1083
+ const text = (source || '').trim();
1084
+ if (/\s(?:and|or)\s|(?:=|!=|>=|<=|>|<)/.test(text)) {
1085
+ return this.condition(text, context);
1086
+ }
1087
+ const wrapped = text.match(/^toString\((.*)\)$/);
1088
+ if (wrapped) return this.string(wrapped[1], context);
1089
+ if (text === '$currentObject') return context;
1090
+ const variable = text.match(/^\$([A-Za-z_]\w*)$/);
1091
+ if (variable) return this.variables[variable[1]] ?? context;
1092
+ const member = text.match(/^\$([A-Za-z_]\w*)\/([A-Za-z_][\w.]*)$/);
1093
+ if (member) {
1094
+ return attributes(this.variables[member[1]] ?? context)[memberName(member[2])];
1095
+ }
1096
+ if (text === 'empty') return null;
1097
+ if (text === 'true') return true;
1098
+ if (text === 'false') return false;
1099
+ if (/^-?\d+(?:\.\d+)?$/.test(text)) return Number(text);
1100
+ if (/^'.*'$/.test(text)) return text.slice(1, -1).replaceAll("''", "'");
1101
+ return text;
1102
+ }
1103
+
1104
+ condition(source: string | undefined, context: EntityRecord | null = null): boolean {
1105
+ const text = (source || '').trim().replace(/^\((.*)\)$/, '$1');
1106
+ const orParts = text.split(/\s+or\s+/);
1107
+ if (orParts.length > 1) return orParts.some(part => this.condition(part, context));
1108
+ const andParts = text.split(/\s+and\s+/);
1109
+ if (andParts.length > 1) return andParts.every(part => this.condition(part, context));
1110
+ const comparison = text.match(/^(.*?)\s*(=|!=|>=|<=|>|<)\s*(.*?)$/);
1111
+ if (!comparison) return Boolean(this.value(text, context));
1112
+ const left = this.value(comparison[1], context);
1113
+ const right = this.value(comparison[3], context);
1114
+ switch (comparison[2]) {
1115
+ case '=': return left === right;
1116
+ case '!=': return left !== right;
1117
+ case '>': return this.comparable(left) > this.comparable(right);
1118
+ case '<': return this.comparable(left) < this.comparable(right);
1119
+ case '>=': return this.comparable(left) >= this.comparable(right);
1120
+ case '<=': return this.comparable(left) <= this.comparable(right);
1121
+ default: return false;
1122
+ }
1123
+ }
1124
+
1125
+ boolean(source: string | undefined, context: EntityRecord | null = null): boolean {
1126
+ return this.condition(source, context);
1127
+ }
1128
+
1129
+ number(source: string | undefined, context: EntityRecord | null = null): number {
1130
+ return Number(this.value(source, context));
1131
+ }
1132
+
1133
+ string(source: string | undefined, context: EntityRecord | null = null): string {
1134
+ return String(this.value(source, context) ?? '');
1135
+ }
1136
+
1137
+ set(name: string, value: RuntimeValue | undefined): void {
1138
+ this.variables[name] = value;
1139
+ }
1140
+
1141
+ log(message: string): void {
1142
+ console.info(`[nanoflow] ${message || this.metadata.name}`);
1143
+ }
1144
+
1145
+ change(variable: string, expressions: ChangeExpressions): void {
1146
+ const record = this.variables[variable];
1147
+ if (!isRecord(record)) throw new Error(`Nanoflow object $${variable} is missing`);
1148
+ Object.entries(expressions).forEach(([member, expression]) => {
1149
+ record.attributes[member] = this.value(expression, record);
1150
+ });
1151
+ this.#changes.set(`${record.type}:${record.id}`, record);
1152
+ }
1153
+
1154
+ complete<R>(result: R): NanoflowExecution<R> {
1155
+ return { result, variables: this.variables, changes: [...this.#changes.values()] };
1156
+ }
1157
+
1158
+ missing(current: string | null): Error {
1159
+ return new Error(`Nanoflow ${this.metadata.name} points to missing object ${current || '(none)'}`);
1160
+ }
1161
+
1162
+ stopped(type: string): Error {
1163
+ return new Error(`Nanoflow ${this.metadata.name} stops at ${type}`);
1164
+ }
1165
+
1166
+ unsupported(type: string): Error {
1167
+ return new Error(`Unsupported frontend nanoflow action: ${type || '(empty)'}`);
1168
+ }
1169
+
1170
+ exceeded(): Error {
1171
+ return new Error(`Nanoflow ${this.metadata.name} exceeded 10000 steps`);
1172
+ }
1173
+
1174
+ private comparable(value: RuntimeValue | undefined): Comparable {
1175
+ return typeof value === 'number' ? value : String(value ?? '');
1176
+ }
1177
+ }
1178
+
1179
+ export const defineNanoflow = <P extends NanoflowParameters, R extends RuntimeValue | undefined>(
1180
+ metadata: NanoflowMetadata,
1181
+ compiled: (runtime: NanoflowRuntime<P>) => NanoflowExecution<R> | Promise<NanoflowExecution<R>>
1182
+ ): RegisteredNanoflow => ({
1183
+ ...metadata,
1184
+ execute: async parameters => compiled(new NanoflowRuntime(parameters as P, metadata))
1185
+ });
1186
+ TS
1187
+ end
1188
+
1189
+ def frontend_marketplace_runtime
1190
+ <<~'TS'
1191
+ import { useState } from 'react';
1192
+ import type { ReactNode } from 'react';
1193
+ import type { EntityRecord, RuntimeValue, WidgetDefinition } from '../types';
1194
+
1195
+ export interface MarketplaceWidgetProps {
1196
+ widget: WidgetDefinition;
1197
+ context: EntityRecord | null;
1198
+ children?: ReactNode;
1199
+ onChange(attribute: string | undefined, value: RuntimeValue): unknown;
1200
+ }
1201
+
1202
+ type Properties = Record<string, unknown>;
1203
+
1204
+ const asProperties = (value: unknown): Properties =>
1205
+ value && typeof value === 'object' && !Array.isArray(value) ? value as Properties : {};
1206
+
1207
+ const firstText = (properties: Properties, keys: string[], fallback: string): string => {
1208
+ for (const key of keys) {
1209
+ const value = properties[key];
1210
+ if (typeof value === 'string' && value.trim()) return value;
1211
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
1212
+ }
1213
+ return fallback;
1214
+ };
1215
+
1216
+ const findAttribute = (value: unknown): string | undefined => {
1217
+ if (!value || typeof value !== 'object') return undefined;
1218
+ if (Array.isArray(value)) {
1219
+ for (const child of value) {
1220
+ const found = findAttribute(child);
1221
+ if (found) return found;
1222
+ }
1223
+ return undefined;
1224
+ }
1225
+ for (const [key, child] of Object.entries(value as Properties)) {
1226
+ if (/attribute/i.test(key) && typeof child === 'string' && child.includes('.')) return child;
1227
+ const found = findAttribute(child);
1228
+ if (found) return found;
1229
+ }
1230
+ return undefined;
1231
+ };
1232
+
1233
+ const memberName = (value: string | undefined): string =>
1234
+ (value || '').split(/[./]/).pop() || '';
1235
+
1236
+ const numericValue = (value: RuntimeValue | undefined, fallback = 0): number => {
1237
+ const number = Number(value);
1238
+ return Number.isFinite(number) ? number : fallback;
1239
+ };
1240
+
1241
+ const chart = (name: string, children?: ReactNode) => <>
1242
+ <figure className="mxrb-marketplace-chart">
1243
+ <svg viewBox="0 0 240 100" role="img" aria-label={name}>
1244
+ <title>{name}</title>
1245
+ <polyline points="8,82 48,55 88,68 128,25 168,42 228,12" fill="none"
1246
+ stroke="currentColor" strokeWidth="4" />
1247
+ <line x1="8" y1="90" x2="232" y2="90" stroke="currentColor" />
1248
+ </svg>
1249
+ <figcaption>{name}</figcaption>
1250
+ </figure>
1251
+ {children}
1252
+ </>;
1253
+
1254
+ export function MarketplaceWidget({ widget, context, children, onChange }: MarketplaceWidgetProps) {
1255
+ const options = widget.options || {};
1256
+ const properties = asProperties(options.properties);
1257
+ const id = String(options.widget_id || options.native_type || widget.name).toLowerCase();
1258
+ const name = String(options.widget_name || widget.name);
1259
+ const attribute = findAttribute(properties);
1260
+ const current = context?.attributes?.[memberName(attribute)];
1261
+ const [localValue, setLocalValue] = useState<RuntimeValue>(current ?? 0);
1262
+ const update = (value: RuntimeValue) => {
1263
+ setLocalValue(value);
1264
+ return onChange(attribute, value);
1265
+ };
1266
+ const label = firstText(
1267
+ properties,
1268
+ ['label', 'value', 'caption', 'title', 'legend', 'textMessage', 'alternativeText'],
1269
+ name
1270
+ );
1271
+
1272
+ if (/(area|bar|bubble|column|custom|heatmap|line|pie|time)chart/.test(id)
1273
+ || id.includes('timeseries') || id.includes('heatmap')) return chart(name, children);
1274
+ if (id.includes('progresscircle') || id.includes('progressbar')) {
1275
+ const value = numericValue(current ?? localValue, 50);
1276
+ return <label>{label}<progress value={value} max={100}>{value}%</progress></label>;
1277
+ }
1278
+ if (id.includes('rangeslider')) {
1279
+ const start = Array.isArray(localValue) ? numericValue(localValue[0]) : numericValue(localValue);
1280
+ return <label>{label}<input aria-label={`${label} minimum`} type="range" value={start}
1281
+ onChange={event => update(Number(event.target.value))} /></label>;
1282
+ }
1283
+ if (id.includes('slider')) {
1284
+ return <label>{label}<input aria-label={label} type="range"
1285
+ value={numericValue(current ?? localValue)}
1286
+ onChange={event => update(Number(event.target.value))} /></label>;
1287
+ }
1288
+ if (id.includes('starrating') || id.endsWith('.rating')) {
1289
+ const rating = numericValue(current ?? localValue);
1290
+ return <fieldset className="mxrb-marketplace-rating"><legend>{label}</legend>
1291
+ {[1, 2, 3, 4, 5].map(value => <button type="button" key={value}
1292
+ aria-label={`${value} stars`} aria-pressed={value <= rating}
1293
+ onClick={() => update(value)}>{value <= rating ? '★' : '☆'}</button>)}
1294
+ </fieldset>;
1295
+ }
1296
+ if (id.includes('switch')) {
1297
+ return <label><input type="checkbox" checked={Boolean(current ?? localValue)}
1298
+ onChange={event => update(event.target.checked)} />{label}</label>;
1299
+ }
1300
+ if (id.includes('badgebutton')) return <button type="button">{label}</button>;
1301
+ if (id.includes('badge')) return <output className="mxrb-marketplace-badge">{label}</output>;
1302
+ if (id.includes('accordion')) return <details><summary>{label}</summary>{children}</details>;
1303
+ if (id.includes('fieldset')) return <fieldset><legend>{label}</legend>{children}</fieldset>;
1304
+ if (id.includes('accessibilityhelper')) return <div aria-live="polite">{children}</div>;
1305
+ if (id.includes('htmlelement')) return <article>{children || label}</article>;
1306
+ if (id.endsWith('.image')) {
1307
+ const source = firstText(properties, ['imageUrl', 'url'], '');
1308
+ return source ? <img src={source} alt={label} /> : <span>{label}</span>;
1309
+ }
1310
+ if (id.includes('languageselector')) return <label>{label}<select defaultValue="pt-BR">
1311
+ <option value="pt-BR">Português</option><option value="en-US">English</option>
1312
+ </select></label>;
1313
+ if (id.includes('popupmenu')) return <details><summary>{label}</summary>{children || 'Menu'}</details>;
1314
+ if (id.includes('timeline')) return <ol className="mxrb-marketplace-timeline"><li>{label}</li>{children}</ol>;
1315
+ if (id.includes('tooltip')) return <span title={label}>{children || label}</span>;
1316
+ if (id.includes('treenode') || id.includes('treeview')) return <ul><li>{label}{children}</li></ul>;
1317
+ if (id.includes('videoplayer')) {
1318
+ const source = firstText(properties, ['videoUrl', 'videoURL', 'url'], '');
1319
+ return <video controls src={source || undefined}>{label}</video>;
1320
+ }
1321
+ if (id.includes('barcodescanner')) return <button type="button">{label}</button>;
1322
+
1323
+ return <section className="mxrb-marketplace-generic" aria-label={name}>
1324
+ <strong>{name}</strong>{children}
1325
+ </section>;
1326
+ }
1327
+ TS
1328
+ end
1329
+
775
1330
  def frontend_types # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
776
1331
  modules = @module_manifests || []
777
1332
  entities = modules.flat_map { |mod| Array(mod['models']) + Array(mod['dtos']) }
@@ -810,6 +1365,8 @@ module Mxrb
810
1365
  end
811
1366
  <<~TS
812
1367
  // Generated from the Mendix domain, page, widget, effect, and API contracts.
1368
+ import type { ComponentType, ReactNode } from 'react';
1369
+
813
1370
  export type RuntimeScalar = string | number | boolean | null;
814
1371
  export type RuntimeValue = RuntimeScalar | EntityRecord | RuntimeValue[] | { [key: string]: RuntimeValue };
815
1372
  export type RuntimeVariables = Record<string, RuntimeValue | undefined>;
@@ -821,6 +1378,7 @@ module Mxrb
821
1378
  id: string;
822
1379
  type: Name;
823
1380
  attributes: Attributes;
1381
+ transient?: boolean;
824
1382
  }
825
1383
 
826
1384
  export interface WidgetEvent {
@@ -830,11 +1388,54 @@ module Mxrb
830
1388
  arguments?: Record<string, string>;
831
1389
  }
832
1390
 
1391
+ export interface WidgetColumn {
1392
+ name?: string;
1393
+ attribute?: string;
1394
+ caption?: string;
1395
+ }
1396
+
1397
+ export interface WidgetTab {
1398
+ name: string;
1399
+ caption?: string;
1400
+ widgets?: WidgetDefinition[];
1401
+ }
1402
+
1403
+ export interface WidgetOptions {
1404
+ [key: string]: unknown;
1405
+ association?: string;
1406
+ attribute?: string;
1407
+ caption?: string;
1408
+ class?: string;
1409
+ columns?: WidgetColumn[];
1410
+ display_attribute?: string;
1411
+ dynamic_class?: string;
1412
+ entity?: string;
1413
+ items?: RuntimeValue[] | Record<string, RuntimeValue>;
1414
+ lines?: number;
1415
+ native_type?: string;
1416
+ options?: RuntimeValue[] | Record<string, RuntimeValue>;
1417
+ pageSize?: number;
1418
+ page_size?: number;
1419
+ parameters?: string[];
1420
+ read_only?: boolean;
1421
+ sort?: Array<{ attribute: string; direction?: string }>;
1422
+ style?: string;
1423
+ tabs?: WidgetTab[];
1424
+ target_entity?: string;
1425
+ toolbar?: { buttons?: Array<{ type: string }> };
1426
+ values?: RuntimeValue[] | Record<string, RuntimeValue>;
1427
+ visible?: string | boolean;
1428
+ platform?: string;
1429
+ properties?: Record<string, unknown>;
1430
+ widget_id?: string;
1431
+ widget_name?: string;
1432
+ }
1433
+
833
1434
  export interface WidgetDefinition<Name extends string = string> {
834
1435
  type: string;
835
1436
  name: Name;
836
1437
  caption?: string;
837
- options?: Record<string, any>;
1438
+ options?: WidgetOptions;
838
1439
  events?: WidgetEvent[];
839
1440
  children?: WidgetDefinition[];
840
1441
  }
@@ -844,9 +1445,21 @@ module Mxrb
844
1445
  title: string;
845
1446
  appearance_class?: string;
846
1447
  appearance_style?: string;
1448
+ data_source?: { kind: 'microflow' | 'nanoflow' | string; name: string } | null;
847
1449
  widgets: WidgetDefinition<WidgetName>[];
848
1450
  }
849
1451
 
1452
+ export interface PageComponentProps {
1453
+ busy: boolean;
1454
+ Widget: ComponentType<PageWidgetProps>;
1455
+ }
1456
+
1457
+ export interface PageWidgetProps {
1458
+ widget: WidgetDefinition;
1459
+ index: number;
1460
+ children?: ReactNode;
1461
+ }
1462
+
850
1463
  export interface AttributeDefinition {
851
1464
  name: string;
852
1465
  type: string;
@@ -933,28 +1546,26 @@ module Mxrb
933
1546
  status?: number;
934
1547
  }
935
1548
 
936
- export type ApiRequest = <T = any>(path: string, options?: RequestInit) => Promise<T>;
1549
+ export type ApiRequest = <T = RuntimeValue | undefined>(
1550
+ path: string, options?: RequestInit
1551
+ ) => Promise<T>;
937
1552
 
938
- export interface NanoflowObject {
1553
+ export type NanoflowParameters = RuntimeVariables;
1554
+
1555
+ export interface NanoflowMetadata {
1556
+ name: string;
939
1557
  id: string;
940
- type: string;
941
- action?: Record<string, any>;
942
- condition?: string;
943
- return?: string;
1558
+ parameters: string[];
944
1559
  }
945
1560
 
946
- export interface NanoflowEdge {
947
- origin: string;
948
- destination: string;
949
- case?: string;
1561
+ export interface NanoflowExecution<R = RuntimeValue | undefined> {
1562
+ result: R;
1563
+ variables: NanoflowParameters;
1564
+ changes: EntityRecord[];
950
1565
  }
951
1566
 
952
- export interface NanoflowPlan {
953
- name: string;
954
- id: string;
955
- parameters: string[];
956
- objects: NanoflowObject[];
957
- flows: NanoflowEdge[];
1567
+ export interface RegisteredNanoflow extends NanoflowMetadata {
1568
+ execute(parameters: NanoflowParameters): Promise<NanoflowExecution>;
958
1569
  }
959
1570
 
960
1571
  #{enumeration_types.join("\n")}
@@ -1006,33 +1617,90 @@ module Mxrb
1006
1617
  def frontend_app
1007
1618
  <<~'JS'
1008
1619
  import { useCallback, useEffect, useRef, useState } from 'react';
1009
- import nanoflows from './nanoflows';
1620
+ import type { CSSProperties, FormEvent, ReactNode } from 'react';
1621
+ import { api } from '../api/client';
1622
+ import { MarketplaceWidget } from '../runtime/marketplace';
1623
+ import nanoflows from '../nanoflows';
1624
+ import pages from '../pages';
1010
1625
  import type {
1011
- ApiFailure, ApiRequest, ApplicationSchema, EntityRecord, InvocationResult,
1012
- LoginResponse, NanoflowPlan, NavigationItem, OpenPageEffect, PageDefinition, Session
1013
- } from './types';
1626
+ ApiFailure, ApiRequest, ApplicationSchema, EntityCollectionResponse, EntityRecord,
1627
+ InvocationResult, LoginResponse, NavigationItem, OpenPageEffect, PageDefinition,
1628
+ PageWidgetProps, RuntimeValue, RuntimeVariables, Session, WidgetDefinition,
1629
+ WidgetEvent, WidgetOptions
1630
+ } from '../types';
1014
1631
 
1015
1632
  const TOKEN_KEY = 'mxrb.session.token';
1016
- const api = async <T = any>(path: string, options: RequestInit = {}, token: string | null = null): Promise<T> => {
1017
- const headers = new Headers(options.headers);
1018
- headers.set('Content-Type', 'application/json');
1019
- if (token) headers.set('Authorization', `Bearer ${token}`);
1020
- const response = await fetch(path, {
1021
- ...options, headers
1022
- });
1023
- const payload = await response.json();
1024
- if (!response.ok) {
1025
- const error: ApiFailure = new Error(payload.error?.message || `HTTP ${response.status}`);
1026
- error.status = response.status;
1027
- throw error;
1028
- }
1029
- return payload;
1030
- };
1031
1633
 
1032
- const classes = (...values) => values.filter(Boolean).join(' ');
1033
- const attributes = object => object?.attributes || {};
1034
- const memberName = value => (value || '').split(/[./]/).pop();
1035
- const entityCollectionPath = (entity, association, context) => {
1634
+ type ErrorHandler = (failure: unknown) => void;
1635
+ type SaveRecord = (
1636
+ record: EntityRecord | null, changes: Record<string, RuntimeValue | undefined>
1637
+ ) => Promise<EntityRecord | null>;
1638
+ type InvokeHandler = (
1639
+ name: string, parameters?: RuntimeVariables, contextOverride?: EntityRecord | null
1640
+ ) => Promise<unknown>;
1641
+ type NavigateHandler = (name: string, context?: EntityRecord | null) => Promise<unknown>;
1642
+ type SelectRecord = (record: EntityRecord | null) => void;
1643
+
1644
+ interface WidgetRuntimeProps {
1645
+ widget: WidgetDefinition;
1646
+ children?: ReactNode;
1647
+ moduleName: string;
1648
+ invoke: InvokeHandler;
1649
+ invokeNanoflow: InvokeHandler;
1650
+ navigate: NavigateHandler;
1651
+ context?: EntityRecord | null;
1652
+ pageContext: EntityRecord | null;
1653
+ revision: number;
1654
+ schema: ApplicationSchema;
1655
+ request: ApiRequest;
1656
+ saveRecord: SaveRecord;
1657
+ onError: ErrorHandler;
1658
+ onMutation: () => void;
1659
+ onSelectRecord: SelectRecord;
1660
+ }
1661
+
1662
+ interface BoundFieldProps {
1663
+ widget: WidgetDefinition;
1664
+ record: EntityRecord | null;
1665
+ schema: ApplicationSchema;
1666
+ request: ApiRequest;
1667
+ saveRecord: SaveRecord;
1668
+ revision: number;
1669
+ onChanged?: (record: EntityRecord) => unknown;
1670
+ onError: ErrorHandler;
1671
+ }
1672
+
1673
+ interface DataGridProps {
1674
+ widget: WidgetDefinition;
1675
+ request: ApiRequest;
1676
+ pageContext: EntityRecord | null;
1677
+ revision: number;
1678
+ onError: ErrorHandler;
1679
+ onMutation: () => void;
1680
+ onRowAction: (record: EntityRecord) => unknown;
1681
+ onSelectRecord: SelectRecord;
1682
+ }
1683
+
1684
+ type GalleryProps = Omit<WidgetRuntimeProps, 'context'>;
1685
+
1686
+ interface LoginProps {
1687
+ onLogin: (username: string, password: string) => Promise<void>;
1688
+ error: ApiFailure | null;
1689
+ busy: boolean;
1690
+ }
1691
+
1692
+ const classes = (...values: Array<string | false | null | undefined>): string =>
1693
+ values.filter(Boolean).join(' ');
1694
+ const isEntityRecord = (value: RuntimeValue | undefined): value is EntityRecord =>
1695
+ Boolean(value && typeof value === 'object' && !Array.isArray(value)
1696
+ && 'id' in value && 'type' in value && 'attributes' in value);
1697
+ const attributes = (object: RuntimeValue | undefined): Record<string, RuntimeValue | undefined> =>
1698
+ isEntityRecord(object) ? object.attributes : {};
1699
+ const memberName = (value: string | undefined): string =>
1700
+ (value || '').split(/[./]/).pop() || '';
1701
+ const entityCollectionPath = (
1702
+ entity: string, association: string | undefined, context: EntityRecord | null
1703
+ ): string => {
1036
1704
  const path = `/api/entities/${encodeURIComponent(entity)}`;
1037
1705
  if (!association || !context?.type || !context?.id) return path;
1038
1706
  const query = new URLSearchParams({
@@ -1040,7 +1708,10 @@ module Mxrb
1040
1708
  });
1041
1709
  return `${path}?${query}`;
1042
1710
  };
1043
- const expressionValue = (source, context, variables = {}) => {
1711
+ const expressionValue = (
1712
+ source: string | undefined, context: EntityRecord | null,
1713
+ variables: RuntimeVariables = {}
1714
+ ): RuntimeValue | undefined => {
1044
1715
  const text = (source || '').trim();
1045
1716
  const wrapped = text.match(/^toString\((.*)\)$/);
1046
1717
  if (wrapped) return String(expressionValue(wrapped[1], context, variables) ?? '');
@@ -1055,7 +1726,10 @@ module Mxrb
1055
1726
  if (/^'.*'$/.test(text)) return text.slice(1, -1).replaceAll("''", "'");
1056
1727
  return text;
1057
1728
  };
1058
- const conditionValue = (source, context, variables = {}) => {
1729
+ const conditionValue = (
1730
+ source: string | undefined, context: EntityRecord | null,
1731
+ variables: RuntimeVariables = {}
1732
+ ): boolean => {
1059
1733
  const text = (source || '').trim().replace(/^\((.*)\)$/, '$1');
1060
1734
  const orParts = text.split(/\s+or\s+/);
1061
1735
  if (orParts.length > 1) return orParts.some(part => conditionValue(part, context, variables));
@@ -1065,58 +1739,79 @@ module Mxrb
1065
1739
  if (!comparison) return Boolean(expressionValue(text, context, variables));
1066
1740
  const left = expressionValue(comparison[1], context, variables);
1067
1741
  const right = expressionValue(comparison[3], context, variables);
1068
- return ({
1069
- '=': left === right, '!=': left !== right, '>': left > right,
1070
- '<': left < right, '>=': left >= right, '<=': left <= right
1071
- })[comparison[2]];
1742
+ if (comparison[2] === '=') return left === right;
1743
+ if (comparison[2] === '!=') return left !== right;
1744
+ const comparable = (value: RuntimeValue | undefined): string | number =>
1745
+ typeof value === 'number' ? value : String(value ?? '');
1746
+ if (comparison[2] === '>') return comparable(left) > comparable(right);
1747
+ if (comparison[2] === '<') return comparable(left) < comparable(right);
1748
+ if (comparison[2] === '>=') return comparable(left) >= comparable(right);
1749
+ return comparable(left) <= comparable(right);
1072
1750
  };
1073
- const nanoflowValue = (source, context, variables = {}) => {
1074
- const text = (source || '').trim();
1075
- if (/\s(?:and|or)\s|(?:=|!=|>=|<=|>|<)/.test(text)) {
1076
- return conditionValue(text, context, variables);
1077
- }
1078
- return expressionValue(text, context, variables);
1079
- };
1080
- const isVisible = (source, context) => {
1751
+ const isVisible = (source: string | boolean | undefined, context: EntityRecord | null): boolean => {
1752
+ if (typeof source === 'boolean') return source;
1081
1753
  return !source || conditionValue(source, context);
1082
1754
  };
1083
- const dynamicClass = (source, context) => {
1755
+ const dynamicClass = (source: string | undefined, context: EntityRecord | null): string => {
1084
1756
  let text = source || '';
1085
1757
  text = text.replace(/\(?if\s+(.+?)\s+then\s+'([^']*)'\s+else\s+'([^']*)'\)?/g,
1086
- (_, condition, yes, no) => conditionValue(condition, context) ? yes : no);
1758
+ (_match: string, condition: string, yes: string, no: string) =>
1759
+ conditionValue(condition, context) ? yes : no);
1087
1760
  text = text.replace(/toString\(\$[A-Za-z_]\w*\/([A-Za-z_][\w.]*)\)/g,
1088
- (_, member) => String(attributes(context)[memberName(member)] ?? ''));
1761
+ (_match: string, member: string) => String(attributes(context)[memberName(member)] ?? ''));
1089
1762
  text = text.replace(/\$[A-Za-z_]\w*\/([A-Za-z_][\w.]*)/g,
1090
- (_, member) => attributes(context)[memberName(member)] ?? '');
1763
+ (_match: string, member: string) => String(attributes(context)[memberName(member)] ?? ''));
1091
1764
  return text.replace(/[+()']/g, ' ').replace(/\s+/g, ' ').trim();
1092
1765
  };
1093
- const caption = (widget, options, context) => {
1766
+ const caption = (
1767
+ widget: WidgetDefinition, options: WidgetOptions, context: EntityRecord | null
1768
+ ): string => {
1094
1769
  let value = options.caption || widget.caption || widget.name;
1095
1770
  (options.parameters || []).forEach((parameter, index) => {
1096
- value = value.replaceAll(`{${index + 1}}`, expressionValue(parameter, context) ?? '');
1771
+ value = value.replaceAll(`{${index + 1}}`, String(expressionValue(parameter, context) ?? ''));
1097
1772
  });
1098
1773
  return value;
1099
1774
  };
1100
- const inlineStyle = value => Object.fromEntries((value || '').split(';').filter(Boolean).map(rule => {
1775
+ const inlineStyle = (value: string | undefined): CSSProperties =>
1776
+ Object.fromEntries((value || '').split(';').filter(Boolean).map((rule: string) => {
1101
1777
  const [property, ...parts] = rule.split(':');
1102
- const name = property.trim().replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
1778
+ const name = property.trim().replace(/-([a-z])/g,
1779
+ (_match: string, letter: string) => letter.toUpperCase());
1103
1780
  return [name, parts.join(':').trim()];
1104
1781
  }));
1105
1782
 
1106
- const eventArguments = (event, context) => Object.fromEntries(
1783
+ const eventArguments = (event: WidgetEvent | undefined, context: EntityRecord | null): RuntimeVariables => Object.fromEntries(
1107
1784
  Object.entries(event?.arguments || {}).map(([name, expression]) => [name, expressionValue(expression, context)])
1108
1785
  );
1109
1786
 
1110
- const recordValue = (record, attribute) => attributes(record)[memberName(attribute)];
1111
- const displayValue = value => {
1787
+ const recordValue = (record: EntityRecord | null, attribute: string | undefined): RuntimeValue | undefined =>
1788
+ attributes(record || undefined)[memberName(attribute)];
1789
+ const displayValue = (value: RuntimeValue | undefined): string | number | boolean => {
1112
1790
  if (value == null) return '';
1113
1791
  if (Array.isArray(value)) return value.map(displayValue).join(', ');
1114
- if (value?.attributes) return Object.values(value.attributes).find(item =>
1115
- ['string', 'number', 'boolean'].includes(typeof item)) ?? value.id;
1116
- if (typeof value === 'object') return value.id || JSON.stringify(value);
1792
+ if (isEntityRecord(value)) return Object.values(value.attributes).find(item =>
1793
+ ['string', 'number', 'boolean'].includes(typeof item)) as string | number | boolean || value.id;
1794
+ if (typeof value === 'object') return JSON.stringify(value);
1117
1795
  return String(value);
1118
1796
  };
1119
1797
 
1798
+ const choiceValue = (value: RuntimeValue, key: string): RuntimeValue | undefined => {
1799
+ if (isEntityRecord(value)) return key === 'id' ? value.id : value.attributes[key];
1800
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value[key];
1801
+ return undefined;
1802
+ };
1803
+
1804
+ const draftValue = (value: RuntimeValue | undefined): string | number | boolean => {
1805
+ if (isEntityRecord(value)) return value.id;
1806
+ return ['string', 'number', 'boolean'].includes(typeof value)
1807
+ ? value as string | number | boolean : '';
1808
+ };
1809
+
1810
+ const apiFailure = (failure: unknown): ApiFailure => {
1811
+ if (failure instanceof Error) return failure as ApiFailure;
1812
+ return new Error(String(failure));
1813
+ };
1814
+
1120
1815
  const sortRecords = (
1121
1816
  records: EntityRecord[], sortings: Array<{ attribute: string; direction?: string }> = []
1122
1817
  ) => {
@@ -1131,51 +1826,16 @@ module Mxrb
1131
1826
  return result;
1132
1827
  };
1133
1828
 
1134
- const executeNanoflow = async (
1135
- plan: NanoflowPlan | undefined, parameters: Record<string, any>
1136
- ): Promise<{ result: any; variables: Record<string, any> }> => {
1137
- if (!plan) throw new Error('Nanoflow frontend not found');
1138
- const variables = structuredClone(parameters || {});
1139
- const objects = Object.fromEntries(plan.objects.map(object => [object.id, object]));
1140
- const outgoing = {};
1141
- plan.flows.forEach(flow => { (outgoing[flow.origin] ||= []).push(flow); });
1142
- let current = plan.objects.find(object => object.type === 'StartEvent');
1143
- for (let step = 0; step < 10000; step += 1) {
1144
- if (!current) throw new Error(`Nanoflow ${plan.name} points to a missing object`);
1145
- if (current.type === 'EndEvent') {
1146
- return { result: expressionValue(current.return, null, variables), variables };
1147
- }
1148
- if (current.type === 'ActionActivity') {
1149
- const action = current.action || {};
1150
- if (action.type === 'LogMessage') console.info(`[nanoflow] ${action.message || plan.name}`);
1151
- else if (action.type === 'CreateVariable' || action.type === 'ChangeVariable') {
1152
- variables[action.variable] = nanoflowValue(action.value, null, variables);
1153
- } else if (action.type === 'Change') {
1154
- const object = variables[action.variable];
1155
- if (!object?.attributes) throw new Error(`Nanoflow object $${action.variable} is missing`);
1156
- (action.changes || []).forEach(change => {
1157
- object.attributes[change.member] = nanoflowValue(change.value, object, variables);
1158
- });
1159
- } else throw new Error(`Unsupported frontend nanoflow action: ${action.type}`);
1160
- }
1161
- const edges = outgoing[current.id] || [];
1162
- let edge = edges[0];
1163
- if (current.type === 'ExclusiveSplit') {
1164
- const value = String(conditionValue(current.condition, null, variables));
1165
- edge = edges.find(item => item.case === value) || edges.find(item => !item.case);
1166
- }
1167
- if (!edge) throw new Error(`Nanoflow ${plan.name} stops at ${current.type}`);
1168
- current = objects[edge.destination];
1169
- }
1170
- throw new Error(`Nanoflow ${plan.name} exceeded 10000 steps`);
1171
- };
1172
-
1173
- function BoundField({ widget, record, schema, request, saveRecord, onChanged, onError }) {
1829
+ function BoundField({
1830
+ widget, record, schema, request, saveRecord, revision, onChanged, onError
1831
+ }: BoundFieldProps) {
1174
1832
  const options = widget.options || {};
1175
1833
  const member = memberName(options.attribute || widget.name);
1176
1834
  const kind = widget.type;
1177
1835
  const value = recordValue(record, member);
1178
- const [draft, setDraft] = useState(kind === 'check_box' ? Boolean(value) : (value ?? ''));
1836
+ const [draft, setDraft] = useState<string | number | boolean>(
1837
+ kind === 'check_box' ? Boolean(value) : draftValue(value)
1838
+ );
1179
1839
  const [references, setReferences] = useState<EntityRecord[]>([]);
1180
1840
  const associations = (schema?.modules || []).flatMap(module => module.associations || []);
1181
1841
  const association = associations.find(item =>
@@ -1194,19 +1854,19 @@ module Mxrb
1194
1854
  || item.name === attributeDefinition?.enumeration);
1195
1855
 
1196
1856
  useEffect(() => {
1197
- setDraft(kind === 'check_box' ? Boolean(value) : (value?.id || value || ''));
1198
- }, [kind, record?.id, value?.id, value]);
1857
+ setDraft(kind === 'check_box' ? Boolean(value) : draftValue(value));
1858
+ }, [kind, record?.id, value]);
1199
1859
 
1200
1860
  useEffect(() => {
1201
1861
  if (kind !== 'reference_selector' || !referenceEntity) return;
1202
- request(`/api/entities/${encodeURIComponent(referenceEntity)}`)
1862
+ request<EntityCollectionResponse>(`/api/entities/${encodeURIComponent(referenceEntity)}`)
1203
1863
  .then(payload => setReferences(payload.records || [])).catch(onError);
1204
- }, [kind, referenceEntity, request, onError]);
1864
+ }, [kind, referenceEntity, revision, request, onError]);
1205
1865
 
1206
- const persist = next => {
1866
+ const persist = (next: string | number | boolean) => {
1207
1867
  setDraft(next);
1208
1868
  if (!record?.type || !record?.id || !member) return Promise.resolve(record);
1209
- let normalized = next;
1869
+ let normalized: RuntimeValue = next;
1210
1870
  if (kind === 'number_input') normalized = next === '' ? null : Number(next);
1211
1871
  if (kind === 'reference_selector') {
1212
1872
  normalized = references.find(item => item.id === next) || null;
@@ -1220,7 +1880,7 @@ module Mxrb
1220
1880
  const disabled = !record?.id || !member || options.read_only === true;
1221
1881
 
1222
1882
  if (kind === 'text_area') {
1223
- return <textarea rows={options.lines || 4} value={draft} disabled={disabled}
1883
+ return <textarea rows={options.lines || 4} value={String(draft)} disabled={disabled}
1224
1884
  onChange={event => setDraft(event.target.value)} onBlur={() => persist(draft)} />;
1225
1885
  }
1226
1886
  if (kind === 'check_box') {
@@ -1232,28 +1892,31 @@ module Mxrb
1232
1892
  id: item.name, label: item.caption || item.name
1233
1893
  }));
1234
1894
  const configuredValue = options.values || options.items || options.options || enumValues;
1235
- const configured = Array.isArray(configuredValue) ? configuredValue : Object.values(configuredValue);
1236
- const choices = kind === 'reference_selector' ? references : configured.map(item =>
1237
- typeof item === 'object' ? item : { id: item, label: item }
1238
- );
1239
- return <select value={draft} disabled={disabled} onChange={event => persist(event.target.value)}>
1895
+ const configured: RuntimeValue[] = Array.isArray(configuredValue)
1896
+ ? configuredValue : configuredValue ? Object.values(configuredValue) : [];
1897
+ const choices: RuntimeValue[] = kind === 'reference_selector' ? references : configured;
1898
+ return <select value={String(draft)} disabled={disabled} onChange={event => persist(event.target.value)}>
1240
1899
  <option value="">—</option>
1241
- {draft && !choices.some(item => (item.id || item.value) === draft) ?
1242
- <option value={draft}>{displayValue(value)}</option> : null}
1243
- {choices.map(item => <option key={item.id || item.value} value={item.id || item.value}>
1244
- {displayValue(item.label || item.caption || recordValue(item, options.display_attribute)
1245
- || item.id || item.value)}
1900
+ {draft && !choices.some(item => (choiceValue(item, 'id') || choiceValue(item, 'value') || item) === draft) ?
1901
+ <option value={String(draft)}>{displayValue(value)}</option> : null}
1902
+ {choices.map((item, index) => <option
1903
+ key={String(choiceValue(item, 'id') || choiceValue(item, 'value') || index)}
1904
+ value={String(choiceValue(item, 'id') || choiceValue(item, 'value') || item)}>
1905
+ {displayValue(choiceValue(item, 'label') || choiceValue(item, 'caption')
1906
+ || (isEntityRecord(item) ? recordValue(item, options.display_attribute) : undefined)
1907
+ || choiceValue(item, 'id') || choiceValue(item, 'value') || item)}
1246
1908
  </option>)}
1247
1909
  </select>;
1248
1910
  }
1249
1911
  const inputType = kind === 'date_picker' ? 'date' : kind === 'number_input' ? 'number' : 'text';
1250
- const inputValue = inputType === 'date' ? String(draft).slice(0, 10) : draft;
1912
+ const inputValue = inputType === 'date' ? String(draft).slice(0, 10)
1913
+ : typeof draft === 'boolean' ? String(draft) : draft;
1251
1914
  return <input type={inputType} value={inputValue} disabled={disabled}
1252
1915
  onChange={event => setDraft(event.target.value)} onBlur={() => persist(draft)} />;
1253
1916
  }
1254
1917
 
1255
1918
  function DataGrid({ widget, request, pageContext, revision, onError, onMutation,
1256
- onRowAction, onSelectRecord }) {
1919
+ onRowAction, onSelectRecord }: DataGridProps) {
1257
1920
  const options = widget.options || {};
1258
1921
  const [records, setRecords] = useState<EntityRecord[]>([]);
1259
1922
  const [pageNumber, setPageNumber] = useState(0);
@@ -1265,7 +1928,9 @@ module Mxrb
1265
1928
  useEffect(() => {
1266
1929
  if (!options.entity) return;
1267
1930
  setLoading(true);
1268
- request(entityCollectionPath(options.entity, options.association, pageContext)).then(payload => {
1931
+ request<EntityCollectionResponse>(
1932
+ entityCollectionPath(options.entity, options.association, pageContext)
1933
+ ).then(payload => {
1269
1934
  let values = payload.records || [];
1270
1935
  values = sortRecords(values, options.sort || []);
1271
1936
  setRecords(values);
@@ -1274,22 +1939,25 @@ module Mxrb
1274
1939
  }, [options.entity, options.association, pageContext?.type, pageContext?.id,
1275
1940
  pageSize, reload, revision, request, onError]);
1276
1941
 
1277
- const mutate = operation => operation.then(result => {
1942
+ const mutate = <T,>(operation: Promise<T>): Promise<T> => operation.then(result => {
1278
1943
  setReload(value => value + 1);
1279
1944
  onMutation();
1280
1945
  return result;
1281
- }).catch(onError);
1282
- const createRecord = () => mutate(request(`/api/entities/${encodeURIComponent(options.entity)}`, {
1946
+ }).catch(failure => {
1947
+ onError(failure);
1948
+ throw failure;
1949
+ });
1950
+ const createRecord = () => mutate(request<EntityRecord>(`/api/entities/${encodeURIComponent(options.entity || '')}`, {
1283
1951
  method: 'POST', body: '{}'
1284
1952
  })).then(record => {
1285
1953
  setSelected(record);
1286
1954
  if (record) onSelectRecord(record);
1287
1955
  });
1288
1956
  const deleteRecord = () => selected && mutate(request(
1289
- `/api/entities/${encodeURIComponent(options.entity)}/${encodeURIComponent(selected.id)}`,
1957
+ `/api/entities/${encodeURIComponent(options.entity || '')}/${encodeURIComponent(selected.id)}`,
1290
1958
  { method: 'DELETE' }
1291
1959
  )).then(() => { setSelected(null); onSelectRecord(null); });
1292
- const toolbar = options.toolbar?.buttons || [];
1960
+ const toolbar = options.toolbar?.buttons || [{ type: 'new' }, { type: 'delete' }];
1293
1961
  const pageCount = Math.max(1, Math.ceil(records.length / pageSize));
1294
1962
  const visible = records.slice(pageNumber * pageSize, (pageNumber + 1) * pageSize);
1295
1963
 
@@ -1320,12 +1988,14 @@ module Mxrb
1320
1988
  }
1321
1989
 
1322
1990
  function Gallery({ widget, moduleName, invoke, invokeNanoflow, navigate, pageContext, revision,
1323
- schema, request, saveRecord, onError, onMutation, onSelectRecord }) {
1991
+ schema, request, saveRecord, onError, onMutation, onSelectRecord }: GalleryProps) {
1324
1992
  const options = widget.options || {};
1325
1993
  const [records, setRecords] = useState<EntityRecord[]>([]);
1326
1994
  useEffect(() => {
1327
1995
  if (!options.entity) return;
1328
- request(entityCollectionPath(options.entity, options.association, pageContext)).then(payload => {
1996
+ request<EntityCollectionResponse>(
1997
+ entityCollectionPath(options.entity, options.association, pageContext)
1998
+ ).then(payload => {
1329
1999
  setRecords(sortRecords(payload.records || [], options.sort || []));
1330
2000
  }).catch(onError);
1331
2001
  }, [options.entity, options.association, pageContext?.type, pageContext?.id,
@@ -1345,18 +2015,19 @@ module Mxrb
1345
2015
  </div>;
1346
2016
  }
1347
2017
 
1348
- function Widget({ widget, moduleName, invoke, invokeNanoflow, navigate,
2018
+ function Widget({ widget, children: compiledChildren, moduleName, invoke, invokeNanoflow, navigate,
1349
2019
  context, pageContext, revision, schema, request, saveRecord,
1350
- onError, onMutation, onSelectRecord }) {
2020
+ onError, onMutation, onSelectRecord }: WidgetRuntimeProps) {
1351
2021
  const options = widget.options || {};
1352
2022
  if (!isVisible(options.visible, context || pageContext)) return null;
1353
- const className = classes('mxrb-widget', `mxrb-${widget.type}`, options.class,
2023
+ const className = classes('mxrb-widget', `mxrb-${widget.type}`, `mx-name-${widget.name}`,
2024
+ options.class,
1354
2025
  dynamicClass(options.dynamic_class, context || pageContext));
1355
2026
  const runtimeProps = {
1356
2027
  'data-widget-name': widget.name,
1357
2028
  'data-widget-type': widget.type
1358
2029
  };
1359
- const children = (widget.children || []).map((child, index) =>
2030
+ const children = compiledChildren ?? (widget.children || []).map((child, index) =>
1360
2031
  <Widget key={`${child.name}-${index}`} widget={child} moduleName={moduleName} invoke={invoke}
1361
2032
  invokeNanoflow={invokeNanoflow} navigate={navigate}
1362
2033
  context={context} pageContext={pageContext} revision={revision} schema={schema}
@@ -1364,19 +2035,22 @@ module Mxrb
1364
2035
  onSelectRecord={onSelectRecord} />);
1365
2036
  const click = (widget.events || []).find(event => event.event === 'on_click');
1366
2037
  const change = (widget.events || []).find(event => event.event === 'on_change');
1367
- const runEvent = (event, eventContext = context || pageContext) => {
2038
+ const runEvent = (
2039
+ event: WidgetEvent | undefined, eventContext: EntityRecord | null = context || pageContext
2040
+ ): Promise<unknown> => {
1368
2041
  if (!event) return Promise.resolve();
1369
2042
  const handler = event.handler.includes('.') ? event.handler : `${moduleName}.${event.handler}`;
1370
2043
  const parameters = eventArguments(event, eventContext);
1371
2044
  if (event.kind === 'nanoflow') return invokeNanoflow(handler, parameters, eventContext);
1372
2045
  if (event.kind === 'page') {
1373
- const targetContext = Object.values(parameters)[0] || pageContext || context || null;
2046
+ const candidate = Object.values(parameters)[0];
2047
+ const targetContext = isEntityRecord(candidate) ? candidate : pageContext || context || null;
1374
2048
  return navigate(handler, targetContext);
1375
2049
  }
1376
2050
  return invoke(handler, parameters, eventContext);
1377
2051
  };
1378
2052
  const onClick = click ? () => runEvent(click) : undefined;
1379
- const onChanged = updated => runEvent(change, updated);
2053
+ const onChanged = (updated: EntityRecord) => runEvent(change, updated);
1380
2054
 
1381
2055
  switch (widget.type) {
1382
2056
  case 'container':
@@ -1393,30 +2067,35 @@ module Mxrb
1393
2067
  case 'text_area':
1394
2068
  return <label {...runtimeProps} className={className}>{caption(widget, options, context || pageContext)}
1395
2069
  <BoundField widget={widget} record={context || pageContext} schema={schema}
1396
- request={request} saveRecord={saveRecord} onChanged={onChanged} onError={onError} />
2070
+ request={request} saveRecord={saveRecord} revision={revision}
2071
+ onChanged={onChanged} onError={onError} />
1397
2072
  </label>;
1398
2073
  case 'text_box':
1399
2074
  case 'number_input':
1400
2075
  return <label {...runtimeProps} className={className}>{caption(widget, options, context || pageContext)}
1401
2076
  <BoundField widget={widget} record={context || pageContext} schema={schema}
1402
- request={request} saveRecord={saveRecord} onChanged={onChanged} onError={onError} />
2077
+ request={request} saveRecord={saveRecord} revision={revision}
2078
+ onChanged={onChanged} onError={onError} />
1403
2079
  </label>;
1404
2080
  case 'check_box':
1405
2081
  return <label {...runtimeProps} className={className}>
1406
2082
  <BoundField widget={widget} record={context || pageContext} schema={schema}
1407
- request={request} saveRecord={saveRecord} onChanged={onChanged} onError={onError} />
2083
+ request={request} saveRecord={saveRecord} revision={revision}
2084
+ onChanged={onChanged} onError={onError} />
1408
2085
  {caption(widget, options, context || pageContext)}
1409
2086
  </label>;
1410
2087
  case 'date_picker':
1411
2088
  return <label {...runtimeProps} className={className}>{caption(widget, options, context || pageContext)}
1412
2089
  <BoundField widget={widget} record={context || pageContext} schema={schema}
1413
- request={request} saveRecord={saveRecord} onChanged={onChanged} onError={onError} />
2090
+ request={request} saveRecord={saveRecord} revision={revision}
2091
+ onChanged={onChanged} onError={onError} />
1414
2092
  </label>;
1415
2093
  case 'drop_down':
1416
2094
  case 'reference_selector':
1417
2095
  return <label {...runtimeProps} className={className}>{caption(widget, options, context || pageContext)}
1418
2096
  <BoundField widget={widget} record={context || pageContext} schema={schema}
1419
- request={request} saveRecord={saveRecord} onChanged={onChanged} onError={onError} />
2097
+ request={request} saveRecord={saveRecord} revision={revision}
2098
+ onChanged={onChanged} onError={onError} />
1420
2099
  </label>;
1421
2100
  case 'tab_control':
1422
2101
  return <div {...runtimeProps} className={className}>{(options.tabs || []).map(tab =>
@@ -1440,6 +2119,20 @@ module Mxrb
1440
2119
  pageContext={pageContext} revision={revision} schema={schema} request={request}
1441
2120
  saveRecord={saveRecord} onError={onError} onMutation={onMutation}
1442
2121
  onSelectRecord={onSelectRecord} />;
2122
+ case 'pluggable_widget':
2123
+ return <div {...runtimeProps} className={classes(className, 'mxrb-marketplace-widget')}
2124
+ data-widget-id={options.widget_id || ''}>
2125
+ <MarketplaceWidget widget={widget} context={context || pageContext}
2126
+ onChange={(attribute, value) => {
2127
+ const active = context || pageContext;
2128
+ const member = memberName(attribute);
2129
+ if (!active || !member) return Promise.resolve(active);
2130
+ return saveRecord(active, { [member]: value }).then(updated => {
2131
+ if (updated) return onChanged(updated);
2132
+ return updated;
2133
+ });
2134
+ }}>{children}</MarketplaceWidget>
2135
+ </div>;
1443
2136
  case 'native_widget':
1444
2137
  return <div {...runtimeProps} className={classes(className, 'mxrb-native-widget')}
1445
2138
  data-native-type={options.native_type || ''} role="alert">
@@ -1461,10 +2154,10 @@ module Mxrb
1461
2154
  </li>);
1462
2155
  }
1463
2156
 
1464
- function Login({ onLogin, error, busy }) {
2157
+ function Login({ onLogin, error, busy }: LoginProps) {
1465
2158
  const [username, setUsername] = useState('');
1466
2159
  const [password, setPassword] = useState('');
1467
- const submit = event => {
2160
+ const submit = (event: FormEvent<HTMLFormElement>) => {
1468
2161
  event.preventDefault();
1469
2162
  onLogin(username, password).finally(() => setPassword(''));
1470
2163
  };
@@ -1487,24 +2180,55 @@ module Mxrb
1487
2180
  const [pageContext, setPageContext] = useState<EntityRecord | null>(null);
1488
2181
  const [error, setError] = useState<ApiFailure | null>(null);
1489
2182
  const [busy, setBusy] = useState(false);
2183
+ const initialLoadStarted = useRef(false);
1490
2184
  const invocationInFlight = useRef(false);
1491
2185
  const [revision, setRevision] = useState(0);
1492
2186
  const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY));
1493
2187
  const [session, setSession] = useState<Session | null>(null);
1494
2188
  const [authRequired, setAuthRequired] = useState(false);
1495
2189
 
1496
- const handleError = useCallback(failure => {
1497
- if (failure?.status === 401) setAuthRequired(true);
1498
- setError(failure);
2190
+ const handleError = useCallback((failure: unknown) => {
2191
+ const normalized = apiFailure(failure);
2192
+ if (normalized.status === 401) setAuthRequired(true);
2193
+ setError(normalized);
1499
2194
  }, []);
1500
2195
  const request: ApiRequest = useCallback(
1501
2196
  (path: string, options: RequestInit = {}) => api(path, options, token), [token]
1502
2197
  );
1503
2198
 
1504
- const openPage = (name: string, context: EntityRecord | null = null, activeToken = token) =>
1505
- api<PageDefinition>(`/api/pages/${encodeURIComponent(name)}`, {}, activeToken)
1506
- .then(value => { setPage(value); setPageContext(context); setError(null); })
1507
- .catch(handleError);
2199
+ const openPage = async (
2200
+ name: string, context: EntityRecord | null = null, activeToken = token
2201
+ ): Promise<void> => {
2202
+ try {
2203
+ const value = await api<PageDefinition>(
2204
+ `/api/pages/${encodeURIComponent(name)}`, {}, activeToken
2205
+ );
2206
+ let resolvedContext = context;
2207
+ if (!resolvedContext && value.data_source?.name) {
2208
+ if (value.data_source.kind === 'nanoflow') {
2209
+ const source = nanoflows[value.data_source.name as keyof typeof nanoflows];
2210
+ if (!source) throw new Error(`Page data source nanoflow not found: ${value.data_source.name}`);
2211
+ const execution = await source.execute({});
2212
+ resolvedContext = isEntityRecord(execution.result)
2213
+ ? { ...execution.result, transient: true } : null;
2214
+ } else {
2215
+ const payload = await api<InvocationResult>(
2216
+ `/api/microflows/${encodeURIComponent(value.data_source.name)}`,
2217
+ { method: 'POST', body: '{}' }, activeToken
2218
+ );
2219
+ const candidate = payload.context || payload.result;
2220
+ resolvedContext = isEntityRecord(candidate)
2221
+ ? { ...candidate, transient: true } : null;
2222
+ }
2223
+ }
2224
+ setPage(value);
2225
+ setPageContext(resolvedContext);
2226
+ setRevision(current => current + 1);
2227
+ setError(null);
2228
+ } catch (failure) {
2229
+ handleError(failure);
2230
+ }
2231
+ };
1508
2232
 
1509
2233
  const loadApplication = async (activeToken = token) => {
1510
2234
  try {
@@ -1521,21 +2245,24 @@ module Mxrb
1521
2245
  const fallback = value.modules.flatMap(module => module.pages)[0]?.name;
1522
2246
  await openPage(profile?.home_page || fallback, null, activeToken);
1523
2247
  } catch (failure) {
1524
- if (failure?.status === 401) {
2248
+ const normalized = apiFailure(failure);
2249
+ if (normalized.status === 401) {
1525
2250
  localStorage.removeItem(TOKEN_KEY);
1526
2251
  setToken(null);
1527
2252
  setSession(null);
1528
2253
  setAuthRequired(true);
1529
2254
  }
1530
- setError(failure);
2255
+ setError(normalized);
1531
2256
  }
1532
2257
  };
1533
2258
 
1534
2259
  useEffect(() => {
2260
+ if (initialLoadStarted.current) return;
2261
+ initialLoadStarted.current = true;
1535
2262
  loadApplication(token);
1536
2263
  }, []);
1537
2264
 
1538
- const login = async (username, password) => {
2265
+ const login = async (username: string, password: string): Promise<void> => {
1539
2266
  setBusy(true);
1540
2267
  try {
1541
2268
  const authenticated = await api<LoginResponse>('/api/login', {
@@ -1545,7 +2272,7 @@ module Mxrb
1545
2272
  setToken(authenticated.token);
1546
2273
  await loadApplication(authenticated.token);
1547
2274
  } catch (failure) {
1548
- setError(failure);
2275
+ setError(apiFailure(failure));
1549
2276
  } finally {
1550
2277
  setBusy(false);
1551
2278
  }
@@ -1555,7 +2282,8 @@ module Mxrb
1555
2282
  try {
1556
2283
  await api('/api/logout', { method: 'POST' }, token);
1557
2284
  } catch (failure) {
1558
- if (failure?.status !== 401) setError(failure);
2285
+ const normalized = apiFailure(failure);
2286
+ if (normalized.status !== 401) setError(normalized);
1559
2287
  } finally {
1560
2288
  localStorage.removeItem(TOKEN_KEY);
1561
2289
  setToken(null);
@@ -1568,13 +2296,22 @@ module Mxrb
1568
2296
 
1569
2297
  const refreshPageContext = () => {
1570
2298
  if (!pageContext?.type || !pageContext?.id) return Promise.resolve();
1571
- return request(
1572
- `/api/entities/${encodeURIComponent(pageContext.type)}/${encodeURIComponent(pageContext.id)}`
2299
+ return request<EntityRecord>(
2300
+ `/api/entities/${encodeURIComponent(pageContext.type)}/${encodeURIComponent(pageContext.id)}`
1573
2301
  ).then(setPageContext).catch(handleError);
1574
2302
  };
1575
2303
 
1576
- const saveRecord = useCallback((record, changes) => {
2304
+ const saveRecord: SaveRecord = useCallback((record, changes) => {
1577
2305
  if (!record?.type || !record?.id) return Promise.resolve(record);
2306
+ if (record.transient) {
2307
+ const updated: EntityRecord = {
2308
+ ...record, attributes: { ...record.attributes, ...changes }
2309
+ };
2310
+ setPageContext(current => current?.id === updated.id ? updated : current);
2311
+ setRevision(value => value + 1);
2312
+ setError(null);
2313
+ return Promise.resolve(updated);
2314
+ }
1578
2315
  return request<EntityRecord>(`/api/entities/${encodeURIComponent(record.type)}/${encodeURIComponent(record.id)}`, {
1579
2316
  method: 'PATCH', body: JSON.stringify(changes)
1580
2317
  }).then(updated => {
@@ -1583,15 +2320,25 @@ module Mxrb
1583
2320
  setError(null);
1584
2321
  return updated;
1585
2322
  }).catch(failure => {
2323
+ const normalized = apiFailure(failure);
2324
+ if (normalized.status === 404) {
2325
+ const updated: EntityRecord = {
2326
+ ...record, attributes: { ...record.attributes, ...changes }
2327
+ };
2328
+ setPageContext(current => current?.id === updated.id ? updated : current);
2329
+ setRevision(value => value + 1);
2330
+ setError(null);
2331
+ return updated;
2332
+ }
1586
2333
  handleError(failure);
1587
2334
  return null;
1588
2335
  });
1589
2336
  }, [request, handleError]);
1590
2337
 
1591
2338
  const markMutation = useCallback(() => setRevision(value => value + 1), []);
1592
- const selectRecord = useCallback(record => setPageContext(record), []);
2339
+ const selectRecord: SelectRecord = useCallback(record => setPageContext(record), []);
1593
2340
 
1594
- const invoke = (name, parameters = {}, contextOverride = null) => {
2341
+ const invoke: InvokeHandler = (name, parameters = {}, contextOverride = null) => {
1595
2342
  if (invocationInFlight.current) return Promise.resolve(null);
1596
2343
  invocationInFlight.current = true;
1597
2344
  setBusy(true);
@@ -1619,25 +2366,27 @@ module Mxrb
1619
2366
  });
1620
2367
  };
1621
2368
 
1622
- const invokeNanoflow = async (name, parameters = {}, contextOverride = null) => {
2369
+ const invokeNanoflow: InvokeHandler = async (
2370
+ name, parameters = {}, contextOverride = null
2371
+ ) => {
1623
2372
  setBusy(true);
1624
2373
  try {
1625
- const plan = nanoflows[name];
1626
- const resolvedParameters = { ...parameters };
1627
- const activeContext = contextOverride || pageContext;
1628
- if (plan?.parameters?.length === 1 && !(plan.parameters[0] in resolvedParameters)
1629
- && activeContext) {
1630
- resolvedParameters[plan.parameters[0]] = activeContext;
1631
- }
1632
- const execution = await executeNanoflow(plan, resolvedParameters);
1633
- const changedContext = Object.values(execution.variables).find(value =>
1634
- value?.id && value.id === activeContext?.id
1635
- );
1636
- if (changedContext) await saveRecord(changedContext, changedContext.attributes || {});
1637
- setError(null);
2374
+ const definition = nanoflows[name as keyof typeof nanoflows];
2375
+ const resolvedParameters: RuntimeVariables = { ...parameters };
2376
+ const activeContext = contextOverride || pageContext;
2377
+ if (definition?.parameters?.length === 1 && !(definition.parameters[0] in resolvedParameters)
2378
+ && activeContext) {
2379
+ resolvedParameters[definition.parameters[0]] = activeContext;
2380
+ }
2381
+ if (!definition) throw new Error(`Nanoflow frontend not found: ${name}`);
2382
+ const execution = await definition.execute(resolvedParameters);
2383
+ for (const changed of execution.changes) {
2384
+ await saveRecord(changed, changed.attributes);
2385
+ }
2386
+ setError(null);
1638
2387
  return execution.result;
1639
2388
  } catch (failure) {
1640
- setError(failure);
2389
+ setError(apiFailure(failure));
1641
2390
  return null;
1642
2391
  } finally {
1643
2392
  setBusy(false);
@@ -1648,21 +2397,24 @@ module Mxrb
1648
2397
  if (!schema || !page) return <main className="mxrb-loading">Loading application…</main>;
1649
2398
  const profile = schema.navigation?.profiles?.find(item => item.kind === 'Responsive')
1650
2399
  || schema.navigation?.profiles?.[0];
1651
- const moduleName = page.name.split('.')[0];
1652
-
1653
- return <div className={classes('mxrb-app-shell', 'mx-page', page.appearance_class)} style={inlineStyle(page.appearance_style)}>
2400
+ const moduleName = page.name.split('.')[0];
2401
+ const PageComponent = pages[page.name as keyof typeof pages];
2402
+ const PageWidget = ({ widget, children }: PageWidgetProps) =>
2403
+ <Widget widget={widget} moduleName={moduleName} invoke={invoke}
2404
+ invokeNanoflow={invokeNanoflow} navigate={openPage}
2405
+ context={pageContext} pageContext={pageContext} revision={revision} schema={schema}
2406
+ request={request} saveRecord={saveRecord} onError={handleError}
2407
+ onMutation={markMutation} onSelectRecord={selectRecord}>{children}</Widget>;
2408
+
2409
+ return <div className={classes('mxrb-app-shell', 'mx-page', page.appearance_class)} style={inlineStyle(page.appearance_style)}>
1654
2410
  {profile?.items?.length || session ? <nav className="mxrb-navigation region-sidebar">
1655
2411
  {profile?.items?.length ? <ul>{navigationItems(profile.items, openPage)}</ul> : null}
1656
2412
  {session ? <button type="button" onClick={logout}>Sign out</button> : null}
1657
2413
  </nav> : null}
1658
- <main className="mxrb-page region-content mx-scrollcontainer-wrapper" aria-busy={busy}>
1659
- {(page.widgets || []).map((widget, index) =>
1660
- <Widget key={`${widget.name}-${index}`} widget={widget} moduleName={moduleName} invoke={invoke}
1661
- invokeNanoflow={invokeNanoflow} navigate={openPage}
1662
- context={pageContext} pageContext={pageContext} revision={revision} schema={schema}
1663
- request={request} saveRecord={saveRecord} onError={handleError}
1664
- onMutation={markMutation} onSelectRecord={selectRecord} />)}
1665
- </main>
2414
+ {PageComponent ? <PageComponent busy={busy} Widget={PageWidget} /> :
2415
+ <main className="mxrb-page region-content" role="alert">
2416
+ Generated React page not found: {page.name}
2417
+ </main>}
1666
2418
  {error ? <aside className="mxrb-runtime-error" role="alert">
1667
2419
  <button type="button" onClick={() => setError(null)}>×</button>{error.message}
1668
2420
  </aside> : null}
@@ -1690,6 +2442,13 @@ module Mxrb
1690
2442
  .mxrb-login { display: grid; min-height: 100vh; place-items: center; padding: 1rem; }
1691
2443
  .mxrb-login form { display: grid; width: min(24rem, 100%); gap: 1rem; padding: 2rem; border: 1px solid #d1d5db; border-radius: .75rem; }
1692
2444
  .mxrb-login label { display: grid; gap: .25rem; }
2445
+ .mxrb-marketplace-widget { display: block; min-width: 0; }
2446
+ .mxrb-marketplace-widget img, .mxrb-marketplace-widget video,
2447
+ .mxrb-marketplace-chart svg { display: block; width: 100%; height: 12rem; max-width: 100%; }
2448
+ .mxrb-marketplace-chart { margin: .5rem 0; }
2449
+ .mxrb-marketplace-rating { display: inline-flex; border: 0; padding: 0; }
2450
+ .mxrb-marketplace-rating button { border: 0; background: transparent; cursor: pointer; }
2451
+ .mxrb-marketplace-badge { display: inline-block; padding: .125rem .5rem; border-radius: 999px; background: #e5e7eb; }
1693
2452
  .mxrb-grid-toolbar, .mxrb-grid-pagination { display: flex; align-items: center; gap: .5rem; margin-block: .5rem; }
1694
2453
  .mxrb-data-grid-runtime table { width: 100%; border-collapse: collapse; }
1695
2454
  .mxrb-data-grid-runtime th, .mxrb-data-grid-runtime td { padding: .5rem; border-bottom: 1px solid #d1d5db; text-align: left; }