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/[:\/]$//')
fi
# this will fix branching conflicts
# poetry run flask db merge heads -m "merging two heads"
# uncomment this line to fix branching conflicts
# poetry run flask db merge heads -m "merging heads"
tasks=""
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 (
ConfigurationModel,
) # noqa: F401
from spiffworkflow_backend.models.user_property import (
UserPropertyModel,
) # noqa: F401
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_queue_service import ProcessInstanceIsAlreadyLockedError
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.spec_file_service import SpecFileService
from spiffworkflow_backend.services.workflow_execution_service import WorkflowExecutionServiceError
@ -121,17 +122,25 @@ def _run_extension(
status_code=400,
)
ui_schema_page_definition = None
if body and "ui_schema_page_definition" in body:
ui_schema_page_definition = body["ui_schema_page_definition"]
ui_schema_action = None
persistence_level = "none"
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(
status=ProcessInstanceStatus.not_started.value,
process_initiator_id=g.user.id,
process_model_identifier=process_model.id,
process_model_display_name=process_model.display_name,
persistence_level="none",
)
process_instance = None
if persistence_level == "none":
process_instance = ProcessInstanceModel(
status=ProcessInstanceStatus.not_started.value,
process_initiator_id=g.user.id,
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
try:
@ -170,10 +179,10 @@ def _run_extension(
task_data = processor.get_data()
result: dict[str, Any] = {"task_data": task_data}
if ui_schema_page_definition:
if "results_markdown_filename" in ui_schema_page_definition:
if ui_schema_action:
if "results_markdown_filename" in ui_schema_action:
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")
form_contents = JinjaService.render_jinja_template(file_contents, task_data=task_data)
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
class InvalidArgsGivenToScriptError(Exception):
pass
class Script:
"""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="/editor/*" element={<EditorRoutes />} />
<Route
path="/extensions/:process_model"
element={<Extension />}
/>
<Route
path="/extensions/:process_model/:extension_route"
path="/extensions/:page_identifier"
element={<Extension />}
/>
</Routes>

View File

@ -24,13 +24,11 @@ import { Can } from '@casl/react';
import logo from '../logo.svg';
import UserService from '../services/UserService';
import { useUriListForPermissions } from '../hooks/UriListForPermissions';
import { PermissionsToCheck, ProcessModel, ProcessFile } from '../interfaces';
import {
PermissionsToCheck,
ProcessModel,
ProcessFile,
ExtensionUiSchema,
UiSchemaNavItem,
} from '../interfaces';
UiSchemaUxElement,
} from '../extension_ui_schema_interfaces';
import { usePermissionFetcher } from '../hooks/PermissionService';
import HttpService, { UnauthenticatedError } from '../services/HttpService';
import { DOCUMENTATION_URL, SPIFF_ENVIRONMENT } from '../config';
@ -49,7 +47,7 @@ export default function NavigationBar() {
const location = useLocation();
const [activeKey, setActiveKey] = useState<string>('');
const [extensionNavigationItems, setExtensionNavigationItems] = useState<
UiSchemaNavItem[] | null
UiSchemaUxElement[] | null
>(null);
const { targetUris } = useUriListForPermissions();
@ -109,7 +107,7 @@ export default function NavigationBar() {
}
const processExtensionResult = (processModels: ProcessModel[]) => {
const eni: UiSchemaNavItem[] = processModels
const eni: UiSchemaUxElement[] = processModels
.map((processModel: ProcessModel) => {
const extensionUiSchemaFile = processModel.files.find(
(file: ProcessFile) => file.name === 'extension_uischema.json'
@ -119,8 +117,8 @@ export default function NavigationBar() {
const extensionUiSchema: ExtensionUiSchema = JSON.parse(
extensionUiSchemaFile.file_contents
);
if (extensionUiSchema.navigation_items) {
return extensionUiSchema.navigation_items;
if (extensionUiSchema.ux_elements) {
return extensionUiSchema.ux_elements;
}
} catch (jsonParseError: any) {
console.error(
@ -128,7 +126,7 @@ export default function NavigationBar() {
);
}
}
return [] as UiSchemaNavItem[];
return [] as UiSchemaUxElement[];
})
.flat();
if (eni) {
@ -157,6 +155,27 @@ export default function NavigationBar() {
const userEmail = UserService.getUserEmail();
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 = (
<div style={{ display: 'flex' }} id="user-profile-toggletip">
<Toggletip isTabTip align="bottom-right">
@ -177,6 +196,10 @@ export default function NavigationBar() {
<a target="_blank" href={documentationUrl} rel="noreferrer">
Documentation
</a>
{extensionNavigationElementsForDisplayLocation(
'user_profile_item',
extensionUserProfileElement
)}
{!UserService.authenticationDisabled() ? (
<>
<hr />
@ -258,27 +281,21 @@ export default function NavigationBar() {
);
};
const extensionNavigationElements = () => {
if (!extensionNavigationItems) {
return null;
const extensionHeaderMenuItemElement = (uxElement: UiSchemaUxElement) => {
const navItemPage = `/extensions${uxElement.page}`;
const regexp = new RegExp(`^${navItemPage}$`);
if (regexp.test(location.pathname)) {
setActiveKey(navItemPage);
}
return extensionNavigationItems.map((navItem: UiSchemaNavItem) => {
const navItemRoute = `/extensions${navItem.route}`;
const regexp = new RegExp(`^${navItemRoute}`);
if (regexp.test(location.pathname)) {
setActiveKey(navItemRoute);
}
return (
<HeaderMenuItem
href={navItemRoute}
isCurrentPage={isActivePage(navItemRoute)}
data-qa={`extension-${slugifyString(navItem.label)}`}
>
{navItem.label}
</HeaderMenuItem>
);
});
return (
<HeaderMenuItem
href={navItemPage}
isCurrentPage={isActivePage(navItemPage)}
data-qa={`extension-${slugifyString(uxElement.label)}`}
>
{uxElement.label}
</HeaderMenuItem>
);
};
const headerMenuItems = () => {
@ -328,7 +345,10 @@ export default function NavigationBar() {
</HeaderMenuItem>
</Can>
{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;
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 { useParams } from 'react-router-dom';
import { Editor } from '@monaco-editor/react';
import { useUriListForPermissions } from '../hooks/UriListForPermissions';
import {
ExtensionPostBody,
ExtensionUiSchema,
ProcessFile,
ProcessModel,
UiSchemaPageDefinition,
} from '../interfaces';
import { ProcessFile, ProcessModel } from '../interfaces';
import HttpService from '../services/HttpService';
import useAPIError from '../hooks/UseApiError';
import { recursivelyChangeNullAndUndefined } from '../helpers';
import CustomForm from '../components/CustomForm';
import { BACKEND_BASE_URL } from '../config';
import {
ExtensionPostBody,
ExtensionUiSchema,
UiSchemaPageDefinition,
} from '../extension_ui_schema_interfaces';
import ErrorDisplay from '../components/ErrorDisplay';
// eslint-disable-next-line sonarjs/cognitive-complexity
@ -26,7 +25,12 @@ export default function Extension() {
const [formData, setFormData] = useState<any>(null);
const [formButtonsDisabled, setFormButtonsDisabled] = useState(false);
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<{
[key: string]: ProcessFile;
}>({});
@ -35,18 +39,15 @@ export default function Extension() {
const { addError, removeError } = useAPIError();
useEffect(() => {
const processExtensionResult = (pm: ProcessModel) => {
setProcessModel(pm);
let extensionUiSchemaFile: ProcessFile | null = null;
pm.files.forEach((file: ProcessFile) => {
filesByName[file.name] = file;
if (file.name === 'extension_uischema.json') {
extensionUiSchemaFile = file;
const setConfigsIfDesiredSchemaFile = useCallback(
(extensionUiSchemaFile: ProcessFile | null, pm: ProcessModel) => {
const processLoadResult = (result: any) => {
setFormData(result.task_data);
if (result.rendered_results_markdown) {
setMarkdownToRenderOnLoad(result.rendered_results_markdown);
}
});
};
// typescript is really confused by extensionUiSchemaFile so force it since we are properly checking
if (
extensionUiSchemaFile &&
(extensionUiSchemaFile as ProcessFile).file_contents
@ -55,24 +56,61 @@ export default function Extension() {
(extensionUiSchemaFile as any).file_contents
);
let routeIdentifier = `/${params.process_model}`;
if (params.extension_route) {
routeIdentifier = `${routeIdentifier}/${params.extension_route}`;
const pageIdentifier = `/${params.page_identifier}`;
if (
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({
path: targetUris.extensionPath,
path: targetUris.extensionListPath,
successCallback: processExtensionResult,
});
}, [targetUris.extensionPath, params, filesByName]);
}, [
filesByName,
params,
setConfigsIfDesiredSchemaFile,
targetUris.extensionListPath,
targetUris.extensionPath,
]);
const processSubmitResult = (result: any) => {
setProcessedTaskData(result.task_data);
if (result.rendered_results_markdown) {
setMarkdownToRender(result.rendered_results_markdown);
setMarkdownToRenderOnSubmit(result.rendered_results_markdown);
}
setFormButtonsDisabled(false);
};
@ -111,15 +149,15 @@ export default function Extension() {
if (!isValid) {
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;
setFormButtonsDisabled(false);
} else {
const postBody: ExtensionPostBody = { extension_input: dataToSubmit };
let apiPath = targetUris.extensionPath;
if (uiSchemaPageDefinition && uiSchemaPageDefinition.api) {
apiPath = `${targetUris.extensionListPath}/${uiSchemaPageDefinition.api}`;
postBody.ui_schema_page_definition = uiSchemaPageDefinition;
if (uiSchemaPageDefinition && uiSchemaPageDefinition.on_form_submit) {
apiPath = `${targetUris.extensionListPath}/${uiSchemaPageDefinition.on_form_submit.api_path}`;
postBody.ui_schema_action = uiSchemaPageDefinition.on_form_submit;
}
// 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) {
const componentsToDisplay = [<h1>{uiSchemaPageDefinition.header}</h1>];
const markdownContentsToRender = [];
if (uiSchemaPageDefinition.markdown_instruction_filename) {
const markdownFile =
filesByName[uiSchemaPageDefinition.markdown_instruction_filename];
if (markdownFile.file_contents) {
componentsToDisplay.push(
<div data-color-mode="light">
<MDEditor.Markdown
linkTarget="_blank"
source={markdownFile.file_contents}
/>
</div>
);
markdownContentsToRender.push(markdownFile.file_contents);
}
}
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) {
const formSchemaFile =
@ -180,13 +226,13 @@ export default function Extension() {
}
}
if (processedTaskData) {
if (markdownToRender) {
if (markdownToRenderOnSubmit) {
componentsToDisplay.push(
<div data-color-mode="light" className="with-top-margin">
<MDEditor.Markdown
className="onboarding"
linkTarget="_blank"
source={markdownToRender}
source={markdownToRenderOnSubmit}
/>
</div>
);