mirror of
https://github.com/status-im/spiff-arena.git
synced 2025-01-29 19:25:50 +00:00
701 lines
20 KiB
TypeScript
701 lines
20 KiB
TypeScript
import { useContext, useEffect, useState } from 'react';
|
|
import Editor from '@monaco-editor/react';
|
|
import {
|
|
useParams,
|
|
useNavigate,
|
|
Link,
|
|
useSearchParams,
|
|
} from 'react-router-dom';
|
|
import {
|
|
TrashCan,
|
|
StopOutline,
|
|
PauseOutline,
|
|
PlayOutline,
|
|
CaretLeft,
|
|
CaretRight,
|
|
InProgress,
|
|
Checkmark,
|
|
Warning,
|
|
// @ts-ignore
|
|
} from '@carbon/icons-react';
|
|
import {
|
|
Grid,
|
|
Column,
|
|
Button,
|
|
ButtonSet,
|
|
Tag,
|
|
Modal,
|
|
Stack,
|
|
// @ts-ignore
|
|
} from '@carbon/react';
|
|
import { Can } from '@casl/react';
|
|
import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
|
|
import HttpService from '../services/HttpService';
|
|
import ReactDiagramEditor from '../components/ReactDiagramEditor';
|
|
import {
|
|
convertSecondsToFormattedDateTime,
|
|
unModifyProcessIdentifierForPathParam,
|
|
} from '../helpers';
|
|
import ButtonWithConfirmation from '../components/ButtonWithConfirmation';
|
|
import ErrorContext from '../contexts/ErrorContext';
|
|
import { useUriListForPermissions } from '../hooks/UriListForPermissions';
|
|
import {
|
|
PermissionsToCheck,
|
|
ProcessInstance,
|
|
ProcessInstanceTask,
|
|
} from '../interfaces';
|
|
import { usePermissionFetcher } from '../hooks/PermissionService';
|
|
import ProcessInstanceClass from '../classes/ProcessInstanceClass';
|
|
|
|
type OwnProps = {
|
|
variant: string;
|
|
};
|
|
|
|
export default function ProcessInstanceShow({ variant }: OwnProps) {
|
|
const navigate = useNavigate();
|
|
const params = useParams();
|
|
const [searchParams] = useSearchParams();
|
|
|
|
const [processInstance, setProcessInstance] =
|
|
useState<ProcessInstance | null>(null);
|
|
const [tasks, setTasks] = useState<ProcessInstanceTask[] | null>(null);
|
|
const [tasksCallHadError, setTasksCallHadError] = useState<boolean>(false);
|
|
const [taskToDisplay, setTaskToDisplay] = useState<object | null>(null);
|
|
const [taskDataToDisplay, setTaskDataToDisplay] = useState<string>('');
|
|
const [editingTaskData, setEditingTaskData] = useState<boolean>(false);
|
|
|
|
const setErrorMessage = (useContext as any)(ErrorContext)[1];
|
|
|
|
const unModifiedProcessModelId = unModifyProcessIdentifierForPathParam(
|
|
`${params.process_model_id}`
|
|
);
|
|
const modifiedProcessModelId = params.process_model_id;
|
|
|
|
const { targetUris } = useUriListForPermissions();
|
|
const permissionRequestData: PermissionsToCheck = {
|
|
[targetUris.messageInstanceListPath]: ['GET'],
|
|
[targetUris.processInstanceTaskListPath]: ['GET'],
|
|
[targetUris.processInstanceTaskListDataPath]: ['GET', 'PUT'],
|
|
[targetUris.processInstanceActionPath]: ['DELETE'],
|
|
[targetUris.processInstanceLogListPath]: ['GET'],
|
|
[targetUris.processModelShowPath]: ['PUT'],
|
|
[`${targetUris.processInstanceResumePath}`]: ['POST'],
|
|
[`${targetUris.processInstanceSuspendPath}`]: ['POST'],
|
|
[`${targetUris.processInstanceTerminatePath}`]: ['POST'],
|
|
};
|
|
const { ability, permissionsLoaded } = usePermissionFetcher(
|
|
permissionRequestData
|
|
);
|
|
|
|
const navigateToProcessInstances = (_result: any) => {
|
|
navigate(
|
|
`/admin/process-instances?process_model_identifier=${unModifiedProcessModelId}`
|
|
);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (permissionsLoaded) {
|
|
const processTaskFailure = () => {
|
|
setTasksCallHadError(true);
|
|
};
|
|
let queryParams = '';
|
|
const processIdentifier = searchParams.get('process_identifier');
|
|
if (processIdentifier) {
|
|
queryParams = `?process_identifier=${processIdentifier}`;
|
|
}
|
|
let apiPath = '/process-instances/for-me';
|
|
if (variant === 'all') {
|
|
apiPath = '/process-instances';
|
|
}
|
|
HttpService.makeCallToBackend({
|
|
path: `${apiPath}/${modifiedProcessModelId}/${params.process_instance_id}${queryParams}`,
|
|
successCallback: setProcessInstance,
|
|
});
|
|
let taskParams = '?all_tasks=true';
|
|
if (typeof params.spiff_step !== 'undefined') {
|
|
taskParams = `${taskParams}&spiff_step=${params.spiff_step}`;
|
|
}
|
|
let taskPath = '';
|
|
if (ability.can('GET', targetUris.processInstanceTaskListDataPath)) {
|
|
taskPath = `${targetUris.processInstanceTaskListDataPath}${taskParams}`;
|
|
} else if (ability.can('GET', targetUris.processInstanceTaskListPath)) {
|
|
taskPath = `${targetUris.processInstanceTaskListPath}${taskParams}`;
|
|
}
|
|
if (taskPath) {
|
|
HttpService.makeCallToBackend({
|
|
path: taskPath,
|
|
successCallback: setTasks,
|
|
failureCallback: processTaskFailure,
|
|
});
|
|
} else {
|
|
setTasksCallHadError(true);
|
|
}
|
|
}
|
|
}, [
|
|
params,
|
|
modifiedProcessModelId,
|
|
permissionsLoaded,
|
|
ability,
|
|
targetUris,
|
|
searchParams,
|
|
]);
|
|
|
|
const deleteProcessInstance = () => {
|
|
HttpService.makeCallToBackend({
|
|
path: targetUris.processInstanceActionPath,
|
|
successCallback: navigateToProcessInstances,
|
|
httpMethod: 'DELETE',
|
|
});
|
|
};
|
|
|
|
// to force update the diagram since it could have changed
|
|
const refreshPage = () => {
|
|
window.location.reload();
|
|
};
|
|
|
|
const terminateProcessInstance = () => {
|
|
HttpService.makeCallToBackend({
|
|
path: `${targetUris.processInstanceTerminatePath}`,
|
|
successCallback: refreshPage,
|
|
httpMethod: 'POST',
|
|
});
|
|
};
|
|
|
|
const suspendProcessInstance = () => {
|
|
HttpService.makeCallToBackend({
|
|
path: `${targetUris.processInstanceSuspendPath}`,
|
|
successCallback: refreshPage,
|
|
httpMethod: 'POST',
|
|
});
|
|
};
|
|
|
|
const resumeProcessInstance = () => {
|
|
HttpService.makeCallToBackend({
|
|
path: `${targetUris.processInstanceResumePath}`,
|
|
successCallback: refreshPage,
|
|
httpMethod: 'POST',
|
|
});
|
|
};
|
|
|
|
const getTaskIds = () => {
|
|
const taskIds = { completed: [], readyOrWaiting: [] };
|
|
if (tasks) {
|
|
tasks.forEach(function getUserTasksElement(task: ProcessInstanceTask) {
|
|
if (task.state === 'COMPLETED') {
|
|
(taskIds.completed as any).push(task);
|
|
}
|
|
if (task.state === 'READY' || task.state === 'WAITING') {
|
|
(taskIds.readyOrWaiting as any).push(task);
|
|
}
|
|
});
|
|
}
|
|
return taskIds;
|
|
};
|
|
|
|
const currentSpiffStep = () => {
|
|
if (processInstance && typeof params.spiff_step === 'undefined') {
|
|
return processInstance.spiff_step || 0;
|
|
}
|
|
|
|
return Number(params.spiff_step);
|
|
};
|
|
|
|
const showingFirstSpiffStep = () => {
|
|
return currentSpiffStep() === 1;
|
|
};
|
|
|
|
const showingLastSpiffStep = () => {
|
|
return processInstance && currentSpiffStep() === processInstance.spiff_step;
|
|
};
|
|
|
|
const spiffStepLink = (label: any, distance: number) => {
|
|
const processIdentifier = searchParams.get('process_identifier');
|
|
let queryParams = '';
|
|
if (processIdentifier) {
|
|
queryParams = `?process_identifier=${processIdentifier}`;
|
|
}
|
|
return (
|
|
<Link
|
|
reloadDocument
|
|
data-qa="process-instance-step-link"
|
|
to={`/admin/process-instances/${params.process_model_id}/${
|
|
params.process_instance_id
|
|
}/${currentSpiffStep() + distance}${queryParams}`}
|
|
>
|
|
{label}
|
|
</Link>
|
|
);
|
|
};
|
|
|
|
const previousStepLink = () => {
|
|
if (showingFirstSpiffStep()) {
|
|
return null;
|
|
}
|
|
|
|
return spiffStepLink(<CaretLeft />, -1);
|
|
};
|
|
|
|
const nextStepLink = () => {
|
|
if (showingLastSpiffStep()) {
|
|
return null;
|
|
}
|
|
|
|
return spiffStepLink(<CaretRight />, 1);
|
|
};
|
|
|
|
const getInfoTag = () => {
|
|
if (!processInstance) {
|
|
return null;
|
|
}
|
|
const currentEndDate = convertSecondsToFormattedDateTime(
|
|
processInstance.end_in_seconds || 0
|
|
);
|
|
let currentEndDateTag;
|
|
if (currentEndDate) {
|
|
currentEndDateTag = (
|
|
<Grid condensed fullWidth>
|
|
<Column sm={1} md={1} lg={2} className="grid-list-title">
|
|
Completed:{' '}
|
|
</Column>
|
|
<Column sm={3} md={3} lg={3} className="grid-date">
|
|
{convertSecondsToFormattedDateTime(
|
|
processInstance.end_in_seconds || 0
|
|
) || 'N/A'}
|
|
</Column>
|
|
</Grid>
|
|
);
|
|
}
|
|
|
|
let statusIcon = <InProgress />;
|
|
if (processInstance.status === 'suspended') {
|
|
statusIcon = <PauseOutline />;
|
|
} else if (processInstance.status === 'complete') {
|
|
statusIcon = <Checkmark />;
|
|
} else if (processInstance.status === 'terminated') {
|
|
statusIcon = <StopOutline />;
|
|
} else if (processInstance.status === 'error') {
|
|
statusIcon = <Warning />;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Grid condensed fullWidth>
|
|
<Column sm={1} md={1} lg={2} className="grid-list-title">
|
|
Started:{' '}
|
|
</Column>
|
|
<Column sm={3} md={3} lg={3} className="grid-date">
|
|
{convertSecondsToFormattedDateTime(
|
|
processInstance.start_in_seconds || 0
|
|
)}
|
|
</Column>
|
|
</Grid>
|
|
{currentEndDateTag}
|
|
<Grid condensed fullWidth>
|
|
<Column sm={1} md={1} lg={2} className="grid-list-title">
|
|
Status:{' '}
|
|
</Column>
|
|
<Column sm={3} md={3} lg={3}>
|
|
<Tag type="gray" size="sm" className="span-tag">
|
|
{processInstance.status} {statusIcon}
|
|
</Tag>
|
|
</Column>
|
|
</Grid>
|
|
<br />
|
|
<Grid condensed fullWidth>
|
|
<Column sm={2} md={2} lg={2}>
|
|
<ButtonSet>
|
|
<Can
|
|
I="GET"
|
|
a={targetUris.processInstanceLogListPath}
|
|
ability={ability}
|
|
>
|
|
<Button
|
|
size="sm"
|
|
className="button-white-background"
|
|
data-qa="process-instance-log-list-link"
|
|
href={`/admin/logs/${modifiedProcessModelId}/${params.process_instance_id}`}
|
|
>
|
|
Logs
|
|
</Button>
|
|
</Can>
|
|
<Can
|
|
I="GET"
|
|
a={targetUris.messageInstanceListPath}
|
|
ability={ability}
|
|
>
|
|
<Button
|
|
size="sm"
|
|
className="button-white-background"
|
|
data-qa="process-instance-message-instance-list-link"
|
|
href={`/admin/messages?process_model_id=${params.process_model_id}&process_instance_id=${params.process_instance_id}`}
|
|
>
|
|
Messages
|
|
</Button>
|
|
</Can>
|
|
</ButtonSet>
|
|
</Column>
|
|
</Grid>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const terminateButton = () => {
|
|
if (
|
|
processInstance &&
|
|
!ProcessInstanceClass.terminalStatuses().includes(processInstance.status)
|
|
) {
|
|
return (
|
|
<ButtonWithConfirmation
|
|
kind="ghost"
|
|
renderIcon={StopOutline}
|
|
iconDescription="Terminate"
|
|
hasIconOnly
|
|
description={`Terminate Process Instance: ${processInstance.id}`}
|
|
onConfirmation={terminateProcessInstance}
|
|
confirmButtonLabel="Terminate"
|
|
/>
|
|
);
|
|
}
|
|
return <div />;
|
|
};
|
|
|
|
const suspendButton = () => {
|
|
if (
|
|
processInstance &&
|
|
!ProcessInstanceClass.terminalStatuses()
|
|
.concat(['suspended'])
|
|
.includes(processInstance.status)
|
|
) {
|
|
return (
|
|
<Button
|
|
onClick={suspendProcessInstance}
|
|
kind="ghost"
|
|
renderIcon={PauseOutline}
|
|
iconDescription="Suspend"
|
|
hasIconOnly
|
|
size="lg"
|
|
/>
|
|
);
|
|
}
|
|
return <div />;
|
|
};
|
|
|
|
const resumeButton = () => {
|
|
if (processInstance && processInstance.status === 'suspended') {
|
|
return (
|
|
<Button
|
|
onClick={resumeProcessInstance}
|
|
kind="ghost"
|
|
renderIcon={PlayOutline}
|
|
iconDescription="Resume"
|
|
hasIconOnly
|
|
size="lg"
|
|
/>
|
|
);
|
|
}
|
|
return <div />;
|
|
};
|
|
|
|
const initializeTaskDataToDisplay = (task: any) => {
|
|
if (task == null) {
|
|
setTaskDataToDisplay('');
|
|
} else {
|
|
setTaskDataToDisplay(JSON.stringify(task.data, null, 2));
|
|
}
|
|
};
|
|
|
|
const handleClickedDiagramTask = (
|
|
shapeElement: any,
|
|
bpmnProcessIdentifiers: any
|
|
) => {
|
|
if (tasks) {
|
|
const matchingTask: any = tasks.find(
|
|
(task: any) =>
|
|
task.name === shapeElement.id &&
|
|
bpmnProcessIdentifiers.includes(task.process_identifier)
|
|
);
|
|
if (matchingTask) {
|
|
setTaskToDisplay(matchingTask);
|
|
initializeTaskDataToDisplay(matchingTask);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleTaskDataDisplayClose = () => {
|
|
setTaskToDisplay(null);
|
|
initializeTaskDataToDisplay(null);
|
|
};
|
|
|
|
const getTaskById = (taskId: string) => {
|
|
if (tasks !== null) {
|
|
return tasks.find((task: any) => task.id === taskId);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const processScriptUnitTestCreateResult = (result: any) => {
|
|
console.log('result', result);
|
|
};
|
|
|
|
const createScriptUnitTest = () => {
|
|
if (taskToDisplay) {
|
|
const taskToUse: any = taskToDisplay;
|
|
const previousTask: any = getTaskById(taskToUse.parent);
|
|
HttpService.makeCallToBackend({
|
|
path: `/process-models/${modifiedProcessModelId}/script-unit-tests`,
|
|
httpMethod: 'POST',
|
|
successCallback: processScriptUnitTestCreateResult,
|
|
postBody: {
|
|
bpmn_task_identifier: taskToUse.name,
|
|
input_json: previousTask.data,
|
|
expected_output_json: taskToUse.data,
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
const canEditTaskData = (task: any) => {
|
|
return (
|
|
processInstance &&
|
|
ability.can('PUT', targetUris.processInstanceTaskListDataPath) &&
|
|
task.state === 'READY' &&
|
|
processInstance.status === 'suspended' &&
|
|
showingLastSpiffStep()
|
|
);
|
|
};
|
|
|
|
const cancelEditingTaskData = () => {
|
|
setEditingTaskData(false);
|
|
initializeTaskDataToDisplay(taskToDisplay);
|
|
setErrorMessage(null);
|
|
};
|
|
|
|
const taskDataStringToObject = (dataString: string) => {
|
|
return JSON.parse(dataString);
|
|
};
|
|
|
|
const saveTaskDataResult = (_: any) => {
|
|
setEditingTaskData(false);
|
|
const dataObject = taskDataStringToObject(taskDataToDisplay);
|
|
const taskToDisplayCopy = { ...taskToDisplay, data: dataObject }; // spread operator
|
|
setTaskToDisplay(taskToDisplayCopy);
|
|
refreshPage();
|
|
};
|
|
|
|
const saveTaskDataFailure = (result: any) => {
|
|
setErrorMessage({ message: result.message });
|
|
};
|
|
|
|
const saveTaskData = () => {
|
|
if (!taskToDisplay) {
|
|
return;
|
|
}
|
|
|
|
setErrorMessage(null);
|
|
|
|
// taskToUse is copy of taskToDisplay, with taskDataToDisplay in data attribute
|
|
const taskToUse: any = { ...taskToDisplay, data: taskDataToDisplay };
|
|
HttpService.makeCallToBackend({
|
|
path: `/task-data/${modifiedProcessModelId}/${params.process_instance_id}/${taskToUse.id}`,
|
|
httpMethod: 'PUT',
|
|
successCallback: saveTaskDataResult,
|
|
failureCallback: saveTaskDataFailure,
|
|
postBody: {
|
|
new_task_data: taskToUse.data,
|
|
},
|
|
});
|
|
};
|
|
|
|
const taskDataButtons = (task: any) => {
|
|
const buttons = [];
|
|
|
|
if (
|
|
task.type === 'Script Task' &&
|
|
ability.can('PUT', targetUris.processModelShowPath)
|
|
) {
|
|
buttons.push(
|
|
<Button
|
|
data-qa="create-script-unit-test-button"
|
|
onClick={createScriptUnitTest}
|
|
>
|
|
Create Script Unit Test
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
if (task.type === 'Call Activity') {
|
|
buttons.push(
|
|
<Link
|
|
data-qa="go-to-call-activity-result"
|
|
to={`${window.location.pathname}?process_identifier=${task.call_activity_process_identifier}`}
|
|
target="_blank"
|
|
>
|
|
View Call Activity Diagram
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
if (canEditTaskData(task)) {
|
|
if (editingTaskData) {
|
|
buttons.push(
|
|
<Button data-qa="save-task-data-button" onClick={saveTaskData}>
|
|
Save
|
|
</Button>
|
|
);
|
|
buttons.push(
|
|
<Button
|
|
data-qa="cancel-task-data-edit-button"
|
|
onClick={cancelEditingTaskData}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
);
|
|
} else {
|
|
buttons.push(
|
|
<Button
|
|
data-qa="edit-task-data-button"
|
|
onClick={() => setEditingTaskData(true)}
|
|
>
|
|
Edit
|
|
</Button>
|
|
);
|
|
}
|
|
}
|
|
|
|
return buttons;
|
|
};
|
|
|
|
const taskDataContainer = () => {
|
|
return editingTaskData ? (
|
|
<Editor
|
|
height={600}
|
|
width="auto"
|
|
defaultLanguage="json"
|
|
defaultValue={taskDataToDisplay}
|
|
onChange={(value) => setTaskDataToDisplay(value || '')}
|
|
/>
|
|
) : (
|
|
<pre>{taskDataToDisplay}</pre>
|
|
);
|
|
};
|
|
|
|
const taskDataDisplayArea = () => {
|
|
const taskToUse: any = { ...taskToDisplay, data: taskDataToDisplay };
|
|
if (taskToDisplay) {
|
|
return (
|
|
<Modal
|
|
open={!!taskToUse}
|
|
passiveModal
|
|
onRequestClose={handleTaskDataDisplayClose}
|
|
>
|
|
<Stack orientation="horizontal" gap={2}>
|
|
{taskToUse.name} ({taskToUse.type}): {taskToUse.state}
|
|
{taskDataButtons(taskToUse)}
|
|
</Stack>
|
|
{taskDataContainer()}
|
|
</Modal>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const stepsElement = () => {
|
|
if (!processInstance) {
|
|
return null;
|
|
}
|
|
return (
|
|
<Grid condensed fullWidth>
|
|
<Column sm={3} md={3} lg={3}>
|
|
<Stack orientation="horizontal" gap={3} className="smaller-text">
|
|
{previousStepLink()}
|
|
Step {currentSpiffStep()} of {processInstance.spiff_step}
|
|
{nextStepLink()}
|
|
</Stack>
|
|
</Column>
|
|
</Grid>
|
|
);
|
|
};
|
|
|
|
const buttonIcons = () => {
|
|
if (!processInstance) {
|
|
return null;
|
|
}
|
|
const elements = [];
|
|
if (ability.can('POST', `${targetUris.processInstanceTerminatePath}`)) {
|
|
elements.push(terminateButton());
|
|
}
|
|
if (ability.can('POST', `${targetUris.processInstanceSuspendPath}`)) {
|
|
elements.push(suspendButton());
|
|
}
|
|
if (ability.can('POST', `${targetUris.processInstanceResumePath}`)) {
|
|
elements.push(resumeButton());
|
|
}
|
|
if (
|
|
ability.can('DELETE', targetUris.processInstanceActionPath) &&
|
|
ProcessInstanceClass.terminalStatuses().includes(processInstance.status)
|
|
) {
|
|
elements.push(
|
|
<ButtonWithConfirmation
|
|
data-qa="process-instance-delete"
|
|
kind="ghost"
|
|
renderIcon={TrashCan}
|
|
iconDescription="Delete"
|
|
hasIconOnly
|
|
description={`Delete Process Instance: ${processInstance.id}`}
|
|
onConfirmation={deleteProcessInstance}
|
|
confirmButtonLabel="Delete"
|
|
/>
|
|
);
|
|
}
|
|
return elements;
|
|
};
|
|
|
|
if (processInstance && (tasks || tasksCallHadError)) {
|
|
const taskIds = getTaskIds();
|
|
const processModelId = unModifyProcessIdentifierForPathParam(
|
|
params.process_model_id ? params.process_model_id : ''
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<ProcessBreadcrumb
|
|
hotCrumbs={[
|
|
['Process Groups', '/admin'],
|
|
{
|
|
entityToExplode: processModelId,
|
|
entityType: 'process-model-id',
|
|
linkLastItem: true,
|
|
},
|
|
[`Process Instance Id: ${processInstance.id}`],
|
|
]}
|
|
/>
|
|
<Stack orientation="horizontal" gap={1}>
|
|
<h1 className="with-icons">
|
|
Process Instance Id: {processInstance.id}
|
|
</h1>
|
|
{buttonIcons()}
|
|
</Stack>
|
|
<br />
|
|
<br />
|
|
{getInfoTag()}
|
|
<br />
|
|
{taskDataDisplayArea()}
|
|
{stepsElement()}
|
|
<br />
|
|
<ReactDiagramEditor
|
|
processModelId={processModelId || ''}
|
|
diagramXML={processInstance.bpmn_xml_file_contents || ''}
|
|
fileName={processInstance.bpmn_xml_file_contents || ''}
|
|
readyOrWaitingProcessInstanceTasks={taskIds.readyOrWaiting}
|
|
completedProcessInstanceTasks={taskIds.completed}
|
|
diagramType="readonly"
|
|
onElementClick={handleClickedDiagramTask}
|
|
/>
|
|
|
|
<div id="diagram-container" />
|
|
</>
|
|
);
|
|
}
|
|
return null;
|
|
}
|