Feature/user preference extension (#472)

* added ability to display navigation items in user profile toggle

* updated naming of some extension elements

* added user property table and updates for extensions to use it w/ burnettk

* moved extension ui interfaces to own file and linting issues

* some updates to render markdown results on load w/ burnettk

* added migration merge file w/ burnettk

* moved code to fix linting issues w/ burnettk

* resolved db migration conflict

* removed unnecessary migrations and added just one w/ burnettk

---------

Co-authored-by: jasquat <jasquat@users.noreply.github.com>
This commit is contained in:
jasquat 2023-09-07 14:22:40 -04:00 committed by GitHub
parent 655d384645
commit 4cf33b62fc
13 changed files with 341 additions and 117 deletions

View File

@ -28,8 +28,8 @@ if [[ -n "${SPIFFWORKFLOW_BACKEND_DATABASE_URI:-}" ]]; then
database_host=$(grep -oP "^[^:]+://.*@\K(.+?)[:/]" <<<"$SPIFFWORKFLOW_BACKEND_DATABASE_URI" | sed -E 's/[:\/]$//') database_host=$(grep -oP "^[^:]+://.*@\K(.+?)[:/]" <<<"$SPIFFWORKFLOW_BACKEND_DATABASE_URI" | sed -E 's/[:\/]$//')
fi fi
# this will fix branching conflicts # uncomment this line to fix branching conflicts
# poetry run flask db merge heads -m "merging two heads" # poetry run flask db merge heads -m "merging heads"
tasks="" tasks=""
if [[ "${1:-}" == "clean" ]]; then if [[ "${1:-}" == "clean" ]]; then

View File

@ -0,0 +1,44 @@
"""empty message
Revision ID: 844cee572018
Revises: 57df21dc569d
Create Date: 2023-09-07 14:18:12.357989
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '844cee572018'
down_revision = '57df21dc569d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user_property',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('key', sa.String(length=255), nullable=False),
sa.Column('value', sa.String(length=255), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'key', name='user_id_key_uniq')
)
with op.batch_alter_table('user_property', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_user_property_key'), ['key'], unique=False)
batch_op.create_index(batch_op.f('ix_user_property_user_id'), ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user_property', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_user_property_user_id'))
batch_op.drop_index(batch_op.f('ix_user_property_key'))
op.drop_table('user_property')
# ### end Alembic commands ###

View File

@ -88,5 +88,8 @@ from spiffworkflow_backend.models.task_draft_data import (
from spiffworkflow_backend.models.configuration import ( from spiffworkflow_backend.models.configuration import (
ConfigurationModel, ConfigurationModel,
) # noqa: F401 ) # noqa: F401
from spiffworkflow_backend.models.user_property import (
UserPropertyModel,
) # noqa: F401
add_listeners() add_listeners()

View File

@ -0,0 +1,20 @@
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import ForeignKey
from spiffworkflow_backend.models.db import SpiffworkflowBaseDBModel
from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.user import UserModel
@dataclass
class UserPropertyModel(SpiffworkflowBaseDBModel):
__tablename__ = "user_property"
__table_args__ = (db.UniqueConstraint("user_id", "key", name="user_id_key_uniq"),)
id: int = db.Column(db.Integer, primary_key=True)
user_id: int = db.Column(ForeignKey(UserModel.id), nullable=False, index=True) # type: ignore
key: str = db.Column(db.String(255), nullable=False, index=True)
value: str | None = db.Column(db.String(255))

View File

@ -21,6 +21,7 @@ from spiffworkflow_backend.services.process_instance_processor import CustomBpmn
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor
from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsAlreadyLockedError from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsAlreadyLockedError
from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsNotEnqueuedError from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsNotEnqueuedError
from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService
from spiffworkflow_backend.services.process_model_service import ProcessModelService from spiffworkflow_backend.services.process_model_service import ProcessModelService
from spiffworkflow_backend.services.spec_file_service import SpecFileService from spiffworkflow_backend.services.spec_file_service import SpecFileService
from spiffworkflow_backend.services.workflow_execution_service import WorkflowExecutionServiceError from spiffworkflow_backend.services.workflow_execution_service import WorkflowExecutionServiceError
@ -121,17 +122,25 @@ def _run_extension(
status_code=400, status_code=400,
) )
ui_schema_page_definition = None ui_schema_action = None
if body and "ui_schema_page_definition" in body: persistence_level = "none"
ui_schema_page_definition = body["ui_schema_page_definition"] if body and "ui_schema_action" in body:
ui_schema_action = body["ui_schema_action"]
persistence_level = ui_schema_action.get("persistence_level", "none")
process_instance = ProcessInstanceModel( process_instance = None
status=ProcessInstanceStatus.not_started.value, if persistence_level == "none":
process_initiator_id=g.user.id, process_instance = ProcessInstanceModel(
process_model_identifier=process_model.id, status=ProcessInstanceStatus.not_started.value,
process_model_display_name=process_model.display_name, process_initiator_id=g.user.id,
persistence_level="none", process_model_identifier=process_model.id,
) process_model_display_name=process_model.display_name,
persistence_level=persistence_level,
)
else:
process_instance = ProcessInstanceService.create_process_instance_from_process_model_identifier(
process_model_identifier, g.user
)
processor = None processor = None
try: try:
@ -170,10 +179,10 @@ def _run_extension(
task_data = processor.get_data() task_data = processor.get_data()
result: dict[str, Any] = {"task_data": task_data} result: dict[str, Any] = {"task_data": task_data}
if ui_schema_page_definition: if ui_schema_action:
if "results_markdown_filename" in ui_schema_page_definition: if "results_markdown_filename" in ui_schema_action:
file_contents = SpecFileService.get_data( file_contents = SpecFileService.get_data(
process_model, ui_schema_page_definition["results_markdown_filename"] process_model, ui_schema_action["results_markdown_filename"]
).decode("utf-8") ).decode("utf-8")
form_contents = JinjaService.render_jinja_template(file_contents, task_data=task_data) form_contents = JinjaService.render_jinja_template(file_contents, task_data=task_data)
result["rendered_results_markdown"] = form_contents result["rendered_results_markdown"] = form_contents

View File

@ -0,0 +1,23 @@
from typing import Any
from flask import g
from spiffworkflow_backend.models.script_attributes_context import ScriptAttributesContext
from spiffworkflow_backend.models.user_property import UserPropertyModel
from spiffworkflow_backend.scripts.script import Script
class GetUserProperties(Script):
@staticmethod
def requires_privileged_permissions() -> bool:
"""We have deemed this function safe to run without elevated permissions."""
return False
def get_description(self) -> str:
return """Gets the user properties for current user."""
def run(self, script_attributes_context: ScriptAttributesContext, *_args: Any, **kwargs: Any) -> Any:
user_properties = UserPropertyModel.query.filter_by(user_id=g.user.id).all()
dict_to_return = {}
for up in user_properties:
dict_to_return[up.key] = up.value
return dict_to_return

View File

@ -31,6 +31,10 @@ class ProcessModelIdentifierMissingError(Exception):
pass pass
class InvalidArgsGivenToScriptError(Exception):
pass
class Script: class Script:
"""Provides an abstract class that defines how scripts should work, this must be extended in all Script Tasks.""" """Provides an abstract class that defines how scripts should work, this must be extended in all Script Tasks."""

View File

@ -0,0 +1,36 @@
from typing import Any
from flask import g
from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.script_attributes_context import ScriptAttributesContext
from spiffworkflow_backend.models.user_property import UserPropertyModel
from spiffworkflow_backend.scripts.script import InvalidArgsGivenToScriptError
from spiffworkflow_backend.scripts.script import Script
class SetUserProperties(Script):
@staticmethod
def requires_privileged_permissions() -> bool:
"""We have deemed this function safe to run without elevated permissions."""
return False
def get_description(self) -> str:
return """Sets given user properties on current user."""
def run(self, script_attributes_context: ScriptAttributesContext, *args: Any, **kwargs: Any) -> Any:
properties = args[0]
if not isinstance(properties, dict):
raise InvalidArgsGivenToScriptError(
f"Args to set_user_properties must be a dict. '{properties}' is invalid."
)
# consider using engine-specific insert or update metaphor in future: https://stackoverflow.com/a/68431412/6090676
for property_key, property_value in properties.items():
user_property = UserPropertyModel.query.filter_by(user_id=g.user.id, key=property_key).first()
if user_property is None:
user_property = UserPropertyModel(
user_id=g.user.id,
key=property_key,
)
user_property.value = property_value
db.session.add(user_property)
db.session.commit()

View File

@ -48,11 +48,7 @@ export default function App() {
<Route path="/admin/*" element={<AdminRoutes />} /> <Route path="/admin/*" element={<AdminRoutes />} />
<Route path="/editor/*" element={<EditorRoutes />} /> <Route path="/editor/*" element={<EditorRoutes />} />
<Route <Route
path="/extensions/:process_model" path="/extensions/:page_identifier"
element={<Extension />}
/>
<Route
path="/extensions/:process_model/:extension_route"
element={<Extension />} element={<Extension />}
/> />
</Routes> </Routes>

View File

@ -24,13 +24,11 @@ import { Can } from '@casl/react';
import logo from '../logo.svg'; import logo from '../logo.svg';
import UserService from '../services/UserService'; import UserService from '../services/UserService';
import { useUriListForPermissions } from '../hooks/UriListForPermissions'; import { useUriListForPermissions } from '../hooks/UriListForPermissions';
import { PermissionsToCheck, ProcessModel, ProcessFile } from '../interfaces';
import { import {
PermissionsToCheck,
ProcessModel,
ProcessFile,
ExtensionUiSchema, ExtensionUiSchema,
UiSchemaNavItem, UiSchemaUxElement,
} from '../interfaces'; } from '../extension_ui_schema_interfaces';
import { usePermissionFetcher } from '../hooks/PermissionService'; import { usePermissionFetcher } from '../hooks/PermissionService';
import HttpService, { UnauthenticatedError } from '../services/HttpService'; import HttpService, { UnauthenticatedError } from '../services/HttpService';
import { DOCUMENTATION_URL, SPIFF_ENVIRONMENT } from '../config'; import { DOCUMENTATION_URL, SPIFF_ENVIRONMENT } from '../config';
@ -49,7 +47,7 @@ export default function NavigationBar() {
const location = useLocation(); const location = useLocation();
const [activeKey, setActiveKey] = useState<string>(''); const [activeKey, setActiveKey] = useState<string>('');
const [extensionNavigationItems, setExtensionNavigationItems] = useState< const [extensionNavigationItems, setExtensionNavigationItems] = useState<
UiSchemaNavItem[] | null UiSchemaUxElement[] | null
>(null); >(null);
const { targetUris } = useUriListForPermissions(); const { targetUris } = useUriListForPermissions();
@ -109,7 +107,7 @@ export default function NavigationBar() {
} }
const processExtensionResult = (processModels: ProcessModel[]) => { const processExtensionResult = (processModels: ProcessModel[]) => {
const eni: UiSchemaNavItem[] = processModels const eni: UiSchemaUxElement[] = processModels
.map((processModel: ProcessModel) => { .map((processModel: ProcessModel) => {
const extensionUiSchemaFile = processModel.files.find( const extensionUiSchemaFile = processModel.files.find(
(file: ProcessFile) => file.name === 'extension_uischema.json' (file: ProcessFile) => file.name === 'extension_uischema.json'
@ -119,8 +117,8 @@ export default function NavigationBar() {
const extensionUiSchema: ExtensionUiSchema = JSON.parse( const extensionUiSchema: ExtensionUiSchema = JSON.parse(
extensionUiSchemaFile.file_contents extensionUiSchemaFile.file_contents
); );
if (extensionUiSchema.navigation_items) { if (extensionUiSchema.ux_elements) {
return extensionUiSchema.navigation_items; return extensionUiSchema.ux_elements;
} }
} catch (jsonParseError: any) { } catch (jsonParseError: any) {
console.error( console.error(
@ -128,7 +126,7 @@ export default function NavigationBar() {
); );
} }
} }
return [] as UiSchemaNavItem[]; return [] as UiSchemaUxElement[];
}) })
.flat(); .flat();
if (eni) { if (eni) {
@ -157,6 +155,27 @@ export default function NavigationBar() {
const userEmail = UserService.getUserEmail(); const userEmail = UserService.getUserEmail();
const username = UserService.getPreferredUsername(); const username = UserService.getPreferredUsername();
const extensionNavigationElementsForDisplayLocation = (
displayLocation: string,
elementCallback: Function
) => {
if (!extensionNavigationItems) {
return null;
}
return extensionNavigationItems.map((uxElement: UiSchemaUxElement) => {
if (uxElement.display_location === displayLocation) {
return elementCallback(uxElement);
}
return null;
});
};
const extensionUserProfileElement = (uxElement: UiSchemaUxElement) => {
const navItemPage = `/extensions${uxElement.page}`;
return <a href={navItemPage}>{uxElement.label}</a>;
};
const profileToggletip = ( const profileToggletip = (
<div style={{ display: 'flex' }} id="user-profile-toggletip"> <div style={{ display: 'flex' }} id="user-profile-toggletip">
<Toggletip isTabTip align="bottom-right"> <Toggletip isTabTip align="bottom-right">
@ -177,6 +196,10 @@ export default function NavigationBar() {
<a target="_blank" href={documentationUrl} rel="noreferrer"> <a target="_blank" href={documentationUrl} rel="noreferrer">
Documentation Documentation
</a> </a>
{extensionNavigationElementsForDisplayLocation(
'user_profile_item',
extensionUserProfileElement
)}
{!UserService.authenticationDisabled() ? ( {!UserService.authenticationDisabled() ? (
<> <>
<hr /> <hr />
@ -258,27 +281,21 @@ export default function NavigationBar() {
); );
}; };
const extensionNavigationElements = () => { const extensionHeaderMenuItemElement = (uxElement: UiSchemaUxElement) => {
if (!extensionNavigationItems) { const navItemPage = `/extensions${uxElement.page}`;
return null; const regexp = new RegExp(`^${navItemPage}$`);
if (regexp.test(location.pathname)) {
setActiveKey(navItemPage);
} }
return (
return extensionNavigationItems.map((navItem: UiSchemaNavItem) => { <HeaderMenuItem
const navItemRoute = `/extensions${navItem.route}`; href={navItemPage}
const regexp = new RegExp(`^${navItemRoute}`); isCurrentPage={isActivePage(navItemPage)}
if (regexp.test(location.pathname)) { data-qa={`extension-${slugifyString(uxElement.label)}`}
setActiveKey(navItemRoute); >
} {uxElement.label}
return ( </HeaderMenuItem>
<HeaderMenuItem );
href={navItemRoute}
isCurrentPage={isActivePage(navItemRoute)}
data-qa={`extension-${slugifyString(navItem.label)}`}
>
{navItem.label}
</HeaderMenuItem>
);
});
}; };
const headerMenuItems = () => { const headerMenuItems = () => {
@ -328,7 +345,10 @@ export default function NavigationBar() {
</HeaderMenuItem> </HeaderMenuItem>
</Can> </Can>
{configurationElement()} {configurationElement()}
{extensionNavigationElements()} {extensionNavigationElementsForDisplayLocation(
'header_menu_item',
extensionHeaderMenuItemElement
)}
</> </>
); );
}; };

View File

@ -0,0 +1,49 @@
export enum UiSchemaDisplayLocation {
header_menu_item = 'header_menu_item',
user_profile_item = 'user_profile_item',
}
export enum UiSchemaPersistenceLevel {
full = 'full',
none = 'none',
}
export interface UiSchemaUxElement {
label: string;
page: string;
display_location: UiSchemaDisplayLocation;
}
export interface UiSchemaAction {
api_path: string;
persistence_level?: UiSchemaPersistenceLevel;
navigate_to_on_form_submit?: string;
results_markdown_filename?: string;
}
export interface UiSchemaPageDefinition {
header: string;
api: string;
on_load?: UiSchemaAction;
on_form_submit?: UiSchemaAction;
form_schema_filename?: any;
form_ui_schema_filename?: any;
markdown_instruction_filename?: string;
navigate_to_on_form_submit?: string;
}
export interface UiSchemaPage {
[key: string]: UiSchemaPageDefinition;
}
export interface ExtensionUiSchema {
ux_elements?: UiSchemaUxElement[];
pages: UiSchemaPage;
}
export interface ExtensionPostBody {
extension_input: any;
ui_schema_action?: UiSchemaAction;
}

View File

@ -435,29 +435,3 @@ export interface DataStore {
name: string; name: string;
type: string; type: string;
} }
export interface UiSchemaNavItem {
label: string;
route: string;
}
export interface UiSchemaPageDefinition {
header: string;
api: string;
form_schema_filename?: any;
form_ui_schema_filename?: any;
markdown_instruction_filename?: string;
navigate_to_on_form_submit?: string;
}
export interface UiSchemaRoute {
[key: string]: UiSchemaPageDefinition;
}
export interface ExtensionUiSchema {
navigation_items?: UiSchemaNavItem[];
routes: UiSchemaRoute;
}
export interface ExtensionPostBody {
extension_input: any;
ui_schema_page_definition?: UiSchemaPageDefinition;
}

View File

@ -1,20 +1,19 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import MDEditor from '@uiw/react-md-editor'; import MDEditor from '@uiw/react-md-editor';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Editor } from '@monaco-editor/react'; import { Editor } from '@monaco-editor/react';
import { useUriListForPermissions } from '../hooks/UriListForPermissions'; import { useUriListForPermissions } from '../hooks/UriListForPermissions';
import { import { ProcessFile, ProcessModel } from '../interfaces';
ExtensionPostBody,
ExtensionUiSchema,
ProcessFile,
ProcessModel,
UiSchemaPageDefinition,
} from '../interfaces';
import HttpService from '../services/HttpService'; import HttpService from '../services/HttpService';
import useAPIError from '../hooks/UseApiError'; import useAPIError from '../hooks/UseApiError';
import { recursivelyChangeNullAndUndefined } from '../helpers'; import { recursivelyChangeNullAndUndefined } from '../helpers';
import CustomForm from '../components/CustomForm'; import CustomForm from '../components/CustomForm';
import { BACKEND_BASE_URL } from '../config'; import { BACKEND_BASE_URL } from '../config';
import {
ExtensionPostBody,
ExtensionUiSchema,
UiSchemaPageDefinition,
} from '../extension_ui_schema_interfaces';
import ErrorDisplay from '../components/ErrorDisplay'; import ErrorDisplay from '../components/ErrorDisplay';
// eslint-disable-next-line sonarjs/cognitive-complexity // eslint-disable-next-line sonarjs/cognitive-complexity
@ -26,7 +25,12 @@ export default function Extension() {
const [formData, setFormData] = useState<any>(null); const [formData, setFormData] = useState<any>(null);
const [formButtonsDisabled, setFormButtonsDisabled] = useState(false); const [formButtonsDisabled, setFormButtonsDisabled] = useState(false);
const [processedTaskData, setProcessedTaskData] = useState<any>(null); const [processedTaskData, setProcessedTaskData] = useState<any>(null);
const [markdownToRender, setMarkdownToRender] = useState<string | null>(null); const [markdownToRenderOnSubmit, setMarkdownToRenderOnSubmit] = useState<
string | null
>(null);
const [markdownToRenderOnLoad, setMarkdownToRenderOnLoad] = useState<
string | null
>(null);
const [filesByName] = useState<{ const [filesByName] = useState<{
[key: string]: ProcessFile; [key: string]: ProcessFile;
}>({}); }>({});
@ -35,18 +39,15 @@ export default function Extension() {
const { addError, removeError } = useAPIError(); const { addError, removeError } = useAPIError();
useEffect(() => { const setConfigsIfDesiredSchemaFile = useCallback(
const processExtensionResult = (pm: ProcessModel) => { (extensionUiSchemaFile: ProcessFile | null, pm: ProcessModel) => {
setProcessModel(pm); const processLoadResult = (result: any) => {
let extensionUiSchemaFile: ProcessFile | null = null; setFormData(result.task_data);
pm.files.forEach((file: ProcessFile) => { if (result.rendered_results_markdown) {
filesByName[file.name] = file; setMarkdownToRenderOnLoad(result.rendered_results_markdown);
if (file.name === 'extension_uischema.json') {
extensionUiSchemaFile = file;
} }
}); };
// typescript is really confused by extensionUiSchemaFile so force it since we are properly checking
if ( if (
extensionUiSchemaFile && extensionUiSchemaFile &&
(extensionUiSchemaFile as ProcessFile).file_contents (extensionUiSchemaFile as ProcessFile).file_contents
@ -55,24 +56,61 @@ export default function Extension() {
(extensionUiSchemaFile as any).file_contents (extensionUiSchemaFile as any).file_contents
); );
let routeIdentifier = `/${params.process_model}`; const pageIdentifier = `/${params.page_identifier}`;
if (params.extension_route) { if (
routeIdentifier = `${routeIdentifier}/${params.extension_route}`; extensionUiSchema.pages &&
Object.keys(extensionUiSchema.pages).includes(pageIdentifier)
) {
const pageDefinition = extensionUiSchema.pages[pageIdentifier];
setUiSchemaPageDefinition(pageDefinition);
setProcessModel(pm);
const postBody: ExtensionPostBody = { extension_input: {} };
postBody.ui_schema_action = pageDefinition.on_load;
if (pageDefinition.on_load) {
HttpService.makeCallToBackend({
path: `${targetUris.extensionListPath}/${pageDefinition.on_load.api_path}`,
successCallback: processLoadResult,
httpMethod: 'POST',
postBody,
});
}
} }
setUiSchemaPageDefinition(extensionUiSchema.routes[routeIdentifier]);
} }
},
[targetUris.extensionListPath, params]
);
useEffect(() => {
const processExtensionResult = (processModels: ProcessModel[]) => {
processModels.forEach((pm: ProcessModel) => {
let extensionUiSchemaFile: ProcessFile | null = null;
pm.files.forEach((file: ProcessFile) => {
filesByName[file.name] = file;
if (file.name === 'extension_uischema.json') {
extensionUiSchemaFile = file;
}
});
setConfigsIfDesiredSchemaFile(extensionUiSchemaFile, pm);
});
}; };
HttpService.makeCallToBackend({ HttpService.makeCallToBackend({
path: targetUris.extensionPath, path: targetUris.extensionListPath,
successCallback: processExtensionResult, successCallback: processExtensionResult,
}); });
}, [targetUris.extensionPath, params, filesByName]); }, [
filesByName,
params,
setConfigsIfDesiredSchemaFile,
targetUris.extensionListPath,
targetUris.extensionPath,
]);
const processSubmitResult = (result: any) => { const processSubmitResult = (result: any) => {
setProcessedTaskData(result.task_data); setProcessedTaskData(result.task_data);
if (result.rendered_results_markdown) { if (result.rendered_results_markdown) {
setMarkdownToRender(result.rendered_results_markdown); setMarkdownToRenderOnSubmit(result.rendered_results_markdown);
} }
setFormButtonsDisabled(false); setFormButtonsDisabled(false);
}; };
@ -111,15 +149,15 @@ export default function Extension() {
if (!isValid) { if (!isValid) {
return; return;
} }
const url = `${BACKEND_BASE_URL}/extensions-get-data/${params.process_model}/${optionString}`; const url = `${BACKEND_BASE_URL}/extensions-get-data/${params.page_identifier}/${optionString}`;
window.location.href = url; window.location.href = url;
setFormButtonsDisabled(false); setFormButtonsDisabled(false);
} else { } else {
const postBody: ExtensionPostBody = { extension_input: dataToSubmit }; const postBody: ExtensionPostBody = { extension_input: dataToSubmit };
let apiPath = targetUris.extensionPath; let apiPath = targetUris.extensionPath;
if (uiSchemaPageDefinition && uiSchemaPageDefinition.api) { if (uiSchemaPageDefinition && uiSchemaPageDefinition.on_form_submit) {
apiPath = `${targetUris.extensionListPath}/${uiSchemaPageDefinition.api}`; apiPath = `${targetUris.extensionListPath}/${uiSchemaPageDefinition.on_form_submit.api_path}`;
postBody.ui_schema_page_definition = uiSchemaPageDefinition; postBody.ui_schema_action = uiSchemaPageDefinition.on_form_submit;
} }
// NOTE: rjsf sets blanks values to undefined and JSON.stringify removes keys with undefined values // NOTE: rjsf sets blanks values to undefined and JSON.stringify removes keys with undefined values
@ -141,22 +179,30 @@ export default function Extension() {
if (uiSchemaPageDefinition) { if (uiSchemaPageDefinition) {
const componentsToDisplay = [<h1>{uiSchemaPageDefinition.header}</h1>]; const componentsToDisplay = [<h1>{uiSchemaPageDefinition.header}</h1>];
const markdownContentsToRender = [];
if (uiSchemaPageDefinition.markdown_instruction_filename) { if (uiSchemaPageDefinition.markdown_instruction_filename) {
const markdownFile = const markdownFile =
filesByName[uiSchemaPageDefinition.markdown_instruction_filename]; filesByName[uiSchemaPageDefinition.markdown_instruction_filename];
if (markdownFile.file_contents) { if (markdownFile.file_contents) {
componentsToDisplay.push( markdownContentsToRender.push(markdownFile.file_contents);
<div data-color-mode="light">
<MDEditor.Markdown
linkTarget="_blank"
source={markdownFile.file_contents}
/>
</div>
);
} }
} }
if (markdownToRenderOnLoad) {
markdownContentsToRender.push(markdownToRenderOnLoad);
}
if (markdownContentsToRender.length > 0) {
componentsToDisplay.push(
<div data-color-mode="light" className="with-bottom-margin">
<MDEditor.Markdown
linkTarget="_blank"
source={markdownContentsToRender.join('\n')}
/>
</div>
);
}
if (uiSchemaPageDefinition.form_schema_filename) { if (uiSchemaPageDefinition.form_schema_filename) {
const formSchemaFile = const formSchemaFile =
@ -180,13 +226,13 @@ export default function Extension() {
} }
} }
if (processedTaskData) { if (processedTaskData) {
if (markdownToRender) { if (markdownToRenderOnSubmit) {
componentsToDisplay.push( componentsToDisplay.push(
<div data-color-mode="light" className="with-top-margin"> <div data-color-mode="light" className="with-top-margin">
<MDEditor.Markdown <MDEditor.Markdown
className="onboarding" className="onboarding"
linkTarget="_blank" linkTarget="_blank"
source={markdownToRender} source={markdownToRenderOnSubmit}
/> />
</div> </div>
); );