From 6a922b2eb4ec3fb306fae80ec757829bac5bad64 Mon Sep 17 00:00:00 2001 From: jasquat <2487833+jasquat@users.noreply.github.com> Date: Thu, 27 Jul 2023 07:30:16 -0400 Subject: [PATCH] =?UTF-8?q?added=20a=20loading=20icon=20on=20task=20show?= =?UTF-8?q?=20page=20to=20avoid=20blank=20page=20when=20loadi=E2=80=A6=20(?= =?UTF-8?q?#411)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added a loading icon on task show page to avoid blank page when loading large datasets w/ burnettk * fixed broken tests w/ burnettk --------- Co-authored-by: jasquat --- .../src/spiffworkflow_backend/api.yml | 6 + .../routes/tasks_controller.py | 130 +++++++------ .../integration/test_for_good_errors.py | 2 +- .../integration/test_tasks_controller.py | 10 +- spiffworkflow-frontend/src/interfaces.ts | 15 +- .../src/routes/TaskShow.tsx | 182 +++++++----------- 6 files changed, 165 insertions(+), 180 deletions(-) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml index 124ce3ce..a6a299f8 100755 --- a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml @@ -1904,6 +1904,12 @@ paths: description: The unique id of an existing process instance. schema: type: integer + - name: with_form_data + in: query + required: false + description: Include task data for forms + schema: + type: boolean get: tags: - Tasks diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py index 5af444de..68e8e9c9 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py @@ -300,7 +300,9 @@ def task_assign( return make_response(jsonify({"ok": True}), 200) -def task_show(process_instance_id: int, task_guid: str = "next") -> flask.wrappers.Response: +def task_show( + 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) if process_instance.status == ProcessInstanceStatus.suspended.value: @@ -314,20 +316,8 @@ def task_show(process_instance_id: int, task_guid: str = "next") -> flask.wrappe process_instance.process_model_identifier, ) - form_schema_file_name = "" - form_ui_schema_file_name = "" - task_model = _get_task_model_from_guid_or_raise(task_guid, process_instance_id) task_definition = task_model.task_definition - extensions = TaskService.get_extensions_from_task_model(task_model) - task_model.signal_buttons = TaskService.get_ready_signals_with_button_labels(process_instance_id, task_model.guid) - - if "properties" in extensions: - properties = extensions["properties"] - if "formJsonSchemaFilename" in properties: - form_schema_file_name = properties["formJsonSchemaFilename"] - if "formUiSchemaFilename" in properties: - form_ui_schema_file_name = properties["formUiSchemaFilename"] can_complete = False try: @@ -336,71 +326,89 @@ def task_show(process_instance_id: int, task_guid: str = "next") -> flask.wrappe except (HumanTaskNotFoundError, UserDoesNotHaveAccessToTaskError, HumanTaskAlreadyCompletedError): can_complete = False - task_draft_data = TaskService.task_draft_data_from_task_model(task_model) - - saved_form_data = None - if task_draft_data is not None: - saved_form_data = task_draft_data.get_saved_form_data() - - task_model.data = task_model.get_data() - task_model.saved_form_data = saved_form_data task_model.process_model_display_name = process_model.display_name task_model.process_model_identifier = process_model.id task_model.typename = task_definition.typename task_model.can_complete = can_complete - task_process_identifier = task_model.bpmn_process.bpmn_process_definition.bpmn_identifier task_model.name_for_display = TaskService.get_name_for_display(task_definition) - process_model_with_form = process_model + if with_form_data: + task_process_identifier = task_model.bpmn_process.bpmn_process_definition.bpmn_identifier + process_model_with_form = process_model - refs = SpecFileService.get_references_for_process(process_model_with_form) - all_processes = [i.identifier for i in refs] - if task_process_identifier not in all_processes: - top_bpmn_process = TaskService.bpmn_process_for_called_activity_or_top_level_process(task_model) - bpmn_file_full_path = ProcessInstanceProcessor.bpmn_file_full_path_from_bpmn_process_identifier( - top_bpmn_process.bpmn_process_definition.bpmn_identifier - ) - relative_path = os.path.relpath(bpmn_file_full_path, start=FileSystemService.root_path()) - process_model_relative_path = os.path.dirname(relative_path) - process_model_with_form = ProcessModelService.get_process_model_from_relative_path(process_model_relative_path) - - if task_definition.typename == "UserTask": - if not form_schema_file_name: - raise ( - ApiError( - error_code="missing_form_file", - message=( - f"Cannot find a form file for process_instance_id: {process_instance_id}, task_guid:" - f" {task_guid}" - ), - status_code=400, - ) + refs = SpecFileService.get_references_for_process(process_model_with_form) + all_processes = [i.identifier for i in refs] + if task_process_identifier not in all_processes: + top_bpmn_process = TaskService.bpmn_process_for_called_activity_or_top_level_process(task_model) + bpmn_file_full_path = ProcessInstanceProcessor.bpmn_file_full_path_from_bpmn_process_identifier( + top_bpmn_process.bpmn_process_definition.bpmn_identifier + ) + relative_path = os.path.relpath(bpmn_file_full_path, start=FileSystemService.root_path()) + process_model_relative_path = os.path.dirname(relative_path) + process_model_with_form = ProcessModelService.get_process_model_from_relative_path( + process_model_relative_path ) - form_dict = _prepare_form_data( - form_schema_file_name, - task_model, - process_model_with_form, + form_schema_file_name = "" + form_ui_schema_file_name = "" + extensions = TaskService.get_extensions_from_task_model(task_model) + task_model.signal_buttons = TaskService.get_ready_signals_with_button_labels( + process_instance_id, task_model.guid ) - if task_model.data: - _update_form_schema_with_task_data_as_needed(form_dict, task_model) + if "properties" in extensions: + properties = extensions["properties"] + if "formJsonSchemaFilename" in properties: + form_schema_file_name = properties["formJsonSchemaFilename"] + if "formUiSchemaFilename" in properties: + form_ui_schema_file_name = properties["formUiSchemaFilename"] - if form_dict: - task_model.form_schema = form_dict + task_draft_data = TaskService.task_draft_data_from_task_model(task_model) - if form_ui_schema_file_name: - ui_form_contents = _prepare_form_data( - form_ui_schema_file_name, + saved_form_data = None + if task_draft_data is not None: + saved_form_data = task_draft_data.get_saved_form_data() + + task_model.data = task_model.get_data() + task_model.saved_form_data = saved_form_data + if task_definition.typename == "UserTask": + if not form_schema_file_name: + raise ( + ApiError( + error_code="missing_form_file", + message=( + f"Cannot find a form file for process_instance_id: {process_instance_id}, task_guid:" + f" {task_guid}" + ), + status_code=400, + ) + ) + + form_dict = _prepare_form_data( + form_schema_file_name, task_model, process_model_with_form, ) - 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) - JinjaService.render_instructions_for_end_user(task_model, extensions) - task_model.extensions = extensions + if task_model.data: + _update_form_schema_with_task_data_as_needed(form_dict, task_model) + + if form_dict: + task_model.form_schema = form_dict + + if form_ui_schema_file_name: + ui_form_contents = _prepare_form_data( + form_ui_schema_file_name, + task_model, + process_model_with_form, + ) + 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) + JinjaService.render_instructions_for_end_user(task_model, extensions) + task_model.extensions = extensions + return make_response(jsonify(task_model), 200) diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_for_good_errors.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_for_good_errors.py index a6689133..bd6298ce 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_for_good_errors.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_for_good_errors.py @@ -93,7 +93,7 @@ class TestForGoodErrors(BaseTest): assert len(human_tasks) > 0, "No human tasks found for process." human_task = human_tasks[0] response = client.get( - f"/v1.0/tasks/{process_instance_id}/{human_task.task_id}", + f"/v1.0/tasks/{process_instance_id}/{human_task.task_id}?with_form_data=true", headers=self.logged_in_headers(with_super_admin_user), ) return response diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_tasks_controller.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_tasks_controller.py index ae15bbfe..60d2c7b8 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_tasks_controller.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_tasks_controller.py @@ -55,7 +55,7 @@ class TestTasksController(BaseTest): assert len(human_tasks) == 1 human_task = human_tasks[0] response = client.get( - f"/v1.0/tasks/{process_instance_id}/{human_task.task_id}", + f"/v1.0/tasks/{process_instance_id}/{human_task.task_id}?with_form_data=true", headers=self.logged_in_headers(with_super_admin_user), ) assert response.status_code == 200 @@ -173,7 +173,7 @@ class TestTasksController(BaseTest): assert json_results[1]["task"]["title"] == "Manual Task" response = client.put( - f"/v1.0/tasks/{process_instance_id}/{json_results[1]['task']['id']}", + f"/v1.0/tasks/{process_instance_id}/{json_results[1]['task']['id']}?with_form_data=true", headers=headers, ) @@ -204,7 +204,7 @@ class TestTasksController(BaseTest): # Complete task as the finance user. response = client.put( - f"/v1.0/tasks/{process_instance_id}/{json_results[0]['task']['id']}", + f"/v1.0/tasks/{process_instance_id}/{json_results[0]['task']['id']}?with_form_data=true", headers=self.logged_in_headers(finance_user), ) @@ -363,7 +363,7 @@ class TestTasksController(BaseTest): assert response.status_code == 200 response = client.get( - f"/v1.0/tasks/{process_instance_id}/{task_id}", + f"/v1.0/tasks/{process_instance_id}/{task_id}?with_form_data=true", headers=self.logged_in_headers(with_super_admin_user), ) assert response.status_code == 200 @@ -380,7 +380,7 @@ class TestTasksController(BaseTest): # ensure draft data is deleted after submitting the task response = client.get( - f"/v1.0/tasks/{process_instance_id}/{task_id}", + f"/v1.0/tasks/{process_instance_id}/{task_id}?with_form_data=true", headers=self.logged_in_headers(with_super_admin_user), ) assert response.status_code == 200 diff --git a/spiffworkflow-frontend/src/interfaces.ts b/spiffworkflow-frontend/src/interfaces.ts index ff9c23dd..6a39dc9a 100644 --- a/spiffworkflow-frontend/src/interfaces.ts +++ b/spiffworkflow-frontend/src/interfaces.ts @@ -51,8 +51,8 @@ export interface SignalButton { event: EventDefinition; } -// TODO: merge with ProcessInstanceTask -export interface Task { +// Task withouth task data and form info - just the basics +export interface BasicTask { id: number; guid: string; process_instance_id: number; @@ -60,25 +60,30 @@ export interface Task { bpmn_name?: string; bpmn_process_direct_parent_guid: string; bpmn_process_definition_identifier: string; - data: any; state: string; typename: string; properties_json: TaskPropertiesJson; task_definition_properties_json: TaskDefinitionPropertiesJson; - event_definition?: EventDefinition; - process_model_display_name: string; process_model_identifier: string; name_for_display: string; can_complete: boolean; +} + +// TODO: merge with ProcessInstanceTask +// Currently used like TaskModel in backend +export interface Task extends BasicTask { + data: any; form_schema: any; form_ui_schema: any; signal_buttons: SignalButton[]; + event_definition?: EventDefinition; saved_form_data?: any; } +// Currently used like ApiTask in backend export interface ProcessInstanceTask { id: string; task_id: string; diff --git a/spiffworkflow-frontend/src/routes/TaskShow.tsx b/spiffworkflow-frontend/src/routes/TaskShow.tsx index fa880149..32265b95 100644 --- a/spiffworkflow-frontend/src/routes/TaskShow.tsx +++ b/spiffworkflow-frontend/src/routes/TaskShow.tsx @@ -2,15 +2,7 @@ import React, { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import validator from '@rjsf/validator-ajv8'; -import { - TabList, - Tab, - Tabs, - Grid, - Column, - Button, - ButtonSet, -} from '@carbon/react'; +import { Grid, Column, Button, ButtonSet, Loading } from '@carbon/react'; import { useDebouncedCallback } from 'use-debounce'; import { Form } from '../rjsf/carbon_theme'; @@ -21,7 +13,7 @@ import { modifyProcessIdentifierForPathParam, recursivelyChangeNullAndUndefined, } from '../helpers'; -import { EventDefinition, Task } from '../interfaces'; +import { BasicTask, EventDefinition, Task } from '../interfaces'; import ProcessBreadcrumb from '../components/ProcessBreadcrumb'; import InstructionsForEndUser from '../components/InstructionsForEndUser'; import TypeaheadWidget from '../rjsf/custom_widgets/TypeaheadWidget/TypeaheadWidget'; @@ -29,8 +21,8 @@ import DateRangePickerWidget from '../rjsf/custom_widgets/DateRangePicker/DateRa import { DATE_RANGE_DELIMITER } from '../config'; export default function TaskShow() { - const [task, setTask] = useState(null); - const [userTasks] = useState(null); + const [basicTask, setBasicTask] = useState(null); + const [taskWithTaskData, setTaskWithTaskData] = useState(null); const params = useParams(); const navigate = useNavigate(); const [formButtonsDisabled, setFormButtonsDisabled] = useState(false); @@ -48,7 +40,7 @@ export default function TaskShow() { // 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 - const navigateToInterstitial = (myTask: Task) => { + const navigateToInterstitial = (myTask: BasicTask) => { navigate( `/admin/process-instances/for-me/${modifyProcessIdentifierForPathParam( myTask.process_model_identifier @@ -57,20 +49,29 @@ export default function TaskShow() { }; useEffect(() => { - const processResult = (result: Task) => { - setTask(result); + const processBasicTaskResult = (result: BasicTask) => { + setBasicTask(result); + if (!result.can_complete) { + navigateToInterstitial(result); + } + }; + const processTaskWithDataResult = (result: Task) => { + setTaskWithTaskData(result); // convert null back to undefined so rjsf doesn't attempt to incorrectly validate them const taskDataToUse = result.saved_form_data || result.data; setTaskData(recursivelyChangeNullAndUndefined(taskDataToUse, undefined)); setFormButtonsDisabled(false); - if (!result.can_complete) { - navigateToInterstitial(result); - } }; + HttpService.makeCallToBackend({ path: `/tasks/${params.process_instance_id}/${params.task_id}`, - successCallback: processResult, + successCallback: processBasicTaskResult, + failureCallback: addError, + }); + HttpService.makeCallToBackend({ + path: `/tasks/${params.process_instance_id}/${params.task_id}?with_form_data=true`, + successCallback: processTaskWithDataResult, failureCallback: addError, }); // FIXME: not sure what to do about addError. adding it to this array causes the page to endlessly reload @@ -83,7 +84,7 @@ export default function TaskShow() { // https://github.com/sartography/spiff-arena/blob/182f56a1ad23ce780e8f5b0ed00efac3e6ad117b/spiffworkflow-frontend/src/routes/TaskShow.tsx#L329 const autoSaveTaskData = (formData: any, successCallback?: Function) => { // save-draft gets called when a manual task form loads but there's no data to save so don't do it - if (task?.typename === 'ManualTask') { + if (taskWithTaskData?.typename === 'ManualTask') { return undefined; } let successCallbackToUse = successCallback; @@ -180,7 +181,7 @@ export default function TaskShow() { }; const handleSignalSubmit = (event: EventDefinition) => { - if (formButtonsDisabled || !task) { + if (formButtonsDisabled || !taskWithTaskData) { return; } setFormButtonsDisabled(true); @@ -195,58 +196,6 @@ export default function TaskShow() { }); }; - const buildTaskNavigation = () => { - let userTasksElement; - let selectedTabIndex = 0; - if (userTasks) { - userTasksElement = (userTasks as any).map(function getUserTasksElement( - userTask: any, - index: number - ) { - const taskUrl = `/tasks/${params.process_instance_id}/${userTask.id}`; - if (userTask.id === params.task_id) { - selectedTabIndex = index; - return {userTask.name_for_display}; - } - if (userTask.state === 'COMPLETED') { - return ( - navigate(taskUrl)} - data-qa={`form-nav-${userTask.name}`} - > - {userTask.name_for_display} - - ); - } - if (userTask.state === 'FUTURE') { - return {userTask.name_for_display}; - } - if (userTask.state === 'READY') { - return ( - navigate(taskUrl)} - data-qa={`form-nav-${userTask.name}`} - > - {userTask.name_for_display} - - ); - } - return null; - }); - return ( - - - {userTasksElement} - - - ); - } - return null; - }; - const formatDateString = (dateString?: string) => { let dateObject = new Date(); if (dateString) { @@ -406,14 +355,14 @@ export default function TaskShow() { }; const formElement = () => { - if (!task) { + if (!taskWithTaskData) { return null; } let formUiSchema; - let jsonSchema = task.form_schema; + let jsonSchema = taskWithTaskData.form_schema; let reactFragmentToHideSubmitButton = null; - if (task.typename === 'ManualTask') { + if (taskWithTaskData.typename === 'ManualTask') { jsonSchema = { type: 'object', required: [], @@ -430,10 +379,10 @@ export default function TaskShow() { 'ui:widget': 'hidden', }, }; - } else if (task.form_ui_schema) { - formUiSchema = task.form_ui_schema; + } else if (taskWithTaskData.form_ui_schema) { + formUiSchema = taskWithTaskData.form_ui_schema; } - if (task.state !== 'READY') { + if (taskWithTaskData.state !== 'READY') { formUiSchema = Object.assign(formUiSchema || {}, { 'ui:readonly': true, }); @@ -445,12 +394,12 @@ export default function TaskShow() { reactFragmentToHideSubmitButton =
; } - if (task.state === 'READY') { + if (taskWithTaskData.state === 'READY') { let submitButtonText = 'Submit'; let closeButton = null; - if (task.typename === 'ManualTask') { + if (taskWithTaskData.typename === 'ManualTask') { submitButtonText = 'Continue'; - } else if (task.typename === 'UserTask') { + } else if (taskWithTaskData.typename === 'UserTask') { closeButton = (