This module allows you to display complex nested information neatly, in a user-clickable, expanding and contracting view.
The TreeNodeUI class handles all the logic of formatting these trees and responding to clicks.
You just need to put your information into a tree datastructure, using the flexible TreeNode and ChildContainer classes.
ButtonChildContainers, which each have a single list of Node Children; These are formatted as buttons and can be clicked to alternately expand/contract their children.
MenuChildContainers, which each contain within them multiple labeled lists of Node Children. These can be formatted as static menus, overflow menus [...] or radio buttons, and in each case, selecting an option reveals its Node children.
Containers have a field for child_pageination which controls how many of its children are displayed at a time when they are visible; the rest will be accessed by clicking foward (and backward) buttons.
The TreeNodeUI class offers two methods for posting nodes (post_single_node and post_treenodes), and also handles all the logic of responding to UI callbacks and updating the tree.
You can always directly instantiate a TreeNode or ChildContainer, but there are also static helper methods defined on some classes to help more easily construct frequently used variants of those classes. You can see some of them in action in the demos below. The most important of these are the ones which allow you to easily format an entire JSONlike object (ie what json.loads returns, a nested dict/list/primitive object) into a NodeTree.
Here's the simplest possible demo to get us started
1 2 3 4 5 6 7 8 91011121314151617181920
months=TreeNode("There are a lot of months in the year",
[
ButtonChildContainer([
TreeNode("January"),
TreeNode("February"),
TreeNode("March"),
TreeNode("April"),
TreeNode("May"),
TreeNode("June"),
TreeNode("July"),
TreeNode("August"),
TreeNode("September"),
TreeNode("October"),
TreeNode("November"),
TreeNode("December"),
],
child_pageination=4)
])
treenodeui.post_single_node(TEST_CHANNEL,months)
We're using a single ButtonChildContainer, which defaults to being on the side of the original node, and applying a pageination of 4 to the months, so they display 4 at a time.
Seasons as buttons in a row, with months as children¶
1 2 3 4 5 6 7 8 91011121314151617181920212223
seasons_buttons = TreeNode("These are the four seasons of a year",
children_containers=[
ButtonChildContainer([
TreeNode("December"),
TreeNode("January"),
TreeNode("February")], static_button_text="Winter"),
ButtonChildContainer([
TreeNode("March"),
TreeNode("April"),
TreeNode("May")], static_button_text="Spring"),
ButtonChildContainer([
TreeNode("June"),
TreeNode("July"),
TreeNode("August")], static_button_text="Summer"),
ButtonChildContainer([
TreeNode("September"),
TreeNode("October"),
TreeNode("November")], static_button_text="Fall"),
])
treenodeui.post_single_node(TEST_CHANNEL,seasons_buttons)
All the examples until now have used simple strings for the formatting blocks, but you can be as fancy as you like, and use any Blocks that Slack provides
from slack_sdk.models.blocks import *
hotel_node=TreeNode.withSimpleSideButton(
"We found *6 Hotels* in New Orleans, LA from *12/14 to 12/17*",
child_pageination=3,
children=[
TreeNode([
SectionBlock(
text="*<fakeLink.toHotelPage.com|Windsor Court Hotel>*\n★★★★★\n$340 per night\nRated: 9.4 - Excellent",
accessory=ImageElement(
image_url="https://api.slack.com/img/blocks/bkb_template_images/tripAgent_1.png",
alt_text="Windsor Court Hotel thumbnail"
)
),
ContextBlock(elements=[
ImageElement(image_url="https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png",
alt_text="Location Pin Icon"),
TextObject(type="plain_text",text="Location: Central Business District"),
]),
DividerBlock()
]),
TreeNode([SectionBlock(
text="*<fakeLink.toHotelPage.com|The Ritz-Carlton New Orleans>*\n★★★★★\n$340 per night\nRated: 9.1 - Excellent",
accessory=ImageElement(
image_url="https://api.slack.com/img/blocks/bkb_template_images/tripAgent_2.png",
alt_text="Ritz-Carlton New Orleans thumbnail"
)
),
ContextBlock(elements=[
ImageElement(image_url="https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png",
alt_text="Location Pin Icon"),
TextObject(type="plain_text",text="Location: French Quarter"),
]),
DividerBlock()
]),
TreeNode(
[SectionBlock(
text="*<fakeLink.toHotelPage.com|Omni Royal Orleans Hotel>*\n★★★★★\n$419 per night\nRated: 8.8 - Excellent",
accessory=ImageElement(
image_url="https://api.slack.com/img/blocks/bkb_template_images/tripAgent_3.png",
alt_text="Omni Royal Orleans Hotel thumbnail"
)
),
ContextBlock(elements=[
ImageElement(image_url="https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png",
alt_text="Location Pin Icon"),
TextObject(type="plain_text",text="Location: French Quarter"),
]),
DividerBlock()]
),
TreeNode([SectionBlock(
text="*<fakeLink.toHotelPage.com|The Roosevelt New Orleans, A Waldorf Astoria Hotel>*\n★★★★★\n$299 per night\nRated: 9.3 - Excellent",
accessory=ImageElement(
image_url="https://upload.wikimedia.org/wikipedia/en/2/2c/Ambassador_Hotel_Kaohsiung_at_Night.jpeg",
alt_text="The Roosevelt New Orleans, A Waldorf Astoria Hotel thumbnail"
)
),
ContextBlock(elements=[
ImageElement(image_url="https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png",
alt_text="Location Pin Icon"),
TextObject(type="plain_text",text="Location: Central Business District"),
]),
DividerBlock(),
]),
TreeNode([
SectionBlock(
text="*<fakeLink.toHotelPage.com|The Pontchartrain Hotel>*\n★★★★\n$229 per night\nRated: 8.9 - Excellent",
accessory=ImageElement(
image_url="https://live.staticflickr.com/65535/50033621878_24a67c0281_b.jpg",
alt_text="The Pontchartrain Hotel thumbnail"
)
),
ContextBlock(elements=[
ImageElement(image_url="https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png",
alt_text="Location Pin Icon"),
TextObject(type="plain_text",text="Location: Garden District"),
]),
DividerBlock(),
]),
TreeNode([
SectionBlock(
text="*<fakeLink.toHotelPage.com|Ace Hotel New Orleans>*\n★★★★\n$239 per night\nRated: 8.7 - Excellent",
accessory=ImageElement(
image_url="https://ygt-res.cloudinary.com/image/upload/c_fit,h_1280,q_80,w_1920/v1656076242/Venues/Hotel%20Las%20Palmeras/Las_Palmeras_Affiliated_Hotel_Pool.3428_gipozg.jpg",
alt_text="Ace Hotel New Orleans thumbnail"
)
),
ContextBlock(elements=[
ImageElement(image_url="https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png",
alt_text="Location Pin Icon"),
TextObject(type="plain_text",text="Location: Warehouse District"),
]),
DividerBlock(),
]),
])
treenodeui.post_single_node(TEST_CHANNEL,hotel_node,expand_first=True)
Putting it all together: the Weather Forecast Example¶
Lets build a CLI command for requesting a weather forecast and displaying it as a NodeTree.
First the CLI method. We'll use api.weatherapi.com for obtaining the forecast and build a CLI method around it, and create the parser automagically.
123456789
@app.command("/weather")
@argparse_command(automagic=True)
def get_and_post_weather(args: Args, location: str, days: int = 3, *, aqi=True, alerts=True, units:list[Literal["metric","imperial"]]=["metric","imperial"]):
u = units[0] if len(units)==1 else "not_specified"
response = requests.get("http://api.weatherapi.com/v1/forecast.json", params=dict(key=WEATHER_API_KEY,
q=location, days=days, aqi="yes" if aqi else "no", alerts="yes" if alerts else "no"))
weather = response.json()
node = get_root_weather_results_node(weather,u)
treenodeui.post_single_node(args.say, node)
It's usually easiest to start at the bottom level (the leaf nodes) and work your way backwards to the root from there. So let's start by formatting a node to show an hourly forecast.
The following two functions will return a Slack Blocks representing an hourly and daily forecast respectively
Now we make a method to create a node for the daily and hourly forecasts:
1 2 3 4 5 6 7 8 910111213
def get_days_weather_node(weather_dict, units: Literal["imperial", "metric"]) -> TreeNode:
containers = [
ButtonChildContainer(child_nodes=
[TreeNode.fromJson(format_hour(h, units), h) for h in weather_dict['hour']],
static_button_text='Hourly Forecast', child_pageination=6),
ButtonChildContainer.forJsonDetails(weather_dict['astro'], 'Astronomic Data'),
]
if 'air_quality' in weather_dict['day']:
containers.append(
ButtonChildContainer.forJsonDetails(weather_dict['day']['air_quality'], 'Air Quality')
)
return TreeNode(format_day(weather_dict, units),
children_containers=containers)
So what we just did was define the child containers for the daily_forecast nodes. We set the hourly forecast button container to contain Nodes for every hour with the format from the method we defined before, and containing a detail button on their side for displaying the full Json of that hourly forecast. We are similiarly using a ButtonChildContainer.forJsonDetails for the daily air quality (if that exists in the forecast), and for the daily astronomical data.
Now let's put it together into a RootNode.
1 2 3 4 5 6 7 8 910111213141516171819202122232425
def get_root_weather_results_node(weather_dict, units: Literal["imperial", "metric", "not_specified"] = "not_specified"):
forecast: StaticSelectMenuChildContainer | ButtonChildContainer
if units == "not_specified":
forecast = StaticSelectMenuChildContainer(
[MenuOption(u.capitalize(),
[get_days_weather_node(d, u) for d in weather_dict['forecast']['forecastday']]) for u in ("imperial", "metric")],
placeholder="Forecast units", child_pageination=4)
else:
forecast = ButtonChildContainer(child_nodes=[
get_days_weather_node(d, units) for d in weather_dict['forecast']['forecastday']],
static_button_text='Forecast', child_pageination=4)
node = TreeNode(SectionBlock(
text=f"Weather for {weather_dict['location']['name']}, as of {weather_dict['location']['localtime']}"),
children_containers=[
forecast, #the button or menu we defined above
ButtonChildContainer.forJsonDetails(
weather_dict['location'], 'Location Data')])
if 'alerts' in weather_dict and 'alert' in weather_dict['alerts']:
node.children_containers.append(ButtonChildContainer(
[TreeNode(
f"[{i+1}]: {a['event']}" + (f" for {a['areas']}" if a['areas'] else ""),
ButtonChildContainer.forJsonDetails(a)) for i, a in enumerate(weather_dict['alerts']['alert'])], 'Special Weather Alerts'))
return node
We're doing the same sort of thing here again. If we already know the user's units preference, we are the daily forecast into a button. Otherwise we will put a tree representing each unit type into a menu. Then we are putting that, and a JsonDetails button for location data, and another custom defined ButtonChildContainer for each special weather alert if those exist, into the new Node which we are returning.
And just like that we have a super nice dynamically formatted weather forecast view!