added a loading icon on task show page to avoid blank page when loadi… (#411)

* 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 <jasquat@users.noreply.github.com>
This commit is contained in:
jasquat 2023-07-27 07:30:16 -04:00 committed by GitHub
parent 43beb916a3
commit cdaf2ea6c5
6 changed files with 165 additions and 180 deletions

View File

@ -1904,6 +1904,12 @@ 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: with_form_data
in: query
required: false
description: Include task data for forms
schema:
type: boolean
get: get:
tags: tags:
- Tasks - Tasks

View File

@ -300,7 +300,9 @@ def task_assign(
return make_response(jsonify({"ok": True}), 200) 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) process_instance = _find_process_instance_by_id_or_raise(process_instance_id)
if process_instance.status == ProcessInstanceStatus.suspended.value: 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, 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_model = _get_task_model_from_guid_or_raise(task_guid, process_instance_id)
task_definition = task_model.task_definition 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 can_complete = False
try: try:
@ -336,71 +326,89 @@ def task_show(process_instance_id: int, task_guid: str = "next") -> flask.wrappe
except (HumanTaskNotFoundError, UserDoesNotHaveAccessToTaskError, HumanTaskAlreadyCompletedError): except (HumanTaskNotFoundError, UserDoesNotHaveAccessToTaskError, HumanTaskAlreadyCompletedError):
can_complete = False 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_display_name = process_model.display_name
task_model.process_model_identifier = process_model.id task_model.process_model_identifier = process_model.id
task_model.typename = task_definition.typename task_model.typename = task_definition.typename
task_model.can_complete = can_complete 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) 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) refs = SpecFileService.get_references_for_process(process_model_with_form)
all_processes = [i.identifier for i in refs] all_processes = [i.identifier for i in refs]
if task_process_identifier not in all_processes: if task_process_identifier not in all_processes:
top_bpmn_process = TaskService.bpmn_process_for_called_activity_or_top_level_process(task_model) 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( bpmn_file_full_path = ProcessInstanceProcessor.bpmn_file_full_path_from_bpmn_process_identifier(
top_bpmn_process.bpmn_process_definition.bpmn_identifier top_bpmn_process.bpmn_process_definition.bpmn_identifier
) )
relative_path = os.path.relpath(bpmn_file_full_path, start=FileSystemService.root_path()) relative_path = os.path.relpath(bpmn_file_full_path, start=FileSystemService.root_path())
process_model_relative_path = os.path.dirname(relative_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) 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,
)
) )
form_dict = _prepare_form_data( form_schema_file_name = ""
form_schema_file_name, form_ui_schema_file_name = ""
task_model, extensions = TaskService.get_extensions_from_task_model(task_model)
process_model_with_form, task_model.signal_buttons = TaskService.get_ready_signals_with_button_labels(
process_instance_id, task_model.guid
) )
if task_model.data: if "properties" in extensions:
_update_form_schema_with_task_data_as_needed(form_dict, task_model) 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_draft_data = TaskService.task_draft_data_from_task_model(task_model)
task_model.form_schema = form_dict
if form_ui_schema_file_name: saved_form_data = None
ui_form_contents = _prepare_form_data( if task_draft_data is not None:
form_ui_schema_file_name, 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, task_model,
process_model_with_form, 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) if task_model.data:
JinjaService.render_instructions_for_end_user(task_model, extensions) _update_form_schema_with_task_data_as_needed(form_dict, task_model)
task_model.extensions = extensions
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) return make_response(jsonify(task_model), 200)

View File

@ -93,7 +93,7 @@ class TestForGoodErrors(BaseTest):
assert len(human_tasks) > 0, "No human tasks found for process." assert len(human_tasks) > 0, "No human tasks found for process."
human_task = human_tasks[0] human_task = human_tasks[0]
response = client.get( 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), headers=self.logged_in_headers(with_super_admin_user),
) )
return response return response

View File

