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 c416a5a05e
commit 6a922b2eb4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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.
schema:
type: integer
- name: with_form_data
in: query
required: false
description: Include task data for forms
schema:
type: boolean
get:
tags:
- Tasks

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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<Task | null>(null);
const [userTasks] = useState(null);
const [basicTask, setBasicTask] = useState<BasicTask | null>(null);
const [taskWithTaskData, setTaskWithTaskData] = useState<Task | null>(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 <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) => {
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 = <div />;
}
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 = (
<Button
id="close-button"
@ -474,7 +423,7 @@ export default function TaskShow() {
</Button>
{closeButton}
<>
{task.signal_buttons.map((signal) => (
{taskWithTaskData.signal_buttons.map((signal) => (
<Button
name="signal.signal"
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 = '';
if (task.state !== 'READY') {
statusString = ` ${task.state}`;
if (basicTask.state !== 'READY') {
statusString = ` ${basicTask.state}`;
}
return (
<>
<ProcessBreadcrumb
hotCrumbs={[
[
`Process Instance Id: ${params.process_instance_id}`,
`/admin/process-instances/for-me/${modifyProcessIdentifierForPathParam(
task.process_model_identifier
)}/${params.process_instance_id}`,
],
[`Task: ${task.name_for_display || task.id}`],
]}
/>
<div>{buildTaskNavigation()}</div>
<h3>
Task: {task.name_for_display} ({task.process_model_display_name})
{statusString}
</h3>
<InstructionsForEndUser task={task} />
{formElement()}
</>
pageElements.push(
<ProcessBreadcrumb
hotCrumbs={[
[
`Process Instance Id: ${params.process_instance_id}`,
`/admin/process-instances/for-me/${modifyProcessIdentifierForPathParam(
basicTask.process_model_identifier
)}/${params.process_instance_id}`,
],
[`Task: ${basicTask.name_for_display || basicTask.id}`],
]}
/>
);
pageElements.push(
<h3>
Task: {basicTask.name_for_display} (
{basicTask.process_model_display_name}){statusString}
</h3>
);
}
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}</>;
}