diff --git a/.flake8 b/.flake8 index 6e5fa533b..eb9a3a611 100644 --- a/.flake8 +++ b/.flake8 @@ -44,3 +44,6 @@ per-file-ignores = # S607 Starting a process with a partial executable path # S605 Starting a process with a shell: Seems safe, but may be changed in the future, consider rewriting without shell spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py:S607,S101,S605,D102,D103,D101 + + # TODO: refactor this service so complexity should be reduced throughout + spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py:C901,D100,D101,D102,D103,D107 diff --git a/spiffworkflow-backend/.flake8 b/spiffworkflow-backend/.flake8 index d73f1dba4..36c7a9124 100644 --- a/spiffworkflow-backend/.flake8 +++ b/spiffworkflow-backend/.flake8 @@ -41,3 +41,6 @@ per-file-ignores = src/spiffworkflow_backend/services/logging_service.py:N802,B950 tests/spiffworkflow_backend/integration/test_process_api.py:S607,S101,S605,D102,D103,D101 + + # TODO: refactor this service so complexity should be reduced throughout + src/spiffworkflow_backend/services/process_instance_report_service.py:C901,D100,D101,D102,D103,D107 diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/local_development.yml b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/local_development.yml index 049c991ed..eb9ce4b7a 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/local_development.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/local_development.yml @@ -3,10 +3,14 @@ default_group: everybody groups: admin: users: [admin@spiffworkflow.org] + group1: + users: [jason@sartography.com, kb@sartography.com] + group2: + users: [dan@sartography.com] permissions: admin: - groups: [admin] + groups: [admin, group1, group2] users: [] allowed_permissions: [create, read, update, delete] uri: /* diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py index a67b7d5aa..5b35df3e1 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py @@ -161,9 +161,12 @@ class ProcessInstanceModel(SpiffworkflowBaseDBModel): @classmethod def terminal_statuses(cls) -> list[str]: - """Terminal_statuses.""" return ["complete", "error", "terminated"] + @classmethod + def active_statuses(cls) -> list[str]: + return ["user_input_required", "waiting"] + class ProcessInstanceModelSchema(Schema): """ProcessInstanceModelSchema.""" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py index cbf25bb6e..e354d31ee 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py @@ -6,11 +6,13 @@ from typing import Optional from typing import Type import sqlalchemy +from flask import current_app from sqlalchemy import and_ from sqlalchemy import func from sqlalchemy import or_ from sqlalchemy.orm import aliased from sqlalchemy.orm import selectinload +from sqlalchemy.orm.util import AliasedClass from spiffworkflow_backend.exceptions.api_error import ApiError from spiffworkflow_backend.models.db import SpiffworkflowBaseDBModel @@ -46,12 +48,15 @@ class ProcessInstanceReportFilter: process_status: Optional[list[str]] = None initiated_by_me: Optional[bool] = None has_terminal_status: Optional[bool] = None + has_active_status: Optional[bool] = None with_tasks_completed_by_me: Optional[bool] = None + with_tasks_i_can_complete: Optional[bool] = None with_tasks_assigned_to_my_group: Optional[bool] = None with_relation_to_me: Optional[bool] = None process_initiator_username: Optional[str] = None report_column_list: Optional[list] = None report_filter_by_list: Optional[list] = None + oldest_open_human_task_fields: Optional[list] = None def to_dict(self) -> dict[str, str]: """To_dict.""" @@ -75,8 +80,12 @@ class ProcessInstanceReportFilter: d["initiated_by_me"] = str(self.initiated_by_me).lower() if self.has_terminal_status is not None: d["has_terminal_status"] = str(self.has_terminal_status).lower() + if self.has_active_status is not None: + d["has_active_status"] = str(self.has_active_status).lower() if self.with_tasks_completed_by_me is not None: d["with_tasks_completed_by_me"] = str(self.with_tasks_completed_by_me).lower() + if self.with_tasks_i_can_complete is not None: + d["with_tasks_i_can_complete"] = str(self.with_tasks_i_can_complete).lower() if self.with_tasks_assigned_to_my_group is not None: d["with_tasks_assigned_to_my_group"] = str(self.with_tasks_assigned_to_my_group).lower() if self.with_relation_to_me is not None: @@ -87,6 +96,8 @@ class ProcessInstanceReportFilter: d["report_column_list"] = str(self.report_column_list) if self.report_filter_by_list is not None: d["report_filter_by_list"] = str(self.report_filter_by_list) + if self.oldest_open_human_task_fields is not None: + d["oldest_open_human_task_fields"] = str(self.oldest_open_human_task_fields) return d @@ -140,6 +151,78 @@ class ProcessInstanceReportService: ], "order_by": ["-start_in_seconds", "-id"], }, + "system_report_in_progress_instances_initiated_by_me": { + "columns": [ + {"Header": "id", "accessor": "id"}, + { + "Header": "process_model_display_name", + "accessor": "process_model_display_name", + }, + {"Header": "Task", "accessor": "task_title"}, + {"Header": "Waiting For", "accessor": "waiting_for"}, + {"Header": "Started", "accessor": "start_in_seconds"}, + {"Header": "Last Updated", "accessor": "updated_at_in_seconds"}, + {"Header": "status", "accessor": "status"}, + ], + "filter_by": [ + {"field_name": "initiated_by_me", "field_value": "true"}, + {"field_name": "has_terminal_status", "field_value": "false"}, + { + "field_name": "oldest_open_human_task_fields", + "field_value": ( + "task_id,task_title,task_name,potential_owner_usernames,assigned_user_group_identifier" + ), + }, + ], + "order_by": ["-start_in_seconds", "-id"], + }, + "system_report_in_progress_instances_with_tasks_for_me": { + "columns": [ + {"Header": "id", "accessor": "id"}, + { + "Header": "process_model_display_name", + "accessor": "process_model_display_name", + }, + {"Header": "Task", "accessor": "task_title"}, + {"Header": "Started By", "accessor": "process_initiator_username"}, + {"Header": "Started", "accessor": "start_in_seconds"}, + {"Header": "Last Updated", "accessor": "updated_at_in_seconds"}, + ], + "filter_by": [ + {"field_name": "with_tasks_i_can_complete", "field_value": "true"}, + {"field_name": "has_active_status", "field_value": "true"}, + { + "field_name": "oldest_open_human_task_fields", + "field_value": "task_id,task_title,task_name", + }, + ], + "order_by": ["-start_in_seconds", "-id"], + }, + "system_report_in_progress_instances_with_tasks_for_my_group": { + "columns": [ + {"Header": "id", "accessor": "id"}, + { + "Header": "process_model_display_name", + "accessor": "process_model_display_name", + }, + {"Header": "Task", "accessor": "task_title"}, + {"Header": "Started By", "accessor": "process_initiator_username"}, + {"Header": "Started", "accessor": "start_in_seconds"}, + {"Header": "Last Updated", "accessor": "updated_at_in_seconds"}, + ], + "filter_by": [ + { + "field_name": "with_tasks_assigned_to_my_group", + "field_value": "true", + }, + {"field_name": "has_active_status", "field_value": "true"}, + { + "field_name": "oldest_open_human_task_fields", + "field_value": "task_id,task_title,task_name", + }, + ], + "order_by": ["-start_in_seconds", "-id"], + }, } if metadata_key not in temp_system_metadata_map: @@ -199,14 +282,18 @@ class ProcessInstanceReportService: def bool_value(key: str) -> Optional[bool]: """Bool_value.""" - return bool(filters[key]) if key in filters else None + if key not in filters: + return None + # bool returns True if not an empty string so check explicitly for false + if filters[key] in ["false", "False"]: + return False + return bool(filters[key]) def int_value(key: str) -> Optional[int]: """Int_value.""" return int(filters[key]) if key in filters else None def list_value(key: str) -> Optional[list[str]]: - """List_value.""" return filters[key].split(",") if key in filters else None process_model_identifier = filters.get("process_model_identifier") @@ -218,12 +305,15 @@ class ProcessInstanceReportService: process_status = list_value("process_status") initiated_by_me = bool_value("initiated_by_me") has_terminal_status = bool_value("has_terminal_status") + has_active_status = bool_value("has_active_status") with_tasks_completed_by_me = bool_value("with_tasks_completed_by_me") + with_tasks_i_can_complete = bool_value("with_tasks_i_can_complete") with_tasks_assigned_to_my_group = bool_value("with_tasks_assigned_to_my_group") with_relation_to_me = bool_value("with_relation_to_me") process_initiator_username = filters.get("process_initiator_username") report_column_list = list_value("report_column_list") report_filter_by_list = list_value("report_filter_by_list") + oldest_open_human_task_fields = list_value("oldest_open_human_task_fields") report_filter = ProcessInstanceReportFilter( process_model_identifier=process_model_identifier, @@ -235,12 +325,15 @@ class ProcessInstanceReportService: process_status=process_status, initiated_by_me=initiated_by_me, has_terminal_status=has_terminal_status, + has_active_status=has_active_status, with_tasks_completed_by_me=with_tasks_completed_by_me, + with_tasks_i_can_complete=with_tasks_i_can_complete, with_tasks_assigned_to_my_group=with_tasks_assigned_to_my_group, with_relation_to_me=with_relation_to_me, process_initiator_username=process_initiator_username, report_column_list=report_column_list, report_filter_by_list=report_filter_by_list, + oldest_open_human_task_fields=oldest_open_human_task_fields, ) return report_filter @@ -258,12 +351,15 @@ class ProcessInstanceReportService: process_status: Optional[str] = None, initiated_by_me: Optional[bool] = None, has_terminal_status: Optional[bool] = None, + has_active_status: Optional[bool] = None, with_tasks_completed_by_me: Optional[bool] = None, + with_tasks_i_can_complete: Optional[bool] = None, with_tasks_assigned_to_my_group: Optional[bool] = None, with_relation_to_me: Optional[bool] = None, process_initiator_username: Optional[str] = None, report_column_list: Optional[list] = None, report_filter_by_list: Optional[list] = None, + oldest_open_human_task_fields: Optional[list] = None, ) -> ProcessInstanceReportFilter: """Filter_from_metadata_with_overrides.""" report_filter = cls.filter_from_metadata(process_instance_report) @@ -286,14 +382,20 @@ class ProcessInstanceReportService: report_filter.initiated_by_me = initiated_by_me if has_terminal_status is not None: report_filter.has_terminal_status = has_terminal_status + if has_active_status is not None: + report_filter.has_active_status = has_active_status if with_tasks_completed_by_me is not None: report_filter.with_tasks_completed_by_me = with_tasks_completed_by_me + if with_tasks_i_can_complete is not None: + report_filter.with_tasks_i_can_complete = with_tasks_i_can_complete if process_initiator_username is not None: report_filter.process_initiator_username = process_initiator_username if report_column_list is not None: report_filter.report_column_list = report_column_list if report_filter_by_list is not None: report_filter.report_filter_by_list = report_filter_by_list + if oldest_open_human_task_fields is not None: + report_filter.oldest_open_human_task_fields = oldest_open_human_task_fields if with_tasks_assigned_to_my_group is not None: report_filter.with_tasks_assigned_to_my_group = with_tasks_assigned_to_my_group if with_relation_to_me is not None: @@ -321,6 +423,54 @@ class ProcessInstanceReportService: results.append(process_instance_dict) return results + @classmethod + def add_human_task_fields( + cls, process_instance_dicts: list[dict], oldest_open_human_task_fields: list + ) -> list[dict]: + for process_instance_dict in process_instance_dicts: + assigned_user = aliased(UserModel) + human_task_query = ( + HumanTaskModel.query.filter_by(process_instance_id=process_instance_dict["id"], completed=False) + .group_by(HumanTaskModel.id) + .outerjoin( + HumanTaskUserModel, + HumanTaskModel.id == HumanTaskUserModel.human_task_id, + ) + .outerjoin(assigned_user, assigned_user.id == HumanTaskUserModel.user_id) + .outerjoin(GroupModel, GroupModel.id == HumanTaskModel.lane_assignment_id) + ) + potential_owner_usernames_from_group_concat_or_similar = cls._get_potential_owner_usernames(assigned_user) + human_task = ( + human_task_query.add_columns( + HumanTaskModel.task_id, + HumanTaskModel.task_name, + HumanTaskModel.task_title, + func.max(GroupModel.identifier).label("assigned_user_group_identifier"), + potential_owner_usernames_from_group_concat_or_similar, + ) + .order_by(HumanTaskModel.id.asc()) # type: ignore + .first() + ) + if human_task is not None: + for field in oldest_open_human_task_fields: + process_instance_dict[field] = getattr(human_task, field) + return process_instance_dicts + + @classmethod + def _get_potential_owner_usernames(cls, assigned_user: AliasedClass) -> Any: + """_get_potential_owner_usernames.""" + potential_owner_usernames_from_group_concat_or_similar = func.group_concat( + assigned_user.username.distinct() + ).label("potential_owner_usernames") + db_type = current_app.config.get("SPIFFWORKFLOW_BACKEND_DATABASE_TYPE") + + if db_type == "postgres": + potential_owner_usernames_from_group_concat_or_similar = func.string_agg( + assigned_user.username.distinct(), ", " + ).label("potential_owner_usernames") + + return potential_owner_usernames_from_group_concat_or_similar + @classmethod def get_column_names_for_model(cls, model: Type[SpiffworkflowBaseDBModel]) -> list[str]: """Get_column_names_for_model.""" @@ -405,6 +555,14 @@ class ProcessInstanceReportService: process_instance_query = process_instance_query.filter( ProcessInstanceModel.status.in_(ProcessInstanceModel.terminal_statuses()) # type: ignore ) + elif report_filter.has_terminal_status is False: + process_instance_query = process_instance_query.filter( + ProcessInstanceModel.status.not_in(ProcessInstanceModel.terminal_statuses()) # type: ignore + ) + if report_filter.has_active_status is True: + process_instance_query = process_instance_query.filter( + ProcessInstanceModel.status.in_(ProcessInstanceModel.active_statuses()) # type: ignore + ) if report_filter.process_initiator_username is not None: initiator = UserModel.query.filter_by(username=report_filter.process_initiator_username).first() @@ -416,6 +574,7 @@ class ProcessInstanceReportService: if ( not report_filter.with_tasks_completed_by_me and not report_filter.with_tasks_assigned_to_my_group + and not report_filter.with_tasks_i_can_complete and report_filter.with_relation_to_me is True ): process_instance_query = process_instance_query.outerjoin(HumanTaskModel).outerjoin( @@ -444,6 +603,21 @@ class ProcessInstanceReportService: ), ) + if report_filter.with_tasks_i_can_complete is True: + process_instance_query = process_instance_query.filter( + ProcessInstanceModel.process_initiator_id != user.id + ) + process_instance_query = process_instance_query.join( + HumanTaskModel, + and_( + HumanTaskModel.process_instance_id == ProcessInstanceModel.id, + HumanTaskModel.lane_assignment_id.is_(None), # type: ignore + ), + ).join( + HumanTaskUserModel, + and_(HumanTaskUserModel.human_task_id == HumanTaskModel.id, HumanTaskUserModel.user_id == user.id), + ) + if report_filter.with_tasks_assigned_to_my_group is True: group_model_join_conditions = [GroupModel.id == HumanTaskModel.lane_assignment_id] if report_filter.user_group_identifier: @@ -457,7 +631,7 @@ class ProcessInstanceReportService: process_instance_query = process_instance_query.filter(UserGroupAssignmentModel.user_id == user.id) instance_metadata_aliases = {} - stock_columns = ProcessInstanceReportService.get_column_names_for_model(ProcessInstanceModel) + stock_columns = cls.get_column_names_for_model(ProcessInstanceModel) if isinstance(report_filter.report_column_list, list): process_instance_report.report_metadata["columns"] = report_filter.report_column_list if isinstance(report_filter.report_filter_by_list, list): @@ -507,16 +681,19 @@ class ProcessInstanceReportService: order_by_query_array.append(func.max(instance_metadata_aliases[attribute].value).desc()) else: order_by_query_array.append(func.max(instance_metadata_aliases[attribute].value).asc()) - # return process_instance_query + process_instances = ( process_instance_query.group_by(ProcessInstanceModel.id) .add_columns(ProcessInstanceModel.id) .order_by(*order_by_query_array) .paginate(page=page, per_page=per_page, error_out=False) ) - results = ProcessInstanceReportService.add_metadata_columns_to_process_instance( + results = cls.add_metadata_columns_to_process_instance( process_instances.items, process_instance_report.report_metadata["columns"] ) + + if report_filter.oldest_open_human_task_fields: + results = cls.add_human_task_fields(results, report_filter.oldest_open_human_task_fields) response_json = { "report": process_instance_report, "results": results, diff --git a/spiffworkflow-frontend/cypress/e2e/tasks.cy.js b/spiffworkflow-frontend/cypress/e2e/tasks.cy.js index a4b4a4ddc..50d24899f 100644 --- a/spiffworkflow-frontend/cypress/e2e/tasks.cy.js +++ b/spiffworkflow-frontend/cypress/e2e/tasks.cy.js @@ -90,7 +90,9 @@ describe('tasks', () => { cy.get('.is-visible .cds--modal-close').click(); cy.navigateToHome(); - cy.contains('Tasks').should('exist'); + + // look for somethig to make sure the homepage has loaded + cy.contains('Waiting for me').should('exist'); // FIXME: this will probably need a better way to link to the proper form that we want cy.contains('Go').click(); diff --git a/spiffworkflow-frontend/src/components/ProcessInstanceListTable.tsx b/spiffworkflow-frontend/src/components/ProcessInstanceListTable.tsx index 81b644d1a..227ae1bc5 100644 --- a/spiffworkflow-frontend/src/components/ProcessInstanceListTable.tsx +++ b/spiffworkflow-frontend/src/components/ProcessInstanceListTable.tsx @@ -69,6 +69,8 @@ import { Notification } from './Notification'; import useAPIError from '../hooks/UseApiError'; import { usePermissionFetcher } from '../hooks/PermissionService'; import { Can } from '../contexts/Can'; +import TableCellWithTimeAgoInWords from './TableCellWithTimeAgoInWords'; +import UserService from '../services/UserService'; type OwnProps = { filtersEnabled?: boolean; @@ -82,6 +84,8 @@ type OwnProps = { autoReload?: boolean; additionalParams?: string; variant?: string; + canCompleteAllTasks?: boolean; + showActionsColumn?: boolean; }; interface dateParameters { @@ -100,6 +104,8 @@ export default function ProcessInstanceListTable({ paginationClassName, autoReload = false, variant = 'for-me', + canCompleteAllTasks = false, + showActionsColumn = false, }: OwnProps) { let apiPath = '/process-instances/for-me'; if (variant === 'all') { @@ -141,6 +147,9 @@ export default function ProcessInstanceListTable({ const [requiresRefilter, setRequiresRefilter] = useState(false); const [lastColumnFilter, setLastColumnFilter] = useState(''); + const preferredUsername = UserService.getPreferredUsername(); + const userEmail = UserService.getUserEmail(); + const processInstanceListPathPrefix = variant === 'all' ? '/admin/process-instances/all' @@ -245,6 +254,8 @@ export default function ProcessInstanceListTable({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const clearRefreshRef = useRef(null); + // eslint-disable-next-line sonarjs/cognitive-complexity useEffect(() => { function setProcessInstancesFromResult(result: any) { @@ -259,6 +270,11 @@ export default function ProcessInstanceListTable({ setProcessInstanceReportSelection(result.report); } } + const stopRefreshing = () => { + if (clearRefreshRef.current) { + clearRefreshRef.current(); + } + }; function getProcessInstances() { // eslint-disable-next-line prefer-const let { page, perPage } = getPageInfoFromSearchParams( @@ -343,6 +359,7 @@ export default function ProcessInstanceListTable({ HttpService.makeCallToBackend({ path: `${apiPath}?${queryParamString}`, successCallback: setProcessInstancesFromResult, + onUnauthorized: stopRefreshing, }); } function processResultForProcessModels(result: any) { @@ -387,11 +404,12 @@ export default function ProcessInstanceListTable({ checkFiltersAndRun(); if (autoReload) { - return refreshAtInterval( + clearRefreshRef.current = refreshAtInterval( REFRESH_INTERVAL_SECONDS, REFRESH_TIMEOUT_SECONDS, checkFiltersAndRun ); + return clearRefreshRef.current; } return undefined; }, [ @@ -1294,6 +1312,111 @@ export default function ProcessInstanceListTable({ ); }; + const getWaitingForTableCellComponent = (processInstanceTask: any) => { + let fullUsernameString = ''; + let shortUsernameString = ''; + if (processInstanceTask.potential_owner_usernames) { + fullUsernameString = processInstanceTask.potential_owner_usernames; + const usernames = + processInstanceTask.potential_owner_usernames.split(','); + const firstTwoUsernames = usernames.slice(0, 2); + if (usernames.length > 2) { + firstTwoUsernames.push('...'); + } + shortUsernameString = firstTwoUsernames.join(','); + } + if (processInstanceTask.assigned_user_group_identifier) { + fullUsernameString = processInstanceTask.assigned_user_group_identifier; + shortUsernameString = processInstanceTask.assigned_user_group_identifier; + } + return {shortUsernameString}; + }; + const formatProcessInstanceId = (row: ProcessInstance, id: number) => { + return {id}; + }; + const formatProcessModelIdentifier = (_row: any, identifier: any) => { + return {identifier}; + }; + const formatProcessModelDisplayName = (_row: any, identifier: any) => { + return {identifier}; + }; + + const formatSecondsForDisplay = (_row: any, seconds: any) => { + return convertSecondsToFormattedDateTime(seconds) || '-'; + }; + const defaultFormatter = (_row: any, value: any) => { + return value; + }; + + const formattedColumn = (row: any, column: any) => { + const reportColumnFormatters: Record = { + id: formatProcessInstanceId, + process_model_identifier: formatProcessModelIdentifier, + process_model_display_name: formatProcessModelDisplayName, + start_in_seconds: formatSecondsForDisplay, + end_in_seconds: formatSecondsForDisplay, + updated_at_in_seconds: formatSecondsForDisplay, + }; + const formatter = + reportColumnFormatters[column.accessor] ?? defaultFormatter; + const value = row[column.accessor]; + const modifiedModelId = modifyProcessIdentifierForPathParam( + row.process_model_identifier + ); + const navigateToProcessInstance = () => { + navigate(`${processInstanceShowPathPrefix}/${modifiedModelId}/${row.id}`); + }; + const navigateToProcessModel = () => { + navigate(`/admin/process-models/${modifiedModelId}`); + }; + + if (column.accessor === 'status') { + return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions + + {formatter(row, value)} + + ); + } + if (column.accessor === 'process_model_display_name') { + const pmStyle = { background: 'rgba(0, 0, 0, .02)' }; + return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions + + {formatter(row, value)} + + ); + } + if (column.accessor === 'waiting_for') { + return {getWaitingForTableCellComponent(row)}; + } + if (column.accessor === 'updated_at_in_seconds') { + return ( + + ); + } + return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions + + {formatter(row, value)} + + ); + }; + const buildTable = () => { const headerLabels: Record = { id: 'Id', @@ -1308,92 +1431,45 @@ export default function ProcessInstanceListTable({ return headerLabels[header] ?? header; }; const headers = reportColumns().map((column: any) => { - // return {getHeaderLabel((column as any).Header)}; return getHeaderLabel((column as any).Header); }); - - const formatProcessInstanceId = (row: ProcessInstance, id: number) => { - return {id}; - }; - const formatProcessModelIdentifier = (_row: any, identifier: any) => { - return {identifier}; - }; - const formatProcessModelDisplayName = (_row: any, identifier: any) => { - return {identifier}; - }; - - const formatSecondsForDisplay = (_row: any, seconds: any) => { - return convertSecondsToFormattedDateTime(seconds) || '-'; - }; - const defaultFormatter = (_row: any, value: any) => { - return value; - }; - - const reportColumnFormatters: Record = { - id: formatProcessInstanceId, - process_model_identifier: formatProcessModelIdentifier, - process_model_display_name: formatProcessModelDisplayName, - start_in_seconds: formatSecondsForDisplay, - end_in_seconds: formatSecondsForDisplay, - }; - const formattedColumn = (row: any, column: any) => { - const formatter = - reportColumnFormatters[column.accessor] ?? defaultFormatter; - const value = row[column.accessor]; - const modifiedModelId = modifyProcessIdentifierForPathParam( - row.process_model_identifier - ); - const navigateToProcessInstance = () => { - navigate( - `${processInstanceShowPathPrefix}/${modifiedModelId}/${row.id}` - ); - }; - const navigateToProcessModel = () => { - navigate(`/admin/process-models/${modifiedModelId}`); - }; - - if (column.accessor === 'status') { - return ( - // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions - - {formatter(row, value)} - - ); - } - if (column.accessor === 'process_model_display_name') { - const pmStyle = { background: 'rgba(0, 0, 0, .02)' }; - return ( - // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions - - {formatter(row, value)} - - ); - } - return ( - // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions - - {formatter(row, value)} - - ); - }; + if (showActionsColumn) { + headers.push('Actions'); + } const rows = processInstances.map((row: any) => { const currentRow = reportColumns().map((column: any) => { return formattedColumn(row, column); }); + if (showActionsColumn) { + let buttonElement = null; + if (row.task_id) { + const taskUrl = `/tasks/${row.id}/${row.task_id}`; + const regex = new RegExp(`\\b(${preferredUsername}|${userEmail})\\b`); + let hasAccessToCompleteTask = false; + if ( + canCompleteAllTasks || + (row.potential_owner_usernames || '').match(regex) + ) { + hasAccessToCompleteTask = true; + } + buttonElement = ( + + ); + } + + currentRow.push({buttonElement}); + } + const rowStyle = { cursor: 'pointer' }; + return ( {currentRow} diff --git a/spiffworkflow-frontend/src/components/ReactDiagramEditor.tsx b/spiffworkflow-frontend/src/components/ReactDiagramEditor.tsx index 82dddd4ad..9b9307f82 100644 --- a/spiffworkflow-frontend/src/components/ReactDiagramEditor.tsx +++ b/spiffworkflow-frontend/src/components/ReactDiagramEditor.tsx @@ -467,7 +467,9 @@ export default function ReactDiagramEditor({ return; } diagramModelerToUse.importXML(diagramXMLToDisplay).then(() => { - diagramModelerToUse.get('canvas').zoom('fit-viewport'); + if (diagramType === 'bpmn' || diagramType === 'readonly') { + diagramModelerToUse.get('canvas').zoom('fit-viewport'); + } }); alreadyImportedXmlRef.current = true; diff --git a/spiffworkflow-frontend/src/index.css b/spiffworkflow-frontend/src/index.css index e0b4336cd..d98187cd3 100644 --- a/spiffworkflow-frontend/src/index.css +++ b/spiffworkflow-frontend/src/index.css @@ -336,10 +336,13 @@ td.actions-cell { width: 1em; } +.process-instance-table-header { + margin-bottom: 1em; +} + .no-results-message { font-style: italic; margin-left: 2em; - margin-top: 1em; font-size: 14px; } diff --git a/spiffworkflow-frontend/src/routes/CompletedInstances.tsx b/spiffworkflow-frontend/src/routes/CompletedInstances.tsx index 5c7ce445b..78f73e92f 100644 --- a/spiffworkflow-frontend/src/routes/CompletedInstances.tsx +++ b/spiffworkflow-frontend/src/routes/CompletedInstances.tsx @@ -18,13 +18,12 @@ export default function CompletedInstances() { } return userGroups.map((userGroup: string) => { + const titleText = `This is a list of instances with tasks that were completed by the ${userGroup} group.`; return ( <> -

