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)
|
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)
|
# Import of custom modules (using the same nodes decorator)
|
||||||
from .custom import groups, storage, posts
|
from .custom import groups, storage, posts
|
||||||
|
@ -25,6 +25,10 @@ class EventName {
|
|||||||
static deleted(nodeId) {
|
static deleted(nodeId) {
|
||||||
return `pillar:node:${nodeId}:deleted`;
|
return `pillar:node:${nodeId}:deleted`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static loaded() {
|
||||||
|
return `pillar:node:loaded`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function trigger(eventName, data) {
|
function trigger(eventName, data) {
|
||||||
@ -139,6 +143,23 @@ class Nodes {
|
|||||||
EventName.deleted(nodeId),
|
EventName.deleted(nodeId),
|
||||||
cb);
|
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 }
|
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 './comments/CommentTree'
|
||||||
import './customdirectives/click-outside'
|
import './customdirectives/click-outside'
|
||||||
import { UnitOfWorkTracker } from './mixins/UnitOfWorkTracker'
|
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