mirror of
https://github.com/status-im/spiff-arena.git
synced 2025-02-23 06:58:10 +00:00
Feature/allow markdown in extension results (#435)
* allow markdown in extensions results w/ burnettk * fixed tests * moved our rjsf form to component so extensions can also use it w/ burnettk * added ability to create extensions that can download files w/ burnettk * added test for extensions-get-data endpoint w/ burnettk * make user optional when getting process instance reports * added extensions-get-data to elevated perm macro and raise an error if user is not specified when needed when running a report * fixed typeguard test * push extensions 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
e24c9a17a9
commit
a4af390118
@ -34,6 +34,7 @@ on:
|
|||||||
- feature/event-payloads-part-2
|
- feature/event-payloads-part-2
|
||||||
- feature/event-payload-migration-fix
|
- feature/event-payload-migration-fix
|
||||||
- spiffdemo
|
- spiffdemo
|
||||||
|
- feature/allow-markdown-in-extension-results
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create_frontend_docker_image:
|
create_frontend_docker_image:
|
||||||
|
@ -11,6 +11,7 @@ script_dir="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )"
|
|||||||
supported_session_types=$(grep -E '^(el)?if.*\<session_type\>.*==' "$0" | sed -E 's/.*== "([^"]+)".*/\1/' | tr '\n' ' ')
|
supported_session_types=$(grep -E '^(el)?if.*\<session_type\>.*==' "$0" | sed -E 's/.*== "([^"]+)".*/\1/' | tr '\n' ' ')
|
||||||
|
|
||||||
session_type="${1:-}"
|
session_type="${1:-}"
|
||||||
|
shift
|
||||||
if [[ -z "${session_type}" ]] || ! grep -qE "\<${session_type}\>" <<<"$supported_session_types"; then
|
if [[ -z "${session_type}" ]] || ! grep -qE "\<${session_type}\>" <<<"$supported_session_types"; then
|
||||||
if [[ -n "$session_type" ]]; then
|
if [[ -n "$session_type" ]]; then
|
||||||
>&2 echo "ERROR: Given session typeis not supported - ${session_type}"
|
>&2 echo "ERROR: Given session typeis not supported - ${session_type}"
|
||||||
@ -57,11 +58,11 @@ poetry install
|
|||||||
|
|
||||||
if [[ "${session_type}" == "tests" ]]; then
|
if [[ "${session_type}" == "tests" ]]; then
|
||||||
setup_db_for_ci
|
setup_db_for_ci
|
||||||
poetry run coverage run --parallel -m pytest
|
poetry run coverage run --parallel -m pytest "$@"
|
||||||
|
|
||||||
elif [[ "${session_type}" == "typeguard" ]]; then
|
elif [[ "${session_type}" == "typeguard" ]]; then
|
||||||
setup_db_for_ci
|
setup_db_for_ci
|
||||||
RUN_TYPEGUARD=true poetry run pytest
|
RUN_TYPEGUARD=true poetry run pytest "$@"
|
||||||
|
|
||||||
elif [[ "${session_type}" == "mypy" ]]; then
|
elif [[ "${session_type}" == "mypy" ]]; then
|
||||||
poetry run mypy src tests
|
poetry run mypy src tests
|
||||||
|
@ -9,21 +9,24 @@ set -o errtrace -o errexit -o nounset -o pipefail
|
|||||||
|
|
||||||
port="${SPIFFWORKFLOW_BACKEND_PORT:-7000}"
|
port="${SPIFFWORKFLOW_BACKEND_PORT:-7000}"
|
||||||
|
|
||||||
arg="${1:-}"
|
proces_model_dir="${1:-}"
|
||||||
if [[ "$arg" == "acceptance" ]]; then
|
if [[ "$proces_model_dir" == "acceptance" ]]; then
|
||||||
export SPIFFWORKFLOW_BACKEND_LOAD_FIXTURE_DATA=true
|
export SPIFFWORKFLOW_BACKEND_LOAD_FIXTURE_DATA=true
|
||||||
export SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME=acceptance_tests.yml
|
export SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME=acceptance_tests.yml
|
||||||
elif [[ "$arg" == "localopenid" ]]; then
|
SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR=$(./bin/find_sample_process_models)
|
||||||
|
elif [[ "$proces_model_dir" == "localopenid" ]]; then
|
||||||
export SPIFFWORKFLOW_BACKEND_OPEN_ID_SERVER_URL="http://localhost:$port/openid"
|
export SPIFFWORKFLOW_BACKEND_OPEN_ID_SERVER_URL="http://localhost:$port/openid"
|
||||||
export SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME="example.yml"
|
export SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME="example.yml"
|
||||||
|
SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR=$(./bin/find_sample_process_models)
|
||||||
|
else
|
||||||
|
SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR="$proces_model_dir"
|
||||||
fi
|
fi
|
||||||
|
export SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR
|
||||||
|
|
||||||
if [[ -z "${SPIFFWORKFLOW_BACKEND_ENV:-}" ]]; then
|
if [[ -z "${SPIFFWORKFLOW_BACKEND_ENV:-}" ]]; then
|
||||||
export SPIFFWORKFLOW_BACKEND_ENV=local_development
|
export SPIFFWORKFLOW_BACKEND_ENV=local_development
|
||||||
fi
|
fi
|
||||||
|
|
||||||
SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR=$(./bin/find_sample_process_models)
|
|
||||||
export SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR
|
|
||||||
|
|
||||||
# export FLASK_SESSION_SECRET_KEY="super_secret_key"
|
# export FLASK_SESSION_SECRET_KEY="super_secret_key"
|
||||||
export FLASK_SESSION_SECRET_KEY="e7711a3ba96c46c68e084a86952de16f"
|
export FLASK_SESSION_SECRET_KEY="e7711a3ba96c46c68e084a86952de16f"
|
||||||
|
@ -848,6 +848,28 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Workflow"
|
$ref: "#/components/schemas/Workflow"
|
||||||
|
|
||||||
|
/extensions-get-data/{query_params}:
|
||||||
|
parameters:
|
||||||
|
- name: query_params
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: The params required to run the extension. The first parameter must be the modified_process_model_identifier of the extension to run.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: path
|
||||||
|
get:
|
||||||
|
operationId: spiffworkflow_backend.routes.extensions_controller.extension_get_data
|
||||||
|
summary: Returns the metadata for a given extension
|
||||||
|
tags:
|
||||||
|
- Extensions
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Resulting extension metadata
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Workflow"
|
||||||
|
|
||||||
/process-models/{modified_process_model_identifier}/script-unit-tests:
|
/process-models/{modified_process_model_identifier}/script-unit-tests:
|
||||||
parameters:
|
parameters:
|
||||||
- name: modified_process_model_identifier
|
- name: modified_process_model_identifier
|
||||||
|
@ -17,5 +17,5 @@ SPIFFWORKFLOW_BACKEND_GIT_USERNAME = "sartography-automated-committer"
|
|||||||
SPIFFWORKFLOW_BACKEND_GIT_USER_EMAIL = f"{SPIFFWORKFLOW_BACKEND_GIT_USERNAME}@users.noreply.github.com"
|
SPIFFWORKFLOW_BACKEND_GIT_USER_EMAIL = f"{SPIFFWORKFLOW_BACKEND_GIT_USERNAME}@users.noreply.github.com"
|
||||||
|
|
||||||
SPIFFWORKFLOW_BACKEND_EXTENSIONS_API_ENABLED = (
|
SPIFFWORKFLOW_BACKEND_EXTENSIONS_API_ENABLED = (
|
||||||
environ.get("SPIFFWORKFLOW_BACKEND_EXTENSIONS_API_ENABLED", default="false")
|
environ.get("SPIFFWORKFLOW_BACKEND_EXTENSIONS_API_ENABLED", default="true")
|
||||||
) == "true"
|
) == "true"
|
||||||
|
@ -1,100 +1,58 @@
|
|||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import flask.wrappers
|
import flask.wrappers
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from flask import g
|
from flask import g
|
||||||
from flask import jsonify
|
from flask import jsonify
|
||||||
from flask import make_response
|
from flask import make_response
|
||||||
|
from flask.wrappers import Response
|
||||||
|
|
||||||
from spiffworkflow_backend.exceptions.api_error import ApiError
|
from spiffworkflow_backend.exceptions.api_error import ApiError
|
||||||
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
|
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
|
||||||
from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus
|
from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus
|
||||||
|
from spiffworkflow_backend.models.process_model import ProcessModelInfo
|
||||||
from spiffworkflow_backend.routes.process_api_blueprint import _get_process_model
|
from spiffworkflow_backend.routes.process_api_blueprint import _get_process_model
|
||||||
from spiffworkflow_backend.routes.process_api_blueprint import _un_modify_modified_process_model_id
|
from spiffworkflow_backend.routes.process_api_blueprint import _un_modify_modified_process_model_id
|
||||||
from spiffworkflow_backend.services.error_handling_service import ErrorHandlingService
|
from spiffworkflow_backend.services.error_handling_service import ErrorHandlingService
|
||||||
from spiffworkflow_backend.services.file_system_service import FileSystemService
|
from spiffworkflow_backend.services.file_system_service import FileSystemService
|
||||||
|
from spiffworkflow_backend.services.jinja_service import JinjaService
|
||||||
from spiffworkflow_backend.services.process_instance_processor import CustomBpmnScriptEngine
|
from spiffworkflow_backend.services.process_instance_processor import CustomBpmnScriptEngine
|
||||||
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor
|
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor
|
||||||
from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsAlreadyLockedError
|
from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsAlreadyLockedError
|
||||||
from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsNotEnqueuedError
|
from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsNotEnqueuedError
|
||||||
from spiffworkflow_backend.services.process_model_service import ProcessModelService
|
from spiffworkflow_backend.services.process_model_service import ProcessModelService
|
||||||
|
from spiffworkflow_backend.services.spec_file_service import SpecFileService
|
||||||
|
from spiffworkflow_backend.services.workflow_execution_service import WorkflowExecutionServiceError
|
||||||
|
|
||||||
|
|
||||||
def extension_run(
|
def extension_run(
|
||||||
modified_process_model_identifier: str,
|
modified_process_model_identifier: str,
|
||||||
body: dict | None = None,
|
body: dict | None = None,
|
||||||
) -> flask.wrappers.Response:
|
) -> flask.wrappers.Response:
|
||||||
_raise_unless_extensions_api_enabled()
|
_, result = _run_extension(modified_process_model_identifier, body)
|
||||||
|
return make_response(jsonify(result), 200)
|
||||||
|
|
||||||
process_model_identifier = _get_process_model_identifier(modified_process_model_identifier)
|
|
||||||
|
|
||||||
try:
|
def extension_get_data(
|
||||||
process_model = _get_process_model(process_model_identifier)
|
query_params: str,
|
||||||
except ApiError as ex:
|
) -> flask.wrappers.Response:
|
||||||
if ex.error_code == "process_model_cannot_be_found":
|
modified_process_model_identifier, *additional_args = query_params.split("/")
|
||||||
raise ApiError(
|
process_model, result = _run_extension(
|
||||||
error_code="invalid_process_model_extension",
|
modified_process_model_identifier, {"extension_input": {"additional_args": additional_args}}
|
||||||
message=(
|
|
||||||
f"Process Model '{process_model_identifier}' cannot be run as an extension. It must be in the"
|
|
||||||
" correct Process Group:"
|
|
||||||
f" {current_app.config['SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX']}"
|
|
||||||
),
|
|
||||||
status_code=403,
|
|
||||||
) from ex
|
|
||||||
raise ex
|
|
||||||
|
|
||||||
if process_model.primary_file_name is None:
|
|
||||||
raise ApiError(
|
|
||||||
error_code="process_model_missing_primary_bpmn_file",
|
|
||||||
message=(
|
|
||||||
f"Process Model '{process_model_identifier}' does not have a primary"
|
|
||||||
" bpmn file. One must be set in order to instantiate this model."
|
|
||||||
),
|
|
||||||
status_code=400,
|
|
||||||
)
|
)
|
||||||
|
response_schema = json.loads(FileSystemService.get_data(process_model, "response_schema.json"))
|
||||||
process_instance = ProcessInstanceModel(
|
headers = response_schema.get("headers", None)
|
||||||
status=ProcessInstanceStatus.not_started.value,
|
mimetype = response_schema.get("mimetype", None)
|
||||||
process_initiator_id=g.user.id,
|
data_extraction_path = response_schema.get("data_extraction_path", "").split(".")
|
||||||
process_model_identifier=process_model.id,
|
contents = _extract_data(data_extraction_path, result["task_data"])
|
||||||
process_model_display_name=process_model.display_name,
|
response = Response(
|
||||||
persistence_level="none",
|
str(contents),
|
||||||
|
mimetype=mimetype,
|
||||||
|
headers=headers,
|
||||||
|
status=200,
|
||||||
)
|
)
|
||||||
|
return response
|
||||||
processor = None
|
|
||||||
try:
|
|
||||||
processor = ProcessInstanceProcessor(
|
|
||||||
process_instance, script_engine=CustomBpmnScriptEngine(use_restricted_script_engine=False)
|
|
||||||
)
|
|
||||||
if body and "extension_input" in body:
|
|
||||||
processor.do_engine_steps(save=False, execution_strategy_name="one_at_a_time")
|
|
||||||
next_task = processor.next_task()
|
|
||||||
next_task.update_data(body["extension_input"])
|
|
||||||
processor.do_engine_steps(save=False, execution_strategy_name="greedy")
|
|
||||||
except (
|
|
||||||
ApiError,
|
|
||||||
ProcessInstanceIsNotEnqueuedError,
|
|
||||||
ProcessInstanceIsAlreadyLockedError,
|
|
||||||
) as e:
|
|
||||||
ErrorHandlingService.handle_error(process_instance, e)
|
|
||||||
raise e
|
|
||||||
except Exception as e:
|
|
||||||
ErrorHandlingService.handle_error(process_instance, e)
|
|
||||||
# FIXME: this is going to point someone to the wrong task - it's misinformation for errors in sub-processes.
|
|
||||||
# we need to recurse through all last tasks if the last task is a call activity or subprocess.
|
|
||||||
if processor is not None:
|
|
||||||
task = processor.bpmn_process_instance.last_task
|
|
||||||
raise ApiError.from_task(
|
|
||||||
error_code="unknown_exception",
|
|
||||||
message=f"An unknown error occurred. Original error: {e}",
|
|
||||||
status_code=400,
|
|
||||||
task=task,
|
|
||||||
) from e
|
|
||||||
raise e
|
|
||||||
|
|
||||||
task_data = {}
|
|
||||||
if processor is not None:
|
|
||||||
task_data = processor.get_data()
|
|
||||||
|
|
||||||
return make_response(jsonify(task_data), 200)
|
|
||||||
|
|
||||||
|
|
||||||
def extension_list() -> flask.wrappers.Response:
|
def extension_list() -> flask.wrappers.Response:
|
||||||
@ -124,6 +82,105 @@ def extension_show(
|
|||||||
return make_response(jsonify(process_model), 200)
|
return make_response(jsonify(process_model), 200)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_data(keys: list[str], data: Any) -> Any:
|
||||||
|
if len(keys) > 0 and isinstance(data, dict) and keys[0] in data:
|
||||||
|
return _extract_data(keys[1:], data[keys[0]])
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _run_extension(
|
||||||
|
modified_process_model_identifier: str,
|
||||||
|
body: dict | None = None,
|
||||||
|
) -> tuple[ProcessModelInfo, dict]:
|
||||||
|
_raise_unless_extensions_api_enabled()
|
||||||
|
|
||||||
|
process_model_identifier = _get_process_model_identifier(modified_process_model_identifier)
|
||||||
|
|
||||||
|
try:
|
||||||
|
process_model = _get_process_model(process_model_identifier)
|
||||||
|
except ApiError as ex:
|
||||||
|
if ex.error_code == "process_model_cannot_be_found":
|
||||||
|
raise ApiError(
|
||||||
|
error_code="invalid_process_model_extension",
|
||||||
|
message=(
|
||||||
|
f"Process Model '{process_model_identifier}' cannot be run as an extension. It must be in the"
|
||||||
|
" correct Process Group:"
|
||||||
|
f" {current_app.config['SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX']}"
|
||||||
|
),
|
||||||
|
status_code=403,
|
||||||
|
) from ex
|
||||||
|
raise ex
|
||||||
|
|
||||||
|
if process_model.primary_file_name is None:
|
||||||
|
raise ApiError(
|
||||||
|
error_code="process_model_missing_primary_bpmn_file",
|
||||||
|
message=(
|
||||||
|
f"Process Model '{process_model_identifier}' does not have a primary"
|
||||||
|
" bpmn file. One must be set in order to instantiate this model."
|
||||||
|
),
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
ui_schema_page_definition = None
|
||||||
|
if body and "ui_schema_page_definition" in body:
|
||||||
|
ui_schema_page_definition = body["ui_schema_page_definition"]
|
||||||
|
|
||||||
|
process_instance = ProcessInstanceModel(
|
||||||
|
status=ProcessInstanceStatus.not_started.value,
|
||||||
|
process_initiator_id=g.user.id,
|
||||||
|
process_model_identifier=process_model.id,
|
||||||
|
process_model_display_name=process_model.display_name,
|
||||||
|
persistence_level="none",
|
||||||
|
)
|
||||||
|
|
||||||
|
processor = None
|
||||||
|
try:
|
||||||
|
processor = ProcessInstanceProcessor(
|
||||||
|
process_instance, script_engine=CustomBpmnScriptEngine(use_restricted_script_engine=False)
|
||||||
|
)
|
||||||
|
if body and "extension_input" in body:
|
||||||
|
processor.do_engine_steps(save=False, execution_strategy_name="one_at_a_time")
|
||||||
|
next_task = processor.next_task()
|
||||||
|
next_task.update_data(body["extension_input"])
|
||||||
|
processor.do_engine_steps(save=False, execution_strategy_name="greedy")
|
||||||
|
except (
|
||||||
|
ApiError,
|
||||||
|
ProcessInstanceIsNotEnqueuedError,
|
||||||
|
ProcessInstanceIsAlreadyLockedError,
|
||||||
|
WorkflowExecutionServiceError,
|
||||||
|
) as e:
|
||||||
|
ErrorHandlingService.handle_error(process_instance, e)
|
||||||
|
raise e
|
||||||
|
except Exception as e:
|
||||||
|
ErrorHandlingService.handle_error(process_instance, e)
|
||||||
|
# FIXME: this is going to point someone to the wrong task - it's misinformation for errors in sub-processes.
|
||||||
|
# we need to recurse through all last tasks if the last task is a call activity or subprocess.
|
||||||
|
if processor is not None:
|
||||||
|
task = processor.bpmn_process_instance.last_task
|
||||||
|
raise ApiError.from_task(
|
||||||
|
error_code="unknown_exception",
|
||||||
|
message=f"An unknown error occurred. Original error: {e}",
|
||||||
|
status_code=400,
|
||||||
|
task=task,
|
||||||
|
) from e
|
||||||
|
raise e
|
||||||
|
|
||||||
|
task_data = {}
|
||||||
|
if processor is not None:
|
||||||
|
task_data = processor.get_data()
|
||||||
|
result: dict[str, Any] = {"task_data": task_data}
|
||||||
|
|
||||||
|
if ui_schema_page_definition:
|
||||||
|
if "results_markdown_filename" in ui_schema_page_definition:
|
||||||
|
file_contents = SpecFileService.get_data(
|
||||||
|
process_model, ui_schema_page_definition["results_markdown_filename"]
|
||||||
|
).decode("utf-8")
|
||||||
|
form_contents = JinjaService.render_jinja_template(file_contents, task_data=task_data)
|
||||||
|
result["rendered_results_markdown"] = form_contents
|
||||||
|
|
||||||
|
return (process_model, result)
|
||||||
|
|
||||||
|
|
||||||
def _raise_unless_extensions_api_enabled() -> None:
|
def _raise_unless_extensions_api_enabled() -> None:
|
||||||
if not current_app.config["SPIFFWORKFLOW_BACKEND_EXTENSIONS_API_ENABLED"]:
|
if not current_app.config["SPIFFWORKFLOW_BACKEND_EXTENSIONS_API_ENABLED"]:
|
||||||
raise ApiError(
|
raise ApiError(
|
||||||
|
@ -851,7 +851,7 @@ def _prepare_form_data(form_file: str, task_model: TaskModel, process_model: Pro
|
|||||||
|
|
||||||
file_contents = SpecFileService.get_data(process_model, form_file).decode("utf-8")
|
file_contents = SpecFileService.get_data(process_model, form_file).decode("utf-8")
|
||||||
try:
|
try:
|
||||||
form_contents = JinjaService.render_jinja_template(file_contents, task_model)
|
form_contents = JinjaService.render_jinja_template(file_contents, task=task_model)
|
||||||
try:
|
try:
|
||||||
# form_contents is a str
|
# form_contents is a str
|
||||||
hot_dict: dict = json.loads(form_contents)
|
hot_dict: dict = json.loads(form_contents)
|
||||||
|
@ -59,7 +59,9 @@ def verify_token(token: str | None = None, force_run: bool | None = False) -> No
|
|||||||
token = request.headers["Authorization"].removeprefix("Bearer ")
|
token = request.headers["Authorization"].removeprefix("Bearer ")
|
||||||
|
|
||||||
if not token and "access_token" in request.cookies:
|
if not token and "access_token" in request.cookies:
|
||||||
if request.path.startswith(f"{V1_API_PATH_PREFIX}/process-data-file-download/"):
|
if request.path.startswith(f"{V1_API_PATH_PREFIX}/process-data-file-download/") or request.path.startswith(
|
||||||
|
f"{V1_API_PATH_PREFIX}/extensions-get-data/"
|
||||||
|
):
|
||||||
token = request.cookies["access_token"]
|
token = request.cookies["access_token"]
|
||||||
|
|
||||||
# This should never be set here but just in case
|
# This should never be set here but just in case
|
||||||
|
@ -549,6 +549,7 @@ class AuthorizationService:
|
|||||||
permissions_to_assign.append(PermissionToAssign(permission="create", target_uri="/send-event/*"))
|
permissions_to_assign.append(PermissionToAssign(permission="create", target_uri="/send-event/*"))
|
||||||
permissions_to_assign.append(PermissionToAssign(permission="create", target_uri="/task-complete/*"))
|
permissions_to_assign.append(PermissionToAssign(permission="create", target_uri="/task-complete/*"))
|
||||||
permissions_to_assign.append(PermissionToAssign(permission="create", target_uri="/extensions/*"))
|
permissions_to_assign.append(PermissionToAssign(permission="create", target_uri="/extensions/*"))
|
||||||
|
permissions_to_assign.append(PermissionToAssign(permission="read", target_uri="/extensions-get-data/*"))
|
||||||
|
|
||||||
# read comes from PG and PM ALL permissions as well
|
# read comes from PG and PM ALL permissions as well
|
||||||
permissions_to_assign.append(PermissionToAssign(permission="create", target_uri="/task-assign/*"))
|
permissions_to_assign.append(PermissionToAssign(permission="create", target_uri="/task-assign/*"))
|
||||||
|
@ -56,17 +56,26 @@ class JinjaService:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def render_jinja_template(cls, unprocessed_template: str, task: TaskModel | SpiffTask) -> str:
|
def render_jinja_template(
|
||||||
|
cls, unprocessed_template: str, task: TaskModel | SpiffTask | None = None, task_data: dict | None = None
|
||||||
|
) -> str:
|
||||||
jinja_environment = jinja2.Environment(autoescape=True, lstrip_blocks=True, trim_blocks=True)
|
jinja_environment = jinja2.Environment(autoescape=True, lstrip_blocks=True, trim_blocks=True)
|
||||||
jinja_environment.filters.update(JinjaHelpers.get_helper_mapping())
|
jinja_environment.filters.update(JinjaHelpers.get_helper_mapping())
|
||||||
try:
|
try:
|
||||||
template = jinja_environment.from_string(unprocessed_template)
|
template = jinja_environment.from_string(unprocessed_template)
|
||||||
if isinstance(task, TaskModel):
|
if task_data is not None:
|
||||||
|
data = task_data
|
||||||
|
elif isinstance(task, TaskModel):
|
||||||
data = task.get_data()
|
data = task.get_data()
|
||||||
else:
|
elif task is not None:
|
||||||
data = task.data
|
data = task.data
|
||||||
|
else:
|
||||||
|
raise ValueError("No task or task data provided to render_jinja_template")
|
||||||
|
|
||||||
return template.render(**data, **JinjaHelpers.get_helper_mapping())
|
return template.render(**data, **JinjaHelpers.get_helper_mapping())
|
||||||
except jinja2.exceptions.TemplateError as template_error:
|
except jinja2.exceptions.TemplateError as template_error:
|
||||||
|
if task is None:
|
||||||
|
raise template_error
|
||||||
if isinstance(task, TaskModel):
|
if isinstance(task, TaskModel):
|
||||||
wfe = TaskModelError(str(template_error), task_model=task, exception=template_error)
|
wfe = TaskModelError(str(template_error), task_model=task, exception=template_error)
|
||||||
else:
|
else:
|
||||||
@ -77,6 +86,8 @@ class JinjaService:
|
|||||||
wfe.add_note("Jinja2 template errors can happen when trying to display task data")
|
wfe.add_note("Jinja2 template errors can happen when trying to display task data")
|
||||||
raise wfe from template_error
|
raise wfe from template_error
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
if task is None:
|
||||||
|
raise error
|
||||||
_type, _value, tb = exc_info()
|
_type, _value, tb = exc_info()
|
||||||
if isinstance(task, TaskModel):
|
if isinstance(task, TaskModel):
|
||||||
wfe = TaskModelError(str(error), task_model=task, exception=error)
|
wfe = TaskModelError(str(error), task_model=task, exception=error)
|
||||||
|
@ -35,6 +35,10 @@ class ProcessInstanceReportMetadataInvalidError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessInstanceReportCannotBeRunError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ProcessInstanceReportService:
|
class ProcessInstanceReportService:
|
||||||
@classmethod
|
@classmethod
|
||||||
def system_metadata_map(cls, metadata_key: str) -> ReportMetadata | None:
|
def system_metadata_map(cls, metadata_key: str) -> ReportMetadata | None:
|
||||||
@ -369,7 +373,7 @@ class ProcessInstanceReportService:
|
|||||||
def run_process_instance_report(
|
def run_process_instance_report(
|
||||||
cls,
|
cls,
|
||||||
report_metadata: ReportMetadata,
|
report_metadata: ReportMetadata,
|
||||||
user: UserModel,
|
user: UserModel | None = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
per_page: int = 100,
|
per_page: int = 100,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@ -436,6 +440,10 @@ class ProcessInstanceReportService:
|
|||||||
and not instances_with_tasks_waiting_for_me
|
and not instances_with_tasks_waiting_for_me
|
||||||
and with_relation_to_me is True
|
and with_relation_to_me is True
|
||||||
):
|
):
|
||||||
|
if user is None:
|
||||||
|
raise ProcessInstanceReportCannotBeRunError(
|
||||||
|
"A user must be specified to run report with with_relation_to_me"
|
||||||
|
)
|
||||||
process_instance_query = process_instance_query.outerjoin(HumanTaskModel).outerjoin(
|
process_instance_query = process_instance_query.outerjoin(HumanTaskModel).outerjoin(
|
||||||
HumanTaskUserModel,
|
HumanTaskUserModel,
|
||||||
and_(
|
and_(
|
||||||
@ -460,6 +468,10 @@ class ProcessInstanceReportService:
|
|||||||
human_task_already_joined = False
|
human_task_already_joined = False
|
||||||
|
|
||||||
if instances_with_tasks_completed_by_me is True:
|
if instances_with_tasks_completed_by_me is True:
|
||||||
|
if user is None:
|
||||||
|
raise ProcessInstanceReportCannotBeRunError(
|
||||||
|
"A user must be specified to run report with instances_with_tasks_completed_by_me."
|
||||||
|
)
|
||||||
process_instance_query = process_instance_query.filter(
|
process_instance_query = process_instance_query.filter(
|
||||||
ProcessInstanceModel.process_initiator_id != user.id
|
ProcessInstanceModel.process_initiator_id != user.id
|
||||||
)
|
)
|
||||||
@ -475,6 +487,10 @@ class ProcessInstanceReportService:
|
|||||||
# this excludes some tasks you can complete, because that's the way the requirements were described.
|
# this excludes some tasks you can complete, because that's the way the requirements were described.
|
||||||
# if it's assigned to one of your groups, it does not get returned by this query.
|
# if it's assigned to one of your groups, it does not get returned by this query.
|
||||||
if instances_with_tasks_waiting_for_me is True:
|
if instances_with_tasks_waiting_for_me is True:
|
||||||
|
if user is None:
|
||||||
|
raise ProcessInstanceReportCannotBeRunError(
|
||||||
|
"A user must be specified to run report with instances_with_tasks_waiting_for_me."
|
||||||
|
)
|
||||||
process_instance_query = process_instance_query.filter(
|
process_instance_query = process_instance_query.filter(
|
||||||
ProcessInstanceModel.process_initiator_id != user.id
|
ProcessInstanceModel.process_initiator_id != user.id
|
||||||
)
|
)
|
||||||
@ -493,6 +509,10 @@ class ProcessInstanceReportService:
|
|||||||
restrict_human_tasks_to_user = user
|
restrict_human_tasks_to_user = user
|
||||||
|
|
||||||
if user_group_identifier is not None:
|
if user_group_identifier is not None:
|
||||||
|
if user is None:
|
||||||
|
raise ProcessInstanceReportCannotBeRunError(
|
||||||
|
"A user must be specified to run report with a group identifier."
|
||||||
|
)
|
||||||
group_model_join_conditions = [GroupModel.id == HumanTaskModel.lane_assignment_id]
|
group_model_join_conditions = [GroupModel.id == HumanTaskModel.lane_assignment_id]
|
||||||
if user_group_identifier:
|
if user_group_identifier:
|
||||||
group_model_join_conditions.append(GroupModel.identifier == user_group_identifier)
|
group_model_join_conditions.append(GroupModel.identifier == user_group_identifier)
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"headers": {"Content-disposition": "attachment; filename=metadata_export.csv"},
|
||||||
|
"mimetype": "text/csv",
|
||||||
|
"data_extraction_path": "pi_json.id"
|
||||||
|
}
|
@ -33,6 +33,7 @@ class TestExtensionsController(BaseTest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
expected_task_data = {
|
expected_task_data = {
|
||||||
|
"task_data": {
|
||||||
"Mike": "Awesome",
|
"Mike": "Awesome",
|
||||||
"my_var": "Hello World",
|
"my_var": "Hello World",
|
||||||
"person": "Kevin",
|
"person": "Kevin",
|
||||||
@ -40,6 +41,7 @@ class TestExtensionsController(BaseTest):
|
|||||||
"wonderfulness": "Very wonderful",
|
"wonderfulness": "Very wonderful",
|
||||||
"OUR_AWESOME_INPUT": "the awesome value",
|
"OUR_AWESOME_INPUT": "the awesome value",
|
||||||
}
|
}
|
||||||
|
}
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json is not None
|
assert response.json is not None
|
||||||
assert response.json == expected_task_data
|
assert response.json == expected_task_data
|
||||||
@ -107,6 +109,7 @@ class TestExtensionsController(BaseTest):
|
|||||||
process_group_id="extensions",
|
process_group_id="extensions",
|
||||||
process_model_id="script_task_with_import",
|
process_model_id="script_task_with_import",
|
||||||
bpmn_file_location="script_task_with_import",
|
bpmn_file_location="script_task_with_import",
|
||||||
|
bpmn_file_name="script_task_with_import.bpmn",
|
||||||
)
|
)
|
||||||
|
|
||||||
# we need a process instance in the database so the scriptTask can work
|
# we need a process instance in the database so the scriptTask can work
|
||||||
@ -118,6 +121,36 @@ class TestExtensionsController(BaseTest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.json is not None
|
assert response.json is not None
|
||||||
assert "pi_json" in response.json
|
assert "task_data" in response.json
|
||||||
assert "id" in response.json["pi_json"]
|
task_data = response.json["task_data"]
|
||||||
assert re.match(r"^\d+$", str(response.json["pi_json"]["id"]))
|
assert "pi_json" in task_data
|
||||||
|
assert "id" in task_data["pi_json"]
|
||||||
|
assert re.match(r"^\d+$", str(task_data["pi_json"]["id"]))
|
||||||
|
|
||||||
|
def test_extension_data_get_can_return_proper_response(
|
||||||
|
self,
|
||||||
|
app: Flask,
|
||||||
|
client: FlaskClient,
|
||||||
|
with_db_and_bpmn_file_cleanup: None,
|
||||||
|
with_super_admin_user: UserModel,
|
||||||
|
) -> None:
|
||||||
|
with self.app_config_mock(app, "SPIFFWORKFLOW_BACKEND_EXTENSIONS_API_ENABLED", True):
|
||||||
|
process_model = self.create_group_and_model_with_bpmn(
|
||||||
|
client=client,
|
||||||
|
user=with_super_admin_user,
|
||||||
|
process_group_id="extensions",
|
||||||
|
process_model_id="script_task_with_import",
|
||||||
|
bpmn_file_location="script_task_with_import",
|
||||||
|
)
|
||||||
|
|
||||||
|
# we need a process instance in the database so the scriptTask can work
|
||||||
|
self.create_process_instance_from_process_model(process_model, user=with_super_admin_user)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/v1.0/extensions-get-data/{self.modify_process_identifier_for_path_param(process_model.id)}",
|
||||||
|
headers=self.logged_in_headers(with_super_admin_user),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.mimetype == "text/csv"
|
||||||
|
assert response.headers[0] == ("Content-disposition", "attachment; filename=metadata_export.csv")
|
||||||
|
assert re.match(r"\d+", response.text)
|
||||||
|
@ -319,8 +319,9 @@ class TestAuthorizationService(BaseTest):
|
|||||||
("/can-run-privileged-script/*", "create"),
|
("/can-run-privileged-script/*", "create"),
|
||||||
("/data-stores/*", "read"),
|
("/data-stores/*", "read"),
|
||||||
("/debug/*", "create"),
|
("/debug/*", "create"),
|
||||||
("/extensions/*", "create"),
|
|
||||||
("/event-error-details/*", "read"),
|
("/event-error-details/*", "read"),
|
||||||
|
("/extensions-get-data/*", "read"),
|
||||||
|
("/extensions/*", "create"),
|
||||||
("/logs/*", "read"),
|
("/logs/*", "read"),
|
||||||
("/messages", "read"),
|
("/messages", "read"),
|
||||||
("/messages/*", "create"),
|
("/messages/*", "create"),
|
||||||
|
210
spiffworkflow-frontend/src/components/CustomForm.tsx
Normal file
210
spiffworkflow-frontend/src/components/CustomForm.tsx
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import validator from '@rjsf/validator-ajv8';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { Form } from '../rjsf/carbon_theme';
|
||||||
|
import { DATE_RANGE_DELIMITER } from '../config';
|
||||||
|
import DateRangePickerWidget from '../rjsf/custom_widgets/DateRangePicker/DateRangePickerWidget';
|
||||||
|
import TypeaheadWidget from '../rjsf/custom_widgets/TypeaheadWidget/TypeaheadWidget';
|
||||||
|
|
||||||
|
type OwnProps = {
|
||||||
|
id: string;
|
||||||
|
formData: any;
|
||||||
|
schema: any;
|
||||||
|
uiSchema: any;
|
||||||
|
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange?: any;
|
||||||
|
onSubmit?: any;
|
||||||
|
children?: ReactNode;
|
||||||
|
noValidate?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CustomForm({
|
||||||
|
id,
|
||||||
|
formData,
|
||||||
|
schema,
|
||||||
|
uiSchema,
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
children,
|
||||||
|
noValidate = false,
|
||||||
|
}: OwnProps) {
|
||||||
|
const rjsfWidgets = {
|
||||||
|
typeahead: TypeaheadWidget,
|
||||||
|
'date-range': DateRangePickerWidget,
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateString = (dateString?: string) => {
|
||||||
|
let dateObject = new Date();
|
||||||
|
if (dateString) {
|
||||||
|
dateObject = new Date(dateString);
|
||||||
|
}
|
||||||
|
return dateObject.toISOString().split('T')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkFieldComparisons = (
|
||||||
|
formDataToCheck: any,
|
||||||
|
propertyKey: string,
|
||||||
|
minimumDateCheck: string,
|
||||||
|
formattedDateString: string,
|
||||||
|
errors: any,
|
||||||
|
jsonSchema: any
|
||||||
|
) => {
|
||||||
|
// field format:
|
||||||
|
// field:[field_name_to_use]
|
||||||
|
//
|
||||||
|
// if field is a range:
|
||||||
|
// field:[field_name_to_use]:[start or end]
|
||||||
|
//
|
||||||
|
// defaults to "start" in all cases
|
||||||
|
const [_, fieldIdentifierToCompareWith, startOrEnd] =
|
||||||
|
minimumDateCheck.split(':');
|
||||||
|
if (!(fieldIdentifierToCompareWith in formDataToCheck)) {
|
||||||
|
errors[propertyKey].addError(
|
||||||
|
`was supposed to be compared against '${fieldIdentifierToCompareWith}' but it either doesn't have a value or does not exist`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawDateToCompareWith = formDataToCheck[fieldIdentifierToCompareWith];
|
||||||
|
if (!rawDateToCompareWith) {
|
||||||
|
errors[propertyKey].addError(
|
||||||
|
`was supposed to be compared against '${fieldIdentifierToCompareWith}' but that field did not have a value`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [startDate, endDate] =
|
||||||
|
rawDateToCompareWith.split(DATE_RANGE_DELIMITER);
|
||||||
|
let dateToCompareWith = startDate;
|
||||||
|
if (startOrEnd && startOrEnd === 'end') {
|
||||||
|
dateToCompareWith = endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dateToCompareWith) {
|
||||||
|
const errorMessage = `was supposed to be compared against '${[
|
||||||
|
fieldIdentifierToCompareWith,
|
||||||
|
startOrEnd,
|
||||||
|
].join(':')}' but that field did not have a value`;
|
||||||
|
errors[propertyKey].addError(errorMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStringToCompareWith = formatDateString(dateToCompareWith);
|
||||||
|
if (dateStringToCompareWith > formattedDateString) {
|
||||||
|
let fieldToCompareWithTitle = fieldIdentifierToCompareWith;
|
||||||
|
if (
|
||||||
|
fieldIdentifierToCompareWith in jsonSchema.properties &&
|
||||||
|
jsonSchema.properties[fieldIdentifierToCompareWith].title
|
||||||
|
) {
|
||||||
|
fieldToCompareWithTitle =
|
||||||
|
jsonSchema.properties[fieldIdentifierToCompareWith].title;
|
||||||
|
}
|
||||||
|
errors[propertyKey].addError(
|
||||||
|
`must be equal to or greater than '${fieldToCompareWithTitle}'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkMinimumDate = (
|
||||||
|
formDataToCheck: any,
|
||||||
|
propertyKey: string,
|
||||||
|
propertyMetadata: any,
|
||||||
|
errors: any,
|
||||||
|
jsonSchema: any
|
||||||
|
) => {
|
||||||
|
// can be either "today" or another field
|
||||||
|
let dateString = formDataToCheck[propertyKey];
|
||||||
|
if (dateString) {
|
||||||
|
if (typeof dateString === 'string') {
|
||||||
|
// in the case of date ranges, just take the start date and check that
|
||||||
|
[dateString] = dateString.split(DATE_RANGE_DELIMITER);
|
||||||
|
}
|
||||||
|
const formattedDateString = formatDateString(dateString);
|
||||||
|
const minimumDateChecks = propertyMetadata.minimumDate.split(',');
|
||||||
|
minimumDateChecks.forEach((mdc: string) => {
|
||||||
|
if (mdc === 'today') {
|
||||||
|
const dateTodayString = formatDateString();
|
||||||
|
if (dateTodayString > formattedDateString) {
|
||||||
|
errors[propertyKey].addError('must be today or after');
|
||||||
|
}
|
||||||
|
} else if (mdc.startsWith('field:')) {
|
||||||
|
checkFieldComparisons(
|
||||||
|
formDataToCheck,
|
||||||
|
propertyKey,
|
||||||
|
mdc,
|
||||||
|
formattedDateString,
|
||||||
|
errors,
|
||||||
|
jsonSchema
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFieldsWithDateValidations = (
|
||||||
|
jsonSchema: any,
|
||||||
|
formDataToCheck: any,
|
||||||
|
errors: any
|
||||||
|
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||||
|
) => {
|
||||||
|
// if the jsonSchema has an items attribute then assume the element itself
|
||||||
|
// doesn't have a custom validation but it's children could so use that
|
||||||
|
const jsonSchemaToUse =
|
||||||
|
'items' in jsonSchema ? jsonSchema.items : jsonSchema;
|
||||||
|
|
||||||
|
if ('properties' in jsonSchemaToUse) {
|
||||||
|
Object.keys(jsonSchemaToUse.properties).forEach((propertyKey: string) => {
|
||||||
|
const propertyMetadata = jsonSchemaToUse.properties[propertyKey];
|
||||||
|
if ('minimumDate' in propertyMetadata) {
|
||||||
|
checkMinimumDate(
|
||||||
|
formDataToCheck,
|
||||||
|
propertyKey,
|
||||||
|
propertyMetadata,
|
||||||
|
errors,
|
||||||
|
jsonSchemaToUse
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// recurse through all nested properties as well
|
||||||
|
let formDataToSend = formDataToCheck[propertyKey];
|
||||||
|
if (formDataToSend) {
|
||||||
|
if (formDataToSend.constructor.name !== 'Array') {
|
||||||
|
formDataToSend = [formDataToSend];
|
||||||
|
}
|
||||||
|
formDataToSend.forEach((item: any, index: number) => {
|
||||||
|
let errorsToSend = errors[propertyKey];
|
||||||
|
if (index in errorsToSend) {
|
||||||
|
errorsToSend = errorsToSend[index];
|
||||||
|
}
|
||||||
|
getFieldsWithDateValidations(propertyMetadata, item, errorsToSend);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
const customValidate = (formDataToCheck: any, errors: any) => {
|
||||||
|
return getFieldsWithDateValidations(schema, formDataToCheck, errors);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
id={id}
|
||||||
|
disabled={disabled}
|
||||||
|
formData={formData}
|
||||||
|
onChange={onChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
schema={schema}
|
||||||
|
uiSchema={uiSchema}
|
||||||
|
widgets={rjsfWidgets}
|
||||||
|
validator={validator}
|
||||||
|
customValidate={customValidate}
|
||||||
|
noValidate={noValidate}
|
||||||
|
omitExtraData
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
@ -437,6 +437,7 @@ export interface UiSchemaPageDefinition {
|
|||||||
form_schema_filename?: any;
|
form_schema_filename?: any;
|
||||||
form_ui_schema_filename?: any;
|
form_ui_schema_filename?: any;
|
||||||
markdown_instruction_filename?: string;
|
markdown_instruction_filename?: string;
|
||||||
|
navigate_to_on_form_submit?: string;
|
||||||
}
|
}
|
||||||
export interface UiSchemaRoute {
|
export interface UiSchemaRoute {
|
||||||
[key: string]: UiSchemaPageDefinition;
|
[key: string]: UiSchemaPageDefinition;
|
||||||
@ -445,3 +446,8 @@ export interface ExtensionUiSchema {
|
|||||||
navigation_items?: UiSchemaNavItem[];
|
navigation_items?: UiSchemaNavItem[];
|
||||||
routes: UiSchemaRoute;
|
routes: UiSchemaRoute;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExtensionPostBody {
|
||||||
|
extension_input: any;
|
||||||
|
ui_schema_page_definition?: UiSchemaPageDefinition;
|
||||||
|
}
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import MDEditor from '@uiw/react-md-editor';
|
import MDEditor from '@uiw/react-md-editor';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import validator from '@rjsf/validator-ajv8';
|
|
||||||
import { Editor } from '@monaco-editor/react';
|
import { Editor } from '@monaco-editor/react';
|
||||||
import { Form } from '../rjsf/carbon_theme';
|
|
||||||
import { useUriListForPermissions } from '../hooks/UriListForPermissions';
|
import { useUriListForPermissions } from '../hooks/UriListForPermissions';
|
||||||
import {
|
import {
|
||||||
|
ExtensionPostBody,
|
||||||
ExtensionUiSchema,
|
ExtensionUiSchema,
|
||||||
ProcessFile,
|
ProcessFile,
|
||||||
ProcessModel,
|
ProcessModel,
|
||||||
@ -14,7 +13,10 @@ import {
|
|||||||
import HttpService from '../services/HttpService';
|
import HttpService from '../services/HttpService';
|
||||||
import useAPIError from '../hooks/UseApiError';
|
import useAPIError from '../hooks/UseApiError';
|
||||||
import { recursivelyChangeNullAndUndefined } from '../helpers';
|
import { recursivelyChangeNullAndUndefined } from '../helpers';
|
||||||
|
import CustomForm from '../components/CustomForm';
|
||||||
|
import { BACKEND_BASE_URL } from '../config';
|
||||||
|
|
||||||
|
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||||
export default function Extension() {
|
export default function Extension() {
|
||||||
const { targetUris } = useUriListForPermissions();
|
const { targetUris } = useUriListForPermissions();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@ -23,6 +25,7 @@ export default function Extension() {
|
|||||||
const [formData, setFormData] = useState<any>(null);
|
const [formData, setFormData] = useState<any>(null);
|
||||||
const [formButtonsDisabled, setFormButtonsDisabled] = useState(false);
|
const [formButtonsDisabled, setFormButtonsDisabled] = useState(false);
|
||||||
const [processedTaskData, setProcessedTaskData] = useState<any>(null);
|
const [processedTaskData, setProcessedTaskData] = useState<any>(null);
|
||||||
|
const [markdownToRender, setMarkdownToRender] = useState<string | null>(null);
|
||||||
const [filesByName] = useState<{
|
const [filesByName] = useState<{
|
||||||
[key: string]: ProcessFile;
|
[key: string]: ProcessFile;
|
||||||
}>({});
|
}>({});
|
||||||
@ -66,7 +69,10 @@ export default function Extension() {
|
|||||||
}, [targetUris.extensionPath, params, filesByName]);
|
}, [targetUris.extensionPath, params, filesByName]);
|
||||||
|
|
||||||
const processSubmitResult = (result: any) => {
|
const processSubmitResult = (result: any) => {
|
||||||
setProcessedTaskData(result);
|
setProcessedTaskData(result.task_data);
|
||||||
|
if (result.rendered_results_markdown) {
|
||||||
|
setMarkdownToRender(result.rendered_results_markdown);
|
||||||
|
}
|
||||||
setFormButtonsDisabled(false);
|
setFormButtonsDisabled(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -82,9 +88,37 @@ export default function Extension() {
|
|||||||
removeError();
|
removeError();
|
||||||
delete dataToSubmit.isManualTask;
|
delete dataToSubmit.isManualTask;
|
||||||
|
|
||||||
|
if (
|
||||||
|
uiSchemaPageDefinition &&
|
||||||
|
uiSchemaPageDefinition.navigate_to_on_form_submit
|
||||||
|
) {
|
||||||
|
let isValid = true;
|
||||||
|
const optionString =
|
||||||
|
uiSchemaPageDefinition.navigate_to_on_form_submit.replace(
|
||||||
|
/{(\w+)}/g,
|
||||||
|
(_, k) => {
|
||||||
|
const value = dataToSubmit[k];
|
||||||
|
if (value === undefined) {
|
||||||
|
isValid = false;
|
||||||
|
addError({
|
||||||
|
message: `Could not find a value for ${k} in form data.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!isValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = `${BACKEND_BASE_URL}/extensions-get-data/${params.process_model}/${optionString}`;
|
||||||
|
window.location.href = url;
|
||||||
|
setFormButtonsDisabled(false);
|
||||||
|
} else {
|
||||||
|
const postBody: ExtensionPostBody = { extension_input: dataToSubmit };
|
||||||
let apiPath = targetUris.extensionPath;
|
let apiPath = targetUris.extensionPath;
|
||||||
if (uiSchemaPageDefinition && uiSchemaPageDefinition.api) {
|
if (uiSchemaPageDefinition && uiSchemaPageDefinition.api) {
|
||||||
apiPath = `${targetUris.extensionListPath}/${uiSchemaPageDefinition.api}`;
|
apiPath = `${targetUris.extensionListPath}/${uiSchemaPageDefinition.api}`;
|
||||||
|
postBody.ui_schema_page_definition = uiSchemaPageDefinition;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: rjsf sets blanks values to undefined and JSON.stringify removes keys with undefined values
|
// NOTE: rjsf sets blanks values to undefined and JSON.stringify removes keys with undefined values
|
||||||
@ -99,8 +133,9 @@ export default function Extension() {
|
|||||||
setFormButtonsDisabled(false);
|
setFormButtonsDisabled(false);
|
||||||
},
|
},
|
||||||
httpMethod: 'POST',
|
httpMethod: 'POST',
|
||||||
postBody: { extension_input: dataToSubmit },
|
postBody,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (uiSchemaPageDefinition) {
|
if (uiSchemaPageDefinition) {
|
||||||
@ -129,7 +164,7 @@ export default function Extension() {
|
|||||||
filesByName[uiSchemaPageDefinition.form_ui_schema_filename];
|
filesByName[uiSchemaPageDefinition.form_ui_schema_filename];
|
||||||
if (formSchemaFile.file_contents && formUiSchemaFile.file_contents) {
|
if (formSchemaFile.file_contents && formUiSchemaFile.file_contents) {
|
||||||
componentsToDisplay.push(
|
componentsToDisplay.push(
|
||||||
<Form
|
<CustomForm
|
||||||
id="form-to-submit"
|
id="form-to-submit"
|
||||||
formData={formData}
|
formData={formData}
|
||||||
onChange={(obj: any) => {
|
onChange={(obj: any) => {
|
||||||
@ -139,13 +174,22 @@ export default function Extension() {
|
|||||||
onSubmit={handleFormSubmit}
|
onSubmit={handleFormSubmit}
|
||||||
schema={JSON.parse(formSchemaFile.file_contents)}
|
schema={JSON.parse(formSchemaFile.file_contents)}
|
||||||
uiSchema={JSON.parse(formUiSchemaFile.file_contents)}
|
uiSchema={JSON.parse(formUiSchemaFile.file_contents)}
|
||||||
validator={validator}
|
|
||||||
omitExtraData
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (processedTaskData) {
|
if (processedTaskData) {
|
||||||
|
if (markdownToRender) {
|
||||||
|
componentsToDisplay.push(
|
||||||
|
<div data-color-mode="light" className="with-top-margin">
|
||||||
|
<MDEditor.Markdown
|
||||||
|
className="onboarding"
|
||||||
|
linkTarget="_blank"
|
||||||
|
source={markdownToRender}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
componentsToDisplay.push(
|
componentsToDisplay.push(
|
||||||
<>
|
<>
|
||||||
<h2 className="with-top-margin">Result:</h2>
|
<h2 className="with-top-margin">Result:</h2>
|
||||||
@ -164,6 +208,7 @@ export default function Extension() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return <div className="fixed-width-container">{componentsToDisplay}</div>;
|
return <div className="fixed-width-container">{componentsToDisplay}</div>;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
@ -615,6 +615,24 @@ export default function ProcessModelShow() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (processModel) {
|
if (processModel) {
|
||||||
|
const processStartButton = (
|
||||||
|
<Stack orientation="horizontal" gap={3}>
|
||||||
|
<Can
|
||||||
|
I="POST"
|
||||||
|
a={targetUris.processInstanceCreatePath}
|
||||||
|
ability={ability}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<ProcessInstanceRun
|
||||||
|
processModel={processModel}
|
||||||
|
onSuccessCallback={setProcessInstance}
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
</Can>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{fileUploadModal()}
|
{fileUploadModal()}
|
||||||
@ -680,22 +698,7 @@ export default function ProcessModelShow() {
|
|||||||
</Can>
|
</Can>
|
||||||
</Stack>
|
</Stack>
|
||||||
<p className="process-description">{processModel.description}</p>
|
<p className="process-description">{processModel.description}</p>
|
||||||
<Stack orientation="horizontal" gap={3}>
|
{processModel.primary_file_name ? processStartButton : null}
|
||||||
<Can
|
|
||||||
I="POST"
|
|
||||||
a={targetUris.processInstanceCreatePath}
|
|
||||||
ability={ability}
|
|
||||||
>
|
|
||||||
<>
|
|
||||||
<ProcessInstanceRun
|
|
||||||
processModel={processModel}
|
|
||||||
onSuccessCallback={setProcessInstance}
|
|
||||||
/>
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
</>
|
|
||||||
</Can>
|
|
||||||
</Stack>
|
|
||||||
{processModelFilesSection()}
|
{processModelFilesSection()}
|
||||||
<Can
|
<Can
|
||||||
I="POST"
|
I="POST"
|
||||||
|
@ -1,11 +1,9 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import validator from '@rjsf/validator-ajv8';
|
|
||||||
|
|
||||||
import { Grid, Column, Button, ButtonSet, Loading } from '@carbon/react';
|
import { Grid, Column, Button, ButtonSet, Loading } from '@carbon/react';
|
||||||
|
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
import { Form } from '../rjsf/carbon_theme';
|
|
||||||
import HttpService from '../services/HttpService';
|
import HttpService from '../services/HttpService';
|
||||||
import useAPIError from '../hooks/UseApiError';
|
import useAPIError from '../hooks/UseApiError';
|
||||||
import {
|
import {
|
||||||
@ -16,9 +14,7 @@ import {
|
|||||||
import { BasicTask, EventDefinition, Task } from '../interfaces';
|
import { BasicTask, EventDefinition, Task } from '../interfaces';
|
||||||
import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
|
import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
|
||||||
import InstructionsForEndUser from '../components/InstructionsForEndUser';
|
import InstructionsForEndUser from '../components/InstructionsForEndUser';
|
||||||
import TypeaheadWidget from '../rjsf/custom_widgets/TypeaheadWidget/TypeaheadWidget';
|
import CustomForm from '../components/CustomForm';
|
||||||
import DateRangePickerWidget from '../rjsf/custom_widgets/DateRangePicker/DateRangePickerWidget';
|
|
||||||
import { DATE_RANGE_DELIMITER } from '../config';
|
|
||||||
|
|
||||||
export default function TaskShow() {
|
export default function TaskShow() {
|
||||||
const [basicTask, setBasicTask] = useState<BasicTask | null>(null);
|
const [basicTask, setBasicTask] = useState<BasicTask | null>(null);
|
||||||
@ -33,11 +29,6 @@ export default function TaskShow() {
|
|||||||
|
|
||||||
const { addError, removeError } = useAPIError();
|
const { addError, removeError } = useAPIError();
|
||||||
|
|
||||||
const rjsfWidgets = {
|
|
||||||
typeahead: TypeaheadWidget,
|
|
||||||
'date-range': DateRangePickerWidget,
|
|
||||||
};
|
|
||||||
|
|
||||||
// if a user can complete a task then the for-me page should
|
// if a user can complete a task then the for-me page should
|
||||||
// always work for them so use that since it will work in all cases
|
// always work for them so use that since it will work in all cases
|
||||||
const navigateToInterstitial = (myTask: BasicTask) => {
|
const navigateToInterstitial = (myTask: BasicTask) => {
|
||||||
@ -196,157 +187,6 @@ export default function TaskShow() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDateString = (dateString?: string) => {
|
|
||||||
let dateObject = new Date();
|
|
||||||
if (dateString) {
|
|
||||||
dateObject = new Date(dateString);
|
|
||||||
}
|
|
||||||
return dateObject.toISOString().split('T')[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkFieldComparisons = (
|
|
||||||
formData: any,
|
|
||||||
propertyKey: string,
|
|
||||||
minimumDateCheck: string,
|
|
||||||
formattedDateString: string,
|
|
||||||
errors: any,
|
|
||||||
jsonSchema: any
|
|
||||||
) => {
|
|
||||||
// field format:
|
|
||||||
// field:[field_name_to_use]
|
|
||||||
//
|
|
||||||
// if field is a range:
|
|
||||||
// field:[field_name_to_use]:[start or end]
|
|
||||||
//
|
|
||||||
// defaults to "start" in all cases
|
|
||||||
const [_, fieldIdentifierToCompareWith, startOrEnd] =
|
|
||||||
minimumDateCheck.split(':');
|
|
||||||
if (!(fieldIdentifierToCompareWith in formData)) {
|
|
||||||
errors[propertyKey].addError(
|
|
||||||
`was supposed to be compared against '${fieldIdentifierToCompareWith}' but it either doesn't have a value or does not exist`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawDateToCompareWith = formData[fieldIdentifierToCompareWith];
|
|
||||||
if (!rawDateToCompareWith) {
|
|
||||||
errors[propertyKey].addError(
|
|
||||||
`was supposed to be compared against '${fieldIdentifierToCompareWith}' but that field did not have a value`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [startDate, endDate] =
|
|
||||||
rawDateToCompareWith.split(DATE_RANGE_DELIMITER);
|
|
||||||
let dateToCompareWith = startDate;
|
|
||||||
if (startOrEnd && startOrEnd === 'end') {
|
|
||||||
dateToCompareWith = endDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dateToCompareWith) {
|
|
||||||
const errorMessage = `was supposed to be compared against '${[
|
|
||||||
fieldIdentifierToCompareWith,
|
|
||||||
startOrEnd,
|
|
||||||
].join(':')}' but that field did not have a value`;
|
|
||||||
errors[propertyKey].addError(errorMessage);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateStringToCompareWith = formatDateString(dateToCompareWith);
|
|
||||||
if (dateStringToCompareWith > formattedDateString) {
|
|
||||||
let fieldToCompareWithTitle = fieldIdentifierToCompareWith;
|
|
||||||
if (
|
|
||||||
fieldIdentifierToCompareWith in jsonSchema.properties &&
|
|
||||||
jsonSchema.properties[fieldIdentifierToCompareWith].title
|
|
||||||
) {
|
|
||||||
fieldToCompareWithTitle =
|
|
||||||
jsonSchema.properties[fieldIdentifierToCompareWith].title;
|
|
||||||
}
|
|
||||||
errors[propertyKey].addError(
|
|
||||||
`must be equal to or greater than '${fieldToCompareWithTitle}'`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkMinimumDate = (
|
|
||||||
formData: any,
|
|
||||||
propertyKey: string,
|
|
||||||
propertyMetadata: any,
|
|
||||||
errors: any,
|
|
||||||
jsonSchema: any
|
|
||||||
) => {
|
|
||||||
// can be either "today" or another field
|
|
||||||
let dateString = formData[propertyKey];
|
|
||||||
if (dateString) {
|
|
||||||
if (typeof dateString === 'string') {
|
|
||||||
// in the case of date ranges, just take the start date and check that
|
|
||||||
[dateString] = dateString.split(DATE_RANGE_DELIMITER);
|
|
||||||
}
|
|
||||||
const formattedDateString = formatDateString(dateString);
|
|
||||||
const minimumDateChecks = propertyMetadata.minimumDate.split(',');
|
|
||||||
minimumDateChecks.forEach((mdc: string) => {
|
|
||||||
if (mdc === 'today') {
|
|
||||||
const dateTodayString = formatDateString();
|
|
||||||
if (dateTodayString > formattedDateString) {
|
|
||||||
errors[propertyKey].addError('must be today or after');
|
|
||||||
}
|
|
||||||
} else if (mdc.startsWith('field:')) {
|
|
||||||
checkFieldComparisons(
|
|
||||||
formData,
|
|
||||||
propertyKey,
|
|
||||||
mdc,
|
|
||||||
formattedDateString,
|
|
||||||
errors,
|
|
||||||
jsonSchema
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFieldsWithDateValidations = (
|
|
||||||
jsonSchema: any,
|
|
||||||
formData: any,
|
|
||||||
errors: any
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
||||||
) => {
|
|
||||||
// if the jsonSchema has an items attribute then assume the element itself
|
|
||||||
// doesn't have a custom validation but it's children could so use that
|
|
||||||
const jsonSchemaToUse =
|
|
||||||
'items' in jsonSchema ? jsonSchema.items : jsonSchema;
|
|
||||||
|
|
||||||
if ('properties' in jsonSchemaToUse) {
|
|
||||||
Object.keys(jsonSchemaToUse.properties).forEach((propertyKey: string) => {
|
|
||||||
const propertyMetadata = jsonSchemaToUse.properties[propertyKey];
|
|
||||||
if ('minimumDate' in propertyMetadata) {
|
|
||||||
checkMinimumDate(
|
|
||||||
formData,
|
|
||||||
propertyKey,
|
|
||||||
propertyMetadata,
|
|
||||||
errors,
|
|
||||||
jsonSchemaToUse
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// recurse through all nested properties as well
|
|
||||||
let formDataToSend = formData[propertyKey];
|
|
||||||
if (formDataToSend) {
|
|
||||||
if (formDataToSend.constructor.name !== 'Array') {
|
|
||||||
formDataToSend = [formDataToSend];
|
|
||||||
}
|
|
||||||
formDataToSend.forEach((item: any, index: number) => {
|
|
||||||
let errorsToSend = errors[propertyKey];
|
|
||||||
if (index in errorsToSend) {
|
|
||||||
errorsToSend = errorsToSend[index];
|
|
||||||
}
|
|
||||||
getFieldsWithDateValidations(propertyMetadata, item, errorsToSend);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return errors;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseButton = () => {
|
const handleCloseButton = () => {
|
||||||
setAutosaveOnFormChanges(false);
|
setAutosaveOnFormChanges(false);
|
||||||
setFormButtonsDisabled(true);
|
setFormButtonsDisabled(true);
|
||||||
@ -437,17 +277,13 @@ export default function TaskShow() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const customValidate = (formData: any, errors: any) => {
|
|
||||||
return getFieldsWithDateValidations(jsonSchema, formData, errors);
|
|
||||||
};
|
|
||||||
|
|
||||||
// we are using two forms here so we can have one that validates data and one that does not.
|
// we are using two forms here so we can have one that validates data and one that does not.
|
||||||
// this allows us to autosave form data without extra attributes and without validations
|
// this allows us to autosave form data without extra attributes and without validations
|
||||||
// but still requires validations when the user submits the form that they can edit.
|
// but still requires validations when the user submits the form that they can edit.
|
||||||
return (
|
return (
|
||||||
<Grid fullWidth condensed>
|
<Grid fullWidth condensed>
|
||||||
<Column sm={4} md={5} lg={8}>
|
<Column sm={4} md={5} lg={8}>
|
||||||
<Form
|
<CustomForm
|
||||||
id="form-to-submit"
|
id="form-to-submit"
|
||||||
disabled={formButtonsDisabled}
|
disabled={formButtonsDisabled}
|
||||||
formData={taskData}
|
formData={taskData}
|
||||||
@ -458,23 +294,16 @@ export default function TaskShow() {
|
|||||||
onSubmit={handleFormSubmit}
|
onSubmit={handleFormSubmit}
|
||||||
schema={jsonSchema}
|
schema={jsonSchema}
|
||||||
uiSchema={formUiSchema}
|
uiSchema={formUiSchema}
|
||||||
widgets={rjsfWidgets}
|
|
||||||
validator={validator}
|
|
||||||
customValidate={customValidate}
|
|
||||||
omitExtraData
|
|
||||||
>
|
>
|
||||||
{reactFragmentToHideSubmitButton}
|
{reactFragmentToHideSubmitButton}
|
||||||
</Form>
|
</CustomForm>
|
||||||
<Form
|
<CustomForm
|
||||||
id="hidden-form-for-autosave"
|
id="hidden-form-for-autosave"
|
||||||
formData={taskData}
|
formData={taskData}
|
||||||
onSubmit={handleAutosaveFormSubmit}
|
onSubmit={handleAutosaveFormSubmit}
|
||||||
schema={jsonSchema}
|
schema={jsonSchema}
|
||||||
uiSchema={formUiSchema}
|
uiSchema={formUiSchema}
|
||||||
widgets={rjsfWidgets}
|
|
||||||
validator={validator}
|
|
||||||
noValidate
|
noValidate
|
||||||
omitExtraData
|
|
||||||
/>
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user