mirror of
https://github.com/sartography/spiff-arena.git
synced 2025-01-12 18:44:14 +00:00
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:
parent
d99054b3a6
commit
948c633b2c
@ -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 ###
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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] = {}
|
||||
|
@ -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,
|
||||
|
38
spiffworkflow-frontend/package-lock.json
generated
38
spiffworkflow-frontend/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -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} />;
|
||||
// }
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
27
spiffworkflow-frontend/src/hooks/useFocusedTabStatus.tsx
Normal file
27
spiffworkflow-frontend/src/hooks/useFocusedTabStatus.tsx
Normal 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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -445,3 +445,9 @@ export interface DataStore {
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface JsonSchemaExample {
|
||||
schema: any;
|
||||
ui: any;
|
||||
data: any;
|
||||
}
|
||||
|
@ -0,0 +1,11 @@
|
||||
{
|
||||
"title": "Checkbox",
|
||||
"description": "A super simple checkbox",
|
||||
"properties": {
|
||||
"done": {
|
||||
"type": "boolean",
|
||||
"title": "Done?",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
{
|
||||
"delivery_date": {
|
||||
"ui:widget": "date",
|
||||
"ui:help": "Specify the preferred delivery date for this service/product"
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
{
|
||||
"fruits": [
|
||||
{
|
||||
"value": "apples",
|
||||
"label": "Apples"
|
||||
},
|
||||
{
|
||||
"value": "oranges",
|
||||
"label": "Oranges"
|
||||
},
|
||||
{
|
||||
"value": "bananas",
|
||||
"label": "Bananas"
|
||||
}
|
||||
]
|
||||
}
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
{
|
||||
"multipleChoicesList": {
|
||||
"ui:widget": "checkboxes"
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
{
|
||||
"password": {
|
||||
"ui:widget": "password",
|
||||
"ui:help": "Hint: Make it strong!"
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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/)."
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
{
|
||||
"bio": {
|
||||
"ui:widget": "textarea"
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
{
|
||||
"city": {
|
||||
"ui:widget": "typeahead",
|
||||
"ui:options": {
|
||||
"category": "cities",
|
||||
"itemFormat": "{name} ({state}, {country})"
|
||||
}
|
||||
}
|
||||
}
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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" />
|
||||
</>
|
||||
|
Loading…
x
Reference in New Issue
Block a user