Show Data Store tiles on the Process Group page (#917)

Co-authored-by: Kevin Burnett <18027+burnettk@users.noreply.github.com>
This commit is contained in:
jbirddog 2024-01-24 10:15:08 -05:00 committed by GitHub
parent 858e8eaec4
commit 4758634c99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 398 additions and 61 deletions

View File

@ -305,7 +305,7 @@ paths:
- name: per_page
in: query
required: false
description: The number of groups to show per page. Defaults to page 10.
description: The number of groups to show per page. Defaults to 10.
schema:
type: integer
get:
@ -449,7 +449,7 @@ paths:
- name: per_page
in: query
required: false
description: The number of models to show per page. Defaults to page 10.
description: The number of models to show per page. Defaults to 10.
schema:
type: integer
get:
@ -2439,7 +2439,7 @@ paths:
- name: per_page
in: query
required: false
description: The number of models to show per page. Defaults to page 10.
description: The number of models to show per page. Defaults to 10.
schema:
type: integer
get:
@ -2498,7 +2498,7 @@ paths:
- name: per_page
in: query
required: false
description: The number of items to show per page. Defaults to page 10.
description: The number of items to show per page. Defaults to 10.
schema:
type: integer
- name: events
@ -2621,7 +2621,7 @@ paths:
- name: per_page
in: query
required: false
description: The number of items to show per page. Defaults to page 10.
description: The number of items to show per page. Defaults to 10.
schema:
type: integer
post:
@ -2759,6 +2759,25 @@ paths:
# - application/json
/data-stores:
parameters:
- name: process_group_identifier
in: query
required: false
description: Optional parameter to filter by a single group
schema:
type: string
- name: page
in: query
required: false
description: The page number to return. Defaults to page 1.
schema:
type: integer
- name: per_page
in: query
required: false
description: The number of groups to show per page. Defaults to 10.
schema:
type: integer
get:
operationId: spiffworkflow_backend.routes.data_store_controller.data_store_list
summary: Return a list of the data store objects.
@ -2780,6 +2799,19 @@ paths:
responses:
"200":
description: The newly created data store instance
put:
operationId: spiffworkflow_backend.routes.data_store_controller.data_store_update
summary: Update a data store instance.
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/DataStore"
tags:
- Data Stores
responses:
"200":
description: The updated data store instance
/data-stores/types:
get:
operationId: spiffworkflow_backend.routes.data_store_controller.data_store_types
@ -2789,7 +2821,7 @@ paths:
responses:
"200":
description: The list of currently defined data store types
/data-stores/{data_store_type}/{name}:
/data-stores/{data_store_type}/{name}/items:
parameters:
- name: data_store_type
in: path
@ -2823,6 +2855,34 @@ paths:
responses:
"200":
description: A list of the data stored in the requested data store.
/data-stores/{data_store_type}/{identifier}:
parameters:
- name: data_store_type
in: path
required: true
description: The type of datastore, such as "typeahead"
schema:
type: string
- name: identifier
in: path
required: true
description: The identifier of the datastore, such as "cities"
schema:
type: string
- name: process_group_identifier
in: query
required: true
description: The process group
schema:
type: string
get:
operationId: spiffworkflow_backend.routes.data_store_controller.data_store_show
summary: Returns a description of the data store.
tags:
- Data Stores
responses:
"200":
description: The requested data store.
components:
securitySchemes:

View File

@ -3,15 +3,19 @@ from typing import Any
class DataStoreCRUD:
@staticmethod
def create_instance(name: str, identifier: str, location: str, schema: dict[str, Any], description: str | None) -> None:
def create_instance(identifier: str, location: str) -> Any:
raise Exception("must implement")
@staticmethod
def existing_data_stores() -> list[dict[str, Any]]:
def existing_instance(identifier: str, location: str) -> Any:
raise Exception("must implement")
@staticmethod
def query_data_store(name: str) -> Any:
def existing_data_stores(process_group_identifier: str | None = None) -> list[dict[str, Any]]:
raise Exception("must implement")
@staticmethod
def get_data_store_query(name: str, process_group_identifier: str | None) -> Any:
raise Exception("must implement")
@staticmethod

View File

@ -33,32 +33,38 @@ class JSONDataStore(BpmnDataStoreSpecification, DataStoreCRUD): # type: ignore
"""JSONDataStore."""
@staticmethod
def create_instance(name: str, identifier: str, location: str, schema: dict[str, Any], description: str | None) -> None:
model = JSONDataStoreModel(
name=name,
def create_instance(identifier: str, location: str) -> Any:
return JSONDataStoreModel(
identifier=identifier,
location=location,
schema=schema,
description=description or "",
data={},
)
db.session.add(model)
db.session.commit()
@staticmethod
def existing_data_stores() -> list[dict[str, Any]]:
def existing_instance(identifier: str, location: str) -> Any:
return db.session.query(JSONDataStoreModel).filter_by(identifier=identifier, location=location).first()
@staticmethod
def existing_data_stores(process_group_identifier: str | None = None) -> list[dict[str, Any]]:
data_stores = []
query = db.session.query(JSONDataStoreModel.name, JSONDataStoreModel.identifier)
if process_group_identifier is not None:
query = query.filter_by(location=process_group_identifier)
keys = query.distinct().order_by(JSONDataStoreModel.name).all() # type: ignore
for key in keys:
data_stores.append({"name": key[0], "type": "json", "identifier": key[1], "clz": "JSONDataStore"})
data_stores.append({"name": key[0], "type": "json", "id": key[1], "clz": "JSONDataStore"})
return data_stores
@staticmethod
def query_data_store(name: str) -> Any:
return JSONDataStoreModel.query.filter_by(name=name).order_by(JSONDataStoreModel.name)
def get_data_store_query(identifier: str, process_group_identifier: str | None) -> Any:
query = JSONDataStoreModel.query
if process_group_identifier is not None:
query = query.filter_by(identifier=identifier, location=process_group_identifier)
else:
query = query.filter_by(name=identifier)
return query.order_by(JSONDataStoreModel.name)
@staticmethod
def build_response_item(model: Any) -> dict[str, Any]:

View File

@ -13,8 +13,12 @@ class KKVDataStore(BpmnDataStoreSpecification, DataStoreCRUD): # type: ignore
"""KKVDataStore."""
@staticmethod
def existing_data_stores() -> list[dict[str, Any]]:
data_stores = []
def existing_data_stores(process_group_identifier: str | None = None) -> list[dict[str, Any]]:
data_stores: list[dict[str, Any]] = []
if process_group_identifier is not None:
# temporary until this data store gets location support
return data_stores
keys = (
db.session.query(KKVDataStoreModel.top_level_key)
@ -23,12 +27,12 @@ class KKVDataStore(BpmnDataStoreSpecification, DataStoreCRUD): # type: ignore
.all()
)
for key in keys:
data_stores.append({"name": key[0], "type": "kkv", "identifier": "", "clz": "KKVDataStore"})
data_stores.append({"name": key[0], "type": "kkv", "id": "", "clz": "KKVDataStore"})
return data_stores
@staticmethod
def query_data_store(name: str) -> Any:
def get_data_store_query(name: str, process_group_identifier: str | None) -> Any:
return KKVDataStoreModel.query.filter_by(top_level_key=name).order_by(
KKVDataStoreModel.top_level_key, KKVDataStoreModel.secondary_key
)

View File

@ -14,17 +14,21 @@ class TypeaheadDataStore(BpmnDataStoreSpecification, DataStoreCRUD): # type: ig
"""TypeaheadDataStore."""
@staticmethod
def existing_data_stores() -> list[dict[str, Any]]:
data_stores = []
def existing_data_stores(process_group_identifier: str | None = None) -> list[dict[str, Any]]:
data_stores: list[dict[str, Any]] = []
if process_group_identifier is not None:
# temporary until this data store gets location support
return data_stores
keys = db.session.query(TypeaheadModel.category).distinct().order_by(TypeaheadModel.category).all() # type: ignore
for key in keys:
data_stores.append({"name": key[0], "type": "typeahead", "identifier": key[0], "clz": "TypeaheadDataStore"})
data_stores.append({"name": key[0], "type": "typeahead", "id": key[0], "clz": "TypeaheadDataStore"})
return data_stores
@staticmethod
def query_data_store(name: str) -> Any:
def get_data_store_query(name: str, process_group_identifier: str | None) -> Any:
return TypeaheadModel.query.filter_by(category=name).order_by(TypeaheadModel.category, TypeaheadModel.search_term)
@staticmethod

View File

@ -10,6 +10,7 @@ from spiffworkflow_backend.data_stores.json import JSONDataStore
from spiffworkflow_backend.data_stores.kkv import KKVDataStore
from spiffworkflow_backend.data_stores.typeahead import TypeaheadDataStore
from spiffworkflow_backend.exceptions.api_error import ApiError
from spiffworkflow_backend.models.db import db
DATA_STORES = {
"json": (JSONDataStore, "JSON Data Store"),
@ -18,15 +19,15 @@ DATA_STORES = {
}
def data_store_list() -> flask.wrappers.Response:
def data_store_list(process_group_identifier: str | None = None, page: int = 1, per_page: int = 100) -> flask.wrappers.Response:
"""Returns a list of the names of all the data stores."""
data_stores = []
# Right now the only data stores we support are type ahead, kkv, json
data_stores.extend(JSONDataStore.existing_data_stores())
data_stores.extend(TypeaheadDataStore.existing_data_stores())
data_stores.extend(KKVDataStore.existing_data_stores())
data_stores.extend(JSONDataStore.existing_data_stores(process_group_identifier))
data_stores.extend(TypeaheadDataStore.existing_data_stores(process_group_identifier))
data_stores.extend(KKVDataStore.existing_data_stores(process_group_identifier))
return make_response(jsonify(data_stores), 200)
@ -42,7 +43,7 @@ def data_store_types() -> flask.wrappers.Response:
def _build_response(data_store_class: Any, name: str, page: int, per_page: int) -> flask.wrappers.Response:
data_store_query = data_store_class.query_data_store(name)
data_store_query = data_store_class.get_data_store_query(name, None)
data = data_store_query.paginate(page=page, per_page=per_page, error_out=False)
results = []
for item in data.items:
@ -70,6 +71,14 @@ def data_store_item_list(data_store_type: str, name: str, page: int = 1, per_pag
def data_store_create(body: dict) -> flask.wrappers.Response:
return _data_store_upsert(body, True)
def data_store_update(body: dict) -> flask.wrappers.Response:
return _data_store_upsert(body, False)
def _data_store_upsert(body: dict, insert: bool) -> flask.wrappers.Response:
try:
data_store_type = body["type"]
name = body["name"]
@ -97,6 +106,46 @@ def data_store_create(body: dict) -> flask.wrappers.Response:
raise ApiError("unknown_data_store", f"Unknown data store type: {data_store_type}", status_code=400)
data_store_class, _ = DATA_STORES[data_store_type]
data_store_class.create_instance(name, identifier, location, schema, description)
if insert:
model = data_store_class.create_instance(identifier, location)
else:
model = data_store_class.existing_instance(identifier, location)
model.name = name
model.schema = schema
model.description = description or ""
db.session.add(model)
db.session.commit()
return make_response(jsonify({"ok": True}), 200)
def data_store_show(data_store_type: str, identifier: str, process_group_identifier: str) -> flask.wrappers.Response:
"""Returns a description of a data store."""
if data_store_type not in DATA_STORES:
raise ApiError("unknown_data_store", f"Unknown data store type: {data_store_type}", status_code=400)
data_store_class, _ = DATA_STORES[data_store_type]
data_store_query = data_store_class.get_data_store_query(identifier, process_group_identifier)
result = data_store_query.first()
if result is None:
raise ApiError(
"could_not_locate_data_store",
f"Could not locate data store type: {data_store_type} for process group: {process_group_identifier}",
status_code=400,
)
response = {
"name": result.name,
"location": result.location,
"type": data_store_type,
"id": result.identifier,
"schema": result.schema,
"description": result.description,
}
return make_response(jsonify(response), 200)

View File

@ -67,8 +67,8 @@ class TestDataStores(BaseTest):
self.load_data_store(app, client, with_db_and_bpmn_file_cleanup, with_super_admin_user)
results = client.get("/v1.0/data-stores", headers=self.logged_in_headers(with_super_admin_user))
assert results.json == [
{"name": "albums", "type": "typeahead", "identifier": "albums", "clz": "TypeaheadDataStore"},
{"name": "cereals", "type": "typeahead", "identifier": "cereals", "clz": "TypeaheadDataStore"},
{"name": "albums", "type": "typeahead", "id": "albums", "clz": "TypeaheadDataStore"},
{"name": "cereals", "type": "typeahead", "id": "cereals", "clz": "TypeaheadDataStore"},
]
def test_get_data_store_returns_paginated_results(
@ -80,7 +80,7 @@ class TestDataStores(BaseTest):
) -> None:
self.load_data_store(app, client, with_db_and_bpmn_file_cleanup, with_super_admin_user)
response = client.get(
"/v1.0/data-stores/typeahead/albums?per_page=10", headers=self.logged_in_headers(with_super_admin_user)
"/v1.0/data-stores/typeahead/albums/items?per_page=10", headers=self.logged_in_headers(with_super_admin_user)
)
expected_item_in_response = {

View File

@ -49,7 +49,11 @@ export default function DataStoreForm({
useEffect(() => {
const handleSetDataStoreTypesCallback = (result: any) => {
const dataStoreType = result.find((item: any) => {
return item.type === dataStore.type;
});
setDataStoreTypes(result);
setSelectedDataStoreType(dataStoreType ?? null);
};
HttpService.makeCallToBackend({
@ -57,7 +61,7 @@ export default function DataStoreForm({
successCallback: handleSetDataStoreTypesCallback,
httpMethod: 'GET',
});
}, [setDataStoreTypes]);
}, [dataStore, setDataStoreTypes]);
const navigateToDataStores = (_result: any) => {
const location = dataStoreLocation();
@ -99,10 +103,9 @@ export default function DataStoreForm({
if (hasErrors) {
return;
}
let path = '/data-stores';
const path = '/data-stores';
let httpMethod = 'POST';
if (mode === 'edit') {
path = `/data-stores/${dataStore.id}`;
httpMethod = 'PUT';
}
const postBody = {
@ -177,26 +180,25 @@ export default function DataStoreForm({
/>,
];
if (mode === 'new') {
textInputs.push(
<TextInput
id="data-store-identifier"
name="id"
invalidText="Identifier is required and must be all lowercase characters and hyphens."
invalid={identifierInvalid}
labelText="Identifier*"
value={dataStore.id}
onChange={(event: any) => {
updateDataStore({ id: event.target.value });
// was invalid, and now valid
if (identifierInvalid && hasValidIdentifier(event.target.value)) {
setIdentifierInvalid(false);
}
setIdHasBeenUpdatedByUser(true);
}}
/>
);
}
textInputs.push(
<TextInput
id="data-store-identifier"
name="id"
readonly={mode === 'edit'}
invalidText="Identifier is required and must be all lowercase characters and hyphens."
invalid={identifierInvalid}
labelText="Identifier*"
value={dataStore.id}
onChange={(event: any) => {
updateDataStore({ id: event.target.value });
// was invalid, and now valid
if (identifierInvalid && hasValidIdentifier(event.target.value)) {
setIdentifierInvalid(false);
}
setIdHasBeenUpdatedByUser(true);
}}
/>
);
textInputs.push(
<ComboBox

View File

@ -51,7 +51,7 @@ export default function DataStoreListTable() {
}
const queryParamString = `per_page=${perPage}&page=${page}`;
HttpService.makeCallToBackend({
path: `/data-stores/${dataStoreType}/${dataStoreName}?${queryParamString}`,
path: `/data-stores/${dataStoreType}/${dataStoreName}/items?${queryParamString}`,
successCallback: (response: DataStoreRecords) => {
setResults(response.results);
setPagination(response.pagination);

View File

@ -0,0 +1,110 @@
import { ReactElement, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ArrowRight } from '@carbon/icons-react';
import { ClickableTile } from '@carbon/react';
import HttpService from '../services/HttpService';
import { DataStore, ProcessGroup } from '../interfaces';
import { truncateString } from '../helpers';
type OwnProps = {
defaultDataStores?: DataStore[];
dataStore?: DataStore;
processGroup?: ProcessGroup;
headerElement?: ReactElement;
showNoItemsDisplayText?: boolean;
userCanCreateDataStores?: boolean;
};
export default function DataStoreListTiles({
defaultDataStores,
dataStore,
processGroup,
headerElement,
showNoItemsDisplayText,
userCanCreateDataStores,
}: OwnProps) {
const [searchParams] = useSearchParams();
const [dataStores, setDataStores] = useState<DataStore[] | null>(null);
useEffect(() => {
const setDataStoresFromResult = (result: any) => {
setDataStores(result);
};
if (defaultDataStores) {
setDataStores(defaultDataStores);
} else {
let queryParams = '?per_page=1000';
if (processGroup) {
queryParams = `${queryParams}&process_group_identifier=${processGroup.id}`;
}
HttpService.makeCallToBackend({
path: `/data-stores${queryParams}`,
successCallback: setDataStoresFromResult,
});
}
}, [searchParams, dataStore, defaultDataStores, processGroup]);
const dataStoresDisplayArea = () => {
let displayText = null;
if (dataStores && dataStores.length > 0) {
displayText = dataStores.map((row: DataStore) => {
return (
<ClickableTile
id={`data-store-tile-${row.id}`}
className="tile-data-store"
href={`/data-stores/${row.id}/edit?parentGroupId=${
processGroup?.id ?? ''
}`}
>
<div className="tile-data-store-content-container">
<ArrowRight />
<div className="tile-data-store-display-name">{row.name}</div>
<p className="tile-description">
{truncateString(row.description || '', 100)}
</p>
</div>
</ClickableTile>
);
});
} else if (userCanCreateDataStores) {
displayText = (
<p className="no-results-message">
There are no data stores to display. You can add one by clicking the
&quot;Add a data store&quot; button.
</p>
);
} else {
displayText = (
<p className="no-results-message">
There are no data stores to display.
</p>
);
}
return displayText;
};
const dataStoreArea = () => {
if (dataStores && (showNoItemsDisplayText || dataStores.length > 0)) {
return (
<>
{headerElement}
{dataStoresDisplayArea()}
</>
);
}
return null;
};
if (dataStores) {
return (
<>
{/* so we can check if the data stores have loaded in cypress tests */}
<div data-qa="data-stores-loaded" className="hidden" />
{dataStoreArea()}
</>
);
}
return null;
}

View File

@ -400,6 +400,32 @@ in on this with the react-jsonschema-form repo. This is just a patch fix to allo
padding-left: 5px;
}
.cds--tile.tile-data-store {
padding: 0px;
margin: 12px 24px 12px 0px;
width: 320px;
height: 264px;
background: #f4f4f4;
order: 1;
float: left;
}
.tile-data-store-content-container {
width: 320px;
height: 264px;
padding: 1rem;
position: relative;
}
.tile-data-store-display-name {
margin-top: 2rem;
margin-bottom: 1rem;
font-size: 20px;
line-height: 28px;
color: #161616;
order: 0;
}
.cds--tile.tile-process-group {
padding: 0px;
margin: 12px 24px 12px 0px;

View File

@ -0,0 +1,58 @@
import { useEffect, useState } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
import DataStoreForm from '../components/DataStoreForm';
import { DataStore, HotCrumbItem } from '../interfaces';
import { setPageTitle } from '../helpers';
import HttpService from '../services/HttpService';
export default function DataStoreEdit() {
const params = useParams();
const [searchParams] = useSearchParams();
const parentGroupId = searchParams.get('parentGroupId');
const dataStoreIdentifier = params.data_store_identifier;
const [dataStore, setDataStore] = useState<DataStore>({
id: '',
name: '',
type: '',
schema: '',
description: '',
});
useEffect(() => {
setPageTitle(['Edit Data Store']);
}, []);
useEffect(() => {
const setDataStoreFromResult = (result: any) => {
const schema = JSON.stringify(result.schema);
setDataStore({ ...result, schema });
};
const queryParams = `?process_group_identifier=${parentGroupId}`;
HttpService.makeCallToBackend({
path: `/data-stores/json/${dataStoreIdentifier}${queryParams}`,
successCallback: setDataStoreFromResult,
});
}, [dataStoreIdentifier, parentGroupId]);
const hotCrumbs: HotCrumbItem[] = [['Process Groups', '/process-groups']];
if (parentGroupId) {
hotCrumbs.push({
entityToExplode: parentGroupId,
entityType: 'process-group-id',
linkLastItem: true,
});
}
return (
<>
<ProcessBreadcrumb hotCrumbs={hotCrumbs} />
<h1>Edit Data Store</h1>
<DataStoreForm
mode="edit"
dataStore={dataStore}
setDataStore={setDataStore}
/>
</>
);
}

View File

@ -1,11 +1,13 @@
import { Route, Routes } from 'react-router-dom';
import DataStoreList from './DataStoreList';
import DataStoreNew from './DataStoreNew';
import DataStoreEdit from './DataStoreEdit';
export default function DataStoreRoutes() {
return (
<Routes>
<Route path="/" element={<DataStoreList />} />
<Route path=":data_store_identifier/edit" element={<DataStoreEdit />} />
<Route path="new" element={<DataStoreNew />} />
</Routes>
);

View File

@ -14,6 +14,7 @@ import { PermissionsToCheck, ProcessGroup } from '../interfaces';
import { useUriListForPermissions } from '../hooks/UriListForPermissions';
import { usePermissionFetcher } from '../hooks/PermissionService';
import ProcessGroupListTiles from '../components/ProcessGroupListTiles';
import DataStoreListTiles from '../components/DataStoreListTiles';
import ButtonWithConfirmation from '../components/ButtonWithConfirmation';
import ProcessModelListTiles from '../components/ProcessModelListTiles';
import useProcessGroupFetcher from '../hooks/useProcessGroupFetcher';
@ -162,6 +163,17 @@ export default function ProcessGroupShow() {
targetUris.processGroupListPath
)}
/>
<br />
<br />
<DataStoreListTiles
processGroup={processGroup}
headerElement={<h2 className="clear-left">Data Stores</h2>}
showNoItemsDisplayText={showNoItemsDisplayText}
userCanCreateDataStores={ability.can(
'POST',
targetUris.dataStoreListPath
)}
/>
</ul>
</>
);