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.
- checksums.yaml +4 -4
- data/docs/de-DE/validation-matrix.md +3 -3
- data/docs/en-US/validation-matrix.md +3 -3
- data/docs/pt-BR/validation-matrix.md +3 -3
- data/lib/mxrb/model/page.rb +131 -2
- data/lib/mxrb/ruby_app/exporter.rb +966 -207
- data/lib/mxrb/ruby_app.rb +9 -1
- data/lib/mxrb/version.rb +1 -1
- metadata +1 -1
|
@@ -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
|
-
|
|
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', '
|
|
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
|
-
|
|
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' =>
|
|
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 {
|
|
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
|
-
}
|
|
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?:
|
|
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 =
|
|
1549
|
+
export type ApiRequest = <T = RuntimeValue | undefined>(
|
|
1550
|
+
path: string, options?: RequestInit
|
|
1551
|
+
) => Promise<T>;
|
|
937
1552
|
|
|
938
|
-
export
|
|
1553
|
+
export type NanoflowParameters = RuntimeVariables;
|
|
1554
|
+
|
|
1555
|
+
export interface NanoflowMetadata {
|
|
1556
|
+
name: string;
|
|
939
1557
|
id: string;
|
|
940
|
-
|
|
941
|
-
action?: Record<string, any>;
|
|
942
|
-
condition?: string;
|
|
943
|
-
return?: string;
|
|
1558
|
+
parameters: string[];
|
|
944
1559
|
}
|
|
945
1560
|
|
|
946
|
-
export interface
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1561
|
+
export interface NanoflowExecution<R = RuntimeValue | undefined> {
|
|
1562
|
+
result: R;
|
|
1563
|
+
variables: NanoflowParameters;
|
|
1564
|
+
changes: EntityRecord[];
|
|
950
1565
|
}
|
|
951
1566
|
|
|
952
|
-
export interface
|
|
953
|
-
|
|
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
|
|
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,
|
|
1012
|
-
|
|
1013
|
-
|
|
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
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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 = (
|
|
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 = (
|
|
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
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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
|
|
1074
|
-
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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 = (
|
|
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
|
|
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,
|
|
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) =>
|
|
1111
|
-
|
|
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
|
|
1115
|
-
['string', 'number', 'boolean'].includes(typeof item))
|
|
1116
|
-
if (typeof value === 'object') return
|
|
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
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
|
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
|
|
1198
|
-
}, [kind, record?.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)
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
|
1242
|
-
<option value={draft}>{displayValue(value)}</option> : null}
|
|
1243
|
-
{choices.map(item => <option
|
|
1244
|
-
{
|
|
1245
|
-
|
|
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)
|
|
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(
|
|
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(
|
|
1282
|
-
|
|
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(
|
|
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}`,
|
|
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 = (
|
|
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
|
|
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}
|
|
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}
|
|
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}
|
|
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}
|
|
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}
|
|
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
|
-
|
|
1498
|
-
|
|
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 = (
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1572
|
-
|
|
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 (
|
|
2369
|
+
const invokeNanoflow: InvokeHandler = async (
|
|
2370
|
+
name, parameters = {}, contextOverride = null
|
|
2371
|
+
) => {
|
|
1623
2372
|
setBusy(true);
|
|
1624
2373
|
try {
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
);
|
|
1636
|
-
|
|
1637
|
-
|
|
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
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
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
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
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; }
|