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:
parent
465f1eb87e
commit
4499f911de
@ -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
|
||||
|
@ -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 }
|
||||
|
@ -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");
|
||||
})
|
||||
;
|
||||
},
|
||||
},
|
||||
});
|
@ -1,3 +1,4 @@
|
||||
import './breadcrumbs/Breadcrumbs'
|
||||
import './comments/CommentTree'
|
||||
import './customdirectives/click-outside'
|
||||
import { UnitOfWorkTracker } from './mixins/UnitOfWorkTracker'
|
||||
|
131
tests/test_web/test_nodes.py
Normal file
131
tests/test_web/test_nodes.py
Normal 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')
|
Loading…
x
Reference in New Issue
Block a user