Node breadcrumbs

Breadcrumbs are served as JSON at `/nodes/{node ID}/breadcrumbs`, with
the top-level parent listed first and the node itself listed last:

    {breadcrumbs: [
        ...
        {_id: "parentID",
         name: "The Parent Node",
         node_type: "group",
         url: "/p/project/parentID"},
        {_id: "deadbeefbeefbeefbeeffeee",
         name: "The Node Itself",
         node_type: "asset",
         url: "/p/project/nodeID",
         _self: true},
    ]}

When a parent node is missing, it has a breadcrumb like this:

    {_id: "deadbeefbeefbeefbeeffeee",
     _exists': false,
     name': '-unknown-'}

Of course this will be the first in the breadcrumbs list, as we won't be
able to determine the parent of a deleted/non-existing node.

Breadcrumbs are rendered with Vue.js in Blender Cloud (not in Pillar);
see projects/view.pug.
This commit is contained in:
Sybren A. Stüvel 2019-03-27 13:57:21 +01:00
parent 465f1eb87e
commit 4499f911de
5 changed files with 291 additions and 0 deletions

View File

@ -604,5 +604,94 @@ def url_for_node(node_id=None, node=None):
return finders.find_url_for_node(node)
@blueprint.route("/<node_id>/breadcrumbs")
def breadcrumbs(node_id: str):
"""Return breadcrumbs for the given node, as JSON.
Note that a missing parent is still returned in the breadcrumbs,
but with `{_exists: false, name: '-unknown-'}`.
The breadcrumbs start with the top-level parent, and end with the node
itself (marked by {_self: true}). Returns JSON like this:
{breadcrumbs: [
...,
{_id: "parentID",
name: "The Parent Node",
node_type: "group",
url: "/p/project/parentID"},
{_id: "deadbeefbeefbeefbeeffeee",
_self: true,
name: "The Node Itself",
node_type: "asset",
url: "/p/project/nodeID"},
]}
When a parent node is missing, it has a breadcrumb like this:
{_id: "deadbeefbeefbeefbeeffeee",
_exists': false,
name': '-unknown-'}
"""
api = system_util.pillar_api()
is_self = True
def make_crumb(some_node: None) -> dict:
"""Construct a breadcrumb for this node."""
nonlocal is_self
crumb = {
'_id': some_node._id,
'name': some_node.name,
'node_type': some_node.node_type,
'url': finders.find_url_for_node(some_node),
}
if is_self:
crumb['_self'] = True
is_self = False
return crumb
def make_missing_crumb(some_node_id: None) -> dict:
"""Construct 'missing parent' breadcrumb."""
return {
'_id': some_node_id,
'_exists': False,
'name': '-unknown-',
}
# The first node MUST exist.
try:
node = Node.find(node_id, api=api)
except ResourceNotFound:
log.warning('breadcrumbs(node_id=%r): Unable to find node', node_id)
raise wz_exceptions.NotFound(f'Unable to find node {node_id}')
except ForbiddenAccess:
log.warning('breadcrumbs(node_id=%r): access denied to current user', node_id)
raise wz_exceptions.Forbidden(f'No access to node {node_id}')
crumbs = []
while True:
crumbs.append(make_crumb(node))
child_id = node._id
node_id = node.parent
if not node_id:
break
# If a subsequent node doesn't exist any more, include that in the breadcrumbs.
# Forbidden nodes are handled as if they don't exist.
try:
node = Node.find(node_id, api=api)
except (ResourceNotFound, ForbiddenAccess):
log.warning('breadcrumbs: Unable to find node %r but it is marked as parent of %r',
node_id, child_id)
crumbs.append(make_missing_crumb(node_id))
break
return jsonify({'breadcrumbs': list(reversed(crumbs))})
# Import of custom modules (using the same nodes decorator)
from .custom import groups, storage, posts

View File

@ -25,6 +25,10 @@ class EventName {
static deleted(nodeId) {
return `pillar:node:${nodeId}:deleted`;
}
static loaded() {
return `pillar:node:loaded`;
}
}
function trigger(eventName, data) {
@ -139,6 +143,23 @@ class Nodes {
EventName.deleted(nodeId),
cb);
}
static triggerLoaded(nodeId) {
trigger(EventName.loaded(), {nodeId: nodeId});
}
/**
* Listen to events of nodes being loaded for display
* @param {Function(Event)} cb
*/
static onLoaded(cb) {
on(EventName.loaded(), cb);
}
static offLoaded(cb) {
off(EventName.loaded(), cb);
}
}
export { Nodes }

View File

