Q2 Console Extension Tutorial (beta)
Warning
This is an early build of Console in the sandbox. There’s bound to be some weirdness. We look forward to hearing about it through our ticketing system. Some things that we know about:
To log out, you have to open your dev tools and clear your local storage variables. (In chrome that’s Dev Tools -> Application -> Local storage -> https://stack.q2developer.com -> Clear All)
Only the ‘sdkteam’ stack works right now we’re working on adding more support. If you want your stack as well, we will have to set it up manually for the moment.
This tutorial kind stinks. While the code samples work, there’s not yet any of the “now let’s move to step 2” language. Figured it’s better to release this big pile of code as is than wait for perfection.
The installer can’t tell the difference between Central and Console. Mostly this is fine, but it leads to some strange interactions (like always choose “Services” when installing in the nav)
Q2 Console is a web based backoffice tool and a spiritual successor to Q2 Central’s Windows based application. Rather than a different instance for each financial institution stack, there is just a single portal with a authentication layer that determines which stacks are presented.
For developing against our sandboxes, we have a q2developer hosted Q2Console. When the time comes to deploy your extension to a higher environment (staging/PTE/production) you will work with Q2’s standard support team to ensure you have login access to the “real” Q2Console.
Sandbox Console: https://stack.q2developer.com/console
Q2 Datacenter console (for reference): https://q2console.okta.com
In this tutorial, we will extend the Q2Console interface using the Q2Console extension type.
As always, let’s start by creating a new extension:
(.env) $ q2 create_extension ViewUserData What type of extension are you creating? 1) Online (default) 2) SSO (Third Party Integration) 3) Ardent (API) 4) Q2Console (Backoffice) <--------------------------- 5) Central (Legacy Backoffice) 6) Adapter 7) Audit Action 8) Custom Health Check 9) Message Bus 10) Caliper API Custom Endpoint 11) Base Extension Please make a selection and press Return [1]: 4 Q2Console (Backoffice) Select Q2Console type to generate 1) Server Side Rendered 2) Client Side Rendered <--------------------------- Please make a selection and press Return: 2 Client Side Rendered What type of framework would you like to integrate with? 1) Continue with no framework <--------------------------- 2) Integrate with React 3) Integrate with Vue Please make a selection and press Return [1]: Preferred HMR port? [random]:
Most of our tutorials use Server Side Rendered extensions. These work with Q2Console types, too. But to mix it up,
let’s make this look really nice with a Client Side Rendered extension. This tutorial is self contained, but for a
deeper dive on CSR capabilities, feel free to check out Client Side Rendered Extensions.
In addition, while it’s possible to use a framework such as React, Vue, Angular, etc for this, Tecton gives us
a lot of wonderful capabilities out of the box with regular vanilla javascript. Let’s see how far we can get without
using a framework at all!
- TODO:
- q2 install
Choose defaults
Add to services nav
Lots of files to make a full featured example:
These both live in the ViewUserData directory
"""
ViewUserData Extension
"""
from q2_sdk.core.http_handlers.console_handler import Q2ConsoleClientRequestHandler
from q2_sdk.hq.hq_api.backoffice import GetDataSetFiltered
from .models import MappedGroup
class ViewUserDataHandler(Q2ConsoleClientRequestHandler):
CONFIG_FILE_NAME = "ViewUserData" # configuration/ViewUserData.py file must exist if REQUIRED_CONFIGURATIONS exist
@property
def router(self):
router = super().router
router.update(
{
"default": self.default,
"get_customers": self.get_customers,
"get_users": self.get_users,
"get_groups": self.get_groups,
"get_user_data": self.get_user_data,
"update_user_data": self.update_user_data,
"create_user_data": self.create_user_data,
}
)
return router
async def default(self):
return {"message": "Beautiful User Picker"}
async def get_groups(self):
cached_mapped_groups: dict = self.cache.get("mapped_groups", {"groups": []})
mapped_group_list = []
if cached_mapped_groups["groups"]:
mapped_group_list = [
MappedGroup.from_dict(group) for group in cached_mapped_groups["groups"]
]
else:
groups = await self.db.group.get()
mapped_group_list = [
MappedGroup(
group.GroupID.pyval,
group.GroupDesc.text,
group.IsTreasury.pyval,
group.IsCommercial.pyval,
)
for group in groups
]
self.cache.set(
"mapped_groups",
{"groups": [group.to_dict() for group in mapped_group_list]},
expire=60 * 10,
)
return {
"rows": [
{
"id": x.group_id,
"groupId": x.group_id,
"name": x.group_desc,
"isTreasury": x.is_treasury,
"isCommercial": x.is_commercial,
}
for x in mapped_group_list
],
"total_size": len(mapped_group_list),
}
async def get_customers(self):
search_id = self.form_fields['search_id']
params_obj = GetDataSetFiltered.ParamsObj(
self.logger,
self.hq_credentials,
GetDataSetFiltered.dataSetType.CustomerList,
1,
1000,
filter_items=GetDataSetFiltered.filterItems(
[
GetDataSetFiltered.FilterItem(
False,
GetDataSetFiltered.Operand.EQ,
0,
0,
field_name='groupID',
comparison_value=search_id,
)
]
),
)
result = await GetDataSetFiltered.execute(params_obj)
customers = result.result_node.find('.//Q2_CustomerList')
return {
"rows": [
{
"id": x.CustomerID.pyval,
"customerID": x.CustomerID.pyval,
"name": x.CustomerName.pyval,
"userCount": x.ActiveUserCount.pyval,
"groupId": x.GroupID.pyval,
}
for x in customers
],
"total_size": len(customers),
}
async def get_users(self):
search_id = self.form_fields['search_id']
params_obj = GetDataSetFiltered.ParamsObj(
self.logger,
self.hq_credentials,
GetDataSetFiltered.dataSetType.UserList,
1,
1000,
filter_items=GetDataSetFiltered.filterItems(
[
GetDataSetFiltered.FilterItem(
False,
GetDataSetFiltered.Operand.EQ,
0,
0,
field_name='customerID',
comparison_value=search_id,
)
]
),
)
result = await GetDataSetFiltered.execute(params_obj)
users = result.result_node.find('.//Q2_UserList')
return {
"rows": [
{
"id": x.UserID.pyval,
"userID": x.UserID.pyval,
"firstName": x.FirstName.pyval,
"lastName": x.LastName.pyval,
"customerID": x.CustomerID.pyval,
}
for x in users
],
"total_size": len(users),
}
async def get_user_data(self):
search_id = self.form_fields['search_id']
user_data = await self.db.user_data.get_multi(search_id)
return {
"rows": [
{
"id": x.DataID.pyval,
"userId": x.UserID,
"key": x.ShortName,
"value": x.GTDataValue,
}
for x in user_data
],
"total_size": 1,
}
async def update_user_data(self):
user_id = self.form_fields['userId']
userdata_key = self.form_fields['key']
userdata_value = self.form_fields['value']
await self.db.user_data.update(user_id, userdata_key, userdata_value)
self.logger.info('Updated UserData "%s" for UserId %d', userdata_key, user_id)
return {'status': 'success'}
async def create_user_data(self):
user_id = self.form_fields['userId']
userdata_key = self.form_fields['key']
userdata_value = self.form_fields['value']
await self.db.user_data.create(user_id, userdata_key, userdata_value)
self.logger.info('Added UserData "%s" for UserId %d', userdata_key, user_id)
return {'status': 'success'}
from __future__ import annotations
from dataclasses import dataclass, asdict
@dataclass
class MappedGroup:
group_id: int
group_desc: str
is_treasury: bool
is_commercial: bool
def to_dict(self):
return asdict(self)
@staticmethod
def from_dict(group: dict) -> MappedGroup:
return MappedGroup(
group["group_id"],
group["group_desc"],
group["is_treasury"],
group["is_commercial"],
)
Index.html should be copied into the ViewUserData/frontend directory
The rest should all be copied into the ViewUserData/frontend/src directory
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>ViewUserData</title>
</head>
<body data-tecton-module>
<p id="title">Beautiful User Picker</p>
<q2-section>
<q2-stepper id="stepper" last-enabled-step="4" current-step="1">
<q2-stepper-pane label="Step 1" description="Choose Group" id="step-1">
<h3>Choose Group</h3>
<div class="q2-row">
<q2-btn id="group-btn" color="primary" type="button">Fetch Groups</q2-btn>
</div>
<div class="q2-row">
<q2-data-table id="group-table" caption="Q2 Groups" bordered="" shadowed="" hide-caption="" selectable="" select-mode="single" block />
</div>
<div class="q2-row">
<q2-pagination class="mrg-t(6x)" id="group-pagination" record-type="Groups" total="0"> </q2-pagination>
</div>
<div>
<p></p>
</div>
</q2-stepper-pane>
<q2-stepper-pane label="Step 2" description="Choose Customer" id="step-2" status="locked">
<h3>Choose Customer</h3>
<div class="q2-row">
<q2-data-table id="customer-table" caption="Q2 Customers" bordered="" shadowed="" hide-caption="" selectable="" select-mode="single" block />
</div>
<div class="q2-row">
<q2-pagination id="customer-pagination" record-type="Customers" total="0" > </q2-pagination>
</div>
<div>
<p></p>
</div>
</q2-stepper-pane>
<q2-stepper-pane label="Step 3" description="Choose User" id="step-3" status="locked">
<h3>Choose User</h3>
<div class="q2-row">
<q2-data-table id="user-table" caption="Q2 Users" bordered="" shadowed="" hide-caption="" selectable="" select-mode="single" block />
</div>
<div class="q2-row">
<q2-pagination id="user-pagination" record-type="Users" total="0" > </q2-pagination>
</div>
<div>
<p></p>
</div>
</q2-stepper-pane>
<q2-stepper-pane label="Step 4" description="User Data" id="step-4" status="locked">
<h3>View User Data</h3>
<div class="q2-row">
<q2-data-table id="userdata-table" caption="Entries in Q2_UserData table" bordered="" shadowed="" hide-caption="" block />
</div>
<div class="q2-row">
<q2-pagination id="userdata-pagination" record-type="UserData" total="0" > </q2-pagination>
</div>
<q2-section label="Add a new one">
<q2-form>
<q2-input type="text" id="newUserDataKey" name="userDataKey" label="Key"></q2-input>
<q2-input type="text" id="newUserDataValue" name="userDataValue" label="Value"></q2-input>
<q2-btn id="userDataAdd-btn" intent="workflow-primary">
<q2-icon type="add"></q2-icon>
</q2-btn>
</q2-form>
</q2-section>
<div>
<p></p>
</div>
</q2-stepper-pane>
</q2-stepper>
</q2-section>
<script type="module" src="./src/index.js"></script>
</body>
</html>
import {connect} from 'q2-tecton-sdk';
import {setup as groupSetup} from './group';
import {setup as customerSetup} from './customer';
import {setup as userSetup} from './user';
import {setup as userDataSetup} from './userdata';
const title = document.getElementById('title');
connect().then(capabilities => {
const groupTable = document.querySelector('#group-table');
const groupPagination = document.querySelector('#group-pagination');
const groupButton = document.querySelector('#group-btn');
const userDataAddButton = document.querySelector('#userDataAdd-btn');
const customerTable = document.querySelector('#customer-table');
const customerPagination = document.querySelector('#customer-pagination');
const userTable = document.querySelector('#user-table');
const userPagination = document.querySelector('#user-pagination');
const userDataTable = document.querySelector('#userdata-table');
const userDataPagination = document.querySelector('#userdata-pagination');
const groupStep = document.querySelector('#step-1');
const customerStep = document.querySelector('#step-2');
const userStep = document.querySelector('#step-3');
const userDataStep = document.querySelector('#step-4');
const stepper = document.querySelector('#stepper');
const newKey = document.querySelector('#newUserDataKey');
const newValue = document.querySelector('#newUserDataValue');
const state = {
capabilities: capabilities,
pageSize: 15,
stepper: stepper,
selections: {
groupId: null,
customerId: null,
userId: null
},
dataTables: {
customer: customerTable,
group: groupTable,
user: userTable,
userData: userDataTable,
},
paginations: {
customer: customerPagination,
group: groupPagination,
user: userPagination,
userData: userDataPagination
},
buttons: {
group: groupButton,
userDataAdd: userDataAddButton
},
steps: {
group: groupStep,
customer: customerStep,
user: userStep,
userData: userDataStep,
},
dataEntries: {
newKey: newKey,
newValue: newValue,
},
updateHandlers: {
group: null,
customer: null,
user: null,
userData: null,
}
}
groupSetup(updateDataHandler, paginationHandler, state);
customerSetup(updateDataHandler, paginationHandler, state);
userSetup(updateDataHandler, paginationHandler, state);
userDataSetup(updateDataHandler, paginationHandler, state);
capabilities.sources
.requestExtensionData({route: 'default'})
.then(response => {
title.innerHTML = response.data.message;
})
.catch(error => showError(error))
.finally(() => capabilities.actions.setFetching(false));
function paginationHandler(e, paginationElem, tableElem, fullDataSet) {
const pageSize = paginationElem.perPage;
const curPage = e.detail.page ? e.detail.page : paginationElem.page;
const startIdx = pageSize * (curPage - 1);
const endIdx = pageSize * curPage;
tableElem.rows = fullDataSet.data.slice(startIdx, endIdx);
}
function updateDataHandler(tableElem, routeName, fullDataSet, paginationElem, mapperFunc, prevSelection) {
tableElem.loading = true;
capabilities.sources
.requestExtensionData({
route: routeName,
body: {
search_id: prevSelection
}
})
.then(response => {
fullDataSet.data = response.data.rows.map(
(x) => {
return {
id: x.id,
cells: mapperFunc(x, state)
}
});
tableElem.rows = fullDataSet.data.slice(0, state.pageSize);
paginationElem.page = 1;
paginationElem.total = response.data.total_size;
tableElem.loading = false;
paginationElem.style.display = 'block';
})
.catch(error => showError(error));
}
function showError(error) {
console.error(error);
}
});
export function setup(updateDataHandler, paginationHandler, state) {
const groupTable = state.dataTables.group;
const groupPagination = state.paginations.group
const groupButton = state.buttons.group;
groupButton.addEventListener('click', () => update(updateDataHandler, state));
groupPagination.perPage = state.pageSize;
groupPagination.addEventListener('change', e => paginationHandler(e, groupPagination, groupTable, groupRowData));
groupPagination.style.display = 'none';
groupTable.headers = groupHeaderData;
groupTable.rows = groupRowData.data;
groupTable.onselect = selectHandler(state);
state.updateHandlers.group = () => update(updateDataHandler, state)
}
function update(updateDataHandler, state) {
updateDataHandler(
state.dataTables.group,
'get_groups',
groupRowData,
state.paginations.group,
groupDataMapper,
null
);
}
function groupDataMapper(dataRow) {
return {
groupId: dataRow.groupId,
name: dataRow.name,
isTreasury: dataRow.isTreasury ? "true" : "",
isCommercial: dataRow.isCommercial ? "true" : "",
}
}
function selectHandler(state) {
return e => {
state.selections.groupId = e.detail.row.id;
if (e.detail.row.selected == true) {
state.updateHandlers.customer();
state.steps.group.status = "complete";
state.steps.customer.status = null;
state.stepper.currentStep = 2;
}
}
}
const groupHeaderData = [
{
title: 'GroupId',
key: 'groupId',
backgroundColor: 'var(--t-gray-14)',
},
{title: 'Name', key: 'name', align: 'center'},
{title: 'Treasury', key: 'isTreasury'},
{title: 'Commercial', key: 'isTreasury'},
];
let groupRowData = {"data": []};
export function setup(updateDataHandler, paginationHandler, state) {
const customerTable = state.dataTables.customer;
const customersPagination = state.paginations.customer;
customersPagination.perPage = state.pageSize;
customersPagination.addEventListener('change', e => paginationHandler(e, customersPagination, customerTable, customerRowData));
customersPagination.style.display = 'none';
customerTable.headers = customerHeaderData;
customerTable.rows = customerRowData.data;
customerTable.onselect = selectHandler(state);
state.updateHandlers.customer = () => update(updateDataHandler, state)
}
function update(updateDataHandler, state) {
updateDataHandler(
state.dataTables.customer,
'get_customers',
customerRowData,
state.paginations.customer,
customerDataMapper,
state.selections.groupId,
)
}
function customerDataMapper(dataRow) {
return {
customerId: dataRow.customerID,
name: dataRow.name,
userCount: dataRow.userCount,
groupId: dataRow.groupId
}
}
function selectHandler(state) {
return e => {
state.selections.customerId = e.detail.row.id;
if (e.detail.row.selected == true) {
state.updateHandlers.user();
state.steps.customer.status = "complete";
state.steps.user.status = null;
state.stepper.currentStep = 3;
}
}
}
const customerHeaderData = [
{
title: 'Customer',
key: 'customerId',
backgroundColor: 'var(--t-gray-14)',
},
{title: 'Group', key: 'groupId', align: 'center'},
{title: 'Name', key: 'name', align: 'center'},
{title: 'Users', key: 'userCount', align: 'center'},
];
let customerRowData = {"data": []};
export function setup(updateDataHandler, paginationHandler, state) {
const userTable = state.dataTables.user;
const usersPagination = state.paginations.user;
usersPagination.perPage = state.pageSize;
usersPagination.addEventListener('change', e => paginationHandler(e, usersPagination, userTable, userRowData));
usersPagination.style.display = 'none';
userTable.headers = userHeaderData;
userTable.rows = userRowData.data;
userTable.onselect = selectHandler(state);
state.updateHandlers.user = () => update(updateDataHandler, state)
}
function update(updateDataHandler, state) {
updateDataHandler(
state.dataTables.user,
'get_users',
userRowData,
state.paginations.user,
userDataMapper,
state.selections.customerId,
)
}
function userDataMapper(dataRow) {
return {
userId: dataRow.userID,
firstName: dataRow.firstName,
lastName: dataRow.lastName,
customerId: dataRow.customerID
}
}
function selectHandler(state) {
return e => {
state.selections.userId = e.detail.row.id;
if (e.detail.row.selected == true) {
state.updateHandlers.userData();
state.steps.user.status = "complete";
state.steps.userData.status = null;
state.stepper.currentStep = 4;
}
}
}
const userHeaderData = [
{
title: 'User',
key: 'userId',
backgroundColor: 'var(--t-gray-14)',
},
{title: 'Customer', key: 'customerId', align: 'center'},
{title: 'First', key: 'firstName', align: 'center'},
{title: 'Last', key: 'lastName', align: 'center'},
];
let userRowData = {"data": []};
export function setup(updateDataHandler, paginationHandler, state) {
const userDataTable = state.dataTables.userData;
const userDataPagination = state.paginations.userData;
const userDataAddButton = state.buttons.userDataAdd;
userDataAddButton.addEventListener('click', () => add(updateDataHandler, state));
userDataPagination.addEventListener('change', e => paginationHandler(e, userDataPagination, userDataTable, userDataRowData));
userDataPagination.style.display = 'none';
userDataPagination.perPage = state.pageSize;
userDataTable.headers = userDataHeaderData;
userDataTable.rows = userDataRowData.data;
state.updateHandlers.userData = () => update(updateDataHandler, state)
}
function update(updateDataHandler, state) {
updateDataHandler(
state.dataTables.userData,
'get_user_data',
userDataRowData,
state.paginations.userData,
userDataDataMapper,
state.selections.userId,
);
}
function userDataDataMapper(dataRow, state) {
const slot = `row-${dataRow.id}-cell-value`;
const existing = document.querySelector(`[slot="${slot}"]`);
if (existing !== null) {
existing.remove();
}
let elem = document.createElement('q2-editable-field');
elem.slot = slot;
elem.value = dataRow.value;
state.dataTables.userData.appendChild(elem);
const rowData = {
id: dataRow.id,
userId: dataRow.userId,
key: dataRow.key,
value: elem,
}
elem.addEventListener('change', e => elemChangeHandler(e, state.capabilities, rowData));
return rowData
}
function add(updateDatahandler, state) {
state.capabilities.sources
.requestExtensionData({
route: 'create_user_data', body: {
userId: state.selections.userId,
key: state.dataEntries.newKey.value,
value: state.dataEntries.newValue.value,
}
})
.then(response => {
console.log(response);
update(updateDatahandler, state);
state.dataEntries.newKey.value = "";
state.dataEntries.newValue.value = "";
})
.catch(error => console.log(error))
}
function elemChangeHandler(e, capabilities, rowData) {
if (e.detail.name === 'save') {
const new_value = e.detail.value;
if (new_value != rowData.value) {
capabilities.sources
.requestExtensionData({
route: 'update_user_data', body: {
userId: rowData.userId,
key: rowData.key,
value: new_value,
}
})
.then(response => {
console.log(response);
})
.catch(error => console.log(error))
}
}
}
let userDataHeaderData = [
{
title: 'UserDataId',
key: 'id',
backgroundColor: 'var(--t-gray-14)',
},
{ title: 'UserId', key: 'userId', align: 'center' },
{ title: 'Key', key: 'key', align: 'center' },
{ title: 'Value', key: 'value', align: 'center' },
]
let userDataRowData = { "data": [] };