orkestr 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +832 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/builds/orkestr/orkestr-editor.js +72 -0
  6. data/app/assets/stylesheets/orkestr/application.css +15 -0
  7. data/app/assets/stylesheets/orkestr/theme.css +62 -0
  8. data/app/controllers/orkestr/api/base_controller.rb +45 -0
  9. data/app/controllers/orkestr/api/executions_controller.rb +50 -0
  10. data/app/controllers/orkestr/api/human_tasks_controller.rb +48 -0
  11. data/app/controllers/orkestr/api/registry_controller.rb +40 -0
  12. data/app/controllers/orkestr/api/workflows_controller.rb +53 -0
  13. data/app/controllers/orkestr/application_controller.rb +4 -0
  14. data/app/controllers/orkestr/human_tasks_controller.rb +30 -0
  15. data/app/controllers/orkestr/ui_controller.rb +8 -0
  16. data/app/controllers/orkestr/webhooks_controller.rb +35 -0
  17. data/app/helpers/orkestr/application_helper.rb +4 -0
  18. data/app/helpers/orkestr/ui_helper.rb +34 -0
  19. data/app/javascript/orkestr-ui/index.html +19 -0
  20. data/app/javascript/orkestr-ui/package-lock.json +2050 -0
  21. data/app/javascript/orkestr-ui/package.json +23 -0
  22. data/app/javascript/orkestr-ui/src/OrkestrApp.tsx +152 -0
  23. data/app/javascript/orkestr-ui/src/api/client.ts +59 -0
  24. data/app/javascript/orkestr-ui/src/api/executions.ts +23 -0
  25. data/app/javascript/orkestr-ui/src/api/humanTasks.ts +32 -0
  26. data/app/javascript/orkestr-ui/src/api/index.ts +5 -0
  27. data/app/javascript/orkestr-ui/src/api/registry.ts +10 -0
  28. data/app/javascript/orkestr-ui/src/api/workflows.ts +33 -0
  29. data/app/javascript/orkestr-ui/src/components/Editor/ActionsBuilder.tsx +213 -0
  30. data/app/javascript/orkestr-ui/src/components/Editor/CustomNode.tsx +31 -0
  31. data/app/javascript/orkestr-ui/src/components/Editor/EditorToolbar.tsx +153 -0
  32. data/app/javascript/orkestr-ui/src/components/Editor/FormSchemaBuilder.tsx +390 -0
  33. data/app/javascript/orkestr-ui/src/components/Editor/NodeConfigPanel.tsx +274 -0
  34. data/app/javascript/orkestr-ui/src/components/Editor/NodePalette.tsx +43 -0
  35. data/app/javascript/orkestr-ui/src/components/Editor/RunDialog.tsx +52 -0
  36. data/app/javascript/orkestr-ui/src/components/Editor/WorkflowEditor.tsx +299 -0
  37. data/app/javascript/orkestr-ui/src/components/Executions/ExecutionDetail.tsx +155 -0
  38. data/app/javascript/orkestr-ui/src/components/Executions/ExecutionList.tsx +74 -0
  39. data/app/javascript/orkestr-ui/src/components/HumanTasks/TaskForm.tsx +216 -0
  40. data/app/javascript/orkestr-ui/src/components/HumanTasks/TaskFormEmbed.tsx +117 -0
  41. data/app/javascript/orkestr-ui/src/components/HumanTasks/TaskList.tsx +110 -0
  42. data/app/javascript/orkestr-ui/src/components/Workflows/NewWorkflowDialog.tsx +64 -0
  43. data/app/javascript/orkestr-ui/src/components/Workflows/WorkflowList.tsx +94 -0
  44. data/app/javascript/orkestr-ui/src/components/shared/EntryConditionsEditor.tsx +138 -0
  45. data/app/javascript/orkestr-ui/src/components/shared/ExpressionEditor.tsx +206 -0
  46. data/app/javascript/orkestr-ui/src/components/shared/HumanTaskFormRenderer.tsx +321 -0
  47. data/app/javascript/orkestr-ui/src/components/shared/JsonSchemaForm.tsx +376 -0
  48. data/app/javascript/orkestr-ui/src/components/shared/Loading.tsx +5 -0
  49. data/app/javascript/orkestr-ui/src/components/shared/StatusBadge.tsx +9 -0
  50. data/app/javascript/orkestr-ui/src/fieldRegistry.ts +74 -0
  51. data/app/javascript/orkestr-ui/src/hooks/useApi.ts +30 -0
  52. data/app/javascript/orkestr-ui/src/hooks/useRegistry.ts +35 -0
  53. data/app/javascript/orkestr-ui/src/main.tsx +75 -0
  54. data/app/javascript/orkestr-ui/src/styles/editor.css +445 -0
  55. data/app/javascript/orkestr-ui/src/styles/index.css +478 -0
  56. data/app/javascript/orkestr-ui/src/types/execution.ts +37 -0
  57. data/app/javascript/orkestr-ui/src/types/humanTask.ts +30 -0
  58. data/app/javascript/orkestr-ui/src/types/index.ts +4 -0
  59. data/app/javascript/orkestr-ui/src/types/registry.ts +22 -0
  60. data/app/javascript/orkestr-ui/src/types/workflow.ts +64 -0
  61. data/app/javascript/orkestr-ui/src/vite-env.d.ts +6 -0
  62. data/app/javascript/orkestr-ui/tsconfig.json +21 -0
  63. data/app/javascript/orkestr-ui/tsconfig.tsbuildinfo +1 -0
  64. data/app/javascript/orkestr-ui/vite.config.ts +30 -0
  65. data/app/jobs/orkestr/application_job.rb +4 -0
  66. data/app/jobs/orkestr/execute_workflow_job.rb +10 -0
  67. data/app/jobs/orkestr/resume_execution_job.rb +15 -0
  68. data/app/mailers/orkestr/application_mailer.rb +6 -0
  69. data/app/models/concerns/orkestr/assignable.rb +28 -0
  70. data/app/models/concerns/orkestr/contextualizable.rb +9 -0
  71. data/app/models/orkestr/application_record.rb +6 -0
  72. data/app/models/orkestr/assignee.rb +40 -0
  73. data/app/models/orkestr/context.rb +42 -0
  74. data/app/models/orkestr/edge.rb +58 -0
  75. data/app/models/orkestr/execution.rb +45 -0
  76. data/app/models/orkestr/execution_log.rb +38 -0
  77. data/app/models/orkestr/human_task.rb +63 -0
  78. data/app/models/orkestr/node.rb +48 -0
  79. data/app/models/orkestr/node_execution.rb +59 -0
  80. data/app/models/orkestr/workflow.rb +39 -0
  81. data/app/orkestr_nodes/action/node.rb +77 -0
  82. data/app/orkestr_nodes/condition/node.rb +67 -0
  83. data/app/orkestr_nodes/http_request/node.rb +88 -0
  84. data/app/orkestr_nodes/human_action/node.rb +103 -0
  85. data/app/orkestr_nodes/transform/node.rb +48 -0
  86. data/app/orkestr_nodes/wait/node.rb +18 -0
  87. data/app/orkestr_triggers/manual/trigger.rb +12 -0
  88. data/app/orkestr_triggers/scheduled/trigger.rb +26 -0
  89. data/app/orkestr_triggers/webhook/trigger.rb +23 -0
  90. data/app/serializers/orkestr/assignee_serializer.rb +30 -0
  91. data/app/serializers/orkestr/context_serializer.rb +30 -0
  92. data/app/serializers/orkestr/edge_serializer.rb +33 -0
  93. data/app/serializers/orkestr/execution_collection_serializer.rb +7 -0
  94. data/app/serializers/orkestr/execution_log_serializer.rb +31 -0
  95. data/app/serializers/orkestr/execution_serializer.rb +37 -0
  96. data/app/serializers/orkestr/human_task_serializer.rb +46 -0
  97. data/app/serializers/orkestr/node_execution_serializer.rb +43 -0
  98. data/app/serializers/orkestr/node_serializer.rb +29 -0
  99. data/app/serializers/orkestr/workflow_collection_serializer.rb +11 -0
  100. data/app/serializers/orkestr/workflow_serializer.rb +30 -0
  101. data/app/services/orkestr/entry_condition_evaluator.rb +68 -0
  102. data/app/services/orkestr/execution_service/complete.rb +64 -0
  103. data/app/services/orkestr/execution_service/join_resolver.rb +56 -0
  104. data/app/services/orkestr/execution_service/node_runner.rb +56 -0
  105. data/app/services/orkestr/execution_service/runner.rb +162 -0
  106. data/app/services/orkestr/execution_service/start.rb +90 -0
  107. data/app/services/orkestr/expression_resolver.rb +72 -0
  108. data/app/services/orkestr/human_task_service/complete.rb +62 -0
  109. data/app/services/orkestr/workflow_service/duplicate.rb +30 -0
  110. data/app/services/orkestr/workflow_service/export.rb +26 -0
  111. data/app/services/orkestr/workflow_service/import.rb +29 -0
  112. data/app/services/orkestr/workflow_synchronizer.rb +102 -0
  113. data/app/views/layouts/orkestr/application.html.erb +17 -0
  114. data/app/views/layouts/orkestr/ui.html.erb +18 -0
  115. data/app/views/orkestr/human_tasks/show.html.erb +17 -0
  116. data/app/views/orkestr/ui/index.html.erb +8 -0
  117. data/config/routes.rb +27 -0
  118. data/db/migrate/20260308204133_enable_pgcrypto_extension.rb +5 -0
  119. data/db/migrate/20260308204558_create_orkestr_workflows.rb +12 -0
  120. data/db/migrate/20260308204703_create_orkestr_nodes.rb +12 -0
  121. data/db/migrate/20260308204807_create_orkestr_edges.rb +12 -0
  122. data/db/migrate/20260308204931_create_orkestr_executions.rb +13 -0
  123. data/db/migrate/20260308205023_create_orkestr_node_executions.rb +16 -0
  124. data/db/migrate/20260308205119_add_react_flow_id_to_nodes.rb +6 -0
  125. data/db/migrate/20260308205123_add_react_flow_id_to_edges.rb +6 -0
  126. data/db/migrate/20260308205745_add_workflow_to_executions.rb +5 -0
  127. data/db/migrate/20260308205940_make_executable_nullable_on_executions.rb +6 -0
  128. data/db/migrate/20260308220730_create_orkestr_human_tasks.rb +15 -0
  129. data/db/migrate/20260308220900_create_orkestr_assignees.rb +13 -0
  130. data/db/migrate/20260308234115_add_unique_index_to_node_executions.rb +7 -0
  131. data/db/migrate/20260309075336_create_orkestr_contexts.rb +10 -0
  132. data/db/migrate/20260309075343_replace_executable_with_context_on_executions.rb +11 -0
  133. data/db/migrate/20260309080416_add_status_key_deadline_to_orkestr_assignees.rb +7 -0
  134. data/db/migrate/20260309082815_add_status_and_workflow_to_orkestr_contexts.rb +7 -0
  135. data/db/migrate/20260309082816_add_status_default_context_and_reuse_context_to_orkestr_workflows.rb +7 -0
  136. data/db/migrate/20260309083328_create_orkestr_execution_logs.rb +14 -0
  137. data/db/migrate/20260310223204_replace_node_executions_unique_index_for_cycle_support.rb +18 -0
  138. data/lib/orkestr/configuration.rb +16 -0
  139. data/lib/orkestr/engine.rb +48 -0
  140. data/lib/orkestr/nodes/base.rb +74 -0
  141. data/lib/orkestr/nodes/loader.rb +32 -0
  142. data/lib/orkestr/nodes/registry.rb +34 -0
  143. data/lib/orkestr/nodes/schema_dsl.rb +73 -0
  144. data/lib/orkestr/triggers/base.rb +49 -0
  145. data/lib/orkestr/triggers/loader.rb +29 -0
  146. data/lib/orkestr/triggers/registry.rb +34 -0
  147. data/lib/orkestr/triggers/schema_dsl.rb +45 -0
  148. data/lib/orkestr/version.rb +3 -0
  149. data/lib/orkestr.rb +27 -0
  150. data/lib/tasks/annotate_rb.rake +10 -0
  151. data/lib/tasks/orkestr_tasks.rake +19 -0
  152. metadata +251 -0
