From 9c0909913510f4022a9d6f9ef88895e5776f6163 Mon Sep 17 00:00:00 2001
From: "Rik D.T. Janssen" <121875841+RikDTJanssen@users.noreply.github.com>
Date: Wed, 15 Feb 2023 19:18:23 +0100
Subject: [PATCH] Improved user interface of Ricgraph explorer
---
ricgraph_explorer/ricgraph_explorer.py | 434 ++++++++++++++-------
ricgraph_explorer/static/0-readme.txt | 24 ++
ricgraph_explorer/static/uu_logo_orig.png | Bin 0 -> 19268 bytes
ricgraph_explorer/static/uu_logo_small.png | Bin 0 -> 7857 bytes
ricgraph_explorer/static/w3pro.css | 150 +++++++
5 files changed, 471 insertions(+), 137 deletions(-)
create mode 100644 ricgraph_explorer/static/0-readme.txt
create mode 100644 ricgraph_explorer/static/uu_logo_orig.png
create mode 100644 ricgraph_explorer/static/uu_logo_small.png
create mode 100644 ricgraph_explorer/static/w3pro.css
diff --git a/ricgraph_explorer/ricgraph_explorer.py b/ricgraph_explorer/ricgraph_explorer.py
index 15db83d..2fa05dd 100644
--- a/ricgraph_explorer/ricgraph_explorer.py
+++ b/ricgraph_explorer/ricgraph_explorer.py
@@ -31,14 +31,28 @@
# This file is Ricgraph explorer, a web based tool to access nodes in
# Ricgraph.
# The purpose is to illustrate how web based access using Flask can be done.
-# To keep it simple, everything has been done in this file. It can be improved
-# in many ways, but I didn't do that, since it is meant for research purposes,
+# To keep it simple, everything has been done in this file.
+# Please note that this code is meant for research purposes,
# not for production use. That means, this code has not been hardened for
# "the outside world". Be careful if you expose it to the outside world.
# Original version Rik D.T. Janssen, January 2023.
# Extended Rik D.T. Janssen, February 2023.
#
# ########################################################################
+#
+# For table sorting. Ricgraph explorer uses sorttable.js.
+# It is copied from https://www.kryogenix.org/code/browser/sorttable
+# on February 1, 2023. At that link, you'll find a how-to. It is licensed under X11-MIT.
+# It is renamed to ricgraph_sorttable.js since it has a small modification
+# related to case-insensitive sorting. The script is included in html_body_end.
+#
+# ##############################################################################
+#
+# Ricgraph explorer uses W3.CSS, a modern, responsive, mobile first CSS framework.
+# See https://www.w3schools.com/w3css/default.asp.
+#
+# ##############################################################################
+
import urllib.parse
from typing import Union
@@ -50,54 +64,112 @@
ricgraph_explorer = Flask(__name__)
# When we do a query, we return at most this number of nodes.
-MAX_RESULTS = 75
+MAX_RESULTS = 50
+
+# The style for the buttons, note the space before and after the text.
+button_style = ' w3-button uu-yellow w3-round-large w3-mobile '
+# A button with a black line around it.
+button_style_border = button_style + ' w3-border rj-border-black '
# The html stylesheet.
stylesheet = ''
+# The html preamble
+html_preamble = ''
+html_preamble += ''
+html_preamble += ''
+
+# The html page header.
+page_header = ''
+page_header += '
'
+page_header += ''
+
# The html page footer.
-footer = '
'
-# For table sorting. sorttable.js is copied from https://www.kryogenix.org/code/browser/sorttable
-# on February 1, 2023. At that link, you'll find a how-to. It is licensed under X11-MIT.
-# It is renamed to ricgraph_sorttable.js since it has a small modification.
-footer += ''
-
-# The html search form.
-search_form = ''
-
-searchcontains_form = ''
+page_footer = ''
+
+# The first part of the html page, up to stylesheet and page_header.
+html_body_start = ''
+html_body_start += ''
+html_body_start += html_preamble
+html_body_start += 'Ricgraph explorer'
+html_body_start += ''
+html_body_start += stylesheet
+html_body_start += page_header
+
+# The last part of the html page, from page_footer to script inclusion.
+html_body_end = page_footer
+html_body_end += ''
+html_body_end += ''
+html_body_end += ''
+
+# The html search form for an exact match search (on /search).
+search_form = ''
+search_form += '
'
+search_form += '
'
+search_form += '
Type something to search
'
+search_form += 'This is an case-sensitive, exact match search, using AND if you use multiple fields:'
+search_form += '
'
+search_form += '
'
+search_form += ''
+search_form += '
'
+search_form += '
'
+search_form += ''
+
+# The html search form for a search on a string (on /searchcontains).
+searchcontains_form = ''
+searchcontains_form += '
'
+searchcontains_form += '
'
+searchcontains_form += '
Type something to search
'
+searchcontains_form += 'This is a case-insensitive, inexact match:'
+searchcontains_form += '
'
+searchcontains_form += ''
# ##############################################################################
@@ -109,18 +181,20 @@ def index_html() -> str:
:return: html to be rendered.
"""
- global stylesheet, footer
+ global html_body_start, html_body_end
- html = stylesheet
- html += '
This is Ricgraph explorer. You can use it to explore Ricgraph.'
+ html = html_body_start
+ html += get_html_for_cardstart()
+ html += 'This is Ricgraph explorer. You can use it to explore Ricgraph.'
html += '
'
- html += footer
+ html += get_html_for_cardend()
+ html += html_body_end
return html
@@ -136,9 +210,9 @@ def search(id_value=None) -> str:
:param id_value: value to search for in the 'value' field.
:return: html to be rendered.
"""
- global stylesheet, footer, search_form
+ global html_body_start, html_body_end, search_form
- html = stylesheet
+ html = html_body_start
if request.method == 'POST':
search_name = request.form['search_name']
search_category = request.form['search_category']
@@ -157,7 +231,7 @@ def search(id_value=None) -> str:
html += find_nodes_in_ricgraph(name='',
category='',
value=escape(id_value))
- html += footer
+ html += html_body_end
return html
@@ -170,9 +244,9 @@ def searchcontains() -> str:
:return: html to be rendered.
"""
- global stylesheet, footer, searchcontains_form
+ global html_body_start, html_body_end, searchcontains_form
- html = stylesheet
+ html = html_body_start
if request.method == 'POST':
search_value = request.form['search_value']
html += find_nodes_in_ricgraph(value=escape(search_value),
@@ -180,7 +254,7 @@ def searchcontains() -> str:
else:
html += searchcontains_form
- html += footer
+ html += html_body_end
return html
@@ -189,7 +263,7 @@ def searchcontains() -> str:
# ##############################################################################
def faceted_navigation_in_ricgraph(nodes: list,
name: str = '', category: str = '', value: str = '') -> str:
- """Do faceted navigation in Ricgraph.
+ """Do facet navigation in Ricgraph.
The facets will be constructed based on 'name' and 'category'.
Facets chosen will be "catched" in function search().
If there is only one facet (for either one or both), it will not be shown.
@@ -198,7 +272,8 @@ def faceted_navigation_in_ricgraph(nodes: list,
:param name: name of the nodes to find.
:param category: category of the nodes to find.
:param value: value of the nodes to find.
- :return: html to be rendered.
+ :return: html to be rendered, or empty string ('') if faceted navigation is
+ not useful because there is only one facet.
"""
if len(nodes) == 0:
return ''
@@ -217,42 +292,58 @@ def faceted_navigation_in_ricgraph(nodes: list,
category_histogram[node['category']] += 1
if len(name_histogram) <= 1 and len(category_histogram) <= 1:
- # Faceted navigation does not make sense, don't show facets.
+ # We have only one facet, so don't show the facet panel.
return ''
- faceted_form = '
'
+ faceted_form += '
'
+ faceted_form += get_html_for_cardend()
html = faceted_form
return html
@@ -284,8 +375,10 @@ def find_nodes_in_ricgraph(name: str = '', category: str = '', value: str = '',
return 'Ricgraph could not be opened.'
if name == '' and category == '' and value == '':
- html = '
Please enter a value in the search field.'
- html += ' ' + 'Try again' + '.'
+ html = get_html_for_cardstart()
+ html += 'The search field should have a value.'
+ html += ' ' + 'Please try again' + '.'
+ html += get_html_for_cardend()
return html
if name_want is None:
@@ -295,8 +388,10 @@ def find_nodes_in_ricgraph(name: str = '', category: str = '', value: str = '',
if use_contain_phrase:
if len(value) < 3:
- html = '
Please use at least three characters for your search string.'
- html += ' ' + 'Try again' + '.'
+ html = get_html_for_cardstart()
+ html += 'The search string should be at least three characters.'
+ html += ' ' + 'Please try again' + '.'
+ html += get_html_for_cardend()
return html
result = rcg.read_all_nodes_containing(value=value)
@@ -304,109 +399,132 @@ def find_nodes_in_ricgraph(name: str = '', category: str = '', value: str = '',
result = rcg.read_all_nodes(name=name, category=category, value=value)
if len(result) == 0:
- html = '
Ricgraph explorer could not find anything.'
- html += ' ' + 'Try again' + '.'
+ html = get_html_for_cardstart()
+ html += 'Ricgraph explorer could not find anything.'
+ html += ' ' + 'Please try again' + '.'
+ html += get_html_for_cardend()
return html
- html = '
You searched for:
'
+ html = get_html_for_cardstart()
+ html += 'You searched for:
'
if not use_contain_phrase:
- html += '
name: "' + str(name) + '"'
- html += '
category: "' + str(category) + '"'
+ html += '
name: "' + str(name) + '"'
+ html += '
category: "' + str(category) + '"'
- html += '
value: "' + str(value) + '"'
+ html += '
value: "' + str(value) + '"'
if len(name_want) > 0:
- html += '
name_want: "' + str(name_want) + '"'
+ html += '
name_want: "' + str(name_want) + '"'
if len(category_want) > 0:
- html += '
category_want: "' + str(category_want) + '"'
html += '
'
+ html += get_html_for_cardend()
if use_contain_phrase:
- header = '
Choose one node to continue:'
- html += get_html_table_from_nodes(nodes=result, header_html=header)
+ table_header = 'Choose one node to continue:'
+ html += get_html_table_from_nodes(nodes=result, table_header=table_header)
return html
if len(result) > MAX_RESULTS:
- html += '
There are ' + str(len(result)) + ' results, showing first '
+ html += get_html_for_cardstart()
+ html += 'There are ' + str(len(result)) + ' results, showing first '
html += str(MAX_RESULTS) + '. '
+ html += get_html_for_cardend()
count = 0
+ # Loop over the nodes found.
for node in result:
- # Loop over the nodes found.
count += 1
if count > MAX_RESULTS:
break
- html += '
Ricgraph explorer found node:'
- html += get_html_for_tablestart()
- html += get_html_for_tableheader()
- html += get_html_for_tablerow(node=node)
- html += get_html_for_tableend()
+ html += get_html_for_cardline()
+ html += get_html_table_from_nodes(nodes=[node],
+ table_header='Ricgraph explorer found node:')
if node['category'] == 'person':
personroot_nodes = rcg.get_all_personroot_nodes(node)
if len(personroot_nodes) == 0:
- html += '
No person-root node found, this should not happen.'
+ html += get_html_for_cardstart()
+ html += 'No person-root node found, this should not happen.'
+ html += get_html_for_cardend()
return html
elif len(personroot_nodes) == 1:
- header = '
This is a person node, '
- header += 'these are all IDs of its person-root node:'
+ table_header = 'This is a person node, '
+ table_header += 'these are all IDs of its person-root node:'
neighbor_nodes = rcg.get_all_neighbor_nodes_person(node)
html += get_html_table_from_nodes(nodes=neighbor_nodes,
- header_html=header)
-
- header = '
These are all the neighbors of this person-root node '
- header += '(without person nodes):'
- neighbor_nodes = rcg.get_all_neighbor_nodes(personroot_nodes[0],
- name_want=name_want,
- category_want=category_want,
- category_dontwant='person')
+ table_header=table_header)
- html += get_html_table_from_nodes(nodes=neighbor_nodes,
- header_html=header)
- html += faceted_navigation_in_ricgraph(nodes=neighbor_nodes,
- name=name,
- category=category,
- value=value)
+ table_header = 'These are all the neighbors of this person-root node '
+ table_header += '(without person nodes):'
+ node_to_find_neighbors = personroot_nodes[0]
+ category_dontwant = 'person'
+ # And now fall through.
else:
# More than one person-root node, that should not happen, but it did.
- header = '
There is more than one person-root node '
- header += 'for the node we have found. '
- header += 'This should not happen, but it did, and that is probably caused '
- header += 'by a mislabeling in a source system we harvested. '
- header += 'Choose one person-root node to continue:'
+ table_header = 'There is more than one person-root node '
+ table_header += 'for the node found. '
+ table_header += 'This should not happen, but it did, and that may have been '
+ table_header += 'caused by a mislabeling in a source system we harvested. '
+ table_header += 'Choose one person-root node to continue:'
html += get_html_table_from_nodes(nodes=personroot_nodes,
- header_html=header)
- break
+ table_header=table_header)
+ return html
+ else:
+ table_header = 'These are the neighbors of this node:'
+ node_to_find_neighbors = node
+ category_dontwant = ''
+
+ neighbor_nodes = rcg.get_all_neighbor_nodes(node=node_to_find_neighbors,
+ name_want=name_want,
+ category_want=category_want,
+ category_dontwant=category_dontwant)
+ faceted_html = faceted_navigation_in_ricgraph(nodes=neighbor_nodes,
+ name=name,
+ category=category,
+ value=value)
+ if faceted_html == '':
+ table_header += ' [Facet panel not shown because there is only one facet to show.]'
+
+ table_html = get_html_table_from_nodes(nodes=neighbor_nodes,
+ table_header=table_header)
+ if faceted_html == '':
+ # Faceted navigation not useful, don't show the panel.
+ html += table_html
else:
- header = '
These are the neighbors of this node:'
- neighbor_nodes = rcg.get_all_neighbor_nodes(node,
- name_want=name_want,
- category_want=category_want)
- html += get_html_table_from_nodes(nodes=neighbor_nodes,
- header_html=header)
- html += faceted_navigation_in_ricgraph(nodes=neighbor_nodes,
- name=name,
- category=category,
- value=value)
+ # Divide space between facet panel and table.
+ html += '
'
+ html += '
'
+ html += faceted_html
+ html += '
'
+ html += '
'
+ html += table_html
+ html += '
'
+ html += '
'
+
return html
-def get_html_table_from_nodes(nodes: Union[list, NodeMatch, None], header_html: str = '') -> str:
+def get_html_table_from_nodes(nodes: Union[list, NodeMatch, None], table_header: str = '') -> str:
"""Create a html table for all nodes in the list.
:param nodes: the nodes to create a table from.
- :param header_html: the html to show above the table.
+ :param table_header: the html to show above the table.
:return: html to be rendered.
"""
if len(nodes) == 0:
- return '
No neighbors found.'
+ html = get_html_for_cardstart()
+ html += 'No neighbors found.'
+ html += get_html_for_cardend()
+ return html
- html = header_html
+ html = get_html_for_cardstart()
+ html += table_header
html += get_html_for_tablestart()
html += get_html_for_tableheader()
for node in nodes:
html += get_html_for_tablerow(node=node)
html += get_html_for_tableend()
+ html += get_html_for_cardend()
return html
@@ -418,7 +536,7 @@ def get_html_for_tablestart() -> str:
:return: html to be rendered.
"""
- html = '
'
+ html = '
'
return html
@@ -427,7 +545,7 @@ def get_html_for_tableheader() -> str:
:return: html to be rendered.
"""
- html = '
'
+ html = '
'
html += '
name
'
html += '
category
'
html += '
value
'
@@ -491,6 +609,49 @@ def get_html_for_tableend() -> str:
return html
+# ##############################################################################
+# The HTML for the W3CSS cards is generated here.
+# ##############################################################################
+def get_html_for_cardstart() -> str:
+ """Get the html required for the start of a W3.CSS 'card'.
+ W3.CSS is a modern, responsive, mobile first CSS framework.
+
+ :return: html to be rendered.
+ """
+ html = ''
+ html += '
'
+ html += '
'
+ return html
+
+
+def get_html_for_cardend() -> str:
+ """Get the html required for the end of a W3.CSS 'card'.
+ W3.CSS is a modern, responsive, mobile first CSS framework.
+
+ :return: html to be rendered.
+ """
+ html = '
'
+ html += '
'
+ html += ''
+ return html
+
+
+def get_html_for_cardline() -> str:
+ """Get the html required for a yellow line. It is a W3.CSS 'card'
+ filled with the color 'yellow'.
+
+ :return: html to be rendered.
+ """
+ html = ''
+ html += '
'
+ # Adjust the height of the line is padding-top + padding-bottom.
+ html += '
'
+ html += '
'
+ html += '
'
+ html += ''
+ return html
+
+
# ############################################
# ################### main ###################
# ############################################
@@ -498,7 +659,6 @@ def get_html_for_tableend() -> str:
# For normal use:
ricgraph_explorer.run(port=3030)
-
# For debug purposes:
# ricgraph_explorer.run(debug=True, port=3030)
diff --git a/ricgraph_explorer/static/0-readme.txt b/ricgraph_explorer/static/0-readme.txt
new file mode 100644
index 0000000..638af89
--- /dev/null
+++ b/ricgraph_explorer/static/0-readme.txt
@@ -0,0 +1,24 @@
+Files in this directory are copies from files existing on the web.
+They are here for performance reasons: it is faster to serve them from
+here than to get them from the web.
+
+
+File: ricgraph_sorttable.js
+From: https://www.kryogenix.org/code/browser/sorttable
+Date copied: February 1, 2023
+Changes: changed sorting to sort case-insensitively
+
+
+File: w3pro.css
+From: https://www.w3schools.com/w3css/4/w3pro.css
+Date copied: February 15, 2023
+Changes: none
+Note: w3pro.css is identical to the standard version except for
+ it has no colors defined, but it is smaller (and loads faster).
+
+
+File: UU logo
+From: https://www.uu.nl/organisatie/huisstijl/downloads/logo
+Date copied: February 15, 2023
+Changes: crop & resize
+
diff --git a/ricgraph_explorer/static/uu_logo_orig.png b/ricgraph_explorer/static/uu_logo_orig.png
new file mode 100644
index 0000000000000000000000000000000000000000..b0efa06eecaa80a0cf4deff307b535b8c0bdb5fd
GIT binary patch
literal 19268
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