@ -55,7 +55,7 @@ class TestTasksController(BaseTest):
assert len(human_tasks) == 1 assert len(human_tasks) == 1
human_task = human_tasks[0] human_task = human_tasks[0]
response = client.get( 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), headers=self.logged_in_headers(with_super_admin_user),
) )
assert response.status_code == 200 assert response.status_code == 200
@ -173,7 +173,7 @@ class TestTasksController(BaseTest):
assert json_results[1]["task"]["title"] == "Manual Task" assert json_results[1]["task"]["title"] == "Manual Task"
response = client.put( 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, headers=headers,
) )
@ -204,7 +204,7 @@ class TestTasksController(BaseTest):
# Complete task as the finance user. # Complete task as the finance user.
response = client.put( 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), headers=self.logged_in_headers(finance_user),
) )
@ -363,7 +363,7 @@ class TestTasksController(BaseTest):
assert response.status_code == 200 assert response.status_code == 200
response = client.get( 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), headers=self.logged_in_headers(with_super_admin_user),
) )
assert response.status_code == 200 assert response.status_code == 200
@ -380,7 +380,7 @@ class TestTasksController(BaseTest):
# ensure draft data is deleted after submitting the task # ensure draft data is deleted after submitting the task
response = client.get( 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), headers=self.logged_in_headers(with_super_admin_user),
) )
assert response.status_code == 200 assert response.status_code == 200

View File