@@ -0,0 +1,153 @@
1
+ import React, { useState } from "react";
2
+ import type { TriggerDefinition } from "../../types";
3
+ import { JsonSchemaForm } from "../shared/JsonSchemaForm";
4
+ import {
5
+ EntryConditionsEditor,
6
+ type EntryConditions,
7
+ } from "../shared/EntryConditionsEditor";
8
+
9
+ interface Props {
10
+ name: string;
11
+ triggerType: string | null;
12
+ triggerConfig: Record<string, unknown>;
13
+ triggers: TriggerDefinition[];
14
+ saving: boolean;
15
+ apiBaseUrl: string;
16
+ workflowId: string;
17
+ onNameChange: (name: string) => void;
18
+ onTriggerChange: (type: string | null, config: Record<string, unknown>) => void;
19
+ onSave: () => void;
20
+ onRun: () => void;
21
+ onBack: () => void;
22
+ }
23
+
24
+ export function EditorToolbar({
25
+ name,
26
+ triggerType,
27
+ triggerConfig,
28
+ triggers,
29
+ saving,
30
+ apiBaseUrl,
31
+ workflowId,
32
+ onNameChange,
33
+ onTriggerChange,
34
+ onSave,
35
+ onRun,
36
+ onBack,
37
+ }: Props) {
38
+ const [showTriggerPanel, setShowTriggerPanel] = useState(false);
39
+ const selectedTrigger = triggers.find((t) => t.id === triggerType);
40
+
41
+ const webhookUrl = triggerType === "webhook"
42
+ ? `${apiBaseUrl.replace(/\/+$/, "")}/webhooks/${workflowId}`
43
+ : null;
44
+
45
+ return (
46
+ <div className="ork-editor-toolbar">
47
+ <div className="ork-editor-toolbar-title">
48
+ <button className="ork-btn ork-btn-sm" onClick={onBack}>
49
+ &larr;
50
+ </button>
51
+ <input type="text" value={name} onChange={(e) => onNameChange(e.target.value)} placeholder="Workflow name" />
52
+ </div>
53
+ <div className="ork-toolbar-actions">
54
+ <button
55
+ className={`ork-btn ork-btn-sm ${showTriggerPanel ? "ork-btn-primary" : ""}`}
56
+ onClick={() => setShowTriggerPanel(!showTriggerPanel)}
57
+ >
58
+ Trigger: {selectedTrigger?.label || triggerType || "none"}
59
+ </button>
60
+ <button className="ork-btn ork-btn-sm" onClick={onRun}>
61
+ Run
62
+ </button>
63
+ <button className="ork-btn ork-btn-primary ork-btn-sm" onClick={onSave} disabled={saving}>
64
+ {saving ? "Saving..." : "Save"}
65
+ </button>
66
+ </div>
67
+
68
+ {showTriggerPanel && (
69
+ <div className="ork-trigger-panel">
70
+ <div className="ork-trigger-panel-header">
71
+ <h3>Trigger Configuration</h3>
72
+ <button className="ork-btn ork-btn-sm" onClick={() => setShowTriggerPanel(false)}>
73
+ &times;
74
+ </button>
75
+ </div>
76
+
77
+ <div className="ork-form-group">
78
+ <label className="ork-label">Trigger Type</label>
79
+ <select
80
+ className="ork-select"
81
+ value={triggerType || ""}
82
+ onChange={(e) => {
83
+ const newType = e.target.value || null;
84
+ onTriggerChange(newType, {});
85
+ }}
86
+ >
87
+ <option value="">None</option>
88
+ {triggers.map((t) => (
89
+ <option key={t.id} value={t.id}>
90
+ {t.label}
91
+ </option>
92
+ ))}
93
+ </select>
94
+ </div>
95
+
96
+ {selectedTrigger?.config_schema && (
97
+ <div className="ork-trigger-config-form">
98
+ <label className="ork-label">Settings</label>
99
+ <JsonSchemaForm
100
+ schema={selectedTrigger.config_schema}
101
+ values={triggerConfig}
102
+ onChange={(vals) => onTriggerChange(triggerType!, vals)}
103
+ />
104
+ </div>
105
+ )}
106
+
107
+ {/* Entry conditions */}
108
+ {triggerType && (
109
+ <div className="ork-trigger-config-form">
110
+ <label className="ork-label">Entry Conditions</label>
111
+ <div className="ork-field-description">
112
+ Restrict which entities or contexts can trigger this workflow
113
+ </div>
114
+ <EntryConditionsEditor
115
+ value={triggerConfig.entry_conditions as EntryConditions | undefined}
116
+ onChange={(conditions) => {
117
+ const updated = { ...triggerConfig };
118
+ if (conditions && conditions.rules.length > 0) {
119
+ updated.entry_conditions = conditions;
120
+ } else {
121
+ delete updated.entry_conditions;
122
+ }
123
+ onTriggerChange(triggerType!, updated);
124
+ }}
125
+ />
126
+ </div>
127
+ )}
128
+
129
+ {webhookUrl && (
130
+ <div className="ork-form-group">
131
+ <label className="ork-label">Webhook URL</label>
132
+ <div className="ork-webhook-url">
133
+ <code>{webhookUrl}</code>
134
+ <button
135
+ className="ork-btn ork-btn-sm"
136
+ onClick={() => navigator.clipboard?.writeText(webhookUrl)}
137
+ title="Copy URL"
138
+ >
139
+ Copy
140
+ </button>
141
+ </div>
142
+ {typeof triggerConfig.secret === "string" && triggerConfig.secret && (
143
+ <div className="ork-field-description">
144
+ Header: <code>X-Webhook-Secret: {triggerConfig.secret}</code>
145
+ </div>
146
+ )}
147
+ </div>
148
+ )}
149
+ </div>
150
+ )}
151
+ </div>
152
+ );
153
+ }
@@ -0,0 +1,390 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { getCustomFieldTypes, getCustomRenderer, onFieldTypesChanged } from "../../fieldRegistry";
3
+
4
+ interface FormField {
5
+ name: string;
6
+ type: string;
7
+ required: boolean;
8
+ description: string;
9
+ // Select-specific
10
+ options?: SelectOption[];
11
+ options_url?: string;
12
+ multiple?: boolean;
13
+ // Text
14
+ placeholder?: string;
15
+ // Extra properties for custom field types (e.g. min, max, step for slider)
16
+ [key: string]: unknown;
17
+ }
18
+
19
+ interface SelectOption {
20
+ label: string;
21
+ value: string;
22
+ }
23
+
24
+ export interface FormSchema {
25
+ type: string;
26
+ properties: Record<string, Record<string, unknown>>;
27
+ required: string[];
28
+ }
29
+
30
+ interface Props {
31
+ value: FormSchema | undefined;
32
+ onChange: (schema: FormSchema) => void;
33
+ }
34
+
35
+ const BUILT_IN_TYPES = [
36
+ { value: "string", label: "Text" },
37
+ { value: "text", label: "Textarea" },
38
+ { value: "number", label: "Number" },
39
+ { value: "boolean", label: "Checkbox" },
40
+ { value: "select", label: "Select" },
41
+ { value: "date", label: "Date" },
42
+ { value: "email", label: "Email" },
43
+ { value: "url", label: "URL" },
44
+ { value: "file", label: "File" },
45
+ ];
46
+
47
+ function getFieldTypes(): { value: string; label: string }[] {
48
+ const custom = getCustomFieldTypes();
49
+ return [...BUILT_IN_TYPES, ...custom];
50
+ }
51
+
52
+ const KNOWN_SCHEMA_KEYS = new Set(["type", "format", "description", "options", "options_url", "multiple", "placeholder"]);
53
+ const KNOWN_FORM_KEYS = new Set(["name", "type", "required", "description", "options", "options_url", "multiple", "placeholder"]);
54
+
55
+ function parseFields(schema: FormSchema | undefined): FormField[] {
56
+ if (!schema?.properties) return [];
57
+ const required = schema.required || [];
58
+ return Object.entries(schema.properties).map(([name, prop]) => {
59
+ const field: FormField = {
60
+ name,
61
+ type: (prop.format as string) || (prop.type as string) || "string",
62
+ required: required.includes(name),
63
+ description: (prop.description as string) || "",
64
+ options: prop.options as SelectOption[] | undefined,
65
+ options_url: prop.options_url as string | undefined,
66
+ multiple: prop.multiple as boolean | undefined,
67
+ placeholder: prop.placeholder as string | undefined,
68
+ };
69
+ // Preserve extra properties for custom field types (e.g. min, max, step)
70
+ for (const [k, v] of Object.entries(prop)) {
71
+ if (!KNOWN_SCHEMA_KEYS.has(k)) {
72
+ field[k] = v;
73
+ }
74
+ }
75
+ return field;
76
+ });
77
+ }
78
+
79
+ function fieldsToSchema(fields: FormField[]): FormSchema {
80
+ const properties: Record<string, Record<string, unknown>> = {};
81
+ const required: string[] = [];
82
+
83
+ fields.forEach((f) => {
84
+ const key = f.name.trim();
85
+ if (!key) return;
86
+
87
+ const prop: Record<string, unknown> = {};
88
+
89
+ // Map UI types to JSON Schema types + format
90
+ switch (f.type) {
91
+ case "text":
92
+ prop.type = "string";
93
+ prop.format = "text";
94
+ break;
95
+ case "date":
96
+ prop.type = "string";
97
+ prop.format = "date";
98
+ break;
99
+ case "email":
100
+ prop.type = "string";
101
+ prop.format = "email";
102
+ break;
103
+ case "url":
104
+ prop.type = "string";
105
+ prop.format = "url";
106
+ break;
107
+ case "file":
108
+ prop.type = "string";
109
+ prop.format = "file";
110
+ break;
111
+ case "select":
112
+ prop.type = "string";
113
+ prop.format = "select";
114
+ if (f.multiple) prop.multiple = true;
115
+ if (f.options?.length) prop.options = f.options;
116
+ if (f.options_url) prop.options_url = f.options_url;
117
+ break;
118
+ case "number":
119
+ prop.type = "number";
120
+ break;
121
+ case "boolean":
122
+ prop.type = "boolean";
123
+ break;
124
+ default:
125
+ // Custom types or plain string
126
+ prop.type = "string";
127
+ if (f.type !== "string") prop.format = f.type;
128
+ // Carry over extra properties from custom field config
129
+ for (const [k, v] of Object.entries(f)) {
130
+ if (!KNOWN_FORM_KEYS.has(k) && v !== undefined) {
131
+ prop[k] = v;
132
+ }
133
+ }
134
+ break;
135
+ }
136
+
137
+ if (f.description) prop.description = f.description;
138
+ if (f.placeholder) prop.placeholder = f.placeholder;
139
+
140
+ properties[key] = prop;
141
+ if (f.required) required.push(key);
142
+ });
143
+
144
+ return { type: "object", properties, required };
145
+ }
146
+
147
+ export function FormSchemaBuilder({ value, onChange }: Props) {
148
+ const [fields, setFields] = useState<FormField[]>(() => parseFields(value));
149
+ const [, setFieldTypesVersion] = useState(0);
150
+
151
+ useEffect(() => {
152
+ setFields(parseFields(value));
153
+ }, [value?.properties ? Object.keys(value.properties).join(",") : ""]);
154
+
155
+ // Re-render when host app registers custom field types
156
+ useEffect(() => onFieldTypesChanged(() => setFieldTypesVersion((v) => v + 1)), []);
157
+
158
+ const commit = (updated: FormField[]) => {
159
+ setFields(updated);
160
+ onChange(fieldsToSchema(updated));
161
+ };
162
+
163
+ const addField = () => {
164
+ setFields([...fields, { name: "", type: "string", required: false, description: "" }]);
165
+ };
166
+
167
+ const removeField = (index: number) => {
168
+ commit(fields.filter((_, i) => i !== index));
169
+ };
170
+
171
+ const updateField = (index: number, patch: Partial<FormField>) => {
172
+ const updated = [...fields];
173
+ updated[index] = { ...updated[index], ...patch };
174
+ commit(updated);
175
+ };
176
+
177
+ const fieldTypes = getFieldTypes();
178
+
179
+ return (
180
+ <div className="ork-form-group">
181
+ <label className="ork-label">Form Fields</label>
182
+ <div className="ork-field-description">
183
+ Define the form that assignees will fill out
184
+ </div>
185
+ <div className="ork-array-items">
186
+ {fields.map((field, index) => (
187
+ <div key={index} className="ork-array-item">
188
+ <div className="ork-array-item-header">
189
+ <span className="ork-array-item-index">#{index + 1}</span>
190
+ <button
191
+ type="button"
192
+ className="ork-btn ork-btn-danger ork-btn-sm"
193
+ onClick={() => removeField(index)}
194
+ >
195
+ &times;
196
+ </button>
197
+ </div>
198
+ <div className="ork-form-group">
199
+ <label className="ork-label">Field Name *</label>
200
+ <input
201
+ className="ork-input"
202
+ type="text"
203
+ value={field.name}
204
+ placeholder="e.g. approved"
205
+ onChange={(e) => updateField(index, { name: e.target.value })}
206
+ />
207
+ </div>
208
+ <div className="ork-form-builder-row">
209
+ <div className="ork-form-group" style={{ flex: 1 }}>
210
+ <label className="ork-label">Type</label>
211
+ <select
212
+ className="ork-select"
213
+ value={field.type}
214
+ onChange={(e) => updateField(index, { type: e.target.value })}
215
+ >
216
+ {fieldTypes.map((t) => (
217
+ <option key={t.value} value={t.value}>{t.label}</option>
218
+ ))}
219
+ </select>
220
+ </div>
221
+ <div className="ork-form-group" style={{ flex: 0 }}>
222
+ <label className="ork-label">Required</label>
223
+ <label className="ork-checkbox-label">
224
+ <input
225
+ type="checkbox"
226
+ checked={field.required}
227
+ onChange={(e) => updateField(index, { required: e.target.checked })}
228
+ />
229
+ </label>
230
+ </div>
231
+ </div>
232
+ <div className="ork-form-group">
233
+ <label className="ork-label">Description</label>
234
+ <input
235
+ className="ork-input"
236
+ type="text"
237
+ value={field.description}
238
+ placeholder="Optional description"
239
+ onChange={(e) => updateField(index, { description: e.target.value })}
240
+ />
241
+ </div>
242
+ <div className="ork-form-group">
243
+ <label className="ork-label">Placeholder</label>
244
+ <input
245
+ className="ork-input"
246
+ type="text"
247
+ value={field.placeholder || ""}
248
+ placeholder="Optional placeholder"
249
+ onChange={(e) => updateField(index, { placeholder: e.target.value || undefined })}
250
+ />
251
+ </div>
252
+
253
+ {/* Select-specific options */}
254
+ {field.type === "select" && (
255
+ <SelectFieldConfig field={field} onUpdate={(patch) => updateField(index, patch)} />
256
+ )}
257
+
258
+ {/* Custom field config from host app */}
259
+ {getCustomRenderer(field.type)?.configRender && (
260
+ <CustomConfigPanel
261
+ fieldType={field.type}
262
+ field={fieldsToSchemaField(field)}
263
+ onChange={(patch) => updateField(index, patch as Partial<FormField>)}
264
+ />
265
+ )}
266
+
267
+ </div>
268
+ ))}
269
+ </div>
270
+ <button type="button" className="ork-btn ork-btn-sm" onClick={addField}>
271
+ + Add Field
272
+ </button>
273
+ </div>
274
+ );
275
+ }
276
+
277
+ /* ── Helper: convert FormField to schema-like prop object for configRender ── */
278
+ function fieldsToSchemaField(field: FormField): Record<string, unknown> {
279
+ const prop: Record<string, unknown> = {};
280
+ for (const [k, v] of Object.entries(field)) {
281
+ if (k !== "name" && k !== "required" && v !== undefined) {
282
+ prop[k] = v;
283
+ }
284
+ }
285
+ return prop;
286
+ }
287
+
288
+ /* ── Custom config panel (bridges DOM configRender to React) ── */
289
+ function CustomConfigPanel({ fieldType, field, onChange }: {
290
+ fieldType: string;
291
+ field: Record<string, unknown>;
292
+ onChange: (patch: Record<string, unknown>) => void;
293
+ }) {
294
+ const containerRef = useRef<HTMLDivElement>(null);
295
+ const renderer = getCustomRenderer(fieldType);
296
+
297
+ useEffect(() => {
298
+ if (!containerRef.current || !renderer?.configRender) return;
299
+ containerRef.current.innerHTML = "";
300
+ renderer.configRender(containerRef.current, { field, onChange });
301
+ }, [renderer, fieldType]);
302
+
303
+ return <div ref={containerRef} />;
304
+ }
305
+
306
+ /* ── Select field configuration ── */
307
+ function SelectFieldConfig({ field, onUpdate }: { field: FormField; onUpdate: (patch: Partial<FormField>) => void }) {
308
+ const options = field.options || [];
309
+
310
+ const addOption = () => {
311
+ onUpdate({ options: [...options, { label: "", value: "" }] });
312
+ };
313
+
314
+ const removeOption = (index: number) => {
315
+ onUpdate({ options: options.filter((_, i) => i !== index) });
316
+ };
317
+
318
+ const updateOption = (index: number, patch: Partial<SelectOption>) => {
319
+ const updated = [...options];
320
+ updated[index] = { ...updated[index], ...patch };
321
+ onUpdate({ options: updated });
322
+ };
323
+
324
+ return (
325
+ <>
326
+ <div className="ork-form-builder-row">
327
+ <div className="ork-form-group" style={{ flex: 0 }}>
328
+ <label className="ork-label">Multiple</label>
329
+ <label className="ork-checkbox-label">
330
+ <input
331
+ type="checkbox"
332
+ checked={!!field.multiple}
333
+ onChange={(e) => onUpdate({ multiple: e.target.checked || undefined })}
334
+ />
335
+ </label>
336
+ </div>
337
+ </div>
338
+
339
+ <div className="ork-form-group">
340
+ <label className="ork-label">Options URL (dynamic)</label>
341
+ <div className="ork-field-description">
342
+ API endpoint returning JSON array of {"{ label, value }"} objects
343
+ </div>
344
+ <input
345
+ className="ork-input"
346
+ type="text"
347
+ value={field.options_url || ""}
348
+ placeholder="/api/users?format=options"
349
+ onChange={(e) => onUpdate({ options_url: e.target.value || undefined })}
350
+ />
351
+ </div>
352
+
353
+ <div className="ork-form-group">
354
+ <label className="ork-label">Static Options</label>
355
+ <div className="ork-array-items">
356
+ {options.map((opt, i) => (
357
+ <div key={i} className="ork-form-builder-row">
358
+ <input
359
+ className="ork-input"
360
+ type="text"
361
+ placeholder="Label"
362
+ value={opt.label}
363
+ onChange={(e) => updateOption(i, { label: e.target.value })}
364
+ style={{ flex: 1 }}
365
+ />
366
+ <input
367
+ className="ork-input"
368
+ type="text"
369
+ placeholder="Value"
370
+ value={opt.value}
371
+ onChange={(e) => updateOption(i, { value: e.target.value })}
372
+ style={{ flex: 1 }}
373
+ />
374
+ <button
375
+ type="button"
376
+ className="ork-btn ork-btn-danger ork-btn-sm"
377
+ onClick={() => removeOption(i)}
378
+ >
379
+ &times;
380
+ </button>
381
+ </div>
382
+ ))}
383
+ </div>
384
+ <button type="button" className="ork-btn ork-btn-sm" onClick={addOption}>
385
+ + Add Option
386
+ </button>
387
+ </div>
388
+ </>
389
+ );
390
+ }