@onehat/ui
Version:
Base UI for OneHat apps
501 lines (468 loc) • 14.1 kB
JavaScript
import { useState, useRef, useEffect, } from 'react';
import {
Box,
HStack,
ScrollView,
Text,
VStack,
} from '@project-components/Gluestack';
import clsx from 'clsx';
import * as Progress from 'react-native-progress';
import useForceUpdate from '../../Hooks/useForceUpdate';
import {
EDITOR_TYPE__PLAIN,
} from '../../Constants/Editor.js';
import {
PROGRESS__NONE_FOUND,
PROGRESS__IN_PROCESS,
PROGRESS__COMPLETED,
PROGRESS__FAILED,
PROGRESS__STUCK,
PROGRESS__UNSTUCK,
ASYNC_OPERATION_MODES__INIT,
ASYNC_OPERATION_MODES__START,
ASYNC_OPERATION_MODES__PROCESSING,
ASYNC_OPERATION_MODES__RESULTS,
} from '../../Constants/Progress.js';
import {
MOMENT_DATE_FORMAT_2,
} from '../../Constants/Dates.js';
import inArray from '../../Functions/inArray.js';
import isJson from '../../Functions/isJson.js';
import Form from '../Form/Form.js';
import Button from '../Buttons/Button.js';
import withComponent from '../Hoc/withComponent.js';
import withAlert from '../Hoc/withAlert.js';
import Loading from '../Messages/Loading.js';
import ChevronLeft from '../Icons/ChevronLeft.js';
import ChevronRight from '../Icons/ChevronRight.js';
import RotateLeft from '../Icons/RotateLeft.js';
import Play from '../Icons/Play.js';
import EllipsisHorizontal from '../Icons/EllipsisHorizontal.js';
import Stop from '../Icons/Stop.js';
import TabBar from '../Tab/TabBar.js';
import Panel from '../Panel/Panel.js';
import Toolbar from '../Toolbar/Toolbar.js';
import moment from 'moment';
import _ from 'lodash';
// MODES:
// ASYNC_OPERATION_MODES__INIT - no footer shown; used when component initially loads, to see if operation is already in progress
// ASYNC_OPERATION_MODES__START - shows the start form
// ASYNC_OPERATION_MODES__PROCESSING - shows the loading indicator while starting the operation
// ASYNC_OPERATION_MODES__RESULTS - shows the results of the operation, or any in-progress updates
// If getProgressUpdates is false, the component will show the start form initially.
// If getProgressUpdates is true, the component will initially query the server to see if
// an operation is already in progress.
// If so, it will automatically poll the server for progress updates
// If not, it will show the start form.
function AsyncOperation(props) {
if (!props.Repository || !props.process) {
throw Error('AsyncOperation: Repository and process are required!');
}
const {
process,
Repository,
formItems = [],
formStartingValues = {},
_form = {},
getProgressUpdates = false,
getInitialProgress = true, // applies only if getProgressUpdates is true
parseProgress, // optional fn, accepts 'response' as arg and returns an object like this: { status, errors, started, lastUpdated, timeElapsed, count, current, total, percentage }
updateInterval = 10000, // ms
progressColor = '#666',
onChangeMode,
// withComponent
self,
// withAlert
alert,
} = props,
forceUpdate = useForceUpdate(),
isValid = useRef(true),
setIsValid = (valid) => {
isValid.current = valid;
},
getIsValid = () => {
return isValid.current;
},
modeRef = useRef(ASYNC_OPERATION_MODES__INIT),
setMode = (mode) => {
modeRef.current = mode;
if (onChangeMode) {
onChangeMode(mode);
}
},
getMode = () => {
return modeRef.current;
},
isInProcess = getMode() === ASYNC_OPERATION_MODES__PROCESSING,
intervalRef = useRef(null),
getInterval = () => {
return intervalRef.current;
},
setIntervalRef = (interval) => { // 'setInterval' is a reserved name
intervalRef.current = interval;
},
formValuesRef = useRef(null),
getFormValues = () => {
return formValuesRef.current;
},
setFormValues = (values) => {
formValuesRef.current = values;
},
isStuckRef = useRef(false),
getIsStuck = () => {
return isStuckRef.current;
},
setIsStuck = (bool) => {
isStuckRef.current = bool;
},
getFooter = () => {
switch(getMode()) {
case ASYNC_OPERATION_MODES__INIT:
return null;
case ASYNC_OPERATION_MODES__START:
return <Toolbar>
<Button
text="Start"
rightIcon={ChevronRight}
onPress={() => startProcess()}
isDisabled={!getIsValid()}
/>
</Toolbar>;
case ASYNC_OPERATION_MODES__PROCESSING:
// TODO: Add a cancellation option to the command.
// would require a backend controller action to support it
return null;
// return <Toolbar>
// <Button
// text="Please wait"
// isLoading={true}
// variant="link"
// />
// </Toolbar>;
case ASYNC_OPERATION_MODES__RESULTS:
let button;
if (getIsStuck()) {
button = <Button
text="Unstick"
icon={RotateLeft}
onPress={() => unstick()}
/>;
} else {
button = <Button
text="Reset"
icon={ChevronLeft}
onPress={() => resetToInitialState()}
/>;
}
return <Toolbar>
{button}
</Toolbar>;
}
},
[footer, setFooter] = useState(getFooter()),
[results, setResults] = useState(null),
[progress, setProgress] = useState(null),
[isReady, setIsReady] = useState(false),
showResults = (results) => {
setMode(ASYNC_OPERATION_MODES__RESULTS);
setFooter(getFooter());
setResults(results);
},
startProcess = async () => {
stopGettingProgress();
setMode(ASYNC_OPERATION_MODES__PROCESSING);
setFooter(getFooter());
const
method = Repository.methods.edit,
uri = Repository.getModel() + '/startProcess',
formValues = self?.children?.form?.formGetValues() || {};
formValues.process = process;
const
result = await Repository._send(method, uri, formValues);
setFormValues(formValues);
const response = Repository._processServerResponse(result);
if (!response?.success) {
alert(response.message || 'Error starting process on server.');
resetToInitialState();
return;
}
if (getProgressUpdates) {
setProgress(<VStack className="p-4">
<Text className="text-lg" key="status">
Process has started. Progress updates will appear here momentarily.
</Text>
<Loading />
</VStack>);
getProgress();
return;
}
let results = <Text>Success</Text>;
if (response.message) {
let message = response.message;
if (isJson(message)) {
message = JSON.parse(message);
}
results = _.isArray(message) ?
<VStack>
{message?.map((line, ix)=> {
return <Text key={ix}>{line}</Text>;
})}
</VStack> :
<Text>{message}</Text>;
}
showResults(results);
},
getProgress = (immediately = false) => {
if (!getProgressUpdates) {
return;
}
async function fetchProgress(isInitial = false) {
setIsStuck(false);
const
method = Repository.methods.edit,
uri = Repository.getModel() + '/getProcessProgress',
data = {
process,
...getFormValues(), // in case options submitted when starting the process affect the progress updates
},
result = await Repository._send(method, uri, data);
const response = Repository._processServerResponse(result);
if (!response.success) {
alert(response.message || 'Error getting progress info from server.');
stopGettingProgress();
return;
}
const
progress = parseProgress ? parseProgress(response.root) : response.root,
{
status,
errors,
started,
lastUpdated,
timeElapsed,
count,
current,
total,
percentage,
message,
} = progress || {},
renderItems = [];
if (status === PROGRESS__NONE_FOUND) {
resetToInitialState();
setIsReady(true);
forceUpdate();
return;
}
let color = 'text-black',
statusMessage = '',
errorMessage = null;
if (status === PROGRESS__IN_PROCESS) {
setMode(ASYNC_OPERATION_MODES__PROCESSING);
color = 'text-green-600';
statusMessage = 'In process...';
} else {
setMode(ASYNC_OPERATION_MODES__RESULTS);
stopGettingProgress();
if (status === PROGRESS__COMPLETED) {
statusMessage = 'Completed';
} else if (status === PROGRESS__FAILED) {
color = 'text-red-400 font-bold';
statusMessage = 'Failed';
} else if (status === PROGRESS__STUCK) {
color = 'text-red-400 font-bold';
setIsStuck(true);
statusMessage = 'Stuck';
}
}
const className = 'text-lg';
renderItems.push(<Text className={className + ' ' + color} key="status">Status: {statusMessage}</Text>);
if (!_.isNil(percentage) && status !== PROGRESS__COMPLETED) {
renderItems.push(<VStack key="progress">
<Progress.Bar
animated={true}
progress={percentage / 100}
width={175}
height={15}
color={progressColor}
/>
<Text className={className}>{percentage}%</Text>
</VStack>);
}
if (started) {
const startedMoment = moment(started);
if (startedMoment.isValid()) {
renderItems.push(<Text className={className} key="started">Started: {startedMoment.format(MOMENT_DATE_FORMAT_2)}</Text>);
}
}
if (lastUpdated) {
const updatedMoment = moment(lastUpdated);
if (updatedMoment.isValid()) {
renderItems.push(<Text className={className} key="lastUpdated">Last Updated: {updatedMoment.format(MOMENT_DATE_FORMAT_2)}</Text>);
}
}
if (timeElapsed) {
renderItems.push(<Text className={className} key="timeElapsed">Time Elapsed: {timeElapsed}</Text>);
}
if (!_.isNil(count) && count !== 0) {
renderItems.push(<Text className={className} key="count">Count: {count}</Text>);
}
if (!_.isNil(current) && !_.isNil(total)) {
renderItems.push(<Text className={className} key="currentTotal">Current/Total: {current} / {total}</Text>);
}
if (!_.isNil(message) && !_.isEmpty(message)) {
renderItems.push(<Text className={className} key="message">{message}</Text>);
}
if (!_.isNil(errors)) {
renderItems.push(<VStack key="errors">
<Text className="text-red-400 font-bold">Errors:</Text>
{errors?.map((line, ix)=> {
return <Text key={ix}>{line}</Text>;
})}
</VStack>);
}
if (getMode() === ASYNC_OPERATION_MODES__PROCESSING) {
setProgress(renderItems);
} else {
setResults(renderItems);
}
setIsReady(true);
setFooter(getFooter());
forceUpdate();
};
let interval = getInterval();
if (interval) {
clearInterval(interval);
}
setIntervalRef(setInterval(fetchProgress, updateInterval));
if (immediately) {
fetchProgress(true); // isInitial
}
},
unstick = async () => {
stopGettingProgress();
setMode(ASYNC_OPERATION_MODES__PROCESSING);
setFooter(getFooter());
const
method = Repository.methods.edit,
uri = Repository.getModel() + '/unstickProcess',
data = {
process
};
const
result = await Repository._send(method, uri, data);
const response = Repository._processServerResponse(result);
if (!response?.success) {
alert(response.message || 'Error unsticking process on server.');
resetToInitialState();
return;
}
if (response.root?.status !== PROGRESS__UNSTUCK) {
alert('Process could not be unstuck.');
return;
}
alert('Process unstuck.');
resetToInitialState();
},
resetToInitialState = () => {
setMode(ASYNC_OPERATION_MODES__START);
setFooter(getFooter());
setIsStuck(false);
stopGettingProgress();
},
stopGettingProgress = () => {
clearInterval(getInterval());
setIntervalRef(null);
},
onValidityChange = (isValid) => {
setIsValid(isValid);
setFooter(getFooter());
};
useEffect(() => {
if (getProgressUpdates && getInitialProgress) {
getProgress(true);
} else {
setMode(ASYNC_OPERATION_MODES__START);
setIsReady(true);
}
return () => {
// clear the interval when the component unmounts
const interval = getInterval();
if (interval) {
clearInterval(interval);
}
};
}, []);
let currentTabIx = 0;
switch(getMode()) {
case ASYNC_OPERATION_MODES__INIT:
case ASYNC_OPERATION_MODES__START:
currentTabIx = 0;
break;
case ASYNC_OPERATION_MODES__PROCESSING:
currentTabIx = 1;
break;
case ASYNC_OPERATION_MODES__RESULTS:
currentTabIx = 2;
break;
}
return <Panel
{...props}
footer={footer}
>
{!isReady && <Loading />}
{isReady &&
<TabBar
tabs={[
{
title: 'Start',
icon: Play,
isDisabled: currentTabIx !== 0,
content: inArray(getMode(), [ASYNC_OPERATION_MODES__INIT, ASYNC_OPERATION_MODES__PROCESSING, ASYNC_OPERATION_MODES__RESULTS]) ?
<Loading /> :
<ScrollView className="ScrollView h-full w-full">
<Form
editorType={EDITOR_TYPE__PLAIN}
reference="form"
parent={self}
className="w-full h-full flex-1"
disableFooter={true}
items={formItems}
startingValues={formStartingValues}
onValidityChange={onValidityChange}
{..._form}
/>
</ScrollView>,
},
{
title: 'Progress',
icon: EllipsisHorizontal,
isDisabled: currentTabIx !== 1,
content: <ScrollView className="ScrollView h-full w-full p-4">
{progress}
</ScrollView>,
},
{
title: 'Results',
icon: Stop,
isDisabled: currentTabIx !== 2,
content: <ScrollView className="ScrollView h-full w-full p-4">
{results}
</ScrollView>,
},
]}
currentTabIx={currentTabIx}
canToggleCollapse={false}
tabsAreButtons={false}
/>}
</Panel>;
}
function withAdditionalProps(WrappedComponent) {
return (props) => {
return <WrappedComponent
reference={props.reference || 'AsyncOperation'}
{...props}
/>;
};
}
export default withAdditionalProps(withComponent(withAlert(AsyncOperation)));