With tasks completed by group: {userGroup}

-

- This is a list of instances with tasks that were completed by the{' '} - {userGroup} group. -

+

+ With tasks completed by {userGroup} +

-

My completed instances

-

- This is a list of instances you started that are now complete. -

+

+ Started by me +

-

With tasks completed by me

-

- This is a list of instances where you have completed tasks. -

+

+ With tasks completed by me +

{renderTabs()} - } /> + } /> } /> } /> - } /> + } /> } /> } /> diff --git a/spiffworkflow-frontend/src/routes/InProgressInstances.tsx b/spiffworkflow-frontend/src/routes/InProgressInstances.tsx new file mode 100644 index 000000000..9ba7cd266 --- /dev/null +++ b/spiffworkflow-frontend/src/routes/InProgressInstances.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from 'react'; +import ProcessInstanceListTable from '../components/ProcessInstanceListTable'; +import { slugifyString } from '../helpers'; +import HttpService from '../services/HttpService'; + +export default function InProgressInstances() { + const [userGroups, setUserGroups] = useState(null); + + useEffect(() => { + HttpService.makeCallToBackend({ + path: `/user-groups/for-current-user`, + successCallback: setUserGroups, + }); + }, [setUserGroups]); + + const groupTableComponents = () => { + if (!userGroups) { + return null; + } + + return userGroups.map((userGroup: string) => { + const titleText = `This is a list of instances with tasks that are waiting for the ${userGroup} group.`; + return ( + <> +

+ Waiting for {userGroup} +

+ + + ); + }); + }; + + const startedByMeTitleText = + 'This is a list of open instances that you started.'; + const waitingForMeTitleText = + 'This is a list of instances that have tasks that you can complete.'; + return ( + <> +

+ Started by me +

+ +

+ Waiting for me +

+ + {groupTableComponents()} + + ); +} diff --git a/spiffworkflow-frontend/src/routes/TaskShow.tsx b/spiffworkflow-frontend/src/routes/TaskShow.tsx index 5b92d64a1..863ee5f3d 100644 --- a/spiffworkflow-frontend/src/routes/TaskShow.tsx +++ b/spiffworkflow-frontend/src/routes/TaskShow.tsx @@ -19,10 +19,7 @@ import MDEditor from '@uiw/react-md-editor'; import Form from '../themes/carbon'; import HttpService from '../services/HttpService'; import useAPIError from '../hooks/UseApiError'; -import { - dateStringToYMDFormat, - modifyProcessIdentifierForPathParam, -} from '../helpers'; +import { modifyProcessIdentifierForPathParam } from '../helpers'; import { ProcessInstanceTask } from '../interfaces'; import ProcessBreadcrumb from '../components/ProcessBreadcrumb';