Squashed 'spiffworkflow-frontend/' changes from f099c181..c22d22ba

c22d22ba logs list page should respect the for-me variant
dcc3e3b1 show help text for textareas as well w/ burnettk
35b8bf35 default to the for-me path on process instance show page links
da646e54 added support to validate custom errors in nested properties in json schema forms
17412e9d make it so the message at the top of form is never strictly wrong
7dea59b7 adding some padding to form text inputs w/ burnettk
3f92f492 added ability to display the environment in the frontend header bar w/ burnettk
85246120 put the env vars in the env section of the github action configs w/ burnettk
58ff9075 added test for quickstart guide w/ burnettk
03e725b0 corrected cypress env var
db755731 added some support for using the backend openid server for cypress tests w/ burnettk
e76b6429 anything in the Tasks waiting for me table can now be completed by the current user
56403b4c users can always complete tasks on process instance show page and on task group table on home page w/ burnettk
e2ab6eff avoid endless redirects on error on authentication list page w/ burnettk
66608837 bug fix
4781d5fc link to the spiff step from a task on the frontend and use the correct db in ci
69eb426c pyl w/ burnettk
3cf036b0 call activities are also working w/ burnettk
40b101a7 add assertions so this fails fast if anything changes in the future
74d2d3fa Update README.md
24c3c431 fix formatting
ed977079 document runtime config
e62427b9 do not fail if SPIFFWORKFLOW_FRONTEND_RUNTIME_CONFIG is missing
551bdf17 allow setting configs for the frontend through env vars w/ burnettk
bc91c62c Merge remote-tracking branch 'origin/main' into frontend/use-api-subpath
eeab0d0f Use the same markdown library for displaying as for editing - could enable a security plugin, but doing so would prevent BPMN developers from using the Sub and Sup markdown supported by GitHub.
28d6f75c When searching for human tasks to determine if the current user can complete it, filter on the "completed" flag.
34b36fe2 put setDisabled back in the awkward place since i was seeing the subsequent form stay disabled
4794a8cd Merge branch 'main' of github.com:sartography/spiff-arena into main
78b9ef73 Fix that dreadful unknown "KeyError" exception that was cropping up. Adding a bit of detail to the spiffworkflow exceptions when a duplicate process model is found. Disable the submit button on tasks after you click submit (avoid the double click and give users a better experience)
d854a122 Merge pull request #125 from sartography/feature/dynamically-hide-fields-w-task-data
eda73854 make form schema and form ui schema both dicts, add support for hiding fields based on task data
bda549f3 Prevent double click on submit of forms.
3f02fa80 try to improve exception handling by avoiding raising ApiError from services
06835267 remove duplicate label on radio buttons
5d1e3517 less annoying file name for autocomplete
c569acde frontend: use /api subpath instead of subdomain
d3dda548 add deps for serve
e6173efe Revert "revert Dockerfile until we get it working"
74103b97 revert Dockerfile until we get it working
8e9badd3 get bin as well for script
df7a97e6 Merge pull request #116 from sartography/frontend/improve-dockerfile
7a17bc6f remove unneeded divs
2cf58c24 IBM says you can't have more columns than your parents, even if you try to start another grid, with kburnett
abc85ee3 make task show wide, and make repeating form icons match site styles. w/ dfunk
6400827e even textareas need to have blank labels since labels are in FieldTemplate
462852d6 replace fieldTemplate with unthemed core version and remove labels since that is handled in there
a50dd004 wrap field template so we can style with margin bottom
e6a1ca97 new mechanism to handle help more in line with how carbon works
cb6ce6b4 frontend: avoid redundant steps in Dockerfile
b6b7ceeb we were expecting an object when doing this check, so codify it
cfcfe292 use the 403 response to tell if a user has access to task data on the task show page w/ burnettk
355406e2 do not reset error state from the adminroutes component since this causes the error to be removed right away and then cannot be displayed w/ burnettk
3e7962cf if we get a result back it will be a task with a model identifier
0f043f72 use the ProcessInstanceTask interface where we can and move some stuff around better for useEffect
4ee4e5e0 run_pyl had various recommendations that I find a bit of a pain in the butt, but that I did anyway.
84e7d3fa Merge remote-tracking branch 'origin/main' into feature/jinja_errors
acbc0a07 Added useMemo to error context No longer clear errors in the task bar, as that will constantly remove them as soon as they are shown.
19c3ab0e TaskShow had a useEffect that depended on params, that dependency caused an infinite request cycle when an error occured. The same issue was happening on the ProcessInstanceListTable, and there it was being managed by a "SafelySetErrorMessage" function in one case, but would not be addressed in all possible cases.
c37224ec added the process model identifier for the diagram if it is not the top level w/ burnettk
befefb6d Merge pull request #107 from sartography/feature/metadata_on_instance_show
65e42110 use a modal for metadata instead w/ burnettk
4a2f2a00 put process instance show page to match main w/ burnettk
0f1ab490 do not allow deleting primary bpmn file and do not allow instantiating models without a primary bpmn file w/ burnettk
3718f1a3 show metadata on instance show page but for some reason it reorders elements w/ burnettk
400a1469 add process model file name validation for new files w/ burnettk
132c5814 added locking system for process instances so hopefully background jobs will not take instances currently being run by the user w/ burnettk
3fa86949 make sure that all new form field elements are not dropdowns
f69d3416 expanded functionality of the form builder
2453938f use the correct place for keycloak w/ burnettk
3d85a5f6 Merges
50d0a8e3 Lots of adjustments from running pyl Main change is in the ErrorDisplay.tsx to assure all error information is provided. and index.css to make it "pretty"

git-subtree-dir: spiffworkflow-frontend
git-subtree-split: c22d22ba5d8a96dca66a405d9c230284c81b4740
This commit is contained in:
burnettk 2023-02-23 10:49:57 -05:00
parent f612f26dd2
commit ce1e54dced
54 changed files with 2962 additions and 556 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
/node_modules

View File

@ -68,7 +68,7 @@ jobs:
path: sample-process-models
- name: start_keycloak
working-directory: ./spiffworkflow-backend
run: ./bin/start_keycloak
run: ./keycloak/bin/start_keycloak
- name: start_backend
working-directory: ./spiffworkflow-backend
run: ./bin/build_and_run_with_docker_compose

View File

@ -1,5 +1,5 @@
### STAGE 1: Build ###
FROM quay.io/sartography/node:latest
# Base image to share ENV vars that activate VENV.
FROM quay.io/sartography/node:latest AS base
RUN mkdir /app
WORKDIR /app
@ -7,8 +7,16 @@ WORKDIR /app
# this matches total memory on spiffworkflow-demo
ENV NODE_OPTIONS=--max_old_space_size=2048
ADD package.json /app/
ADD package-lock.json /app/
# Setup image for installing JS dependencies.
FROM base AS setup
COPY . /app/
RUN cp /app/package.json /app/package.json.bak
ADD justservewebserver.package.json /app/package.json
RUN npm ci --ignore-scripts
RUN cp -r /app/node_modules /app/node_modules.justserve
RUN cp /app/package.json.bak /app/package.json
# npm ci because it respects the lock file.
# --ignore-scripts because authors can do bad things in postinstall scripts.
@ -16,8 +24,19 @@ ADD package-lock.json /app/
# npx can-i-ignore-scripts can check that it's safe to ignore scripts.
RUN npm ci --ignore-scripts
COPY . /app/
RUN npm run build
# Final image without setup dependencies.
FROM base AS final
LABEL source="https://github.com/sartography/spiff-arena"
LABEL description="Software development platform for building, running, and monitoring executable diagrams"
# WARNING: On localhost frontend assumes backend is one port lowe.
ENV PORT0=7001
COPY --from=setup /app/build /app/build
COPY --from=setup /app/bin /app/bin
COPY --from=setup /app/node_modules.justserve /app/node_modules
ENTRYPOINT ["/app/bin/boot_server_in_docker"]

View File

@ -63,6 +63,17 @@ Fix lint in code.
Probably just stick with lint:fix which also runs prettier.
## Runtime configuration options
The frontend docker image respects the following environment variables.
SPIFFWORKFLOW_FRONTEND_RUNTIME_CONFIG_APP_ROUTING_STRATEGY=subdomain_based
SPIFFWORKFLOW_FRONTEND_RUNTIME_CONFIG_APP_ROUTING_STRATEGY=path_based
subdomain_based example: api.spiffworkflow.org goes to backend
path_based example: spiffworkflow.org/api goes to backend
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).

View File