@ -0,0 +1,49 @@
const TEMPLATE = `
<div class='breadcrumbs' v-if="breadcrumbs.length">
<ul>
<li v-for="crumb in breadcrumbs">
<a :href="crumb.url" v-if="!crumb._self">{{ crumb.name }}</a>
<template v-else>{{ crumb.name }}</template>
</li>
</ul>
</div>
`
Vue.component("node-breadcrumbs", {
template: TEMPLATE,
created() {
this.loadBreadcrumbs();
pillar.events.Nodes.onLoaded(event => {
this.nodeId = event.detail.nodeId;
});
},
props: {
nodeId: String,
},
data() { return {
breadcrumbs: [],
}},
watch: {
nodeId() {
this.loadBreadcrumbs();
},
},
methods: {
loadBreadcrumbs() {
// The node ID may not exist (when at project level, for example).
if (!this.nodeId) {
this.breadcrumbs = [];
return;
}
$.get(`/nodes/${this.nodeId}/breadcrumbs`)
.done(data => {
this.breadcrumbs = data.breadcrumbs;
})
.fail(error => {
toastr.error(xhrErrorResponseMessage(error), "Unable to load breadcrumbs");
})
;
},
},
});

View File

@ -1,3 +1,4 @@
import './breadcrumbs/Breadcrumbs'
import './comments/CommentTree'
import './customdirectives/click-outside'
import { UnitOfWorkTracker } from './mixins/UnitOfWorkTracker'

View File

@ -0,0 +1,131 @@
import typing
from bson import ObjectId
import flask
from pillar.tests import AbstractPillarTest
class BreadcrumbsTest(AbstractPillarTest):
def setUp(self, **kwargs):
super().setUp(**kwargs)
self.project_id, self.project = self.ensure_project_exists()
def _create_group(self,
parent_id: typing.Optional[ObjectId],
name: str) -> ObjectId:
node = {
'name': name,
'description': '',
'node_type': 'group',
'user': self.project['user'],
'properties': {'status': 'published'},
'project': self.project_id,
}
if parent_id:
node['parent'] = parent_id
return self.create_node(node)
def test_happy(self) -> ObjectId:
# Create the nodes we expect to be returned in the breadcrumbs.
top_group_node_id = self._create_group(None, 'Top-level node')
group_node_id = self._create_group(top_group_node_id, 'Group node')
fid, _ = self.ensure_file_exists()
node_id = self.create_node({
'name': 'Asset node',
'parent': group_node_id,
'description': '',
'node_type': 'asset',
'user': self.project['user'],
'properties': {'status': 'published', 'file': fid},
'project': self.project_id,
})
# Create some siblings that should not be returned.
self._create_group(None, 'Sibling of top node')
self._create_group(top_group_node_id, 'Sibling of group node')
self._create_group(group_node_id, 'Sibling of asset node')
expected = {'breadcrumbs': [
{'_id': str(top_group_node_id),
'name': 'Top-level node',
'node_type': 'group',
'url': f'/p/{self.project["url"]}/{top_group_node_id}'},
{'_id': str(group_node_id),
'name': 'Group node',
'node_type': 'group',
'url': f'/p/{self.project["url"]}/{group_node_id}'},
{'_id': str(node_id),
'_self': True,
'name': 'Asset node',
'node_type': 'asset',
'url': f'/p/{self.project["url"]}/{node_id}'},
]}
with self.app.app_context():
url = flask.url_for('nodes.breadcrumbs', node_id=str(node_id))
actual = self.get(url).json
self.assertEqual(expected, actual)
return node_id
def test_missing_parent(self):
# Note that this group node doesn't exist in the database:
group_node_id = ObjectId(3 * 'deadbeef')
fid, _ = self.ensure_file_exists()
node_id = self.create_node({
'name': 'Asset node',
'parent': group_node_id,
'description': '',
'node_type': 'asset',
'user': self.project['user'],
'properties': {'status': 'published', 'file': fid},
'project': self.project_id,
})
expected = {'breadcrumbs': [
{'_id': str(group_node_id),
'_exists': False,
'name': '-unknown-'},
{'_id': str(node_id),
'_self': True,
'name': 'Asset node',
'node_type': 'asset',
'url': f'/p/{self.project["url"]}/{node_id}'},
]}
with self.app.app_context():
url = flask.url_for('nodes.breadcrumbs', node_id=str(node_id))
actual = self.get(url).json
self.assertEqual(expected, actual)
def test_missing_node(self):
with self.app.app_context():
url = flask.url_for('nodes.breadcrumbs', node_id=3 * 'deadbeef')
self.get(url, expected_status=404)
def test_permissions(self):
# Use the same test case as the happy case.
node_id = self.test_happy()
# Tweak the project to make it private.
with self.app.app_context():
proj_coll = self.app.db('projects')
result = proj_coll.update_one({'_id': self.project_id},
{'$set': {'permissions.world': []}})
self.assertEqual(1, result.modified_count)
self.project = self.fetch_project_from_db(self.project_id)
with self.app.app_context():
url = flask.url_for('nodes.breadcrumbs', node_id=str(node_id))
# Anonymous access should be forbidden.
self.get(url, expected_status=403)
# Authorized access should work, though.
self.create_valid_auth_token(self.project['user'], token='user-token')
self.get(url, auth_token='user-token')