Feature/better form nav (#474)

* Detect focus loss/return in the Diagram Editor - so that we can reload the process model and assure that we know about any changed files.

Allow for looking specifically for json SCHEMA files (those files that are named -schema.json or .schema.json (as is the convention).  Only show these in the dropdown for the form.

* * Run descriptions through the markdown processor so you can use bold/italic etc... in your description fields within a form.
* Move ExampleTable into it's own view component to keep the size of the form builder sane.
* Assure markdown within jrsf forms have reasonable styling that follows the containers style, rather than setting to some other default.
* Add a couple of example forms so people can get a sense of what is possible.
* Connect up the new Json Schema Editor Component to the process model edit diagram.
* Just select the schema file - not the ui file when selecting the form for a component - we may revert this to just a text box.
*

* Cleanup the formatting of arrays, so that they are sligtly intended, do not contain an awkward unneeded heading, and have some tighter css.

* Connect the form editing in the modal back to the BPMN-JS editor

Auto-Save edits in the Form Builder

Lots and lots of tweaks to the react form builder ui

* various fixes.

* test for prepare_schema

* minor fix for run_pyl

* css cleanup
less issues with reloading and jumping about when in the editor
Don't sort keys when returning the json.
More intelligent "ready"

* bump package to point to branch of bpmn-js-spiffworkflow so others can check it out.

* Assure that json keys are not sorted during serialization by default.
Allow adding example fields to an existing schema

Create a set of examples.

* db complaints in migration change.

* removed items from interface file that had been moved elsewhere w/ burnettk

* rename prepare_form to prepare-form

* rename prepare_form to prepare-form

* Remove commented out code.

* typo

* add a comment about the empty column

* move back to the main branch

---------

Co-authored-by: jasquat <jasquat@users.noreply.github.com>
Co-authored-by: burnettk <burnettk@users.noreply.github.com>
This commit is contained in:
Dan Funk 2023-09-08 11:07:43 -04:00 committed by GitHub
parent d99054b3a6
commit 948c633b2c
35 changed files with 1082 additions and 86 deletions

View File

@ -18,7 +18,8 @@ depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('process_instance_report', schema=None) as batch_op:
batch_op.add_column(sa.Column('json_data_hash', sa.String(length=255), nullable=False))
batch_op.add_column(sa.Column('json_data_hash', sa.String(length=255)))
batch_op.alter_column('json_data_hash', existing_type=sa.String(length=255), nullable=False)
batch_op.create_index(batch_op.f('ix_process_instance_report_json_data_hash'), ['json_data_hash'], unique=False)
# ### end Alembic commands ###

View File

@ -184,6 +184,10 @@ def create_app() -> flask.app.Flask:
app.before_request(AuthorizationService.check_for_permission)
app.after_request(set_new_access_token_in_cookie)
# The default is true, but we want to preserve the order of keys in the json
# This is particularly helpful for forms that are generated from json schemas.
app.json.sort_keys = False
return app # type: ignore

View File

@ -2091,6 +2091,26 @@ paths:
schema:
$ref: "#/components/schemas/Task"
/tasks/prepare-form:
post:
tags:
- Tasks
operationId: spiffworkflow_backend.routes.tasks_controller.prepare_form
summary: Pre-processes a form schema, ui schema, and data, useful for previewing a form
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/Task"
responses:
"200":
description: Returns the same data structure provided, but after some replacements.
content:
application/json:
schema:
$ref: "#/components/schemas/Task"
/tasks/{process_instance_id}/{task_guid}:
parameters:
- name: task_guid

View File

@ -232,7 +232,7 @@ def process_model_list(
def process_model_file_update(
modified_process_model_identifier: str, file_name: str, file_contents_hash: str
modified_process_model_identifier: str, file_name: str, file_contents_hash: str | None = None
) -> flask.wrappers.Response:
message = f"User: {g.user.username} clicked save for"
return _create_or_update_process_model_file(

View File

@ -1,6 +1,7 @@
import json
import os
import uuid
from collections import OrderedDict
from collections.abc import Generator
from typing import Any
from typing import TypedDict
@ -91,7 +92,10 @@ def task_list_my_tasks(
process_initiator_user,
process_initiator_user.id == ProcessInstanceModel.process_initiator_id,
)
.join(HumanTaskUserModel, HumanTaskUserModel.human_task_id == HumanTaskModel.id)
.join(
HumanTaskUserModel,
HumanTaskUserModel.human_task_id == HumanTaskModel.id,
)
.filter(HumanTaskUserModel.user_id == principal.user_id)
.outerjoin(assigned_user, assigned_user.id == HumanTaskUserModel.user_id)
.filter(HumanTaskModel.completed == False) # noqa: E712
@ -327,8 +331,37 @@ def task_assign(
return make_response(jsonify({"ok": True}), 200)
def prepare_form(body: dict) -> flask.wrappers.Response:
"""Does the backend processing of the form schema as it would be done for task_show, including
running the form schema through a jinja rendering, hiding fields, and populating lists of options."""
if "form_schema" not in body:
raise ApiError(
"missing_form_schema",
"The form schema is missing from the request body.",
)
form_schema = body["form_schema"]
form_ui = body.get("form_ui", {})
task_data = body.get("task_data", {})
# Run the form schema through the jinja template engine
form_string = json.dumps(form_schema)
form_string = JinjaService.render_jinja_template(form_string, task_data=task_data)
form_dict = OrderedDict(json.loads(form_string))
# Update the schema if it, for instance, uses task data to populate an options list.
_update_form_schema_with_task_data_as_needed(form_dict, task_data)
# Hide any fields that are marked as hidden in the task data.
_munge_form_ui_schema_based_on_hidden_fields_in_task_data(form_ui, task_data)
return make_response(jsonify({"form_schema": form_dict, "form_ui": form_ui}), 200)
def task_show(
process_instance_id: int, task_guid: str = "next", with_form_data: bool = False
process_instance_id: int,
task_guid: str = "next",
with_form_data: bool = False,
) -> flask.wrappers.Response:
process_instance = _find_process_instance_by_id_or_raise(process_instance_id)
@ -350,7 +383,11 @@ def task_show(
try:
AuthorizationService.assert_user_can_complete_task(process_instance.id, task_model.guid, g.user)
can_complete = True
except (HumanTaskNotFoundError, UserDoesNotHaveAccessToTaskError, HumanTaskAlreadyCompletedError):
except (
HumanTaskNotFoundError,
UserDoesNotHaveAccessToTaskError,
HumanTaskAlreadyCompletedError,
):
can_complete = False
task_model.process_model_display_name = process_model.display_name
@ -417,8 +454,7 @@ def task_show(
process_model_with_form,
)
if task_model.data:
_update_form_schema_with_task_data_as_needed(form_dict, task_model)
_update_form_schema_with_task_data_as_needed(form_dict, task_model.data)
if form_dict:
task_model.form_schema = form_dict
@ -431,8 +467,9 @@ def task_show(
)
if ui_form_contents:
task_model.form_ui_schema = ui_form_contents
_munge_form_ui_schema_based_on_hidden_fields_in_task_data(task_model)
else:
task_model.form_ui_schema = {}
_munge_form_ui_schema_based_on_hidden_fields_in_task_data(task_model.form_ui_schema, task_model.data)
JinjaService.render_instructions_for_end_user(task_model, extensions)
task_model.extensions = extensions
@ -450,7 +487,9 @@ def task_submit(
def _interstitial_stream(
process_instance: ProcessInstanceModel, execute_tasks: bool = True, is_locked: bool = False
process_instance: ProcessInstanceModel,
execute_tasks: bool = True,
is_locked: bool = False,
) -> Generator[str, str | None, None]:
def get_reportable_tasks() -> Any:
return processor.bpmn_process_instance.get_tasks(
@ -541,7 +580,10 @@ def _interstitial_stream(
processor = ProcessInstanceProcessor(process_instance)
# if process instance is done or blocked by a human task, then break out
if is_locked and process_instance.status not in ["not_started", "waiting"]:
if is_locked and process_instance.status not in [
"not_started",
"waiting",
]:
break
tasks = get_reportable_tasks()
@ -933,10 +975,7 @@ def _get_spiff_task_from_process_instance(
# originally from: https://bitcoden.com/answers/python-nested-dictionary-update-value-where-any-nested-key-matches
def _update_form_schema_with_task_data_as_needed(in_dict: dict, task_model: TaskModel) -> None:
if task_model.data is None:
return None
def _update_form_schema_with_task_data_as_needed(in_dict: dict, task_data: dict) -> None:
for k, value in in_dict.items():
if "anyOf" == k:
# value will look like the array on the right of "anyOf": ["options_from_task_data_var:awesome_options"]
@ -947,7 +986,7 @@ def _update_form_schema_with_task_data_as_needed(in_dict: dict, task_model: Task
if first_element_in_value_list.startswith("options_from_task_data_var:"):
task_data_var = first_element_in_value_list.replace("options_from_task_data_var:", "")
if task_data_var not in task_model.data:
if task_data_var not in task_data:
message = (
"Error building form. Attempting to create a selection list with options from"
f" variable '{task_data_var}' but it doesn't exist in the Task Data."
@ -958,7 +997,7 @@ def _update_form_schema_with_task_data_as_needed(in_dict: dict, task_model: Task
status_code=500,
)
select_options_from_task_data = task_model.data.get(task_data_var)
select_options_from_task_data = task_data.get(task_data_var)
if select_options_from_task_data == []:
raise ApiError(
error_code="invalid_form_data",
@ -990,16 +1029,19 @@ def _update_form_schema_with_task_data_as_needed(in_dict: dict, task_model: Task
}
options_for_react_json_schema_form = list(
map(map_function, select_options_from_task_data)
map(
map_function,
select_options_from_task_data,
)
)
in_dict[k] = options_for_react_json_schema_form
elif isinstance(value, dict):
_update_form_schema_with_task_data_as_needed(value, task_model)
_update_form_schema_with_task_data_as_needed(value, task_data)
elif isinstance(value, list):
for o in value:
if isinstance(o, dict):
_update_form_schema_with_task_data_as_needed(o, task_model)
_update_form_schema_with_task_data_as_needed(o, task_data)
def _get_potential_owner_usernames(assigned_user: AliasedClass) -> Any:
@ -1023,7 +1065,9 @@ def _find_human_task_or_raise(
) -> HumanTaskModel:
if only_tasks_that_can_be_completed:
human_task_query = HumanTaskModel.query.filter_by(
process_instance_id=process_instance_id, task_id=task_guid, completed=False
process_instance_id=process_instance_id,
task_id=task_guid,
completed=False,
)
else:
human_task_query = HumanTaskModel.query.filter_by(process_instance_id=process_instance_id, task_id=task_guid)
@ -1043,15 +1087,14 @@ def _find_human_task_or_raise(
return human_task
def _munge_form_ui_schema_based_on_hidden_fields_in_task_data(task_model: TaskModel) -> None:
if task_model.form_ui_schema is None:
task_model.form_ui_schema = {}
if task_model.data and "form_ui_hidden_fields" in task_model.data:
hidden_fields = task_model.data["form_ui_hidden_fields"]
def _munge_form_ui_schema_based_on_hidden_fields_in_task_data(form_ui_schema: dict | None, task_data: dict) -> None:
if form_ui_schema is None:
return
if task_data and "form_ui_hidden_fields" in task_data:
hidden_fields = task_data["form_ui_hidden_fields"]
for hidden_field in hidden_fields:
hidden_field_parts = hidden_field.split(".")
relevant_depth_of_ui_schema = task_model.form_ui_schema
relevant_depth_of_ui_schema = form_ui_schema
for ii, hidden_field_part in enumerate(hidden_field_parts):
if hidden_field_part not in relevant_depth_of_ui_schema:
relevant_depth_of_ui_schema[hidden_field_part] = {}

View File

@ -83,6 +83,58 @@ class TestTasksController(BaseTest):
"veryImportantFieldButOnlySometimes": {"ui:widget": "hidden"},
}
def test_prepare_schema(
self,
app: Flask,
client: FlaskClient,
with_db_and_bpmn_file_cleanup: None,
with_super_admin_user: UserModel,
) -> None:
task_data = {
"title": "Title injected with jinja syntax",
"foods": [
{"value": "apples", "label": "apples"},
{"value": "oranges", "label": "oranges"},
{"value": "bananas", "label": "bananas"},
],
"form_ui_hidden_fields": ["DontShowMe"],
}
form_schema = {
"title": "{{title}}",
"type": "object",
"properties": {
"favoriteFood": {
"type": "string",
"title": "Favorite Food",
"anyOf": ["options_from_task_data_var:foods"],
},
"DontShowMe": {
"type": "string",
"title": "Don't Show Me",
},
},
}
form_ui: dict = {}
data = {"form_schema": form_schema, "form_ui": form_ui, "task_data": task_data}
self.logged_in_headers(with_super_admin_user)
response = client.post(
"/v1.0/tasks/prepare-form",
headers=self.logged_in_headers(with_super_admin_user),
data=json.dumps(data),
content_type="application/json",
)
assert response.status_code == 200
assert response.json is not None
assert response.json["form_schema"]["properties"]["favoriteFood"]["anyOf"] == [
{"enum": ["apples"], "title": "apples", "type": "string"},
{"enum": ["oranges"], "title": "oranges", "type": "string"},
{"enum": ["bananas"], "title": "bananas", "type": "string"},
]
assert response.json["form_schema"]["title"] == task_data["title"]
assert response.json["form_ui"] == {"DontShowMe": {"ui:widget": "hidden"}}
def test_interstitial_returns_process_instance_if_suspended_or_terminated(
self,
app: Flask,

View File

@ -47,6 +47,7 @@
"dmn-js-shared": "^12.1.1",
"jwt-decode": "^3.1.2",
"keycloak-js": "^18.0.1",
"lodash.merge": "^4.6.2",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-datepicker": "^4.8.0",
@ -66,6 +67,7 @@
"devDependencies": {
"@cypress/grep": "^3.1.0",
"@types/cookie": "^0.5.1",
"@types/lodash.merge": "^4.6.7",
"@typescript-eslint/eslint-plugin": "^5.30.5",
"@typescript-eslint/parser": "^5.30.6",
"cypress": "^12",
@ -6099,6 +6101,21 @@
"@types/node": "*"
}
},
"node_modules/@types/lodash": {
"version": "4.14.198",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.198.tgz",
"integrity": "sha512-trNJ/vtMZYMLhfN45uLq4ShQSw0/S7xCTLLVM+WM1rmFpba/VS42jVUgaO3w/NOLiWR/09lnYk0yMaA/atdIsg==",
"dev": true
},
"node_modules/@types/lodash.merge": {
"version": "4.6.7",
"resolved": "https://registry.npmjs.org/@types/lodash.merge/-/lodash.merge-4.6.7.tgz",
"integrity": "sha512-OwxUJ9E50gw3LnAefSHJPHaBLGEKmQBQ7CZe/xflHkyy/wH2zVyEIAKReHvVrrn7zKdF58p16We9kMfh7v0RRQ==",
"dev": true,
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/mdast": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.11.tgz",
@ -8339,7 +8356,7 @@
},
"node_modules/bpmn-js-spiffworkflow": {
"version": "0.0.8",
"resolved": "git+ssh://git@github.com/sartography/bpmn-js-spiffworkflow.git#2c7fca88f5241398bc38709d1e318671d856ed8b",
"resolved": "git+ssh://git@github.com/sartography/bpmn-js-spiffworkflow.git#9a5c333dee239a3489d3f0471a4fae86f343c0a6",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.4",
@ -8350,7 +8367,7 @@
"tiny-svg": "^2.2.3"
},
"peerDependencies": {
"bpmn-js": "*",
"bpmn-js": "^13.0.0",
"bpmn-js-properties-panel": "*",
"diagram-js": "*"
}
@ -36407,6 +36424,21 @@
"@types/node": "*"
}
},
"@types/lodash": {
"version": "4.14.198",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.198.tgz",
"integrity": "sha512-trNJ/vtMZYMLhfN45uLq4ShQSw0/S7xCTLLVM+WM1rmFpba/VS42jVUgaO3w/NOLiWR/09lnYk0yMaA/atdIsg==",
"dev": true
},
"@types/lodash.merge": {
"version": "4.6.7",
"resolved": "https://registry.npmjs.org/@types/lodash.merge/-/lodash.merge-4.6.7.tgz",
"integrity": "sha512-OwxUJ9E50gw3LnAefSHJPHaBLGEKmQBQ7CZe/xflHkyy/wH2zVyEIAKReHvVrrn7zKdF58p16We9kMfh7v0RRQ==",
"dev": true,
"requires": {
"@types/lodash": "*"
}
},
"@types/mdast": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.11.tgz",
@ -38202,7 +38234,7 @@
}
},
"bpmn-js-spiffworkflow": {
"version": "git+ssh://git@github.com/sartography/bpmn-js-spiffworkflow.git#2c7fca88f5241398bc38709d1e318671d856ed8b",
"version": "git+ssh://git@github.com/sartography/bpmn-js-spiffworkflow.git#9a5c333dee239a3489d3f0471a4fae86f343c0a6",
"from": "bpmn-js-spiffworkflow@github:sartography/bpmn-js-spiffworkflow#main",
"requires": {
"inherits": "^2.0.4",

View File

@ -42,6 +42,7 @@
"dmn-js-shared": "^12.1.1",
"jwt-decode": "^3.1.2",
"keycloak-js": "^18.0.1",
"lodash.merge": "^4.6.2",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-datepicker": "^4.8.0",
@ -94,6 +95,7 @@
"devDependencies": {
"@cypress/grep": "^3.1.0",
"@types/cookie": "^0.5.1",
"@types/lodash.merge": "^4.6.7",
"@typescript-eslint/eslint-plugin": "^5.30.5",
"@typescript-eslint/parser": "^5.30.6",
"cypress": "^12",

View File

@ -79,11 +79,11 @@ type OwnProps = {
onLaunchScriptEditor?: (..._args: any[]) => any;
onLaunchMarkdownEditor?: (..._args: any[]) => any;
onLaunchBpmnEditor?: (..._args: any[]) => any;
onLaunchJsonEditor?: (..._args: any[]) => any;
onLaunchJsonSchemaEditor?: (..._args: any[]) => any;
onLaunchDmnEditor?: (..._args: any[]) => any;
onElementClick?: (..._args: any[]) => any;
onServiceTasksRequested?: (..._args: any[]) => any;
onJsonFilesRequested?: (..._args: any[]) => any;
onJsonSchemaFilesRequested?: (..._args: any[]) => any;
onDmnFilesRequested?: (..._args: any[]) => any;
onSearchProcessModels?: (..._args: any[]) => any;
onElementsChanged?: (..._args: any[]) => any;
@ -108,11 +108,11 @@ export default function ReactDiagramEditor({
onLaunchScriptEditor,
onLaunchMarkdownEditor,
onLaunchBpmnEditor,
onLaunchJsonEditor,
onLaunchJsonSchemaEditor,
onLaunchDmnEditor,
onElementClick,
onServiceTasksRequested,
onJsonFilesRequested,
onJsonSchemaFilesRequested,
onDmnFilesRequested,
onSearchProcessModels,
onElementsChanged,
@ -293,8 +293,12 @@ export default function ReactDiagramEditor({
});
diagramModeler.on('spiff.file.edit', (event: any) => {
if (onLaunchJsonEditor) {
onLaunchJsonEditor(event.value);
const { error, element, value, eventBus } = event;
if (error) {
console.error(error);
}
if (onLaunchJsonSchemaEditor) {
onLaunchJsonSchemaEditor(element, value, eventBus);
}
});
@ -323,9 +327,9 @@ export default function ReactDiagramEditor({
handleServiceTasksRequested(event);
});
diagramModeler.on('spiff.json_files.requested', (event: any) => {
if (onJsonFilesRequested) {
onJsonFilesRequested(event);
diagramModeler.on('spiff.json_schema_files.requested', (event: any) => {
if (onJsonSchemaFilesRequested) {
onJsonSchemaFilesRequested(event);
}
});
@ -335,7 +339,7 @@ export default function ReactDiagramEditor({
}
});
diagramModeler.on('spiff.json_files.requested', (event: any) => {
diagramModeler.on('spiff.json_schema_files.requested', (event: any) => {
handleServiceTasksRequested(event);
});
@ -351,10 +355,10 @@ export default function ReactDiagramEditor({
onLaunchMarkdownEditor,
onLaunchBpmnEditor,
onLaunchDmnEditor,
onLaunchJsonEditor,
onLaunchJsonSchemaEditor,
onElementClick,
onServiceTasksRequested,
onJsonFilesRequested,
onJsonSchemaFilesRequested,
onDmnFilesRequested,
onSearchProcessModels,
onElementsChanged,

View File

@ -1,18 +0,0 @@
// // @ts-expect-error TS(7016) FIXME
// import { FormBuilder } from '@ginkgo-bioworks/react-json-schema-form-builder';
//
// type OwnProps = {
// schema: string;
// uischema: string;
// };
//
// export default function ReactFormBuilder({ schema, uischema }: OwnProps) {
// // onChange={(newSchema: string, newUiSchema: string) => {
// // this.setState({
// // schema: newSchema,
// // uischema: newUiSchema,
// // });
// // }}
// // return <main />;
// return <FormBuilder schema={schema} uischema={uischema} />;
// }

View File

@ -0,0 +1,105 @@
import React from 'react';
import {
Table,
TableHead,
TableRow,
TableHeader,
TableBody,
Button,
} from '@carbon/react';
import { JsonSchemaExample } from '../../interfaces';
const examples: JsonSchemaExample[] = [];
examples.push({
schema: require('../../resources/json_schema_examples/text-schema.json'), // eslint-disable-line global-require
ui: require('../../resources/json_schema_examples/text-uischema.json'), // eslint-disable-line global-require
data: {},
});
examples.push({
schema: require('../../resources/json_schema_examples/textarea-schema.json'), // eslint-disable-line global-require
ui: require('../../resources/json_schema_examples/textarea-uischema.json'), // eslint-disable-line global-require
data: {},
});
examples.push({
schema: require('../../resources/json_schema_examples/checkbox-schema.json'), // eslint-disable-line global-require
ui: {},
data: {},
});
examples.push({
schema: require('../../resources/json_schema_examples/date-schema.json'), // eslint-disable-line global-require
ui: require('../../resources/json_schema_examples/date-uischema.json'), // eslint-disable-line global-require
data: {},
});
examples.push({
schema: require('../../resources/json_schema_examples/dropdown-schema.json'), // eslint-disable-line global-require
ui: {},
data: require('../../resources/json_schema_examples/dropdown-exampledata.json'), // eslint-disable-line global-require
});
examples.push({
schema: require('../../resources/json_schema_examples/multiple-choice-schema.json'), // eslint-disable-line global-require
ui: require('../../resources/json_schema_examples/multiple-choice-uischema.json'), // eslint-disable-line global-require
data: {},
});
examples.push({
schema: require('../../resources/json_schema_examples/password-schema.json'), // eslint-disable-line global-require
ui: require('../../resources/json_schema_examples/password-uischema.json'), // eslint-disable-line global-require
data: {},
});
examples.push({
schema: require('../../resources/json_schema_examples/nested-schema.json'), // eslint-disable-line global-require
ui: {},
data: {},
});
examples.push({
schema: require('../../resources/json_schema_examples/typeahead-schema.json'), // eslint-disable-line global-require
ui: require('../../resources/json_schema_examples/typeahead-uischema.json'), // eslint-disable-line global-require
data: {},
});
type OwnProps = {
onSelect: Function;
};
export default function ExamplesTable({ onSelect }: OwnProps) {
function selectExample(index: number) {
onSelect(examples[index].schema, examples[index].ui, examples[index].data);
}
// Render the form in another div
const rows: object[] = examples.map((example, index) => {
return (
<tr>
<td>{example.schema.title}</td>
<td>{example.schema.description}</td>
<td>
<Button
kind="secondary"
size="sm"
onClick={() => selectExample(index)}
>
Load
</Button>
</td>
</tr>
);
});
return (
<Table size="lg">
<TableHead>
<TableRow>
<TableHeader key="name" title="Name">
Name
</TableHeader>
<TableHeader key="desc" title="Description">
Description
</TableHeader>
<TableHeader key="load" title="Load">
Insert
</TableHeader>
</TableRow>
</TableHead>
<TableBody>{rows}</TableBody>
</Table>
);
}

View File

@ -0,0 +1,405 @@
import React, { useCallback, useEffect, useState } from 'react';
import Editor from '@monaco-editor/react';
// eslint-disable-next-line import/no-extraneous-dependencies
import merge from 'lodash/merge';
import {
Column,
Grid,
TabList,
Tab,
TabPanels,
TabPanel,
Tabs,
TextInput,
Button,
Loading,
} from '@carbon/react';
import { useDebounce } from 'use-debounce';
import HttpService from '../../services/HttpService';
import ExamplesTable from './ExamplesTable';
import CustomForm from '../CustomForm';
import ErrorBoundary from '../ErrorBoundary';
type OwnProps = {
processModelId: string;
fileName: string;
onFileNameSet: (fileName: string) => void;
};
export default function ReactFormBuilder({
processModelId,
fileName,
onFileNameSet,
}: OwnProps) {
const SCHEMA_EXTENSION = '-schema.json';
const UI_EXTENSION = '-uischema.json';
const DATA_EXTENSION = '-exampledata.json';
const [fetchFailed, setFetchFailed] = useState<boolean>(false);
const [strSchema, setStrSchema] = useState<string>('');
const [debouncedStrSchema] = useDebounce(strSchema, 500);
const [strUI, setStrUI] = useState<string>('');
const [debouncedStrUI] = useDebounce(strUI, 500);
const [strFormData, setStrFormData] = useState<string>('');
const [debouncedFormData] = useDebounce(strFormData, 500);
const [postJsonSchema, setPostJsonSchema] = useState<object>({});
const [postJsonUI, setPostJsonUI] = useState<object>({});
const [formData, setFormData] = useState<object>({});
const [selectedIndex, setSelectedIndex] = useState(0);
const [errorMessage, setErrorMessage] = useState<string>('');
const [baseFileName, setBaseFileName] = useState<string>('');
const [newFileName, setNewFileName] = useState<string>('');
const saveFile = useCallback(
(file: File, create: boolean = false) => {
let httpMethod = 'PUT';
let url = `/process-models/${processModelId}/files`;
if (create) {
httpMethod = 'POST';
} else {
url += `/${file.name}`;
}
const submission = new FormData();
submission.append('file', file);
submission.append('fileName', file.name);
HttpService.makeCallToBackend({
path: url,
successCallback: () => {},
failureCallback: () => {}, // fixme: handle errors
httpMethod,
postBody: submission,
});
},
[processModelId]
);
const createFiles = (base: string) => {
saveFile(new File(['{}'], base + SCHEMA_EXTENSION), true);
saveFile(new File(['{}'], base + UI_EXTENSION), true);
saveFile(new File(['{}'], base + DATA_EXTENSION), true);
setBaseFileName(base);
onFileNameSet(base + SCHEMA_EXTENSION);
};
const isReady = () => {
return strSchema !== '' && strUI !== '' && strFormData !== '';
};
// Auto save schema changes
useEffect(() => {
if (baseFileName !== '') {
saveFile(new File([debouncedStrSchema], baseFileName + SCHEMA_EXTENSION));
}
}, [debouncedStrSchema, baseFileName, saveFile]);
// Auto save ui changes
useEffect(() => {
if (baseFileName !== '') {
saveFile(new File([debouncedStrUI], baseFileName + UI_EXTENSION));
}
}, [debouncedStrUI, baseFileName, saveFile]);
// Auto save example data changes
useEffect(() => {
if (baseFileName !== '') {
saveFile(new File([debouncedFormData], baseFileName + DATA_EXTENSION));
}
}, [debouncedFormData, baseFileName, saveFile]);
useEffect(() => {
/**
* we need to run the schema and ui through a backend call before rendering the form
* so it can handle certain server side changes, such as jinja rendering and populating dropdowns, etc.
*/
const url: string = '/tasks/prepare-form';
let schema = {};
let ui = {};
let data = {};
if (
debouncedFormData === '' ||
debouncedStrSchema === '' ||
debouncedStrUI === ''
) {
return;
}
try {
schema = JSON.parse(debouncedStrSchema);
} catch (e) {
setErrorMessage('Please check the Json Schema for errors.');
return;
}
try {
ui = JSON.parse(debouncedStrUI);
} catch (e) {
setErrorMessage('Please check the UI Settings for errors.');
return;
}
try {
data = JSON.parse(debouncedFormData);
} catch (e) {
setErrorMessage('Please check the Task Data for errors.');
return;
}
setErrorMessage('');
HttpService.makeCallToBackend({
path: url,
successCallback: (response: any) => {
setPostJsonSchema(response.form_schema);
setPostJsonUI(response.form_ui);
setErrorMessage('');
},
failureCallback: (error: any) => {
setErrorMessage(error.message);
}, // fixme: handle errors
httpMethod: 'POST',
postBody: {
form_schema: schema,
form_ui: ui,
task_data: data,
},
});
}, [debouncedStrSchema, debouncedStrUI, debouncedFormData]);
const handleTabChange = (evt: any) => {
setSelectedIndex(evt.selectedIndex);
};
function setJsonSchemaFromResponseJson(result: any) {
setStrSchema(result.file_contents);
}
function setJsonUiFromResponseJson(result: any) {
setStrUI(result.file_contents);
}
function setDataFromResponseJson(result: any) {
setStrFormData(result.file_contents);
try {
setFormData(JSON.parse(result.file_contents));
} catch (e) {
// todo: show error message
console.log('Error parsing JSON:', e);
}
}
function baseName(myFileName: string): string {
return myFileName.replace(SCHEMA_EXTENSION, '').replace(UI_EXTENSION, '');
}
function fetchSchema() {
HttpService.makeCallToBackend({
path: `/process-models/${processModelId}/files/${baseName(
fileName
)}${SCHEMA_EXTENSION}`,
successCallback: (response: any) => {
setJsonSchemaFromResponseJson(response);
setBaseFileName(baseName(fileName));
},
failureCallback: () => {
setFetchFailed(true);
},
});
}
function fetchUI() {
HttpService.makeCallToBackend({
path: `/process-models/${processModelId}/files/${baseName(
fileName
)}${UI_EXTENSION}`,
successCallback: setJsonUiFromResponseJson,
failureCallback: () => {},
});
}
function fetchExampleData() {
HttpService.makeCallToBackend({
path: `/process-models/${processModelId}/files/${baseName(
fileName
)}${DATA_EXTENSION}`,
successCallback: setDataFromResponseJson,
failureCallback: () => {},
});
}
function insertFields(schema: any, ui: any, data: any) {
setFormData(merge(formData, data));
setStrFormData(JSON.stringify(formData, null, 2));
const tempSchema = merge(JSON.parse(strSchema), schema);
setStrSchema(JSON.stringify(tempSchema, null, 2));
const tempUI = merge(JSON.parse(strUI), ui);
setStrUI(JSON.stringify(tempUI, null, 2));
}
function updateData(newData: object) {
setFormData(newData);
const newDataStr = JSON.stringify(newData, null, 2);
if (newDataStr !== strFormData) {
setStrFormData(newDataStr);
}
}
function updateDataFromStr(newDataStr: string) {
try {
const newData = JSON.parse(newDataStr);
setFormData(newData);
} catch (e) {
/* empty */
}
}
if (!isReady()) {
if (fileName !== '' && !fetchFailed) {
fetchExampleData();
fetchUI();
fetchSchema();
return (
<div style={{ height: 200 }}>
<Loading />
</div>
);
}
return (
<Grid fullWidth>
<Column sm={4} md={5} lg={8}>
<h2>Schema Name</h2>
<p>
Please provide a name for the Schema/Web Form you are about to
create...
</p>
<TextInput
id="file_name"
labelText="Name:"
value={newFileName}
onChange={(event: any) => {
setNewFileName(event.srcElement.value);
}}
size="sm"
/>
<p>The changes you make here will be automatically saved saved to:</p>
<ul>
<li>
{newFileName}
{SCHEMA_EXTENSION} (for the schema)
</li>
<li>
{newFileName}
{UI_EXTENSION} (for additional UI form settings)
</li>
<li>
{newFileName}
{DATA_EXTENSION} (for example data to test the form
</li>
</ul>
<Button
className="react-json-schema-form-submit-button"
type="submit"
onClick={() => {
createFiles(newFileName);
}}
>
Create Files
</Button>
</Column>
</Grid>
);
}
return (
<Grid fullWidth>
<Column sm={4} md={5} lg={8}>
<Tabs selectedIndex={selectedIndex} onChange={handleTabChange}>
<TabList aria-label="Editor Options">
<Tab>Json Schema</Tab>
<Tab>UI Settings</Tab>
<Tab>Data View</Tab>
<Tab>Examples</Tab>
</TabList>
<TabPanels>
<TabPanel>
<p>
The Json Schema describes the structure of the data you want to
collect, and what validation rules should be applied to each
field.
<a
target="new"
href="https://json-schema.org/learn/getting-started-step-by-step"
>
Read More
</a>
</p>
<Editor
height={600}
width="auto"
defaultLanguage="json"
value={strSchema}
onChange={(value) => setStrSchema(value || '')}
/>
</TabPanel>
<TabPanel>
<p>
These UI Settings augment the Json Schema, specifying how the
web form should be displayed.
<a
target="new"
href="https://rjsf-team.github.io/react-jsonschema-form/docs/"
>
Learn More.
</a>
</p>
<Editor
height={600}
width="auto"
defaultLanguage="json"
value={strUI}
onChange={(value) => setStrUI(value || '')}
/>
</TabPanel>
<TabPanel>
<p>
Data entered in the form to the right will appear below in the
same way it will be provided in the Task Data.
</p>
<Editor
height={600}
width="auto"
defaultLanguage="json"
value={strFormData}
onChange={(value: any) => updateDataFromStr(value || '')}
/>
</TabPanel>
<TabPanel>
<p>
If you are looking for a place to start, try adding these
example fields to your form and changing them to meet your
needs.
</p>
<ExamplesTable onSelect={insertFields} />
</TabPanel>
</TabPanels>
</Tabs>
</Column>
<Column sm={4} md={5} lg={8}>
<h2>Form Preview</h2>
<div>{errorMessage}</div>
<ErrorBoundary>
<CustomForm
id="custom_form"
formData={formData}
onChange={(e: any) => updateData(e.formData)}
schema={postJsonSchema}
uiSchema={postJsonUI}
/>
</ErrorBoundary>
</Column>
</Grid>
);
}

View File

@ -0,0 +1,27 @@
import { useEffect, useState } from 'react';
export function useFocusedTabStatus() {
// eslint-disable-next-line no-undef
const [isFocused, setIsFocused] = useState(true);
useEffect(() => {
function handleFocus() {
setIsFocused(true);
}
function handleBlur() {
setIsFocused(false);
}
window.addEventListener('focus', handleFocus);
window.addEventListener('blur', handleBlur);
// Calls onFocus when the window first loads
handleFocus();
// Specify how to clean up after this effect:
return () => {
window.removeEventListener('focus', handleFocus);
window.removeEventListener('blur', handleBlur);
};
}, []);
return isFocused;
}

View File

@ -6,8 +6,8 @@
}
.megacondensed {
padding-left: 0px;
margin-left: 0px;
padding-left: 0;
margin-left: 0;
}
/* defaults to 3rem, which isn't long sufficient for "elizabeth" */
@ -31,7 +31,7 @@
}
.cds--loading__stroke {
stroke: gray;
stroke: rgb(128, 128, 128);
}
/* make this a little less prominent so the actual human beings completing tasks stand out */
@ -709,7 +709,7 @@ hr {
.modal-dropdown {
height: 20rem;
width: "auto";
width: auto;
}
.task-data-details-header {
@ -730,6 +730,11 @@ hr {
display: none;
}
div.json-schema {
display: flex;
flex-direction: column;
}
.my-completed-forms-header {
font-style: italic;
}

View File

@ -445,3 +445,9 @@ export interface DataStore {
name: string;
type: string;
}
export interface JsonSchemaExample {
schema: any;
ui: any;
data: any;
}

View File

@ -0,0 +1,11 @@
{
"title": "Checkbox",
"description": "A super simple checkbox",
"properties": {
"done": {
"type": "boolean",
"title": "Done?",
"default": false
}
}
}

View File

@ -0,0 +1,13 @@
{
"title": "Date",
"description": "Create a date field with a date picker (delivery_date).",
"type": "object",
"properties": {
"delivery_date": {
"type": "string",
"format": "date",
"title": "Preferred Delivery Date",
"validationErrorMessage": "Date must be today's date or later"
}
}
}

View File

@ -0,0 +1,6 @@
{
"delivery_date": {
"ui:widget": "date",
"ui:help": "Specify the preferred delivery date for this service/product"
}
}

View File

@ -0,0 +1,16 @@
{
"fruits": [
{
"value": "apples",
"label": "Apples"
},
{
"value": "oranges",
"label": "Oranges"
},
{
"value": "bananas",
"label": "Bananas"
}
]
}

View File

@ -0,0 +1,14 @@
{
"title": "Dropdown list",
"description": "A dropdown list with options pulled form existing Task Data. IMPORTANT - Add 'fruits' to Task Data before using this component!!!",
"type": "object",
"properties": {
"favoriteFruit": {
"title": "Select your favorite fruit",
"type": "string",
"anyOf": [
"options_from_task_data_var:fruits"
]
}
}
}

View File

@ -0,0 +1,21 @@
{
"title": "Multiple Choice List",
"description": "Build a multiple choice list with a predefined list of options",
"type": "object",
"properties": {
"multipleChoicesList": {
"type": "array",
"title": "A multiple choices list",
"items": {
"type": "string",
"enum": [
"foo",
"bar",
"fuzz",
"qux"
]
},
"uniqueItems": true
}
}
}

View File

@ -0,0 +1,5 @@
{
"multipleChoicesList": {
"ui:widget": "checkboxes"
}
}

View File

@ -0,0 +1,29 @@
{
"title": "Nested Form / Repeating Section",
"description": "Allow the form submitter to add multiple entries for a set of fields.",
"type": "object",
"properties": {
"tasks": {
"type": "array",
"title": "Tasks",
"items": {
"type": "object",
"required": [
"title"
],
"properties": {
"title": {
"type": "string",
"title": "Title",
"description": "Please describe the task to complete"
},
"done": {
"type": "boolean",
"title": "Done?",
"default": false
}
}
}
}
}
}

View File

@ -0,0 +1,12 @@
{
"title": "Password",
"description": "A password field, with a min length of 3 and a help tip",
"type": "object",
"properties": {
"password": {
"type": "string",
"title": "Password",
"minLength": 3
}
}
}

View File

@ -0,0 +1,6 @@
{
"password": {
"ui:widget": "password",
"ui:help": "Hint: Make it strong!"
}
}

View File

@ -0,0 +1,15 @@
{
"title": "Text Field",
"description": "A simple text field that is required, has a default value, sets a placeholder, includes a description. (field name will be 'firstname')",
"type": "object",
"required": [
"firstName"
],
"properties": {
"firstName": {
"type": "string",
"title": "First name",
"default": "Chuck"
}
}
}

View File

@ -0,0 +1,10 @@
{
"firstName": {
"ui:autofocus": true,
"ui:emptyValue": "",
"ui:placeholder": "ui:emptyValue causes this field to always be valid despite being required",
"ui:autocomplete": "family-name",
"ui:enableMarkdownInDescription": true,
"ui:description": "Make text **bold** or *italic*. Take a look at other options [here](https://probablyup.com/markdown-to-jsx/)."
}
}

View File

@ -0,0 +1,11 @@
{
"title": "Text Area",
"description": "A larger resizable area to enter longer text. (field name will be 'bio')",
"type": "object",
"properties": {
"bio": {
"type": "string",
"title": "Bio"
}
}
}

View File

@ -0,0 +1,5 @@
{
"bio": {
"ui:widget": "textarea"
}
}

View File

@ -0,0 +1,12 @@
{
"title": "Auto Complete",
"description": "Connect a field to a data store to auto-complete entered values.",
"type": "object",
"properties": {
"city": {
"type": "string",
"format": "date",
"title": "Select a city"
}
}
}

View File

@ -0,0 +1,9 @@
{
"city": {
"ui:widget": "typeahead",
"ui:options": {
"category": "cities",
"itemFormat": "{name} ({state}, {country})"
}
}
}

View File

@ -40,12 +40,17 @@ export default function ArrayFieldItemTemplate<
const btnStyle: CSSProperties = {
marginBottom: '0.5em',
};
const mainColumnWidthSmall = 3;
const mainColumnWidthMedium = 4;
const mainColumnWidthLarge = 7;
const mainColumnWidthSmall = 2;
const mainColumnWidthMedium = 3;
const mainColumnWidthLarge = 6;
return (
<div className={className}>
<Grid condensed fullWidth>
<Column sm={1} md={1} lg={1}>
{ /* This column is empty on purpose, it helps shift the content and overcomes an abundance of effort
to keep grid content to be pushed hard to left at all times, and in this we really need a slight
indentation, at least, I felt so at the time. Could change my mind, as likely as not. */ }
</Column>
<Column
sm={mainColumnWidthSmall}
md={mainColumnWidthMedium}

View File

@ -5,7 +5,7 @@ import {
RJSFSchema,
StrictRJSFSchema,
} from '@rjsf/utils';
import MDEditor from '@uiw/react-md-editor';
/** The `DescriptionField` is the template to use to render the description of a field
*
* @param props - The `DescriptionFieldProps` for this component
@ -21,8 +21,12 @@ export default function DescriptionField<
}
if (typeof description === 'string') {
return (
<p id={id} className="field-description">
{description}
// const descriptionMarkdown =
// <span data-color-mode="light">
// </span>
<p id={id} className="field-description" data-color-mode="light">
<MDEditor.Markdown linkTarget="_blank" source={description} />
</p>
);
}

View File

@ -10,9 +10,24 @@
line-height: 18px;
letter-spacing: 0.16px;
color: #525252;
margin-bottom: 1em;
margin-bottom: 0em;
}
.rjsf .field-description .wmde-markdown {
font: inherit;
line-height: inherit;
color: inherit;
background-color: inherit;
}
.rjsf .field-description .wmde-markdown p {
font: inherit;
line-height: inherit;
color: inherit;
background-color: inherit;
}
/* for some reason it wraps the entire form using FieldTemplate.jsx, which is where we added the rjsf-field thing (which is only intended for fields, not entire forms. hence the double rjsf-field reference, only for rjsf-fields inside rjsf-fields, so we don't get double margin after the last field */
.rjsf .rjsf-field .rjsf-field {
margin-bottom: 2em;
@ -29,3 +44,31 @@
.rjsf .date-input {
padding-top: 8px;
}
.rjsf .rjsf-field label {
font-weight: bolder;
}
.rjsf .rjsf-field .required {
color: #660000;
}
.rjsf #root #root__description {
margin-bottom: 0.5em;
}
.rjsf .array-item {
padding-top: 1rem;
padding-bottom: 1rem;
margin-bottom: 1rem;
background: rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
}
.rjsf .array-item legend {
display: none;
}
.rjsf .array-item .rjsf-field {
margin-bottom: 0;
}

View File

@ -31,6 +31,7 @@ import Editor, { DiffEditor } from '@monaco-editor/react';
import MDEditor from '@uiw/react-md-editor';
import HttpService from '../services/HttpService';
import ReactDiagramEditor from '../components/ReactDiagramEditor';
import ReactFormBuilder from '../components/ReactFormBuilder/ReactFormBuilder';
import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
import useAPIError from '../hooks/UseApiError';
import {
@ -49,15 +50,21 @@ import ProcessSearch from '../components/ProcessSearch';
import { Notification } from '../components/Notification';
import { usePrompt } from '../hooks/UsePrompt';
import ActiveUsers from '../components/ActiveUsers';
import { useFocusedTabStatus } from '../hooks/useFocusedTabStatus';
export default function ProcessModelEditDiagram() {
const [showFileNameEditor, setShowFileNameEditor] = useState(false);
const isFocused = useFocusedTabStatus();
const handleShowFileNameEditor = () => setShowFileNameEditor(true);
const [processModel, setProcessModel] = useState<ProcessModel | null>(null);
const [diagramHasChanges, setDiagramHasChanges] = useState<boolean>(false);
const [scriptText, setScriptText] = useState<string>('');
const [scriptType, setScriptType] = useState<string>('');
const [fileEventBus, setFileEventBus] = useState<any>(null);
const [jsonScehmaFileName, setJsonScehmaFileName] = useState<string>('');
const [showJsonSchemaEditor, setShowJsonSchemaEditor] = useState(false);
const [scriptEventBus, setScriptEventBus] = useState<any>(null);
const [scriptModeling, setScriptModeling] = useState(null);
const [scriptElement, setScriptElement] = useState(null);
@ -368,19 +375,22 @@ export default function ProcessModelEditDiagram() {
});
};
const onJsonFilesRequested = (event: any) => {
const onJsonSchemaFilesRequested = (event: any) => {
setFileEventBus(event.eventBus);
const re = /.*[-.]schema.json/;
if (processModel) {
const jsonFiles = processModel.files.filter((f) => f.type === 'json');
const jsonFiles = processModel.files.filter((f) => f.name.match(re));
const options = jsonFiles.map((f) => {
return { label: f.name, value: f.name };
});
event.eventBus.fire('spiff.json_files.returned', { options });
event.eventBus.fire('spiff.json_schema_files.returned', { options });
} else {
console.error('There is no process Model.');
}
};
const onDmnFilesRequested = (event: any) => {
setFileEventBus(event.eventBus);
if (processModel) {
const dmnFiles = processModel.files.filter((f) => f.type === 'dmn');
const options: any[] = [];
@ -395,6 +405,27 @@ export default function ProcessModelEditDiagram() {
}
};
useEffect(() => {
const updateDiagramFiles = (pm: ProcessModel) => {
setProcessModel(pm);
const re = /.*[-.]schema.json/;
const jsonFiles = pm.files.filter((f) => f.name.match(re));
const options = jsonFiles.map((f) => {
return { label: f.name, value: f.name };
});
fileEventBus.fire('spiff.json_schema_files.returned', { options });
};
if (isFocused && fileEventBus) {
// Request the process model again, and manually fire off the
// commands to update the file lists for json and dmn files.
HttpService.makeCallToBackend({
path: `/${processModelPath}?include_file_references=true`,
successCallback: updateDiagramFiles,
});
}
}, [isFocused, fileEventBus, processModelPath]);
const getScriptUnitTestElements = (element: any) => {
const { extensionElements } = element.businessObject;
if (extensionElements && extensionElements.values.length > 0) {
@ -957,16 +988,45 @@ export default function ProcessModelEditDiagram() {
});
};
const onLaunchJsonEditor = (fileName: string) => {
const path = generatePath(
'/admin/process-models/:process_model_id/form/:file_name',
{
process_model_id: params.process_model_id,
file_name: fileName,
}
);
window.open(path);
const onLaunchJsonSchemaEditor = (
element: any,
fileName: string,
eventBus: any
) => {
setFileEventBus(eventBus);
setJsonScehmaFileName(fileName);
setShowJsonSchemaEditor(true);
};
const handleJsonScehmaEditorClose = () => {
fileEventBus.fire('spiff.jsonSchema.update', {
value: jsonScehmaFileName,
});
setShowJsonSchemaEditor(false);
};
const jsonSchemaEditor = () => {
if (!showJsonSchemaEditor) {
return null;
}
return (
<Modal
open={showJsonSchemaEditor}
modalHeading="Edit JSON Schema"
primaryButtonText="Close"
onRequestSubmit={handleJsonScehmaEditorClose}
onRequestClose={handleJsonScehmaEditorClose}
size="lg"
>
<ReactFormBuilder
processModelId={params.process_model_id || ''}
fileName={jsonScehmaFileName}
onFileNameSet={setJsonScehmaFileName}
/>
</Modal>
);
};
const onLaunchDmnEditor = (processId: string) => {
const file = findFileNameForReferenceId(processId, 'dmn');
if (file) {
@ -1023,8 +1083,8 @@ export default function ProcessModelEditDiagram() {
onServiceTasksRequested={onServiceTasksRequested}
onLaunchMarkdownEditor={onLaunchMarkdownEditor}
onLaunchBpmnEditor={onLaunchBpmnEditor}
onLaunchJsonEditor={onLaunchJsonEditor}
onJsonFilesRequested={onJsonFilesRequested}
onLaunchJsonSchemaEditor={onLaunchJsonSchemaEditor}
onJsonSchemaFilesRequested={onJsonSchemaFilesRequested}
onLaunchDmnEditor={onLaunchDmnEditor}
onDmnFilesRequested={onDmnFilesRequested}
onSearchProcessModels={onSearchProcessModels}
@ -1074,6 +1134,7 @@ export default function ProcessModelEditDiagram() {
{newFileNameBox()}
{scriptEditorAndTests()}
{markdownEditor()}
{jsonSchemaEditor()}
{processModelSelector()}
<div id="diagram-container" />
</>