Merge pull request #201 from sartography/feature/notion_261.1_save_as_draft

added ability to save a form as draft w/ burnettk
This commit is contained in:
jasquat 2023-03-31 12:46:28 -04:00 committed by GitHub
commit 099973c701
5 changed files with 123 additions and 67 deletions

View File

@ -1832,10 +1832,10 @@ paths:
description: The unique id of an existing process instance. description: The unique id of an existing process instance.
schema: schema:
type: integer type: integer
- name: terminate_loop - name: save_as_draft
in: query in: query
required: false required: false
description: Terminate the loop on a looping task description: Save the data to task but do not complete it.
schema: schema:
type: boolean type: boolean
get: get:

View File

@ -180,13 +180,7 @@ def task_data_show(
process_instance_id: int, process_instance_id: int,
task_guid: str, task_guid: str,
) -> flask.wrappers.Response: ) -> flask.wrappers.Response:
task_model = TaskModel.query.filter_by(guid=task_guid, process_instance_id=process_instance_id).first() task_model = _get_task_model_from_guid_or_raise(task_guid, process_instance_id)
if task_model is None:
raise ApiError(
error_code="task_not_found",
message=f"Cannot find a task with guid '{task_guid}' for process instance '{process_instance_id}'",
status_code=400,
)
task_model.data = task_model.json_data() task_model.data = task_model.json_data()
return make_response(jsonify(task_model), 200) return make_response(jsonify(task_model), 200)
@ -216,13 +210,11 @@ def task_data_update(
if "new_task_data" in body: if "new_task_data" in body:
new_task_data_str: str = body["new_task_data"] new_task_data_str: str = body["new_task_data"]
new_task_data_dict = json.loads(new_task_data_str) new_task_data_dict = json.loads(new_task_data_str)
json_data_dict = TaskService.update_task_data_on_task_model( json_data_dict = TaskService.update_task_data_on_task_model_and_return_dict_if_updated(
task_model, new_task_data_dict, "json_data_hash" task_model, new_task_data_dict, "json_data_hash"
) )
if json_data_dict is not None: if json_data_dict is not None:
TaskService.insert_or_update_json_data_records({json_data_dict["hash"]: json_data_dict}) TaskService.insert_or_update_json_data_records({json_data_dict["hash"]: json_data_dict})
# json_data = JsonDataModel(**json_data_dict)
# db.session.add(json_data)
ProcessInstanceProcessor.add_event_to_process_instance( ProcessInstanceProcessor.add_event_to_process_instance(
process_instance, ProcessInstanceEventType.task_data_edited.value, task_guid=task_guid process_instance, ProcessInstanceEventType.task_data_edited.value, task_guid=task_guid
) )
@ -389,11 +381,11 @@ def process_data_show(
) )
def task_submit_shared( def _task_submit_shared(
process_instance_id: int, process_instance_id: int,
task_guid: str, task_guid: str,
body: Dict[str, Any], body: Dict[str, Any],
terminate_loop: bool = False, save_as_draft: bool = False,
) -> flask.wrappers.Response: ) -> flask.wrappers.Response:
principal = _find_principal_or_raise() principal = _find_principal_or_raise()
process_instance = _find_process_instance_by_id_or_raise(process_instance_id) process_instance = _find_process_instance_by_id_or_raise(process_instance_id)
@ -420,25 +412,10 @@ def task_submit_shared(
) )
) )
if terminate_loop and spiff_task.is_looping(): # multi-instance code from crconnect - we may need it or may not
spiff_task.terminate_loop() # if terminate_loop and spiff_task.is_looping():
# spiff_task.terminate_loop()
human_task = _find_human_task_or_raise( #
process_instance_id=process_instance_id,
task_guid=task_guid,
only_tasks_that_can_be_completed=True,
)
with sentry_sdk.start_span(op="task", description="complete_form_task"):
with ProcessInstanceQueueService.dequeued(process_instance):
ProcessInstanceService.complete_form_task(
processor=processor,
spiff_task=spiff_task,
data=body,
user=g.user,
human_task=human_task,
)
# If we need to update all tasks, then get the next ready task and if it a multi-instance with the same # If we need to update all tasks, then get the next ready task and if it a multi-instance with the same
# task spec, complete that form as well. # task spec, complete that form as well.
# if update_all: # if update_all:
@ -449,15 +426,41 @@ def task_submit_shared(
# last_index = next_task.task_info()["mi_index"] # last_index = next_task.task_info()["mi_index"]
# next_task = processor.next_task() # next_task = processor.next_task()
next_human_task_assigned_to_me = ( if save_as_draft:
HumanTaskModel.query.filter_by(process_instance_id=process_instance_id, completed=False) task_model = _get_task_model_from_guid_or_raise(task_guid, process_instance_id)
.order_by(asc(HumanTaskModel.id)) # type: ignore json_data_dict = TaskService.update_task_data_on_task_model_and_return_dict_if_updated(
.join(HumanTaskUserModel) task_model, body, "json_data_hash"
.filter_by(user_id=principal.user_id) )
.first() if json_data_dict is not None:
) TaskService.insert_or_update_json_data_dict(json_data_dict)
if next_human_task_assigned_to_me: db.session.add(task_model)
return make_response(jsonify(HumanTaskModel.to_task(next_human_task_assigned_to_me)), 200) db.session.commit()
else:
human_task = _find_human_task_or_raise(
process_instance_id=process_instance_id,
task_guid=task_guid,
only_tasks_that_can_be_completed=True,
)
with sentry_sdk.start_span(op="task", description="complete_form_task"):
with ProcessInstanceQueueService.dequeued(process_instance):
ProcessInstanceService.complete_form_task(
processor=processor,
spiff_task=spiff_task,
data=body,
user=g.user,
human_task=human_task,
)
next_human_task_assigned_to_me = (
HumanTaskModel.query.filter_by(process_instance_id=process_instance_id, completed=False)
.order_by(asc(HumanTaskModel.id)) # type: ignore
.join(HumanTaskUserModel)
.filter_by(user_id=principal.user_id)
.first()
)
if next_human_task_assigned_to_me:
return make_response(jsonify(HumanTaskModel.to_task(next_human_task_assigned_to_me)), 200)
return Response(json.dumps({"ok": True}), status=202, mimetype="application/json") return Response(json.dumps({"ok": True}), status=202, mimetype="application/json")
@ -466,11 +469,11 @@ def task_submit(
process_instance_id: int, process_instance_id: int,
task_guid: str, task_guid: str,
body: Dict[str, Any], body: Dict[str, Any],
terminate_loop: bool = False, save_as_draft: bool = False,
) -> flask.wrappers.Response: ) -> flask.wrappers.Response:
"""Task_submit_user_data.""" """Task_submit_user_data."""
with sentry_sdk.start_span(op="controller_action", description="tasks_controller.task_submit"): with sentry_sdk.start_span(op="controller_action", description="tasks_controller.task_submit"):
return task_submit_shared(process_instance_id, task_guid, body, terminate_loop) return _task_submit_shared(process_instance_id, task_guid, body, save_as_draft)
def _get_tasks( def _get_tasks(
@ -764,3 +767,16 @@ def _munge_form_ui_schema_based_on_hidden_fields_in_task_data(task: Task) -> Non
relevant_depth_of_ui_schema = relevant_depth_of_ui_schema[hidden_field_part] relevant_depth_of_ui_schema = relevant_depth_of_ui_schema[hidden_field_part]
if len(hidden_field_parts) == ii + 1: if len(hidden_field_parts) == ii + 1:
relevant_depth_of_ui_schema["ui:widget"] = "hidden" relevant_depth_of_ui_schema["ui:widget"] = "hidden"
def _get_task_model_from_guid_or_raise(task_guid: str, process_instance_id: int) -> TaskModel:
task_model: Optional[TaskModel] = TaskModel.query.filter_by(
guid=task_guid, process_instance_id=process_instance_id
).first()
if task_model is None:
raise ApiError(
error_code="task_not_found",
message=f"Cannot find a task with guid '{task_guid}' for process instance '{process_instance_id}'",
status_code=400,
)
return task_model

View File

@ -93,6 +93,7 @@ from spiffworkflow_backend.services.process_instance_queue_service import Proces
from spiffworkflow_backend.services.process_model_service import ProcessModelService from spiffworkflow_backend.services.process_model_service import ProcessModelService
from spiffworkflow_backend.services.service_task_service import ServiceTaskDelegate from spiffworkflow_backend.services.service_task_service import ServiceTaskDelegate
from spiffworkflow_backend.services.spec_file_service import SpecFileService from spiffworkflow_backend.services.spec_file_service import SpecFileService
from spiffworkflow_backend.services.task_service import JsonDataDict
from spiffworkflow_backend.services.task_service import TaskService from spiffworkflow_backend.services.task_service import TaskService
from spiffworkflow_backend.services.user_service import UserService from spiffworkflow_backend.services.user_service import UserService
from spiffworkflow_backend.services.workflow_execution_service import ( from spiffworkflow_backend.services.workflow_execution_service import (
@ -1790,12 +1791,9 @@ class ProcessInstanceProcessor:
db.session.add(human_task) db.session.add(human_task)
json_data_dict_list = TaskService.update_task_model(task_model, spiff_task, self._serializer) json_data_dict_list = TaskService.update_task_model(task_model, spiff_task, self._serializer)
for json_data_dict in json_data_dict_list: json_data_dict_mapping: dict[str, JsonDataDict] = {}
if json_data_dict is not None: TaskService.update_json_data_dicts_using_list(json_data_dict_list, json_data_dict_mapping)
json_data = db.session.query(JsonDataModel.id).filter_by(hash=json_data_dict["hash"]).first() TaskService.insert_or_update_json_data_records(json_data_dict_mapping)
if json_data is None:
json_data = JsonDataModel(**json_data_dict)
db.session.add(json_data)
self.add_event_to_process_instance( self.add_event_to_process_instance(
self.process_instance_model, self.process_instance_model,

View File

@ -130,7 +130,7 @@ class TaskService:
self.task_models[task_model.guid] = task_model self.task_models[task_model.guid] = task_model
if bpmn_process_json_data is not None: if bpmn_process_json_data is not None:
json_data_dict_list.append(bpmn_process_json_data) json_data_dict_list.append(bpmn_process_json_data)
self._update_json_data_dicts_using_list(json_data_dict_list, self.json_data_dicts) self.update_json_data_dicts_using_list(json_data_dict_list, self.json_data_dicts)
if task_model.state == "COMPLETED" or task_failed: if task_model.state == "COMPLETED" or task_failed:
event_type = ProcessInstanceEventType.task_completed.value event_type = ProcessInstanceEventType.task_completed.value
@ -207,8 +207,12 @@ class TaskService:
python_env_data_dict = cls._get_python_env_data_dict_from_spiff_task(spiff_task, serializer) python_env_data_dict = cls._get_python_env_data_dict_from_spiff_task(spiff_task, serializer)
task_model.properties_json = new_properties_json task_model.properties_json = new_properties_json
task_model.state = TaskStateNames[new_properties_json["state"]] task_model.state = TaskStateNames[new_properties_json["state"]]
json_data_dict = cls.update_task_data_on_task_model(task_model, spiff_task_data, "json_data_hash") json_data_dict = cls.update_task_data_on_task_model_and_return_dict_if_updated(
python_env_dict = cls.update_task_data_on_task_model(task_model, python_env_data_dict, "python_env_data_hash") task_model, spiff_task_data, "json_data_hash"
)
python_env_dict = cls.update_task_data_on_task_model_and_return_dict_if_updated(
task_model, python_env_data_dict, "python_env_data_hash"
)
return [json_data_dict, python_env_dict] return [json_data_dict, python_env_dict]
@classmethod @classmethod
@ -446,7 +450,11 @@ class TaskService:
return json_data_dict return json_data_dict
@classmethod @classmethod
def update_task_data_on_task_model( def insert_or_update_json_data_dict(cls, json_data_dict: JsonDataDict) -> None:
TaskService.insert_or_update_json_data_records({json_data_dict["hash"]: json_data_dict})
@classmethod
def update_task_data_on_task_model_and_return_dict_if_updated(
cls, task_model: TaskModel, task_data_dict: dict, task_model_data_column: str cls, task_model: TaskModel, task_data_dict: dict, task_model_data_column: str
) -> Optional[JsonDataDict]: ) -> Optional[JsonDataDict]:
task_data_json = json.dumps(task_data_dict, sort_keys=True) task_data_json = json.dumps(task_data_dict, sort_keys=True)
@ -501,11 +509,11 @@ class TaskService:
python_env_data_hash: Optional[str] = None, python_env_data_hash: Optional[str] = None,
) -> None: ) -> None:
if json_data_hash is None: if json_data_hash is None:
cls.update_task_data_on_task_model(task_model, {}, "json_data_hash") cls.update_task_data_on_task_model_and_return_dict_if_updated(task_model, {}, "json_data_hash")
else: else:
task_model.json_data_hash = json_data_hash task_model.json_data_hash = json_data_hash
if python_env_data_hash is None: if python_env_data_hash is None:
cls.update_task_data_on_task_model(task_model, {}, "python_env_data") cls.update_task_data_on_task_model_and_return_dict_if_updated(task_model, {}, "python_env_data")
else: else:
task_model.python_env_data_hash = python_env_data_hash task_model.python_env_data_hash = python_env_data_hash
@ -556,7 +564,7 @@ class TaskService:
return converted_data return converted_data
@classmethod @classmethod
def _update_json_data_dicts_using_list( def update_json_data_dicts_using_list(
cls, json_data_dict_list: list[Optional[JsonDataDict]], json_data_dicts: dict[str, JsonDataDict] cls, json_data_dict_list: list[Optional[JsonDataDict]], json_data_dicts: dict[str, JsonDataDict]
) -> None: ) -> None:
for json_data_dict in json_data_dict_list: for json_data_dict in json_data_dict_list:

View File

@ -1,4 +1,4 @@
import { 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 validator from '@rjsf/validator-ajv8';
@ -9,6 +9,7 @@ import {
Grid, Grid,
Column, Column,
Button, Button,
ButtonSet,
// @ts-ignore // @ts-ignore
} from '@carbon/react'; } from '@carbon/react';
@ -21,6 +22,13 @@ import { modifyProcessIdentifierForPathParam } from '../helpers';
import { ProcessInstanceTask } from '../interfaces'; import { ProcessInstanceTask } from '../interfaces';
import ProcessBreadcrumb from '../components/ProcessBreadcrumb'; import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
class UnexpectedHumanTaskType extends Error {
constructor(message: string) {
super(message);
this.name = 'UnexpectedHumanTaskType';
}
}
export default function TaskShow() { export default function TaskShow() {
const [task, setTask] = useState<ProcessInstanceTask | null>(null); const [task, setTask] = useState<ProcessInstanceTask | null>(null);
const [userTasks] = useState(null); const [userTasks] = useState(null);
@ -30,6 +38,9 @@ export default function TaskShow() {
const { addError, removeError } = useAPIError(); const { addError, removeError } = useAPIError();
// eslint-disable-next-line sonarjs/no-duplicate-string
const supportedHumanTaskTypes = ['User Task', 'Manual Task'];
useEffect(() => { useEffect(() => {
const processResult = (result: ProcessInstanceTask) => { const processResult = (result: ProcessInstanceTask) => {
setTask(result); setTask(result);
@ -76,16 +87,22 @@ export default function TaskShow() {
} }
}; };
const handleFormSubmit = (event: any) => { const handleFormSubmit = (formObject: any, event: any) => {
if (disabled) { if (disabled) {
return; return;
} }
const submitButtonId = event.nativeEvent.submitter.id;
let queryParams = '';
console.log('submitButtonId', submitButtonId);
if (submitButtonId === 'save-as-draft-button') {
queryParams = '?save_as_draft=true';
}
setDisabled(true); setDisabled(true);
removeError(); removeError();
const dataToSubmit = event.formData; const dataToSubmit = formObject.formData;
delete dataToSubmit.isManualTask; delete dataToSubmit.isManualTask;
HttpService.makeCallToBackend({ HttpService.makeCallToBackend({
path: `/tasks/${params.process_instance_id}/${params.task_id}`, path: `/tasks/${params.process_instance_id}/${params.task_id}${queryParams}`,
successCallback: processSubmitResult, successCallback: processSubmitResult,
failureCallback: (error: any) => { failureCallback: (error: any) => {
addError(error); addError(error);
@ -226,16 +243,33 @@ export default function TaskShow() {
} }
if (task.state === 'READY') { if (task.state === 'READY') {
let buttonText = 'Submit'; let submitButtonText = 'Submit';
let saveAsDraftButton = null;
if (task.type === 'Manual Task') { if (task.type === 'Manual Task') {
buttonText = 'Continue'; submitButtonText = 'Continue';
} else if (task.type === 'User Task') {
saveAsDraftButton = (
<Button
type="submit"
id="save-as-draft-button"
disabled={disabled}
kind="secondary"
>
Save as draft
</Button>
);
} else {
throw new UnexpectedHumanTaskType(
`Invalid task type given: ${task.type}. Only supported types: ${supportedHumanTaskTypes}`
);
} }
reactFragmentToHideSubmitButton = ( reactFragmentToHideSubmitButton = (
<div> <ButtonSet>
<Button type="submit" disabled={disabled}> <Button type="submit" id="submit-button" disabled={disabled}>
{buttonText} {submitButtonText}
</Button> </Button>
</div> {saveAsDraftButton}
</ButtonSet>
); );
} }