@ -51,8 +51,8 @@ export interface SignalButton {
event: EventDefinition; event: EventDefinition;
} }
// TODO: merge with ProcessInstanceTask // Task withouth task data and form info - just the basics
export interface Task { export interface BasicTask {
id: number; id: number;
guid: string; guid: string;
process_instance_id: number; process_instance_id: number;
@ -60,25 +60,30 @@ export interface Task {
bpmn_name?: string; bpmn_name?: string;
bpmn_process_direct_parent_guid: string; bpmn_process_direct_parent_guid: string;
bpmn_process_definition_identifier: string; bpmn_process_definition_identifier: string;
data: any;
state: string; state: string;
typename: string; typename: string;
properties_json: TaskPropertiesJson; properties_json: TaskPropertiesJson;
task_definition_properties_json: TaskDefinitionPropertiesJson; task_definition_properties_json: TaskDefinitionPropertiesJson;
event_definition?: EventDefinition;
process_model_display_name: string; process_model_display_name: string;
process_model_identifier: string; process_model_identifier: string;
name_for_display: string; name_for_display: string;
can_complete: boolean; can_complete: boolean;
}
// TODO: merge with ProcessInstanceTask
// Currently used like TaskModel in backend
export interface Task extends BasicTask {
data: any;
form_schema: any; form_schema: any;
form_ui_schema: any; form_ui_schema: any;
signal_buttons: SignalButton[]; signal_buttons: SignalButton[];
event_definition?: EventDefinition;
saved_form_data?: any; saved_form_data?: any;
} }
// Currently used like ApiTask in backend
export interface ProcessInstanceTask { export interface ProcessInstanceTask {
id: string; id: string;
task_id: string; task_id: string;

View File

@ -2,15 +2,7 @@ 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';
import { import { Grid, Column, Button, ButtonSet, Loading } from '@carbon/react';
TabList,
Tab,
Tabs,
Grid,
Column,
Button,
ButtonSet,
} from '@carbon/react';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import { Form } from '../rjsf/carbon_theme'; import { Form } from '../rjsf/carbon_theme';
@ -21,7 +13,7 @@ import {
modifyProcessIdentifierForPathParam, modifyProcessIdentifierForPathParam,
recursivelyChangeNullAndUndefined, recursivelyChangeNullAndUndefined,
} from '../helpers'; } from '../helpers';
import { 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 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'; import { DATE_RANGE_DELIMITER } from '../config';
export default function TaskShow() { export default function TaskShow() {
const [task, setTask] = useState<Task | null>(null); const [basicTask, setBasicTask] = useState<BasicTask | null>(null);
const [userTasks] = useState(null); const [taskWithTaskData, setTaskWithTaskData] = useState<Task | null>(null);
const params = useParams(); const params = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [formButtonsDisabled, setFormButtonsDisabled] = useState(false); 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 // 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: Task) => { const navigateToInterstitial = (myTask: BasicTask) => {
navigate( navigate(
`/admin/process-instances/for-me/${modifyProcessIdentifierForPathParam( `/admin/process-instances/for-me/${modifyProcessIdentifierForPathParam(
myTask.process_model_identifier myTask.process_model_identifier
@ -57,20 +49,29 @@ export default function TaskShow() {
}; };
useEffect(() => { useEffect(() => {
const processResult = (result: Task) => { const processBasicTaskResult = (result: BasicTask) => {
setTask(result); 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 // convert null back to undefined so rjsf doesn't attempt to incorrectly validate them
const taskDataToUse = result.saved_form_data || result.data; const taskDataToUse = result.saved_form_data || result.data;
setTaskData(recursivelyChangeNullAndUndefined(taskDataToUse, undefined)); setTaskData(recursivelyChangeNullAndUndefined(taskDataToUse, undefined));
setFormButtonsDisabled(false); setFormButtonsDisabled(false);
if (!result.can_complete) {
navigateToInterstitial(result);
}
}; };
HttpService.makeCallToBackend({ HttpService.makeCallToBackend({
path: `/tasks/${params.process_instance_id}/${params.task_id}`, 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, failureCallback: addError,
}); });
// FIXME: not sure what to do about addError. adding it to this array causes the page to endlessly reload // 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 // https://github.com/sartography/spiff-arena/blob/182f56a1ad23ce780e8f5b0ed00efac3e6ad117b/spiffworkflow-frontend/src/routes/TaskShow.tsx#L329
const autoSaveTaskData = (formData: any, successCallback?: Function) => { 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 // 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; return undefined;
} }
let successCallbackToUse = successCallback; let successCallbackToUse = successCallback;
@ -180,7 +181,7 @@ export default function TaskShow() {
}; };
const handleSignalSubmit = (event: EventDefinition) => { const handleSignalSubmit = (event: EventDefinition) => {
if (formButtonsDisabled || !task) { if (formButtonsDisabled || !taskWithTaskData) {
return; return;
} }
setFormButtonsDisabled(true); 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 <Tab selected>{userTask.name_for_display}</Tab>;
}
if (userTask.state === 'COMPLETED') {
return (
<Tab
onClick={() => navigate(taskUrl)}
data-qa={`form-nav-${userTask.name}`}
>
{userTask.name_for_display}
</Tab>
);
}
if (userTask.state === 'FUTURE') {
return <Tab formButtonsDisabled>{userTask.name_for_display}</Tab>;
}
if (userTask.state === 'READY') {
return (
<Tab
onClick={() => navigate(taskUrl)}
data-qa={`form-nav-${userTask.name}`}
>
{userTask.name_for_display}
</Tab>
);
}
return null;
});
return (
<Tabs
title="Steps in this process instance involving people"
selectedIndex={selectedTabIndex}
>
<TabList aria-label="List of tabs" contained>
{userTasksElement}
</TabList>
</Tabs>
);
}
return null;
};
const formatDateString = (dateString?: string) => { const formatDateString = (dateString?: string) => {
let dateObject = new Date(); let dateObject = new Date();
if (dateString) { if (dateString) {
@ -406,14 +355,14 @@ export default function TaskShow() {
}; };
const formElement = () => { const formElement = () => {
if (!task) { if (!taskWithTaskData) {
return null; return null;
} }
let formUiSchema; let formUiSchema;
let jsonSchema = task.form_schema; let jsonSchema = taskWithTaskData.form_schema;
let reactFragmentToHideSubmitButton = null; let reactFragmentToHideSubmitButton = null;
if (task.typename === 'ManualTask') { if (taskWithTaskData.typename === 'ManualTask') {
jsonSchema = { jsonSchema = {
type: 'object', type: 'object',
required: [], required: [],
@ -430,10 +379,10 @@ export default function TaskShow() {
'ui:widget': 'hidden', 'ui:widget': 'hidden',
}, },
}; };
} else if (task.form_ui_schema) { } else if (taskWithTaskData.form_ui_schema) {
formUiSchema = task.form_ui_schema; formUiSchema = taskWithTaskData.form_ui_schema;
} }
if (task.state !== 'READY') { if (taskWithTaskData.state !== 'READY') {
formUiSchema = Object.assign(formUiSchema || {}, { formUiSchema = Object.assign(formUiSchema || {}, {
'ui:readonly': true, 'ui:readonly': true,
}); });
@ -445,12 +394,12 @@ export default function TaskShow() {
reactFragmentToHideSubmitButton = <div />; reactFragmentToHideSubmitButton = <div />;
} }
if (task.state === 'READY') { if (taskWithTaskData.state === 'READY') {
let submitButtonText = 'Submit'; let submitButtonText = 'Submit';
let closeButton = null; let closeButton = null;
if (task.typename === 'ManualTask') { if (taskWithTaskData.typename === 'ManualTask') {
submitButtonText = 'Continue'; submitButtonText = 'Continue';
} else if (task.typename === 'UserTask') { } else if (taskWithTaskData.typename === 'UserTask') {
closeButton = ( closeButton = (
<Button <Button
id="close-button" id="close-button"
@ -474,7 +423,7 @@ export default function TaskShow() {
</Button> </Button>
{closeButton} {closeButton}
<> <>
{task.signal_buttons.map((signal) => ( {taskWithTaskData.signal_buttons.map((signal) => (
<Button <Button
name="signal.signal" name="signal.signal"
disabled={formButtonsDisabled} disabled={formButtonsDisabled}
@ -532,35 +481,52 @@ export default function TaskShow() {
); );
}; };
if (task) { const getLoadingIcon = () => {
const style = { margin: '50px 0 50px 50px' };
return (
<Loading
description="Active loading indicator"
withOverlay={false}
style={style}
/>
);
};
const pageElements = [];
if (basicTask) {
let statusString = ''; let statusString = '';
if (task.state !== 'READY') { if (basicTask.state !== 'READY') {
statusString = ` ${task.state}`; statusString = ` ${basicTask.state}`;
} }
return ( pageElements.push(
<> <ProcessBreadcrumb
<ProcessBreadcrumb hotCrumbs={[
hotCrumbs={[ [
[ `Process Instance Id: ${params.process_instance_id}`,
`Process Instance Id: ${params.process_instance_id}`, `/admin/process-instances/for-me/${modifyProcessIdentifierForPathParam(
`/admin/process-instances/for-me/${modifyProcessIdentifierForPathParam( basicTask.process_model_identifier
task.process_model_identifier )}/${params.process_instance_id}`,
)}/${params.process_instance_id}`, ],
], [`Task: ${basicTask.name_for_display || basicTask.id}`],
[`Task: ${task.name_for_display || task.id}`], ]}
]} />
/> );
<div>{buildTaskNavigation()}</div> pageElements.push(
<h3> <h3>
Task: {task.name_for_display} ({task.process_model_display_name}) Task: {basicTask.name_for_display} (
{statusString} {basicTask.process_model_display_name}){statusString}
</h3> </h3>
<InstructionsForEndUser task={task} />
{formElement()}
</>
); );
} }
if (basicTask && taskData) {
pageElements.push(<InstructionsForEndUser task={taskWithTaskData} />);
pageElements.push(formElement());
} else {
pageElements.push(getLoadingIcon());
}
return null; // typescript gets angry if we return an array of elements not in a tag
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{pageElements}</>;
} }