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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +832 -0
- data/Rakefile +6 -0
- data/app/assets/builds/orkestr/orkestr-editor.js +72 -0
- data/app/assets/stylesheets/orkestr/application.css +15 -0
- data/app/assets/stylesheets/orkestr/theme.css +62 -0
- data/app/controllers/orkestr/api/base_controller.rb +45 -0
- data/app/controllers/orkestr/api/executions_controller.rb +50 -0
- data/app/controllers/orkestr/api/human_tasks_controller.rb +48 -0
- data/app/controllers/orkestr/api/registry_controller.rb +40 -0
- data/app/controllers/orkestr/api/workflows_controller.rb +53 -0
- data/app/controllers/orkestr/application_controller.rb +4 -0
- data/app/controllers/orkestr/human_tasks_controller.rb +30 -0
- data/app/controllers/orkestr/ui_controller.rb +8 -0
- data/app/controllers/orkestr/webhooks_controller.rb +35 -0
- data/app/helpers/orkestr/application_helper.rb +4 -0
- data/app/helpers/orkestr/ui_helper.rb +34 -0
- data/app/javascript/orkestr-ui/index.html +19 -0
- data/app/javascript/orkestr-ui/package-lock.json +2050 -0
- data/app/javascript/orkestr-ui/package.json +23 -0
- data/app/javascript/orkestr-ui/src/OrkestrApp.tsx +152 -0
- data/app/javascript/orkestr-ui/src/api/client.ts +59 -0
- data/app/javascript/orkestr-ui/src/api/executions.ts +23 -0
- data/app/javascript/orkestr-ui/src/api/humanTasks.ts +32 -0
- data/app/javascript/orkestr-ui/src/api/index.ts +5 -0
- data/app/javascript/orkestr-ui/src/api/registry.ts +10 -0
- data/app/javascript/orkestr-ui/src/api/workflows.ts +33 -0
- data/app/javascript/orkestr-ui/src/components/Editor/ActionsBuilder.tsx +213 -0
- data/app/javascript/orkestr-ui/src/components/Editor/CustomNode.tsx +31 -0
- data/app/javascript/orkestr-ui/src/components/Editor/EditorToolbar.tsx +153 -0
- data/app/javascript/orkestr-ui/src/components/Editor/FormSchemaBuilder.tsx +390 -0
- data/app/javascript/orkestr-ui/src/components/Editor/NodeConfigPanel.tsx +274 -0
- data/app/javascript/orkestr-ui/src/components/Editor/NodePalette.tsx +43 -0
- data/app/javascript/orkestr-ui/src/components/Editor/RunDialog.tsx +52 -0
- data/app/javascript/orkestr-ui/src/components/Editor/WorkflowEditor.tsx +299 -0
- data/app/javascript/orkestr-ui/src/components/Executions/ExecutionDetail.tsx +155 -0
- data/app/javascript/orkestr-ui/src/components/Executions/ExecutionList.tsx +74 -0
- data/app/javascript/orkestr-ui/src/components/HumanTasks/TaskForm.tsx +216 -0
- data/app/javascript/orkestr-ui/src/components/HumanTasks/TaskFormEmbed.tsx +117 -0
- data/app/javascript/orkestr-ui/src/components/HumanTasks/TaskList.tsx +110 -0
- data/app/javascript/orkestr-ui/src/components/Workflows/NewWorkflowDialog.tsx +64 -0
- data/app/javascript/orkestr-ui/src/components/Workflows/WorkflowList.tsx +94 -0
- data/app/javascript/orkestr-ui/src/components/shared/EntryConditionsEditor.tsx +138 -0
- data/app/javascript/orkestr-ui/src/components/shared/ExpressionEditor.tsx +206 -0
- data/app/javascript/orkestr-ui/src/components/shared/HumanTaskFormRenderer.tsx +321 -0
- data/app/javascript/orkestr-ui/src/components/shared/JsonSchemaForm.tsx +376 -0
- data/app/javascript/orkestr-ui/src/components/shared/Loading.tsx +5 -0
- data/app/javascript/orkestr-ui/src/components/shared/StatusBadge.tsx +9 -0
- data/app/javascript/orkestr-ui/src/fieldRegistry.ts +74 -0
- data/app/javascript/orkestr-ui/src/hooks/useApi.ts +30 -0
- data/app/javascript/orkestr-ui/src/hooks/useRegistry.ts +35 -0
- data/app/javascript/orkestr-ui/src/main.tsx +75 -0
- data/app/javascript/orkestr-ui/src/styles/editor.css +445 -0
- data/app/javascript/orkestr-ui/src/styles/index.css +478 -0
- data/app/javascript/orkestr-ui/src/types/execution.ts +37 -0
- data/app/javascript/orkestr-ui/src/types/humanTask.ts +30 -0
- data/app/javascript/orkestr-ui/src/types/index.ts +4 -0
- data/app/javascript/orkestr-ui/src/types/registry.ts +22 -0
- data/app/javascript/orkestr-ui/src/types/workflow.ts +64 -0
- data/app/javascript/orkestr-ui/src/vite-env.d.ts +6 -0
- data/app/javascript/orkestr-ui/tsconfig.json +21 -0
- data/app/javascript/orkestr-ui/tsconfig.tsbuildinfo +1 -0
- data/app/javascript/orkestr-ui/vite.config.ts +30 -0
- data/app/jobs/orkestr/application_job.rb +4 -0
- data/app/jobs/orkestr/execute_workflow_job.rb +10 -0
- data/app/jobs/orkestr/resume_execution_job.rb +15 -0
- data/app/mailers/orkestr/application_mailer.rb +6 -0
- data/app/models/concerns/orkestr/assignable.rb +28 -0
- data/app/models/concerns/orkestr/contextualizable.rb +9 -0
- data/app/models/orkestr/application_record.rb +6 -0
- data/app/models/orkestr/assignee.rb +40 -0
- data/app/models/orkestr/context.rb +42 -0
- data/app/models/orkestr/edge.rb +58 -0
- data/app/models/orkestr/execution.rb +45 -0
- data/app/models/orkestr/execution_log.rb +38 -0
- data/app/models/orkestr/human_task.rb +63 -0
- data/app/models/orkestr/node.rb +48 -0
- data/app/models/orkestr/node_execution.rb +59 -0
- data/app/models/orkestr/workflow.rb +39 -0
- data/app/orkestr_nodes/action/node.rb +77 -0
- data/app/orkestr_nodes/condition/node.rb +67 -0
- data/app/orkestr_nodes/http_request/node.rb +88 -0
- data/app/orkestr_nodes/human_action/node.rb +103 -0
- data/app/orkestr_nodes/transform/node.rb +48 -0
- data/app/orkestr_nodes/wait/node.rb +18 -0
- data/app/orkestr_triggers/manual/trigger.rb +12 -0
- data/app/orkestr_triggers/scheduled/trigger.rb +26 -0
- data/app/orkestr_triggers/webhook/trigger.rb +23 -0
- data/app/serializers/orkestr/assignee_serializer.rb +30 -0
- data/app/serializers/orkestr/context_serializer.rb +30 -0
- data/app/serializers/orkestr/edge_serializer.rb +33 -0
- data/app/serializers/orkestr/execution_collection_serializer.rb +7 -0
- data/app/serializers/orkestr/execution_log_serializer.rb +31 -0
- data/app/serializers/orkestr/execution_serializer.rb +37 -0
- data/app/serializers/orkestr/human_task_serializer.rb +46 -0
- data/app/serializers/orkestr/node_execution_serializer.rb +43 -0
- data/app/serializers/orkestr/node_serializer.rb +29 -0
- data/app/serializers/orkestr/workflow_collection_serializer.rb +11 -0
- data/app/serializers/orkestr/workflow_serializer.rb +30 -0
- data/app/services/orkestr/entry_condition_evaluator.rb +68 -0
- data/app/services/orkestr/execution_service/complete.rb +64 -0
- data/app/services/orkestr/execution_service/join_resolver.rb +56 -0
- data/app/services/orkestr/execution_service/node_runner.rb +56 -0
- data/app/services/orkestr/execution_service/runner.rb +162 -0
- data/app/services/orkestr/execution_service/start.rb +90 -0
- data/app/services/orkestr/expression_resolver.rb +72 -0
- data/app/services/orkestr/human_task_service/complete.rb +62 -0
- data/app/services/orkestr/workflow_service/duplicate.rb +30 -0
- data/app/services/orkestr/workflow_service/export.rb +26 -0
- data/app/services/orkestr/workflow_service/import.rb +29 -0
- data/app/services/orkestr/workflow_synchronizer.rb +102 -0
- data/app/views/layouts/orkestr/application.html.erb +17 -0
- data/app/views/layouts/orkestr/ui.html.erb +18 -0
- data/app/views/orkestr/human_tasks/show.html.erb +17 -0
- data/app/views/orkestr/ui/index.html.erb +8 -0
- data/config/routes.rb +27 -0
- data/db/migrate/20260308204133_enable_pgcrypto_extension.rb +5 -0
- data/db/migrate/20260308204558_create_orkestr_workflows.rb +12 -0
- data/db/migrate/20260308204703_create_orkestr_nodes.rb +12 -0
- data/db/migrate/20260308204807_create_orkestr_edges.rb +12 -0
- data/db/migrate/20260308204931_create_orkestr_executions.rb +13 -0
- data/db/migrate/20260308205023_create_orkestr_node_executions.rb +16 -0
- data/db/migrate/20260308205119_add_react_flow_id_to_nodes.rb +6 -0
- data/db/migrate/20260308205123_add_react_flow_id_to_edges.rb +6 -0
- data/db/migrate/20260308205745_add_workflow_to_executions.rb +5 -0
- data/db/migrate/20260308205940_make_executable_nullable_on_executions.rb +6 -0
- data/db/migrate/20260308220730_create_orkestr_human_tasks.rb +15 -0
- data/db/migrate/20260308220900_create_orkestr_assignees.rb +13 -0
- data/db/migrate/20260308234115_add_unique_index_to_node_executions.rb +7 -0
- data/db/migrate/20260309075336_create_orkestr_contexts.rb +10 -0
- data/db/migrate/20260309075343_replace_executable_with_context_on_executions.rb +11 -0
- data/db/migrate/20260309080416_add_status_key_deadline_to_orkestr_assignees.rb +7 -0
- data/db/migrate/20260309082815_add_status_and_workflow_to_orkestr_contexts.rb +7 -0
- data/db/migrate/20260309082816_add_status_default_context_and_reuse_context_to_orkestr_workflows.rb +7 -0
- data/db/migrate/20260309083328_create_orkestr_execution_logs.rb +14 -0
- data/db/migrate/20260310223204_replace_node_executions_unique_index_for_cycle_support.rb +18 -0
- data/lib/orkestr/configuration.rb +16 -0
- data/lib/orkestr/engine.rb +48 -0
- data/lib/orkestr/nodes/base.rb +74 -0
- data/lib/orkestr/nodes/loader.rb +32 -0
- data/lib/orkestr/nodes/registry.rb +34 -0
- data/lib/orkestr/nodes/schema_dsl.rb +73 -0
- data/lib/orkestr/triggers/base.rb +49 -0
- data/lib/orkestr/triggers/loader.rb +29 -0
- data/lib/orkestr/triggers/registry.rb +34 -0
- data/lib/orkestr/triggers/schema_dsl.rb +45 -0
- data/lib/orkestr/version.rb +3 -0
- data/lib/orkestr.rb +27 -0
- data/lib/tasks/annotate_rb.rake +10 -0
- data/lib/tasks/orkestr_tasks.rake +19 -0
- 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
|
+
←
|
|
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
|
+
×
|
|
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
|
+
×
|
|
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
|
+
×
|
|
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
|
+
}
|