@ -7,4 +7,41 @@ function error_handler() {
trap 'error_handler ${LINENO} $?' ERR
set -o errtrace -o errexit -o nounset -o pipefail
# sort of like https://lithic.tech/blog/2020-05/react-dynamic-config, but without golang
react_configs=$(env | grep -E "^SPIFFWORKFLOW_FRONTEND_RUNTIME_CONFIG_" || echo '')
if [[ -n "$react_configs" ]]; then
index_html_file="./build/index.html"
if [[ ! -f "$index_html_file" ]]; then
>&2 echo "ERROR: Could not find '${index_html_file}'. Cannot use SPIFFWORKFLOW_FRONTEND_RUNTIME_CONFIG values without it."
exit 1
fi
if ! command -v sed >/dev/null ; then
>&2 echo "ERROR: sed command not found. Cannot use SPIFFWORKFLOW_FRONTEND_RUNTIME_CONFIG values without it."
exit 1
fi
if ! command -v perl >/dev/null ; then
>&2 echo "ERROR: perl command not found. Cannot use SPIFFWORKFLOW_FRONTEND_RUNTIME_CONFIG values without it."
exit 1
fi
for react_config in $react_configs; do
react_config_without_prefix=$(sed -E 's/^SPIFFWORKFLOW_FRONTEND_RUNTIME_CONFIG_([^=]*)=(.*)/\1=\\"\2\\"/' <<<"${react_config}")
if [[ -z "$react_config_without_prefix" ]]; then
>&2 echo "ERROR: Could not parse react config line: '${react_config}'."
exit 1
fi
# actually do the search and replace to add the js config to the html page
perl -pi -e "s/(window.spiffworkflowFrontendJsenv=\{\})/\1;window.spiffworkflowFrontendJsenv.${react_config_without_prefix}/" "$index_html_file"
if ! grep -Eq "${react_config_without_prefix}" "$index_html_file"; then
>&2 echo "ERROR: Could not find '${react_config_without_prefix}' in '${index_html_file}' after search and replace. It is likely that the assumptions in boot_server_in_docker about the contents of the html page have changed. Fix the glitch in boot_server_in_docker."
exit 1
fi
done
fi
exec ./node_modules/.bin/serve -s build -l "$PORT0"

View File

@ -7,14 +7,12 @@ function error_handler() {
trap 'error_handler ${LINENO} $?' ERR
set -o errtrace -o errexit -o nounset -o pipefail
max_attempts="${1:-}"
if [[ -z "$max_attempts" ]]; then
max_attempts=100
fi
max_attempts="${1:-100}"
port="${2:-7001}"
echo "waiting for backend to come up..."
echo "waiting for frontend to come up..."
attempts=0
while [[ "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:7001)" != "200" ]]; do
while [[ "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${port}")" != "200" ]]; do
if [[ "$attempts" -gt "$max_attempts" ]]; then
>&2 echo "ERROR: Server not up after $max_attempts attempts. There is probably a problem"
exit 1
@ -22,3 +20,4 @@ while [[ "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:7001)" != "2
attempts=$(( attempts + 1 ))
sleep 1
done
echo "frontend up"

View File

@ -30,7 +30,7 @@ const cypressConfig = {
videoUploadOnPasses: false,
chromeWebSecurity: false,
e2e: {
baseUrl: 'http://localhost:7001',
baseUrl: `http://localhost:${process.env.SPIFFWORKFLOW_FRONTEND_PORT || 7001}`,
setupNodeEvents(on, config) {
deleteVideosOnSuccess(on)
require('@cypress/grep/src/plugin')(config);

View File

@ -33,7 +33,7 @@ describe('process-groups', () => {
cy.contains(newGroupDisplayName).should('not.exist');
// meaning the process group list page is loaded, so we can sign out safely without worrying about ajax requests failing
cy.get('.tile-process-group-content-container').should('exist');
cy.getBySel('process-groups-loaded').should('exist');
});
// process groups no longer has pagination post-tiles

View File

@ -43,17 +43,27 @@ Cypress.Commands.add('navigateToAdmin', () => {
Cypress.Commands.add('login', (selector, ...args) => {
cy.visit('/admin');
cy.get('#username').type('ciadmin1');
cy.get('#password').type('ciadmin1');
cy.get('#kc-login').click();
const username = Cypress.env('SPIFFWORKFLOW_FRONTEND_USERNAME') || 'ciadmin1';
const password = Cypress.env('SPIFFWORKFLOW_FRONTEND_PASSWORD') || 'ciadmin1';
cy.get('#username').type(username);
cy.get('#password').type(password);
if (Cypress.env('SPIFFWORKFLOW_FRONTEND_AUTH_WITH_KEYCLOAK') === true) {
cy.get('#kc-login').click();
} else {
cy.get('#spiff-login-button').click();
}
});
Cypress.Commands.add('logout', (selector, ...args) => {
cy.getBySel('logout-button').click();
// otherwise we can click logout, quickly load the next page, and the javascript
// doesn't have time to actually sign you out
cy.contains('Sign in to your account');
if (Cypress.env('SPIFFWORKFLOW_FRONTEND_AUTH_WITH_KEYCLOAK') === true) {
// otherwise we can click logout, quickly load the next page, and the javascript
// doesn't have time to actually sign you out
cy.contains('Sign in to your account');
} else {
cy.get('#spiff-login-button').should('exist');
}
});
Cypress.Commands.add('createGroup', (groupId, groupDisplayName) => {

View File

@ -0,0 +1,36 @@
{
"name": "spiffworkflow-frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"serve": "^14.0.0"
},
"scripts": {
"start": "ESLINT_NO_DEV_ERRORS=true PORT=7001 craco start",
"build": "craco build",
"test": "react-scripts test --coverage",
"t": "npm test -- --watchAll=false",
"eject": "craco eject",
"format": "prettier --write src/**/*.[tj]s{,x}",
"lint": "./node_modules/.bin/eslint src",
"lint:fix": "./node_modules/.bin/eslint --fix src"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

1870
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -27,7 +27,7 @@
"@types/node": "^18.6.5",
"@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6",
"@uiw/react-md-editor": "^3.19.5",
"@uiw/react-md-editor": "^3.20.2",
"autoprefixer": "10.4.8",
"axios": "^0.27.2",
"bootstrap": "^5.2.0",
@ -49,14 +49,13 @@
"react-bootstrap": "^2.5.0",
"react-bootstrap-typeahead": "^6.0.0",
"react-datepicker": "^4.8.0",
"react-devtools": "^4.27.1",
"react-dom": "^18.2.0",
"react-icons": "^4.4.0",
"react-jsonschema-form": "^1.8.1",
"react-markdown": "^8.0.3",
"react-router": "^6.3.0",
"react-router-dom": "^6.3.0",
"react-scripts": "^5.0.1",
"remark-gfm": "^3.0.1",
"serve": "^14.0.0",
"timepicker": "^1.13.18",
"typescript": "^4.7.4",

View File

@ -25,6 +25,10 @@
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>SpiffWorkflow</title>
<script>
window.spiffworkflowFrontendJsenv = {};
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@ -1,29 +1,20 @@
import { useMemo, useState } from 'react';
// @ts-ignore
import { Content } from '@carbon/react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { defineAbility } from '@casl/ability';
import ErrorContext from './contexts/ErrorContext';
import NavigationBar from './components/NavigationBar';
import HomePageRoutes from './routes/HomePageRoutes';
import ErrorBoundary from './components/ErrorBoundary';
import AdminRoutes from './routes/AdminRoutes';
import { ErrorForDisplay } from './interfaces';
import { AbilityContext } from './contexts/Can';
import UserService from './services/UserService';
import ErrorDisplay from './components/ErrorDisplay';
import APIErrorProvider from './contexts/APIErrorContext';
export default function App() {
const [errorObject, setErrorObject] = useState<ErrorForDisplay | null>(null);
const errorContextValueArray = useMemo(
() => [errorObject, setErrorObject],
[errorObject]
);
if (!UserService.isLoggedIn()) {
UserService.doLogin();
return null;
@ -35,7 +26,7 @@ export default function App() {
<div className="cds--white">
{/* @ts-ignore */}
<AbilityContext.Provider value={ability}>
<ErrorContext.Provider value={errorContextValueArray}>
<APIErrorProvider>
<BrowserRouter>
<NavigationBar />
<Content>
@ -49,7 +40,7 @@ export default function App() {
</ErrorBoundary>
</Content>
</BrowserRouter>
</ErrorContext.Provider>
</APIErrorProvider>
</AbilityContext.Provider>
</div>
);

View File

@ -1,10 +1,26 @@
import { useContext } from 'react';
import ErrorContext from '../contexts/ErrorContext';
import { Notification } from './Notification';
import useAPIError from '../hooks/UseApiError';
function errorDetailDisplay(
errorObject: any,
propertyName: string,
title: string
) {
// Creates a bit of html for displaying a single error property if it exists.
if (propertyName in errorObject && errorObject[propertyName]) {
return (
<div className="error_info">
<span className="error_title">{title}:</span>
{errorObject[propertyName]}
</div>
);
}
return null;
}
export default function ErrorDisplay() {
const [errorObject, setErrorObject] = (useContext as any)(ErrorContext);
const errorObject = useAPIError().error;
const { removeError } = useAPIError();
let errorTag = null;
if (errorObject) {
let sentryLinkTag = null;
@ -21,32 +37,38 @@ export default function ErrorDisplay() {
);
}
let message = <div>{errorObject.message}</div>;
let title = 'Error:';
if ('task_name' in errorObject && errorObject.task_name) {
title = 'Error in python script:';
message = (
<>
<br />
<div>
Task: {errorObject.task_name} ({errorObject.task_id})
</div>
<div>File name: {errorObject.file_name}</div>
<div>Line number in script task: {errorObject.line_number}</div>
<br />
<div>{errorObject.message}</div>
</>
const message = <div>{errorObject.message}</div>;
const title = 'Error:';
const taskName = errorDetailDisplay(errorObject, 'task_name', 'Task Name');
const taskId = errorDetailDisplay(errorObject, 'task_id', 'Task ID');
const fileName = errorDetailDisplay(errorObject, 'file_name', 'File Name');
const lineNumber = errorDetailDisplay(
errorObject,
'line_number',
'Line Number'
);
const errorLine = errorDetailDisplay(errorObject, 'error_line', 'Context');
let taskTrace = null;
if (errorObject.task_trace && errorObject.task_trace.length > 1) {
taskTrace = (
<div className="error_info">
<span className="error_title">Call Activity Trace:</span>
{errorObject.task_trace.reverse().join(' -> ')}
</div>
);
}
errorTag = (
<Notification
title={title}
onClose={() => setErrorObject(null)}
type="error"
>
<Notification title={title} onClose={() => removeError()} type="error">
{message}
<br />
{sentryLinkTag}
{taskName}
{taskId}
{fileName}
{lineNumber}
{errorLine}
{taskTrace}
</Notification>
);
}

View File

@ -25,6 +25,7 @@ import { useUriListForPermissions } from '../hooks/UriListForPermissions';
import { PermissionsToCheck } from '../interfaces';
import { usePermissionFetcher } from '../hooks/PermissionService';
import { UnauthenticatedError } from '../services/HttpService';
import { SPIFF_ENVIRONMENT } from '../config';
// for ref: https://react-bootstrap.github.io/components/navbar/
export default function NavigationBar() {
@ -80,7 +81,12 @@ export default function NavigationBar() {
if (UserService.isLoggedIn()) {
return (
<>
<HeaderGlobalAction className="username-header-text">
{SPIFF_ENVIRONMENT ? (
<HeaderGlobalAction className="spiff-environment-header-text unclickable-text">
{SPIFF_ENVIRONMENT}
</HeaderGlobalAction>
) : null}
<HeaderGlobalAction className="username-header-text unclickable-text">
{UserService.getPreferredUsername()}
</HeaderGlobalAction>
<HeaderGlobalAction

View File

@ -35,7 +35,7 @@ export default function ProcessGroupForm({
};
const hasValidIdentifier = (identifierToCheck: string) => {
return identifierToCheck.match(/^[a-z0-9][0-9a-z-]+[a-z0-9]$/);
return identifierToCheck.match(/^[a-z0-9][0-9a-z-]*[a-z0-9]$/);
};
const handleFormSubmission = (event: any) => {

View File

@ -94,7 +94,13 @@ export default function ProcessGroupListTiles({
};
if (processGroups) {
return <>{processGroupArea()}</>;
return (
<>
{/* so we can check if the groups have loaded in cypress tests */}
<div data-qa="process-groups-loaded" hidden />
{processGroupArea()}
</>
);
}
return null;
}

View File

@ -1,4 +1,4 @@
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Link,
useNavigate,
@ -40,13 +40,11 @@ import {
getProcessModelFullIdentifierFromSearchParams,
modifyProcessIdentifierForPathParam,
refreshAtInterval,
setErrorMessageSafely,
} from '../helpers';
import PaginationForTable from './PaginationForTable';
import 'react-datepicker/dist/react-datepicker.css';
import ErrorContext from '../contexts/ErrorContext';
import HttpService from '../services/HttpService';
import 'react-bootstrap-typeahead/css/Typeahead.css';
@ -61,6 +59,7 @@ import {
ReportMetadata,
ReportFilter,
User,
ErrorForDisplay,
} from '../interfaces';
import ProcessModelSearch from './ProcessModelSearch';
import ProcessInstanceReportSearch from './ProcessInstanceReportSearch';
@ -68,6 +67,7 @@ import ProcessInstanceListDeleteReport from './ProcessInstanceListDeleteReport';
import ProcessInstanceListSaveAsReport from './ProcessInstanceListSaveAsReport';
import { FormatProcessModelDisplayName } from './MiniComponents';
import { Notification } from './Notification';
import useAPIError from '../hooks/UseApiError';
const REFRESH_INTERVAL = 5;
const REFRESH_TIMEOUT = 600;
@ -110,6 +110,7 @@ export default function ProcessInstanceListTable({
const params = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { addError, removeError } = useAPIError();
const [processInstances, setProcessInstances] = useState([]);
const [reportMetadata, setReportMetadata] = useState<ReportMetadata | null>();
@ -133,8 +134,6 @@ export default function ProcessInstanceListTable({
const [endFromTimeInvalid, setEndFromTimeInvalid] = useState<boolean>(false);
const [endToTimeInvalid, setEndToTimeInvalid] = useState<boolean>(false);
const [errorObject, setErrorObject] = (useContext as any)(ErrorContext);
const processInstanceListPathPrefix =
variant === 'all'
? '/admin/process-instances/all'
@ -517,7 +516,7 @@ export default function ProcessInstanceListTable({
}
if (message !== '') {
valid = false;
setErrorMessageSafely(message, errorObject, setErrorObject);
addError({ message } as ErrorForDisplay);
}
}
@ -579,7 +578,7 @@ export default function ProcessInstanceListTable({
queryParamString += `&process_initiator_username=${processInitiatorSelection.username}`;
}
setErrorObject(null);
removeError();
setProcessInstanceReportJustSaved(null);
setProcessInstanceFilters({});
navigate(`${processInstanceListPathPrefix}?${queryParamString}`);
@ -679,7 +678,7 @@ export default function ProcessInstanceListTable({
queryParamString = `?report_id=${selectedReport.id}`;
}
setErrorObject(null);
removeError();
setProcessInstanceReportJustSaved(mode || null);
navigate(`${processInstanceListPathPrefix}${queryParamString}`);
};

View File

@ -1,4 +1,3 @@
import { useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button,
@ -11,9 +10,9 @@ import {
RecentProcessModel,
} from '../interfaces';
import HttpService from '../services/HttpService';
import ErrorContext from '../contexts/ErrorContext';
import { modifyProcessIdentifierForPathParam } from '../helpers';
import { usePermissionFetcher } from '../hooks/PermissionService';
import useAPIError from '../hooks/UseApiError';
const storeRecentProcessModelInLocalStorage = (
processModelForStorage: ProcessModel
@ -78,7 +77,7 @@ export default function ProcessInstanceRun({
checkPermissions = true,
}: OwnProps) {
const navigate = useNavigate();
const setErrorObject = (useContext as any)(ErrorContext)[1];
const { addError, removeError } = useAPIError();
const modifiedProcessModelId = modifyProcessIdentifierForPathParam(
processModel.id
);
@ -105,20 +104,22 @@ export default function ProcessInstanceRun({
};
const processModelRun = (processInstance: any) => {
setErrorObject(null);
removeError();
storeRecentProcessModelInLocalStorage(processModel);
HttpService.makeCallToBackend({
path: `/process-instances/${modifiedProcessModelId}/${processInstance.id}/run`,
successCallback: onProcessInstanceRun,
failureCallback: setErrorObject,
failureCallback: addError,
httpMethod: 'POST',
});
};
const processInstanceCreateAndRun = () => {
removeError();
HttpService.makeCallToBackend({
path: processInstanceCreatePath,
successCallback: processModelRun,
failureCallback: addError,
httpMethod: 'POST',
});
};

View File

@ -6,7 +6,7 @@ import BpmnViewer from 'bpmn-js/lib/Viewer';
import {
BpmnPropertiesPanelModule,
BpmnPropertiesProviderModule,
// @ts-expect-error TS(7016) FIXME: Could not find a declaration file for module 'bpmn... Remove this comment to see the full error message
// @ts-expect-error TS(7016) FIXME: Could not find a declaration file for module 'bpmn... RemoFve this comment to see the full error message
} from 'bpmn-js-properties-panel';
// @ts-expect-error TS(7016) FIXME: Could not find a declaration file for module 'dmn-... Remove this comment to see the full error message

View File

@ -36,6 +36,7 @@ type OwnProps = {
showDateStarted?: boolean;
showLastUpdated?: boolean;
hideIfNoTasks?: boolean;
canCompleteAllTasks?: boolean;
};
export default function TaskListTable({
@ -56,6 +57,7 @@ export default function TaskListTable({
showDateStarted = true,
showLastUpdated = true,
hideIfNoTasks = false,
canCompleteAllTasks = false,
}: OwnProps) {
const [searchParams] = useSearchParams();
const [tasks, setTasks] = useState<ProcessInstanceTask[] | null>(null);
@ -128,7 +130,10 @@ export default function TaskListTable({
const regex = new RegExp(`\\b(${preferredUsername}|${userEmail})\\b`);
let hasAccessToCompleteTask = false;
if ((processInstanceTask.potential_owner_usernames || '').match(regex)) {
if (
canCompleteAllTasks ||
(processInstanceTask.potential_owner_usernames || '').match(regex)
) {
hasAccessToCompleteTask = true;
}
const rowElements = [];

View File

@ -11,6 +11,7 @@ export default function TasksWaitingForMe() {
textToShowIfEmpty="No tasks are waiting for you."
autoReload
showWaitingOn={false}
canCompleteAllTasks
/>
);
}

View File

@ -29,6 +29,7 @@ export default function TasksWaitingForMyGroups() {
textToShowIfEmpty="This group has no task assignments at this time."
autoReload
showWaitingOn={false}
canCompleteAllTasks
/>
);
});

View File

@ -1,17 +1,63 @@
const { port, hostname } = window.location;
let hostAndPort = `api.${hostname}`;
let protocol = 'https';
declare global {
interface SpiffworkflowFrontendJsenvObject {
[key: string]: string;
}
interface Window {
spiffworkflowFrontendJsenv: SpiffworkflowFrontendJsenvObject;
}
}
let spiffEnvironment = '';
let appRoutingStrategy = 'subdomain_based';
if ('spiffworkflowFrontendJsenv' in window) {
if ('APP_ROUTING_STRATEGY' in window.spiffworkflowFrontendJsenv) {
appRoutingStrategy = window.spiffworkflowFrontendJsenv.APP_ROUTING_STRATEGY;
}
if ('ENVIRONMENT_IDENTIFIER' in window.spiffworkflowFrontendJsenv) {
spiffEnvironment = window.spiffworkflowFrontendJsenv.ENVIRONMENT_IDENTIFIER;
}
}
let hostAndPortAndPathPrefix;
if (appRoutingStrategy === 'subdomain_based') {
hostAndPortAndPathPrefix = `api.${hostname}`;
} else if (appRoutingStrategy === 'path_based') {
hostAndPortAndPathPrefix = `${hostname}/api`;
} else {
throw new Error(`Invalid app routing strategy: ${appRoutingStrategy}`);
}
if (/^\d+\./.test(hostname) || hostname === 'localhost') {
let serverPort = 7000;
if (!Number.isNaN(Number(port))) {
serverPort = Number(port) - 1;
}
hostAndPort = `${hostname}:${serverPort}`;
hostAndPortAndPathPrefix = `${hostname}:${serverPort}`;
protocol = 'http';
if (spiffEnvironment === '') {
// using destructuring on an array where we only want the first element
// seems super confusing for non-javascript devs to read so let's NOT do that.
// eslint-disable-next-line prefer-destructuring
spiffEnvironment = hostname.split('.')[0];
}
}
let url = `${protocol}://${hostAndPort}/v1.0`;
if (
'spiffworkflowFrontendJsenv' in window &&
'APP_ROUTING_STRATEGY' in window.spiffworkflowFrontendJsenv
) {
appRoutingStrategy = window.spiffworkflowFrontendJsenv.APP_ROUTING_STRATEGY;
}
let url = `${protocol}://${hostAndPortAndPathPrefix}/v1.0`;
// this can only ever work locally since this is a static site.
// use spiffworkflowFrontendJsenv if you want to inject env vars
// that can be read by the static site.
if (process.env.REACT_APP_BACKEND_BASE_URL) {
url = process.env.REACT_APP_BACKEND_BASE_URL;
}
@ -33,3 +79,5 @@ export const DATE_TIME_FORMAT = 'yyyy-MM-dd HH:mm:ss';
export const TIME_FORMAT_HOURS_MINUTES = 'HH:mm';
export const DATE_FORMAT = 'yyyy-MM-dd';
export const DATE_FORMAT_CARBON = 'Y-m-d';
export const SPIFF_ENVIRONMENT = spiffEnvironment;

View File

@ -0,0 +1,43 @@
import React, { createContext, useState } from 'react';
import { ErrorForDisplay } from '../interfaces';
type ErrorContextType = {
error: null | ErrorForDisplay;
addError: Function;
removeError: Function;
};
export const APIErrorContext = createContext<ErrorContextType>({
error: null,
// eslint-disable-next-line no-unused-vars
addError: () => {},
removeError: () => {},
});
// @ts-ignore
// eslint-disable-next-line react/prop-types
export default function APIErrorProvider({ children }) {
const [error, setError] = useState<ErrorForDisplay | null>(null);
const addError = (errorForDisplay: ErrorForDisplay | null) => {
setError(errorForDisplay);
};
const removeError = () => setError(null);
const contextValue = React.useMemo(
() => ({
error,
addError: (newError: ErrorForDisplay | null) => {
addError(newError);
},
removeError: () => {
removeError();
},
}),
[error]
);
return (
<APIErrorContext.Provider value={contextValue}>
{children}
</APIErrorContext.Provider>
);
}

View File

@ -1,5 +0,0 @@
import { createContext } from 'react';
// @ts-expect-error TS(2554) FIXME: Expected 1 arguments, but got 0.
const ErrorContext = createContext();
export default ErrorContext;

View File

@ -8,7 +8,6 @@ import {
DEFAULT_PER_PAGE,
DEFAULT_PAGE,
} from './components/PaginationForTable';
import { ErrorForDisplay } from './interfaces';
// https://www.30secondsofcode.org/js/s/slugify
export const slugifyString = (str: any) => {
@ -258,20 +257,6 @@ export const getBpmnProcessIdentifiers = (rootBpmnElement: any) => {
return childProcesses;
};
// Setting the error message state to the same string is still considered a change
// and re-renders the page so check the message first to avoid that.
export const setErrorMessageSafely = (
newErrorMessageString: string,
oldErrorMessage: ErrorForDisplay,
errorMessageSetter: any
) => {
if (oldErrorMessage && oldErrorMessage.message === newErrorMessageString) {
return null;
}
errorMessageSetter({ message: newErrorMessageString });
return null;
};
export const isInteger = (str: string | number) => {
return /^\d+$/.test(str.toString());
};

10
src/hooks/UseApiError.tsx Normal file
View File

@ -0,0 +1,10 @@
// src/common/hooks/useAPIError/index.js
import { useContext } from 'react';
import { APIErrorContext } from '../contexts/APIErrorContext';
function useAPIError() {
const { error, addError, removeError } = useContext(APIErrorContext);
return { error, addError, removeError };
}
export default useAPIError;

View File

@ -14,6 +14,21 @@
width: 5rem;
}
.cds--header__action.spiff-environment-header-text {
width: 5rem;
color: #126d82;
}
.cds--header__action.unclickable-text:hover {
background-color: #161616;
cursor: default;
}
.cds--header__action.unclickable-text:focus {
border: none;
box-shadow: none;
border-color: none;
}
h1 {
font-weight: 400;
font-size: 28px;
@ -173,6 +188,10 @@ h1.with-icons {
margin-top: 1.3em;
}
.with-top-margin-for-label-next-to-text-input {
margin-top: 2.3em;
}
.with-tiny-top-margin {
margin-top: 4px;
}
@ -374,3 +393,12 @@ svg.notification-icon {
.tag-type-green:hover {
background-color: #80ee90;
}
/* Errors and notifications */
.error_info .error_title {
display: inline-block;
font-weight: lighter;
width: 100px;
text-align: right;
margin-right: 10px;
}

View File

@ -24,20 +24,29 @@ export interface RecentProcessModel {
export interface ProcessInstanceTask {
id: number;
task_id: string;
calling_subprocess_task_id: string;
created_at_in_seconds: number;
current_user_is_potential_owner: number;
data: any;
form_schema: any;
form_ui_schema: any;
lane_assignment_id: string;
name: string;
process_identifier: string;
process_initiator_username: string;
process_instance_id: number;
process_instance_status: string;
process_model_display_name: string;
process_model_identifier: string;
task_title: string;
lane_assignment_id: string;
process_instance_status: string;
properties: any;
state: string;
process_identifier: string;
name: string;
process_initiator_username: string;
created_at_in_seconds: number;
task_title: string;
title: string;
type: string;
updated_at_in_seconds: number;
current_user_is_potential_owner: number;
calling_subprocess_task_id: string;
task_spiff_step?: number;
potential_owner_usernames?: string;
assigned_user_group_identifier?: string;
}
@ -66,6 +75,12 @@ export interface ProcessFile {
file_contents?: string;
}
export interface ProcessInstanceMetadata {
id: number;
key: string;
value: string;
}
export interface ProcessInstance {
id: number;
process_model_identifier: string;
@ -80,6 +95,8 @@ export interface ProcessInstance {
updated_at_in_seconds: number;
bpmn_version_control_identifier: string;
bpmn_version_control_type: string;
process_metadata?: ProcessInstanceMetadata[];
process_model_with_diagram_identifier?: string;
}
export interface MessageCorrelationProperties {
@ -182,6 +199,7 @@ export interface ErrorForDisplay {
task_id?: string;
line_number?: number;
file_name?: string;
task_trace?: [string];
}
export interface AuthenticationParam {
@ -225,7 +243,16 @@ export interface PermissionCheckResponseBody {
export interface FormField {
id: string;
title: string;
required: boolean;
required?: boolean;
type: string;
enum: string[];
enum?: string[];
default?: any;
pattern?: string;
}
export interface JsonSchemaForm {
file_contents: string;
name: string;
process_model_id: string;
required: string[];
}

View File

@ -1,6 +1,6 @@
import { Routes, Route, useLocation } from 'react-router-dom';
import { useContext, useEffect } from 'react';
import { useEffect } from 'react';
import ProcessGroupList from './ProcessGroupList';
import ProcessGroupShow from './ProcessGroupShow';
import ProcessGroupNew from './ProcessGroupNew';
@ -17,7 +17,6 @@ import ProcessInstanceReportList from './ProcessInstanceReportList';
import ProcessInstanceReportNew from './ProcessInstanceReportNew';
import ProcessInstanceReportEdit from './ProcessInstanceReportEdit';
import ReactFormEditor from './ReactFormEditor';
import ErrorContext from '../contexts/ErrorContext';
import ProcessInstanceLogList from './ProcessInstanceLogList';
import MessageInstanceList from './MessageInstanceList';
import Configuration from './Configuration';
@ -27,11 +26,8 @@ import ProcessInstanceFindById from './ProcessInstanceFindById';
export default function AdminRoutes() {
const location = useLocation();
const setErrorObject = (useContext as any)(ErrorContext)[1];
useEffect(() => {
setErrorObject(null);
}, [location, setErrorObject]);
useEffect(() => {}, [location]);
if (UserService.hasRole(['admin'])) {
return (
@ -114,7 +110,11 @@ export default function AdminRoutes() {
/>
<Route
path="logs/:process_model_id/:process_instance_id"
element={<ProcessInstanceLogList />}
element={<ProcessInstanceLogList variant="all" />}
/>
<Route
path="logs/for-me/:process_model_id/:process_instance_id"
element={<ProcessInstanceLogList variant="for-me" />}
/>
<Route
path="process-instances"

View File

@ -1,14 +1,11 @@
import { useContext, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
// @ts-ignore
import { Table } from '@carbon/react';
import ErrorContext from '../contexts/ErrorContext';
import { AuthenticationItem } from '../interfaces';
import HttpService from '../services/HttpService';
import UserService from '../services/UserService';
export default function AuthenticationList() {
const setErrorObject = (useContext as any)(ErrorContext)[1];
const [authenticationList, setAuthenticationList] = useState<
AuthenticationItem[] | null
>(null);
@ -26,9 +23,8 @@ export default function AuthenticationList() {
HttpService.makeCallToBackend({
path: `/authentications`,
successCallback: processResult,
failureCallback: setErrorObject,
});
}, [setErrorObject]);
}, []);
const buildTable = () => {
if (authenticationList) {

View File

@ -1,9 +1,9 @@
import { useContext, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
// @ts-ignore
import { Tabs, TabList, Tab } from '@carbon/react';
import { Can } from '@casl/react';
import ErrorContext from '../contexts/ErrorContext';
import useAPIError from '../hooks/UseApiError';
import SecretList from './SecretList';
import SecretNew from './SecretNew';
import SecretShow from './SecretShow';
@ -14,7 +14,7 @@ import { usePermissionFetcher } from '../hooks/PermissionService';
export default function Configuration() {
const location = useLocation();
const setErrorObject = (useContext as any)(ErrorContext)[1];
const { removeError } = useAPIError();
const [selectedTabIndex, setSelectedTabIndex] = useState<number>(0);
const navigate = useNavigate();
@ -26,13 +26,14 @@ export default function Configuration() {
const { ability } = usePermissionFetcher(permissionRequestData);
useEffect(() => {
setErrorObject(null);
console.log('Configuration remove error');
removeError();
let newSelectedTabIndex = 0;
if (location.pathname.match(/^\/admin\/configuration\/authentications\b/)) {
newSelectedTabIndex = 1;
}
setSelectedTabIndex(newSelectedTabIndex);
}, [location, setErrorObject]);
}, [location, removeError]);
return (
<>

View File

@ -1,9 +1,8 @@
import { useContext, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
// @ts-ignore
import { Tabs, TabList, Tab } from '@carbon/react';
import TaskShow from './TaskShow';
import ErrorContext from '../contexts/ErrorContext';
import MyTasks from './MyTasks';
import GroupedTasks from './GroupedTasks';
import CompletedInstances from './CompletedInstances';
@ -11,12 +10,11 @@ import CreateNewInstance from './CreateNewInstance';
export default function HomePageRoutes() {
const location = useLocation();
const setErrorObject = (useContext as any)(ErrorContext)[1];
const [selectedTabIndex, setSelectedTabIndex] = useState<number>(0);
const navigate = useNavigate();
useEffect(() => {
setErrorObject(null);
// Do not remove errors here, or they always get removed.
let newSelectedTabIndex = 0;
if (location.pathname.match(/^\/tasks\/completed-instances\b/)) {
newSelectedTabIndex = 1;
@ -24,7 +22,7 @@ export default function HomePageRoutes() {
newSelectedTabIndex = 2;
}
setSelectedTabIndex(newSelectedTabIndex);
}, [location, setErrorObject]);
}, [location]);
const renderTabs = () => {
if (location.pathname.match(/^\/tasks\/\d+\/\b/)) {

View File

@ -1,17 +1,32 @@
import { useEffect, useState } from 'react';
// @ts-ignore
import { Button, Select, SelectItem, TextInput } from '@carbon/react';
import { useParams } from 'react-router-dom';
import { FormField } from '../interfaces';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
Button,
Select,
SelectItem,
TextInput,
Grid,
Column,
// @ts-ignore
} from '@carbon/react';
import validator from '@rjsf/validator-ajv8';
import { FormField, JsonSchemaForm } from '../interfaces';
import { Form } from '../themes/carbon';
import {
modifyProcessIdentifierForPathParam,
slugifyString,
underscorizeString,
} from '../helpers';
import HttpService from '../services/HttpService';
import { Notification } from '../components/Notification';
import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
import ButtonWithConfirmation from '../components/ButtonWithConfirmation';
export default function JsonSchemaFormBuilder() {
const params = useParams();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const formFieldTypes = ['textbox', 'checkbox', 'select'];
const [formTitle, setFormTitle] = useState<string>('');
@ -31,34 +46,90 @@ export default function JsonSchemaFormBuilder() {
const [formFieldId, setFormFieldId] = useState<string>('');
const [formFieldTitle, setFormFieldTitle] = useState<string>('');
const [formFieldType, setFormFieldType] = useState<string>('');
const [requiredFields, setRequiredFields] = useState<string[]>([]);
const [savedJsonSchema, setSavedJsonSchema] = useState<boolean>(false);
const modifiedProcessModelId = modifyProcessIdentifierForPathParam(
`${params.process_model_id}`
);
useEffect(() => {}, []);
useEffect(() => {
const processResult = (result: JsonSchemaForm) => {
const jsonForm = JSON.parse(result.file_contents);
setFormTitle(jsonForm.title);
setFormDescription(jsonForm.description);
setRequiredFields(jsonForm.required);
const newFormId = (searchParams.get('file_name') || '').replace(
'-schema.json',
''
);
setFormId(newFormId);
const newFormFields: FormField[] = [];
Object.keys(jsonForm.properties).forEach((propertyId: string) => {
const propertyDetails = jsonForm.properties[propertyId];
newFormFields.push({
id: propertyId,
title: propertyDetails.title,
required: propertyDetails.required,
type: propertyDetails.type,
enum: propertyDetails.enum,
default: propertyDetails.default,
pattern: propertyDetails.pattern,
});
});
setFormFields(newFormFields);
};
if (searchParams.get('file_name')) {
HttpService.makeCallToBackend({
path: `/process-models/${modifiedProcessModelId}/files/${searchParams.get(
'file_name'
)}`,
successCallback: processResult,
});
}
}, [modifiedProcessModelId, searchParams]);
const formSubmitResultElement = () => {
if (savedJsonSchema) {
return (
<Notification
title="Form Saved"
onClose={() => setSavedJsonSchema(false)}
>
It saved
</Notification>
);
}
return null;
};
const renderFormJson = () => {
const formJson = {
title: formTitle,
description: formDescription,
properties: {},
required: [],
required: requiredFields,
};
formFields.forEach((formField: FormField) => {
let jsonSchemaFieldType = 'string';
if (formField.type === 'checkbox') {
let jsonSchemaFieldType = formField.type;
if (['checkbox'].includes(formField.type)) {
jsonSchemaFieldType = 'boolean';
}
const formJsonObject: any = {
type: jsonSchemaFieldType,
type: jsonSchemaFieldType || 'string',
title: formField.title,
};
if (formField.type === 'select') {
if (formField.enum) {
formJsonObject.enum = formField.enum;
}
if (formField.default !== undefined) {
formJsonObject.default = formField.default;
}
if (formField.pattern) {
formJsonObject.pattern = formField.pattern;
}
(formJson.properties as any)[formField.id] = formJsonObject;
});
@ -86,13 +157,23 @@ export default function JsonSchemaFormBuilder() {
setFormTitle(newFormTitle);
};
const getFormFieldType = (indicatedType: string) => {
if (indicatedType === 'checkbox') {
return 'boolean';
}
// undefined or 'select' or 'textbox'
return 'string';
};
const addFormField = () => {
const newFormField: FormField = {
id: formFieldId,
title: formFieldTitle,
required: false,
type: formFieldType,
enum: formFieldSelectOptions.split(','),
type: getFormFieldType(formFieldType),
enum: showFormFieldSelectTextField
? formFieldSelectOptions.split(',')
: undefined,
};
setFormFieldIdHasBeenUpdatedByUser(false);
@ -168,8 +249,8 @@ export default function JsonSchemaFormBuilder() {
return null;
};
const handleSaveCallback = (result: any) => {
console.log('result', result);
const handleSaveCallback = () => {
setSavedJsonSchema(true);
};
const uploadFile = (file: File) => {
@ -188,17 +269,118 @@ export default function JsonSchemaFormBuilder() {
};
const saveFile = () => {
const formJsonFileName = `${formId}-schema.json`;
const formUiJsonFileName = `${formId}-uischema.json`;
setSavedJsonSchema(false);
let formJsonFileName = `${formId}-schema.json`;
let formUiJsonFileName: string | null = `${formId}-uischema.json`;
if (searchParams.get('file_name')) {
formJsonFileName = searchParams.get('file_name') as any;
if (formJsonFileName.match(/-schema\.json$/)) {
formUiJsonFileName = (searchParams.get('file_name') as any).replace(
'-schema.json',
'-uischema.json'
);
} else {
formUiJsonFileName = null;
}
}
uploadFile(new File([renderFormJson()], formJsonFileName));
uploadFile(new File([renderFormUiJson()], formUiJsonFileName));
if (formUiJsonFileName) {
uploadFile(new File([renderFormUiJson()], formUiJsonFileName));
}
};
const deleteFile = () => {
const url = `/process-models/${modifiedProcessModelId}/files/${params.file_name}`;
const httpMethod = 'DELETE';
const navigateToProcessModelShow = (_httpResult: any) => {
navigate(`/admin/process-models/${modifiedProcessModelId}`);
};
HttpService.makeCallToBackend({
path: url,
successCallback: navigateToProcessModelShow,
httpMethod,
});
};
const formIdTextField = () => {
if (searchParams.get('file_name')) {
return null;
}
return (
<TextInput
id="json-form-id"
name="id"
labelText="ID"
value={formId}
onChange={(event: any) => {
setFormIdHasBeenUpdatedByUser(true);
setFormId(event.srcElement.value);
}}
/>
);
};
const jsonFormButton = () => {
if (!searchParams.get('file_name')) {
return null;
}
return (
<>
<ButtonWithConfirmation
data-qa="delete-process-model-file"
description={`Delete file ${searchParams.get('file_name')}?`}
onConfirmation={deleteFile}
buttonLabel="Delete"
/>
<Button
onClick={() =>
navigate(
`/admin/process-models/${
params.process_model_id
}/form/${searchParams.get('file_name')}`
)
}
variant="danger"
data-qa="form-builder-button"
>
View Json
</Button>
</>
);
};
const processModelFileName = searchParams.get('file_name') || '';
const topOfPageElements = () => {
return (
<>
<ProcessBreadcrumb
hotCrumbs={[
['Process Groups', '/admin'],
{
entityToExplode: params.process_model_id || '',
entityType: 'process-model-id',
linkLastItem: true,
},
[processModelFileName],
]}
/>
<h1>
Process Model File{processModelFileName ? ': ' : ''}
{processModelFileName}
</h1>
</>
);
};
const jsonFormArea = () => {
return (
<>
{formSubmitResultElement()}
<Button onClick={saveFile}>Save</Button>
{jsonFormButton()}
<TextInput
id="json-form-title"
name="title"
@ -208,16 +390,7 @@ export default function JsonSchemaFormBuilder() {
onFormTitleChange(event.srcElement.value);
}}
/>
<TextInput
id="json-form-id"
name="id"
labelText="ID"
value={formId}
onChange={(event: any) => {
setFormIdHasBeenUpdatedByUser(true);
setFormId(event.srcElement.value);
}}
/>
{formIdTextField()}
<TextInput
id="form-description"
name="description"
@ -244,6 +417,28 @@ export default function JsonSchemaFormBuilder() {
</>
);
};
const schemaString = renderFormJson();
const uiSchemaString = renderFormUiJson();
const schema = JSON.parse(schemaString);
const uiSchema = JSON.parse(uiSchemaString);
return <>{jsonFormArea()}</>;
return (
<>
{topOfPageElements()}
<Grid fullWidth>
<Column md={5} lg={8} sm={4}>
{jsonFormArea()}
</Column>
<Column md={5} lg={8} sm={4}>
<h2>Form Preview</h2>
<Form
formData={{}}
schema={schema}
uiSchema={uiSchema}
validator={validator}
/>
</Column>
</Grid>
</>
);
}

View File

@ -12,17 +12,23 @@ import {
import HttpService from '../services/HttpService';
import { useUriListForPermissions } from '../hooks/UriListForPermissions';
export default function ProcessInstanceLogList() {
type OwnProps = {
variant: string;
};
export default function ProcessInstanceLogList({ variant }: OwnProps) {
const params = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const [processInstanceLogs, setProcessInstanceLogs] = useState([]);
const [pagination, setPagination] = useState(null);
const modifiedProcessModelId = modifyProcessIdentifierForPathParam(
`${params.process_model_id}`
);
const { targetUris } = useUriListForPermissions();
const isDetailedView = searchParams.get('detailed') === 'true';
let processInstanceShowPageBaseUrl = `/admin/process-instances/for-me/${params.process_model_id}`;
if (variant === 'all') {
processInstanceShowPageBaseUrl = `/admin/process-instances/${params.process_model_id}`;
}
useEffect(() => {
const setProcessInstanceLogListFromResult = (result: any) => {
setProcessInstanceLogs(result.results);
@ -65,7 +71,7 @@ export default function ProcessInstanceLogList() {
<td>
<Link
data-qa="process-instance-show-link"
to={`/admin/process-instances/${modifiedProcessModelId}/${rowToUse.process_instance_id}/${rowToUse.spiff_step}`}
to={`${processInstanceShowPageBaseUrl}/${rowToUse.process_instance_id}/${rowToUse.spiff_step}`}
>
{convertSecondsToFormattedDateTime(rowToUse.timestamp)}
</Link>
@ -111,7 +117,7 @@ export default function ProcessInstanceLogList() {
},
[
`Process Instance: ${params.process_instance_id}`,
`/admin/process-instances/${params.process_model_id}/${params.process_instance_id}`,
`${processInstanceShowPageBaseUrl}/${params.process_instance_id}`,
],
['Logs'],
]}

View File

@ -1,4 +1,4 @@
import { useContext, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import Editor from '@monaco-editor/react';
import {
useParams,
@ -35,20 +35,22 @@ import HttpService from '../services/HttpService';
import ReactDiagramEditor from '../components/ReactDiagramEditor';
import {
convertSecondsToFormattedDateTime,
modifyProcessIdentifierForPathParam,
unModifyProcessIdentifierForPathParam,
} from '../helpers';
import ButtonWithConfirmation from '../components/ButtonWithConfirmation';
import ErrorContext from '../contexts/ErrorContext';
import { useUriListForPermissions } from '../hooks/UriListForPermissions';
import {
PermissionsToCheck,
ProcessData,
ProcessInstance,
ProcessInstanceMetadata,
ProcessInstanceTask,
} from '../interfaces';
import { usePermissionFetcher } from '../hooks/PermissionService';
import ProcessInstanceClass from '../classes/ProcessInstanceClass';
import TaskListTable from '../components/TaskListTable';
import useAPIError from '../hooks/UseApiError';
type OwnProps = {
variant: string;
@ -74,9 +76,10 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
const [eventTextEditorEnabled, setEventTextEditorEnabled] =
useState<boolean>(false);
const [displayDetails, setDisplayDetails] = useState<boolean>(false);
const [showProcessInstanceMetadata, setShowProcessInstanceMetadata] =
useState<boolean>(false);
const setErrorObject = (useContext as any)(ErrorContext)[1];
const { addError, removeError } = useAPIError();
const unModifiedProcessModelId = unModifyProcessIdentifierForPathParam(
`${params.process_model_id}`
);
@ -112,6 +115,13 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
);
};
let processInstanceShowPageBaseUrl = `/admin/process-instances/for-me/${params.process_model_id}/${params.process_instance_id}`;
let processInstanceLogListPageBaseUrl = `/admin/logs/for-me/${params.process_model_id}/${params.process_instance_id}`;
if (variant === 'all') {
processInstanceShowPageBaseUrl = `/admin/process-instances/${params.process_model_id}/${params.process_instance_id}`;
processInstanceLogListPageBaseUrl = `/admin/logs/${params.process_model_id}/${params.process_instance_id}`;
}
useEffect(() => {
if (permissionsLoaded) {
const processTaskFailure = () => {
@ -151,11 +161,11 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
}
}
}, [
targetUris,
params,
modifiedProcessModelId,
permissionsLoaded,
ability,
targetUris,
searchParams,
taskListPath,
variant,
@ -237,19 +247,26 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
return processInstance && currentSpiffStep() === processInstance.spiff_step;
};
const spiffStepLink = (label: any, distance: number) => {
const spiffStepLink = (label: any, spiffStep: number) => {
const processIdentifier = searchParams.get('process_identifier');
let queryParams = '';
const callActivityTaskId = searchParams.get('call_activity_task_id');
const queryParamArray = [];
if (processIdentifier) {
queryParams = `?process_identifier=${processIdentifier}`;
queryParamArray.push(`process_identifier=${processIdentifier}`);
}
if (callActivityTaskId) {
queryParamArray.push(`call_activity_task_id=${callActivityTaskId}`);
}
let queryParams = '';
if (queryParamArray.length > 0) {
queryParams = `?${queryParamArray.join('&')}`;
}
return (
<Link
reloadDocument
data-qa="process-instance-step-link"
to={`/admin/process-instances/${params.process_model_id}/${
params.process_instance_id
}/${currentSpiffStep() + distance}${queryParams}`}
to={`${processInstanceShowPageBaseUrl}/${spiffStep}${queryParams}`}
>
{label}
</Link>
@ -261,7 +278,7 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
return null;
}
return spiffStepLink(<CaretLeft />, -1);
return spiffStepLink(<CaretLeft />, currentSpiffStep() - 1);
};
const nextStepLink = () => {
@ -269,11 +286,11 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
return null;
}
return spiffStepLink(<CaretRight />, 1);
return spiffStepLink(<CaretRight />, currentSpiffStep() + 1);
};
const returnToLastSpiffStep = () => {
window.location.href = `/admin/process-instances/${params.process_model_id}/${params.process_instance_id}`;
window.location.href = processInstanceShowPageBaseUrl;
};
const resetProcessInstance = () => {
@ -392,6 +409,23 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
{processInstance.process_initiator_username}
</Column>
</Grid>
{processInstance.process_model_with_diagram_identifier ? (
<Grid condensed fullWidth>
<Column sm={1} md={1} lg={2} className="grid-list-title">
Current Diagram:{' '}
</Column>
<Column sm={4} md={6} lg={8} className="grid-date">
<Link
data-qa="go-to-current-diagram-process-model"
to={`/admin/process-models/${modifyProcessIdentifierForPathParam(
processInstance.process_model_with_diagram_identifier || ''
)}`}
>
{processInstance.process_model_with_diagram_identifier}
</Link>
</Column>
</Grid>
) : null}
<Grid condensed fullWidth>
<Column sm={1} md={1} lg={2} className="grid-list-title">
Started:{' '}
@ -427,7 +461,7 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
size="sm"
className="button-white-background"
data-qa="process-instance-log-list-link"
href={`/admin/logs/${modifiedProcessModelId}/${params.process_instance_id}`}
href={`${processInstanceLogListPageBaseUrl}`}
>
Logs
</Button>
@ -446,6 +480,19 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
Messages
</Button>
</Can>
{processInstance.process_metadata &&
processInstance.process_metadata.length > 0 ? (
<Button
size="sm"
className="button-white-background"
data-qa="process-instance-show-metadata"
onClick={() => {
setShowProcessInstanceMetadata(true);
}}
>
Metadata
</Button>
) : null}
</ButtonSet>
</Column>
</Grid>
@ -684,7 +731,8 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
setSelectingEvent(false);
initializeTaskDataToDisplay(taskToDisplay);
setEventPayload('{}');
setErrorObject(null);
console.log('cancel updating task');
removeError();
};
const taskDataStringToObject = (dataString: string) => {
@ -699,16 +747,12 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
refreshPage();
};
const saveTaskDataFailure = (result: any) => {
setErrorObject({ message: result.message });
};
const saveTaskData = () => {
if (!taskToDisplay) {
return;
}
setErrorObject(null);
console.log('saveTaskData');
removeError();
// taskToUse is copy of taskToDisplay, with taskDataToDisplay in data attribute
const taskToUse: any = { ...taskToDisplay, data: taskDataToDisplay };
@ -716,7 +760,7 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
path: `${targetUris.processInstanceTaskListDataPath}/${taskToUse.id}`,
httpMethod: 'PUT',
successCallback: saveTaskDataResult,
failureCallback: saveTaskDataFailure,
failureCallback: addError,
postBody: {
new_task_data: taskToUse.data,
},
@ -730,7 +774,7 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
path: `/send-event/${modifiedProcessModelId}/${params.process_instance_id}`,
httpMethod: 'POST',
successCallback: saveTaskDataResult,
failureCallback: saveTaskDataFailure,
failureCallback: addError,
postBody: eventToSend,
});
};
@ -903,6 +947,41 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
);
};
const processInstanceMetadataArea = () => {
if (
!processInstance ||
(processInstance.process_metadata &&
processInstance.process_metadata.length < 1)
) {
return null;
}
const metadataComponents: any[] = [];
(processInstance.process_metadata || []).forEach(
(processInstanceMetadata: ProcessInstanceMetadata) => {
metadataComponents.push(
<Grid condensed fullWidth>
<Column sm={1} md={1} lg={2} className="grid-list-title">
{processInstanceMetadata.key}
</Column>
<Column sm={3} md={3} lg={3} className="grid-date">
{processInstanceMetadata.value}
</Column>
</Grid>
);
}
);
return (
<Modal
open={showProcessInstanceMetadata}
modalHeading="Metadata"
passiveModal
onRequestClose={() => setShowProcessInstanceMetadata(false)}
>
{metadataComponents}
</Modal>
);
};
const taskUpdateDisplayArea = () => {
const taskToUse: any = { ...taskToDisplay, data: taskDataToDisplay };
const candidateEvents: any = getEvents(taskToUse);
@ -923,6 +1002,19 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
): {taskToUse.state}
{taskDisplayButtons(taskToUse)}
</Stack>
{taskToUse.task_spiff_step ? (
<div>
<Stack orientation="horizontal" gap={2}>
Task completed at step:{' '}
{spiffStepLink(
`${taskToUse.task_spiff_step}`,
taskToUse.task_spiff_step
)}
</Stack>
<br />
<br />
</div>
) : null}
{selectingEvent
? eventSelector(candidateEvents)
: taskDataContainer()}
@ -1027,6 +1119,7 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
showDateStarted={false}
showLastUpdated={false}
hideIfNoTasks
canCompleteAllTasks
/>
</Column>
</Grid>
@ -1034,6 +1127,7 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
<br />
{taskUpdateDisplayArea()}
{processDataDisplayArea()}
{processInstanceMetadataArea()}
{stepsElement()}
<br />
<ReactDiagramEditor

View File

@ -1,4 +1,4 @@
import { useContext, useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
generatePath,
useNavigate,
@ -14,6 +14,9 @@ import {
Tab,
TabPanels,
TabPanel,
TextInput,
Grid,
Column,
// @ts-ignore
} from '@carbon/react';
import Row from 'react-bootstrap/Row';
@ -25,7 +28,7 @@ import MDEditor from '@uiw/react-md-editor';
import ReactDiagramEditor from '../components/ReactDiagramEditor';
import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
import HttpService from '../services/HttpService';
import ErrorContext from '../contexts/ErrorContext';
import useAPIError from '../hooks/UseApiError';
import { makeid, modifyProcessIdentifierForPathParam } from '../helpers';
import {
CarbonComboBoxProcessSelection,
@ -60,6 +63,8 @@ export default function ProcessModelEditDiagram() {
const [processes, setProcesses] = useState<ProcessReference[]>([]);
const [displaySaveFileMessage, setDisplaySaveFileMessage] =
useState<boolean>(false);
const [processModelFileInvalidText, setProcessModelFileInvalidText] =
useState<string>('');
const handleShowMarkdownEditor = () => setShowMarkdownEditor(true);
@ -100,7 +105,7 @@ export default function ProcessModelEditDiagram() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const setErrorObject = (useContext as any)(ErrorContext)[1];
const { addError, removeError } = useAPIError();
const [processModelFile, setProcessModelFile] = useState<ProcessFile | null>(
null
);
@ -160,6 +165,7 @@ export default function ProcessModelEditDiagram() {
const handleFileNameCancel = () => {
setShowFileNameEditor(false);
setNewFileName('');
setProcessModelFileInvalidText('');
};
const navigateToProcessModelFile = (_result: any) => {
@ -176,7 +182,7 @@ export default function ProcessModelEditDiagram() {
const saveDiagram = (bpmnXML: any, fileName = params.file_name) => {
setDisplaySaveFileMessage(false);
setErrorObject(null);
removeError();
setBpmnXmlForDiagramRendering(bpmnXML);
let url = `/process-models/${modifiedProcessModelId}/files`;
@ -202,7 +208,7 @@ export default function ProcessModelEditDiagram() {
HttpService.makeCallToBackend({
path: url,
successCallback: navigateToProcessModelFile,
failureCallback: setErrorObject,
failureCallback: addError,
httpMethod,
postBody: formData,
});
@ -251,6 +257,11 @@ export default function ProcessModelEditDiagram() {
const handleFileNameSave = (event: any) => {
event.preventDefault();
if (!newFileName) {
setProcessModelFileInvalidText('Process Model file name is required.');
return;
}
setProcessModelFileInvalidText('');
setShowFileNameEditor(false);
saveDiagram(bpmnXmlForDiagramRendering);
};
@ -267,17 +278,32 @@ export default function ProcessModelEditDiagram() {
onRequestSubmit={handleFileNameSave}
onRequestClose={handleFileNameCancel}
>
<label>File Name:</label>
<span>
<input
name="file_name"
type="text"
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
autoFocus
/>
{fileExtension}
</span>
<Grid
condensed
fullWidth
className="megacondensed process-model-files-section"
>
<Column md={4} lg={8} sm={4}>
<TextInput
id="process_model_file_name"
labelText="File Name:"
value={newFileName}
onChange={(e: any) => setNewFileName(e.target.value)}
invalidText={processModelFileInvalidText}
invalid={!!processModelFileInvalidText}
size="sm"
autoFocus
/>
</Column>
<Column
md={4}
lg={8}
sm={4}
className="with-top-margin-for-label-next-to-text-input"
>
{fileExtension}
</Column>
</Grid>
</Modal>
);
};

View File

@ -1,4 +1,4 @@
import { useContext, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
Add,
@ -32,7 +32,8 @@ import {
import { Can } from '@casl/react';
import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
import HttpService from '../services/HttpService';
import ErrorContext from '../contexts/ErrorContext';
import useAPIError from '../hooks/UseApiError';
import {
getGroupFromModifiedModelId,
modifyProcessIdentifierForPathParam,
@ -52,7 +53,7 @@ import { Notification } from '../components/Notification';
export default function ProcessModelShow() {
const params = useParams();
const setErrorObject = (useContext as any)(ErrorContext)[1];
const { addError, removeError } = useAPIError();
const [processModel, setProcessModel] = useState<ProcessModel | null>(null);
const [processInstance, setProcessInstance] =
@ -148,7 +149,7 @@ export default function ProcessModelShow() {
!('file_contents' in processModelFile) ||
processModelFile.file_contents === undefined
) {
setErrorObject({
addError({
message: `Could not file file contents for file: ${processModelFile.name}`,
});
return;
@ -169,7 +170,7 @@ export default function ProcessModelShow() {
};
const downloadFile = (fileName: string) => {
setErrorObject(null);
removeError();
const processModelPath = `process-models/${modifiedProcessModelId}`;
HttpService.makeCallToBackend({
path: `/${processModelPath}/files/${fileName}`,
@ -374,7 +375,7 @@ export default function ProcessModelShow() {
const doFileUpload = (event: any) => {
event.preventDefault();
setErrorObject(null);
removeError();
const url = `/process-models/${modifiedProcessModelId}/files`;
const formData = new FormData();
formData.append('file', filesToUpload[0]);
@ -384,7 +385,7 @@ export default function ProcessModelShow() {
successCallback: onUploadedCallback,
httpMethod: 'POST',
postBody: formData,
failureCallback: setErrorObject,
failureCallback: addError,
});
setFilesToUpload(null);
};

View File

@ -1,4 +1,4 @@
import { useContext, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import Editor from '@monaco-editor/react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
// @ts-ignore
@ -8,16 +8,14 @@ import HttpService from '../services/HttpService';
import ButtonWithConfirmation from '../components/ButtonWithConfirmation';
import { modifyProcessIdentifierForPathParam } from '../helpers';
import { ProcessFile } from '../interfaces';
import ErrorContext from '../contexts/ErrorContext';
import { Notification } from '../components/Notification';
import useAPIError from '../hooks/UseApiError';
// NOTE: This is mostly the same as ProcessModelEditDiagram and if we go this route could
// possibly be merged into it. I'm leaving as a separate file now in case it does
// end up diverging greatly
export default function ReactFormEditor() {
const params = useParams();
const setErrorObject = (useContext as any)(ErrorContext)[1];
const { addError, removeError } = useAPIError();
const [showFileNameEditor, setShowFileNameEditor] = useState(false);
const [newFileName, setNewFileName] = useState('');
const searchParams = useSearchParams()[0];
@ -87,7 +85,7 @@ export default function ReactFormEditor() {
};
const saveFile = () => {
setErrorObject(null);
removeError();
setDisplaySaveFileMessage(false);
let url = `/process-models/${modifiedProcessModelId}/files`;
@ -116,7 +114,7 @@ export default function ReactFormEditor() {
HttpService.makeCallToBackend({
path: url,
successCallback: navigateToProcessModelFile,
failureCallback: setErrorObject,
failureCallback: addError,
httpMethod,
postBody: formData,
});
@ -190,6 +188,9 @@ export default function ReactFormEditor() {
if (processModelFile || !params.file_name) {
const processModelFileName = processModelFile ? processModelFile.name : '';
const formBuildFileParam = params.file_name
? `?file_name=${params.file_name}`
: '';
return (
<main>
<ProcessBreadcrumb
@ -212,19 +213,7 @@ export default function ReactFormEditor() {
<Button onClick={saveFile} variant="danger" data-qa="file-save-button">
Save
</Button>
{params.file_name ? null : (
<Button
onClick={() =>
navigate(
`/admin/process-models/${params.process_model_id}/form-builder`
)
}
variant="danger"
data-qa="form-builder-button"
>
Form Builder
</Button>
)}
{params.file_name ? (
<ButtonWithConfirmation
data-qa="delete-process-model-file"
@ -233,6 +222,17 @@ export default function ReactFormEditor() {
buttonLabel="Delete"
/>
) : null}
<Button
onClick={() =>
navigate(
`/admin/process-models/${params.process_model_id}/form-builder${formBuildFileParam}`
)
}
variant="danger"
data-qa="form-builder-button"
>
Form Builder
</Button>
{hasDiagram ? (
<Button
onClick={() =>

View File

@ -1,4 +1,4 @@
import { useContext, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import validator from '@rjsf/validator-ajv8';
@ -12,75 +12,81 @@ import {
// @ts-ignore
} from '@carbon/react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import MDEditor from '@uiw/react-md-editor';
// eslint-disable-next-line import/no-named-as-default
import Form from '../themes/carbon';
import HttpService from '../services/HttpService';
import ErrorContext from '../contexts/ErrorContext';
import useAPIError from '../hooks/UseApiError';
import { modifyProcessIdentifierForPathParam } from '../helpers';
import { useUriListForPermissions } from '../hooks/UriListForPermissions';
import { PermissionsToCheck } from '../interfaces';
import { usePermissionFetcher } from '../hooks/PermissionService';
import { ProcessInstanceTask } from '../interfaces';
export default function TaskShow() {
const [task, setTask] = useState(null);
const [task, setTask] = useState<ProcessInstanceTask | null>(null);
const [userTasks, setUserTasks] = useState(null);
const params = useParams();
const navigate = useNavigate();
const [disabled, setDisabled] = useState(false);
const setErrorObject = (useContext as any)(ErrorContext)[1];
const { targetUris } = useUriListForPermissions();
const permissionRequestData: PermissionsToCheck = {
[targetUris.processInstanceTaskListDataPath]: ['GET'],
};
const { ability, permissionsLoaded } = usePermissionFetcher(
permissionRequestData
);
const { addError, removeError } = useAPIError();
useEffect(() => {
if (permissionsLoaded) {
const processResult = (result: any) => {
setTask(result);
if (ability.can('GET', targetUris.processInstanceTaskListDataPath)) {
HttpService.makeCallToBackend({
path: `/task-data/${modifyProcessIdentifierForPathParam(
result.process_model_identifier
)}/${params.process_instance_id}`,
successCallback: setUserTasks,
});
}
};
const processResult = (result: ProcessInstanceTask) => {
setTask(result);
const url = `/task-data/${modifyProcessIdentifierForPathParam(
result.process_model_identifier
)}/${params.process_instance_id}`;
// if user is unauthorized to get task-data then don't do anything
// Checking like this so we can dynamically create the url with the correct process model
// instead of passing the process model identifier in through the params
HttpService.makeCallToBackend({
path: `/tasks/${params.process_instance_id}/${params.task_id}`,
successCallback: processResult,
// This causes the page to continuously reload
// failureCallback: setErrorObject,
path: url,
successCallback: (tasks: any) => {
setDisabled(false);
setUserTasks(tasks);
},
onUnauthorized: () => {
setDisabled(false);
},
failureCallback: (error: any) => {
addError(error);
},
});
}
}, [params, permissionsLoaded, ability, targetUris]);
};
HttpService.makeCallToBackend({
path: `/tasks/${params.process_instance_id}/${params.task_id}`,
successCallback: processResult,
failureCallback: addError,
});
// FIXME: not sure what to do about addError. adding it to this array causes the page to endlessly reload
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params]);
const processSubmitResult = (result: any) => {
setErrorObject(null);
removeError();
if (result.ok) {
navigate(`/tasks`);
} else if (result.process_instance_id) {
navigate(`/tasks/${result.process_instance_id}/${result.id}`);
} else {
setErrorObject(`Received unexpected error: ${result.message}`);
addError(result);
}
};
const handleFormSubmit = (event: any) => {
setErrorObject(null);
if (disabled) {
return;
}
setDisabled(true);
removeError();
const dataToSubmit = event.formData;
delete dataToSubmit.isManualTask;
HttpService.makeCallToBackend({
path: `/tasks/${params.process_instance_id}/${params.task_id}`,
successCallback: processSubmitResult,
failureCallback: setErrorObject,
failureCallback: (error: any) => {
addError(error);
setDisabled(false);
},
httpMethod: 'PUT',
postBody: dataToSubmit,
});
@ -147,6 +153,7 @@ export default function TaskShow() {
Object.keys(jsonSchema.properties).forEach((propertyKey: string) => {
const propertyMetadata = jsonSchema.properties[propertyKey];
if (
typeof propertyMetadata === 'object' &&
'minimumDate' in propertyMetadata &&
propertyMetadata.minimumDate === 'today'
) {
@ -161,17 +168,28 @@ export default function TaskShow() {
}
}
}
// recurse through all nested properties as well
getFieldsWithDateValidations(
propertyMetadata,
formData[propertyKey],
errors[propertyKey]
);
});
}
return errors;
};
const formElement = (taskToUse: any) => {
const formElement = () => {
if (!task) {
return null;
}
let formUiSchema;
let taskData = taskToUse.data;
let jsonSchema = taskToUse.form_schema;
let taskData = task.data;
let jsonSchema = task.form_schema;
let reactFragmentToHideSubmitButton = null;
if (taskToUse.type === 'Manual Task') {
if (task.type === 'Manual Task') {
taskData = {};
jsonSchema = {
type: 'object',
@ -189,10 +207,10 @@ export default function TaskShow() {
'ui:widget': 'hidden',
},
};
} else if (taskToUse.form_ui_schema) {
formUiSchema = JSON.parse(taskToUse.form_ui_schema);
} else if (task.form_ui_schema) {
formUiSchema = task.form_ui_schema;
}
if (taskToUse.state !== 'READY') {
if (task.state !== 'READY') {
formUiSchema = Object.assign(formUiSchema || {}, {
'ui:readonly': true,
});
@ -204,10 +222,16 @@ export default function TaskShow() {
reactFragmentToHideSubmitButton = <div />;
}
if (taskToUse.type === 'Manual Task' && taskToUse.state === 'READY') {
if (task.state === 'READY') {
let buttonText = 'Submit';
if (task.type === 'Manual Task') {
buttonText = 'Continue';
}
reactFragmentToHideSubmitButton = (
<div>
<Button type="submit">Continue</Button>
<Button type="submit" disabled={disabled}>
{buttonText}
</Button>
</div>
);
}
@ -218,8 +242,9 @@ export default function TaskShow() {
return (
<Grid fullWidth condensed>
<Column md={5} lg={8} sm={4}>
<Column sm={4} md={5} lg={8}>
<Form
disabled={disabled}
formData={taskData}
onSubmit={handleFormSubmit}
schema={jsonSchema}
@ -234,36 +259,35 @@ export default function TaskShow() {
);
};
const instructionsElement = (taskToUse: any) => {
const instructionsElement = () => {
if (!task) {
return null;
}
let instructions = '';
if (taskToUse.properties.instructionsForEndUser) {
instructions = taskToUse.properties.instructionsForEndUser;
if (task.properties.instructionsForEndUser) {
instructions = task.properties.instructionsForEndUser;
}
return (
<div className="markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{instructions}
</ReactMarkdown>
<MDEditor.Markdown source={instructions} />
</div>
);
};
if (task) {
const taskToUse = task as any;
let statusString = '';
if (taskToUse.state !== 'READY') {
statusString = ` ${taskToUse.state}`;
if (task.state !== 'READY') {
statusString = ` ${task.state}`;
}
return (
<main>
<div>{buildTaskNavigation()}</div>
<h3>
Task: {taskToUse.title} ({taskToUse.process_model_display_name})
{statusString}
Task: {task.title} ({task.process_model_display_name}){statusString}
</h3>
{instructionsElement(taskToUse)}
{formElement(taskToUse)}
{instructionsElement()}
{formElement()}
</main>
);
}

View File

@ -21,6 +21,7 @@ type backendCallProps = {
path: string;
successCallback: Function;
failureCallback?: Function;
onUnauthorized?: Function;
httpMethod?: string;
extraHeaders?: object;
postBody?: any;
@ -37,6 +38,7 @@ const makeCallToBackend = ({
path,
successCallback,
failureCallback,
onUnauthorized,
httpMethod = 'GET',
extraHeaders = {},
postBody = {},
@ -88,9 +90,13 @@ backendCallProps) => {
if (isSuccessful) {
successCallback(result);
} else if (is403) {
// Hopefully we can make this service a hook and use the error message context directly
// eslint-disable-next-line no-alert
alert(result.message);
if (onUnauthorized) {
onUnauthorized(result);
} else {
// Hopefully we can make this service a hook and use the error message context directly
// eslint-disable-next-line no-alert
alert(result.message);
}
} else {
let message = 'A server error occurred.';
if (result.message) {

View File

@ -1,17 +1,36 @@
import React from 'react';
import AddIcon from '@mui/icons-material/Add';
import IconButton from '@mui/material/IconButton';
import { IconButtonProps } from '@rjsf/utils';
import {
FormContextType,
IconButtonProps,
RJSFSchema,
StrictRJSFSchema,
} from '@rjsf/utils';
const AddButton: React.ComponentType<IconButtonProps> = ({
uiSchema,
...props
}) => {
// @ts-ignore
import { AddAlt } from '@carbon/icons-react';
import IconButton from '../IconButton/IconButton';
/** The `AddButton` renders a button that represent the `Add` action on a form
*/
export default function AddButton<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any
>({ className, onClick, disabled, registry }: IconButtonProps<T, S, F>) {
return (
<IconButton title="Add Item" {...props} color="primary">
<AddIcon />
</IconButton>
<div className="row">
<p className={`col-xs-3 col-xs-offset-9 text-right ${className}`}>
<IconButton
iconType="info"
icon="plus"
className="btn-add col-xs-12"
title="Add"
onClick={onClick}
disabled={disabled}
registry={registry}
/>
</p>
</div>
);
};
export default AddButton;
}

View File

@ -5,6 +5,11 @@ import {
RJSFSchema,
StrictRJSFSchema,
} from '@rjsf/utils';
import {
Grid,
Column,
// @ts-ignore
} from '@carbon/react';
/** The `ArrayFieldItemTemplate` component is the template used to render an items of an array.
*
@ -33,53 +38,57 @@ export default function ArrayFieldItemTemplate<
const { MoveDownButton, MoveUpButton, RemoveButton } =
registry.templates.ButtonTemplates;
const btnStyle: CSSProperties = {
flex: 1,
paddingLeft: 6,
paddingRight: 6,
fontWeight: 'bold',
marginBottom: '0.5em',
};
const mainColumnWidthSmall = 3;
const mainColumnWidthMedium = 4;
const mainColumnWidthLarge = 7;
return (
<div className={className}>
<div className={hasToolbar ? 'col-xs-9' : 'col-xs-12'}>{children}</div>
{hasToolbar && (
<div className="col-xs-3 array-item-toolbox">
<div
className="btn-group"
style={{
display: 'flex',
justifyContent: 'space-around',
}}
>
{(hasMoveUp || hasMoveDown) && (
<MoveUpButton
style={btnStyle}
disabled={disabled || readonly || !hasMoveUp}
onClick={onReorderClick(index, index - 1)}
uiSchema={uiSchema}
registry={registry}
/>
)}
{(hasMoveUp || hasMoveDown) && (
<MoveDownButton
style={btnStyle}
disabled={disabled || readonly || !hasMoveDown}
onClick={onReorderClick(index, index + 1)}
uiSchema={uiSchema}
registry={registry}
/>
)}
{hasRemove && (
<RemoveButton
style={btnStyle}
disabled={disabled || readonly}
onClick={onDropIndexClick(index)}
uiSchema={uiSchema}
registry={registry}
/>
)}
</div>
</div>
)}
<Grid condensed fullWidth>
<Column
sm={mainColumnWidthSmall}
md={mainColumnWidthMedium}
lg={mainColumnWidthLarge}
>
{children}
</Column>
{hasToolbar && (
<Column sm={1} md={1} lg={1}>
<div className="array-item-toolbox">
<div className="NOT-btn-group">
{(hasMoveUp || hasMoveDown) && (
<MoveUpButton
style={btnStyle}
disabled={disabled || readonly || !hasMoveUp}
onClick={onReorderClick(index, index - 1)}
uiSchema={uiSchema}
registry={registry}
/>
)}
{(hasMoveUp || hasMoveDown) && (
<MoveDownButton
style={btnStyle}
disabled={disabled || readonly || !hasMoveDown}
onClick={onReorderClick(index, index + 1)}
uiSchema={uiSchema}
registry={registry}
/>
)}
{hasRemove && (
<RemoveButton
style={btnStyle}
disabled={disabled || readonly}
onClick={onDropIndexClick(index)}
uiSchema={uiSchema}
registry={registry}
/>
)}
</div>
</div>
</Column>
)}
</Grid>
</div>
);
}

View File

@ -85,6 +85,11 @@ export default function BaseInputTemplate<
labelToUse = `${labelToUse}*`;
}
let helperText = null;
if (uiSchema && uiSchema['ui:help']) {
helperText = uiSchema['ui:help'];
}
let invalid = false;
let errorMessageForField = null;
if (rawErrors && rawErrors.length > 0) {
@ -101,8 +106,8 @@ export default function BaseInputTemplate<
<TextInput
id={id}
name={id}
className="input"
labelText={labelToUse}
className="text-input"
helperText={helperText}
invalid={invalid}
invalidText={errorMessageForField}
autoFocus={autofocus}

View File

@ -5,8 +5,8 @@ import { Tag } from '@carbon/react';
function ErrorList({ errors }: ErrorListProps) {
if (errors) {
return (
<Tag type="red" size="md" title="Fill Required Fields">
Please fill out required fields
<Tag type="red" size="md" title="Fix validation issues">
Some fields are invalid. Please correct them before submitting the form.
</Tag>
);
}

View File

@ -7,10 +7,8 @@ import FormHelperText from '@mui/material/FormHelperText';
* @param props - The `FieldHelpProps` to be rendered
*/
export default function FieldHelpTemplate(props: FieldHelpProps) {
const { idSchema, help } = props;
if (!help) {
return null;
}
const id = `${idSchema.$id}__help`;
return <FormHelperText id={id}>{help}</FormHelperText>;
// ui:help is handled by helperText in all carbon widgets.
// see BaseInputTemplate/BaseInputTemplate.tsx and
// SelectWidget/SelectWidget.tsx
return null;
}

View File

@ -1,64 +1,57 @@
import React from 'react';
import FormControl from '@mui/material/FormControl';
import Typography from '@mui/material/Typography';
import { FieldTemplateProps, getTemplate, getUiOptions } from '@rjsf/utils';
import {
FieldTemplateProps,
FormContextType,
RJSFSchema,
StrictRJSFSchema,
getTemplate,
getUiOptions,
} from '@rjsf/utils';
function FieldTemplate({
id,
children,
classNames,
disabled,
displayLabel,
hidden,
label,
onDropPropertyClick,
onKeyChange,
readonly,
required,
rawErrors = [],
errors,
help,
rawDescription,
schema,
uiSchema,
registry,
}: FieldTemplateProps) {
const uiOptions = getUiOptions(uiSchema);
const WrapIfAdditionalTemplate = getTemplate<'WrapIfAdditionalTemplate'>(
'WrapIfAdditionalTemplate',
import Label from './Label';
/** The `FieldTemplate` component is the template used by `SchemaField` to render any field. It renders the field
* content, (label, description, children, errors and help) inside of a `WrapIfAdditional` component.
*
* @param props - The `FieldTemplateProps` for this component
*/
export default function FieldTemplate<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any
>(props: FieldTemplateProps<T, S, F>) {
const {
id,
label,
children,
errors,
help,
description,
hidden,
required,
displayLabel,
registry,
uiOptions
);
uiSchema,
} = props;
const uiOptions = getUiOptions(uiSchema);
const WrapIfAdditionalTemplate = getTemplate<
'WrapIfAdditionalTemplate',
T,
S,
F
>('WrapIfAdditionalTemplate', registry, uiOptions);
if (hidden) {
return <div style={{ display: 'none' }}>{children}</div>;
return <div className="hidden">{children}</div>;
}
return (
<WrapIfAdditionalTemplate
classNames={classNames}
disabled={disabled}
id={id}
label={label}
onDropPropertyClick={onDropPropertyClick}
onKeyChange={onKeyChange}
readonly={readonly}
required={required}
schema={schema}
uiSchema={uiSchema}
registry={registry}
>
<FormControl fullWidth error={!!rawErrors.length} required={required}>
<div className="rjsf-field">
<WrapIfAdditionalTemplate {...props}>
{displayLabel && <Label label={label} required={required} id={id} />}
{displayLabel && description ? description : null}
{children}
{displayLabel && rawDescription ? (
<Typography variant="caption" color="textSecondary">
{rawDescription}
</Typography>
) : null}
{errors}
{help}
</FormControl>
</WrapIfAdditionalTemplate>
</WrapIfAdditionalTemplate>
</div>
);
}
export default FieldTemplate;

View File

@ -1,55 +1,96 @@
import React from 'react';
import IconButton, {
IconButtonProps as MuiIconButtonProps,
} from '@mui/material/IconButton';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import RemoveIcon from '@mui/icons-material/Remove';
import { IconButtonProps } from '@rjsf/utils';
import {
FormContextType,
IconButtonProps,
RJSFSchema,
StrictRJSFSchema,
} from '@rjsf/utils';
export default function MuiIconButton(props: IconButtonProps) {
const { icon, color, uiSchema, ...otherProps } = props;
// @ts-ignore
import { Add, TrashCan, ArrowUp, ArrowDown } from '@carbon/icons-react';
export default function IconButton<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any
>(props: IconButtonProps<T, S, F>) {
const {
iconType = 'default',
icon,
className,
uiSchema,
registry,
...otherProps
} = props;
// icon string optios: plus, remove, arrow-up, arrow-down
let carbonIcon = (
<p>
Add new <Add />
</p>
);
if (icon === 'remove') {
carbonIcon = <TrashCan />;
}
if (icon === 'arrow-up') {
carbonIcon = <ArrowUp />;
}
if (icon === 'arrow-down') {
carbonIcon = <ArrowDown />;
}
return (
<button
type="button"
className={`btn btn-${iconType} ${className}`}
{...otherProps}
>
{carbonIcon}
</button>
);
}
export function MoveDownButton<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any
>(props: IconButtonProps<T, S, F>) {
return (
<IconButton
{...otherProps}
size="small"
color={color as MuiIconButtonProps['color']}
>
{icon}
</IconButton>
);
}
export function MoveDownButton(props: IconButtonProps) {
return (
<MuiIconButton
title="Move down"
className="array-item-move-down"
{...props}
icon={<ArrowDownwardIcon fontSize="small" />}
icon="arrow-down"
/>
);
}
export function MoveUpButton(props: IconButtonProps) {
export function MoveUpButton<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any
>(props: IconButtonProps<T, S, F>) {
return (
<MuiIconButton
<IconButton
title="Move up"
className="array-item-move-up"
{...props}
icon={<ArrowUpwardIcon fontSize="small" />}
icon="arrow-up"
/>
);
}
export function RemoveButton(props: IconButtonProps) {
const { iconType, ...otherProps } = props;
export function RemoveButton<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any
>(props: IconButtonProps<T, S, F>) {
return (
<MuiIconButton
<IconButton
title="Remove"
{...otherProps}
color="error"
icon={
<RemoveIcon fontSize={iconType === 'default' ? undefined : 'small'} />
}
className="array-item-remove"
{...props}
iconType="danger"
icon="remove"
/>
);
}

View File

@ -32,9 +32,6 @@ const RadioWidget = ({
return (
<>
<FormLabel required={required} htmlFor={id}>
{label || schema.title}
</FormLabel>
<RadioGroup
id={id}
name={id}

View File

@ -41,6 +41,10 @@ function SelectWidget({
} else if (schema && schema.title) {
labelToUse = schema.title;
}
let helperText = null;
if (uiSchema && uiSchema['ui:help']) {
helperText = uiSchema['ui:help'];
}
if (required) {
labelToUse = `${labelToUse}*`;
}
@ -49,16 +53,20 @@ function SelectWidget({
let errorMessageForField = null;
if (rawErrors && rawErrors.length > 0) {
invalid = true;
errorMessageForField = `${labelToUse.replace(/\*$/, '')} ${rawErrors[0]}`;
// errorMessageForField = `${labelToUse.replace(/\*$/, '')} ${rawErrors[0]}`;
errorMessageForField = rawErrors[0];
}
// maybe use placeholder somehow. it was previously jammed into the helperText field,
// but allowing ui:help to grab that spot seems much more appropriate.
return (
<Select
id={id}
name={id}
labelText={labelToUse}
labelText=""
select
helperText={placeholder}
helperText={helperText}
value={typeof value === 'undefined' ? emptyValue : value}
disabled={disabled || readonly}
autoFocus={autofocus}

View File

@ -61,20 +61,26 @@ function TextareaWidget<
labelToUse = `${labelToUse}*`;
}
let helperText = null;
if (uiSchema && uiSchema['ui:help']) {
helperText = uiSchema['ui:help'];
}
let invalid = false;
let errorMessageForField = null;
if (rawErrors && rawErrors.length > 0) {
invalid = true;
errorMessageForField = `${labelToUse.replace(/\*$/, '')} ${rawErrors[0]}`;
errorMessageForField = rawErrors[0];
}
return (
<TextArea
id={id}
name={id}
className="form-control"
className="text-input"
helperText={helperText}
value={value || ''}
labelText={labelToUse}
labelText=""
placeholder={placeholder}
required={required}
disabled={disabled}

View File

@ -1,7 +1,3 @@
button.react-json-schema-form-submit-button {
margin-top: 1.5em;
}
.rjsf .header {
font-weight: 400;
font-size: 20px;
@ -17,6 +13,15 @@ button.react-json-schema-form-submit-button {
margin-bottom: 1em;
}
.rjsf .input {
/* for some reason it wraps the entire form using FieldTemplate.jsx, which is where we added the rjsf-field thing (which is only intended for fields, not entire forms. hence the double rjsf-field reference, only for rjsf-fields inside rjsf-fields, so we don't get double margin after the last field */
.rjsf .rjsf-field .rjsf-field {
margin-bottom: 2em;
}
.array-item-toolbox {
margin-left: 2em;
}
.rjsf .text-input {
padding-top: 8px;
}