Merge branch 'master' into dillo

This commit is contained in:
Francesco Siddi 2019-04-01 18:53:28 +02:00
commit 32361a0e70
77 changed files with 15152 additions and 378 deletions

View File

@ -107,10 +107,26 @@ function browserify_base(entry) {
})); }));
} }
/**
* Transcompile and package common modules to be included in tutti.js.
*
* Example:
* src/scripts/js/es6/common/api/init.js
* src/scripts/js/es6/common/events/init.js
* Everything exported in api/init.js will end up in module pillar.api.*, and everything exported in events/init.js
* will end up in pillar.events.*
*/
function browserify_common() { function browserify_common() {
return glob.sync('src/scripts/js/es6/common/**/init.js').map(browserify_base); return glob.sync('src/scripts/js/es6/common/**/init.js').map(browserify_base);
} }
/**
* Transcompile and package individual modules.
*
* Example:
* src/scripts/js/es6/individual/coolstuff/init.js
* Will create a coolstuff.js and everything exported in init.js will end up in namespace pillar.coolstuff.*
*/
gulp.task('scripts_browserify', function(done) { gulp.task('scripts_browserify', function(done) {
glob('src/scripts/js/es6/individual/**/init.js', function(err, files) { glob('src/scripts/js/es6/individual/**/init.js', function(err, files) {
if(err) done(err); if(err) done(err);
@ -128,7 +144,7 @@ gulp.task('scripts_browserify', function(done) {
}); });
/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js /* Collection of scripts in src/scripts/tutti/ and src/scripts/js/es6/common/ to merge into tutti.min.js
* Since it's always loaded, it's only for functions that we want site-wide. * Since it's always loaded, it's only for functions that we want site-wide.
* It also includes jQuery and Bootstrap (and its dependency popper), since * It also includes jQuery and Bootstrap (and its dependency popper), since
* the site doesn't work without it anyway.*/ * the site doesn't work without it anyway.*/

12790
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -790,7 +790,7 @@ class PillarServer(BlinkerCompatibleEve):
return 'basic ' + base64.b64encode('%s:%s' % (username, subclient_id)) return 'basic ' + base64.b64encode('%s:%s' % (username, subclient_id))
def post_internal(self, resource: str, payl=None, skip_validation=False): def post_internal(self, resource: str, payl=None, skip_validation=False):
"""Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810""" """Workaround for Eve issue https://github.com/pyeve/eve/issues/810"""
from eve.methods.post import post_internal from eve.methods.post import post_internal
url = self.config['URLS'][resource] url = self.config['URLS'][resource]
@ -800,7 +800,7 @@ class PillarServer(BlinkerCompatibleEve):
def put_internal(self, resource: str, payload=None, concurrency_check=False, def put_internal(self, resource: str, payload=None, concurrency_check=False,
skip_validation=False, **lookup): skip_validation=False, **lookup):
"""Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810""" """Workaround for Eve issue https://github.com/pyeve/eve/issues/810"""
from eve.methods.put import put_internal from eve.methods.put import put_internal
url = self.config['URLS'][resource] url = self.config['URLS'][resource]
@ -811,7 +811,7 @@ class PillarServer(BlinkerCompatibleEve):
def patch_internal(self, resource: str, payload=None, concurrency_check=False, def patch_internal(self, resource: str, payload=None, concurrency_check=False,
skip_validation=False, **lookup): skip_validation=False, **lookup):
"""Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810""" """Workaround for Eve issue https://github.com/pyeve/eve/issues/810"""
from eve.methods.patch import patch_internal from eve.methods.patch import patch_internal
url = self.config['URLS'][resource] url = self.config['URLS'][resource]
@ -822,7 +822,7 @@ class PillarServer(BlinkerCompatibleEve):
def delete_internal(self, resource: str, concurrency_check=False, def delete_internal(self, resource: str, concurrency_check=False,
suppress_callbacks=False, **lookup): suppress_callbacks=False, **lookup):
"""Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810""" """Workaround for Eve issue https://github.com/pyeve/eve/issues/810"""
from eve.methods.delete import deleteitem_internal from eve.methods.delete import deleteitem_internal
url = self.config['URLS'][resource] url = self.config['URLS'][resource]

View File

@ -161,13 +161,14 @@ class ValidateCustomFields(Validator):
""" """
Cache markdown as html. Cache markdown as html.
:param markdown_field: name of the field containing mark down :param markdown_field: name of the field containing Markdown
:return: html string :return: html string
""" """
my_log = log.getChild('_normalize_coerce_markdown') my_log = log.getChild('_normalize_coerce_markdown')
mdown = self.document.get(markdown_field, '') mdown = self.document.get(markdown_field, '')
html = markdown.markdown(mdown) html = markdown.markdown(mdown)
my_log.debug('Generated html for markdown field %s in doc with id %s', markdown_field, id(self.document)) my_log.debug('Generated html for markdown field %s in doc with id %s',
markdown_field, id(self.document))
return html return html

View File

@ -6,7 +6,7 @@ import pymongo.errors
import werkzeug.exceptions as wz_exceptions import werkzeug.exceptions as wz_exceptions
from flask import current_app, Blueprint, request from flask import current_app, Blueprint, request
from pillar.api.nodes import eve_hooks, comments from pillar.api.nodes import eve_hooks, comments, activities
from pillar.api.utils import str2id, jsonify from pillar.api.utils import str2id, jsonify
from pillar.api.utils.authorization import check_permissions, require_login from pillar.api.utils.authorization import check_permissions, require_login
from pillar.web.utils import pretty_date from pillar.web.utils import pretty_date
@ -87,6 +87,12 @@ def post_node_comment_vote(node_path: str, comment_path: str):
return comments.post_node_comment_vote(node_id, comment_id, vote) return comments.post_node_comment_vote(node_id, comment_id, vote)
@blueprint.route('/<string(length=24):node_path>/activities', methods=['GET'])
def activities_for_node(node_path: str):
node_id = str2id(node_path)
return jsonify(activities.for_node(node_id))
@blueprint.route('/tagged/') @blueprint.route('/tagged/')
@blueprint.route('/tagged/<tag>') @blueprint.route('/tagged/<tag>')
def tagged(tag=''): def tagged(tag=''):
@ -265,3 +271,5 @@ def setup_app(app, url_prefix):
app.on_deleted_item_nodes += eve_hooks.after_deleting_node app.on_deleted_item_nodes += eve_hooks.after_deleting_node
app.register_api_blueprint(blueprint, url_prefix=url_prefix) app.register_api_blueprint(blueprint, url_prefix=url_prefix)
activities.setup_app(app)

View File

@ -0,0 +1,43 @@
from eve.methods import get
from pillar.api.utils import gravatar
def for_node(node_id):
activities, _, _, status, _ =\
get('activities',
{
'$or': [
{'object_type': 'node',
'object': node_id},
{'context_object_type': 'node',
'context_object': node_id},
],
},)
for act in activities['_items']:
act['actor_user'] = _user_info(act['actor_user'])
return activities
def _user_info(user_id):
users, _, _, status, _ = get('users', {'_id': user_id})
if len(users['_items']) > 0:
user = users['_items'][0]
user['gravatar'] = gravatar(user['email'])
public_fields = {'full_name', 'username', 'gravatar'}
for field in list(user.keys()):
if field not in public_fields:
del user[field]
return user
return {}
def setup_app(app):
global _user_info
decorator = app.cache.memoize(timeout=300, make_name='%s.public_user_info' % __name__)
_user_info = decorator(_user_info)

View File

@ -253,26 +253,30 @@ def parse_markdown(project, original=None):
schema = current_app.config['DOMAIN']['projects']['schema'] schema = current_app.config['DOMAIN']['projects']['schema']
def find_markdown_fields(schema, project): def find_markdown_fields(schema, project):
"""Find and process all makrdown validated fields.""" """Find and process all Markdown coerced fields.
for k, v in schema.items():
if not isinstance(v, dict): - look for fields with a 'coerce': 'markdown' property
- parse the name of the field and generate the sibling field name (_<field_name>_html -> <field_name>)
- parse the content of the <field_name> field as markdown and save it in _<field_name>_html
"""
for field_name, field_value in schema.items():
if not isinstance(field_value, dict):
continue
if field_value.get('coerce') != 'markdown':
continue
if field_name not in project:
continue continue
if v.get('validator') == 'markdown': # Construct markdown source field name (strip the leading '_' and the trailing '_html')
# If there is a match with the validator: markdown pair, assign the sibling source_field_name = field_name[1:-5]
# property (following the naming convention _<property>_html) html = pillar.markdown.markdown(project[source_field_name])
# the processed value.
if k in project:
html = pillar.markdown.markdown(project[k])
field_name = pillar.markdown.cache_field_name(k)
project[field_name] = html project[field_name] = html
if isinstance(project, dict) and k in project:
find_markdown_fields(v, project[k]) if isinstance(project, dict) and field_name in project:
find_markdown_fields(field_value, project[field_name])
find_markdown_fields(schema, project) find_markdown_fields(schema, project)
return 'ok'
def parse_markdowns(items): def parse_markdowns(items):
for item in items: for item in items:

View File

@ -223,7 +223,8 @@ def doc_diff(doc1, doc2, *, falsey_is_equal=True, superkey: str = None):
function won't report differences between DoesNotExist, False, '', and 0. function won't report differences between DoesNotExist, False, '', and 0.
""" """
private_keys = {'_id', '_etag', '_deleted', '_updated', '_created'} def is_private(key):
return str(key).startswith('_')
def combine_key(some_key): def combine_key(some_key):
"""Combine this key with the superkey. """Combine this key with the superkey.
@ -244,7 +245,7 @@ def doc_diff(doc1, doc2, *, falsey_is_equal=True, superkey: str = None):
if isinstance(doc1, dict) and isinstance(doc2, dict): if isinstance(doc1, dict) and isinstance(doc2, dict):
for key in set(doc1.keys()).union(set(doc2.keys())): for key in set(doc1.keys()).union(set(doc2.keys())):
if key in private_keys: if is_private(key):
continue continue
val1 = doc1.get(key, DoesNotExist) val1 = doc1.get(key, DoesNotExist)

View File

@ -331,8 +331,9 @@ def require_login(*, require_roles=set(),
def render_error() -> Response: def render_error() -> Response:
if error_view is None: if error_view is None:
abort(403) resp = Forbidden().get_response()
resp: Response = error_view() else:
resp = error_view()
resp.status_code = 403 resp.status_code = 403
return resp return resp

48
pillar/auth/cors.py Normal file
View File

@ -0,0 +1,48 @@
"""Support for adding CORS headers to responses."""
import functools
import flask
import werkzeug.wrappers as wz_wrappers
import werkzeug.exceptions as wz_exceptions
def allow(*, allow_credentials=False):
"""Flask endpoint decorator, adds CORS headers to the response.
If the request has a non-empty 'Origin' header, the response header
'Access-Control-Allow-Origin' is set to the value of that request header,
and some other CORS headers are set.
"""
def decorator(wrapped):
@functools.wraps(wrapped)
def wrapper(*args, **kwargs):
request_origin = flask.request.headers.get('Origin')
if not request_origin:
# No CORS headers requested, so don't bother touching the response.
return wrapped(*args, **kwargs)
try:
response = wrapped(*args, **kwargs)
except wz_exceptions.HTTPException as ex:
response = ex.get_response()
else:
if isinstance(response, tuple):
response = flask.make_response(*response)
elif isinstance(response, str):
response = flask.make_response(response)
elif isinstance(response, wz_wrappers.Response):
pass
else:
raise TypeError(f'unknown response type {type(response)}')
assert isinstance(response, wz_wrappers.Response)
response.headers.set('Access-Control-Allow-Origin', request_origin)
response.headers.set('Access-Control-Allow-Headers', 'x-requested-with')
if allow_credentials:
response.headers.set('Access-Control-Allow-Credentials', 'true')
return response
return wrapper
return decorator

View File

@ -1,5 +1,4 @@
import os import os
import json
import logging import logging
from datetime import datetime from datetime import datetime
@ -18,15 +17,12 @@ from flask import request
from flask import jsonify from flask import jsonify
from flask import abort from flask import abort
from flask_login import current_user from flask_login import current_user
from flask_wtf.csrf import validate_csrf
import werkzeug.exceptions as wz_exceptions import werkzeug.exceptions as wz_exceptions
from wtforms import SelectMultipleField from wtforms import SelectMultipleField
from flask_login import login_required from flask_login import login_required
from jinja2.exceptions import TemplateNotFound from jinja2.exceptions import TemplateNotFound
from pillar.api.utils.authorization import check_permissions
from pillar.web.utils import caching
from pillar.markdown import markdown from pillar.markdown import markdown
from pillar.web.nodes.forms import get_node_form from pillar.web.nodes.forms import get_node_form
from pillar.web.nodes.forms import process_node_form from pillar.web.nodes.forms import process_node_form
@ -109,6 +105,11 @@ def view(node_id, extra_template_args: dict=None):
node_type_name = node.node_type node_type_name = node.node_type
if node_type_name == 'page':
# HACK: The 'edit node' page GETs this endpoint, but for pages it's plain wrong,
# so we just redirect to the correct URL.
return redirect(url_for_node(node=node))
if node_type_name == 'post' and not request.args.get('embed'): if node_type_name == 'post' and not request.args.get('embed'):
# Posts shouldn't be shown at this route (unless viewed embedded, tipically # Posts shouldn't be shown at this route (unless viewed embedded, tipically
# after an edit. Redirect to the correct one. # after an edit. Redirect to the correct one.
@ -608,5 +609,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

File diff suppressed because one or more lines are too long

View File

@ -31,8 +31,10 @@ def check_oauth_provider(provider):
@blueprint.route('/authorize/<provider>') @blueprint.route('/authorize/<provider>')
def oauth_authorize(provider): def oauth_authorize(provider):
if not current_user.is_anonymous: if current_user.is_authenticated:
return redirect(url_for('main.homepage')) next_after_login = session.pop('next_after_login', None) or url_for('main.homepage')
log.debug('Redirecting user to %s', next_after_login)
return redirect(next_after_login)
try: try:
oauth = OAuthSignIn.get_provider(provider) oauth = OAuthSignIn.get_provider(provider)
@ -52,8 +54,10 @@ def oauth_callback(provider):
from pillar.api.utils.authentication import store_token from pillar.api.utils.authentication import store_token
from pillar.api.utils import utcnow from pillar.api.utils import utcnow
next_after_login = session.pop('next_after_login', None) or url_for('main.homepage')
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('main.homepage')) log.debug('Redirecting user to %s', next_after_login)
return redirect(next_after_login)
oauth = OAuthSignIn.get_provider(provider) oauth = OAuthSignIn.get_provider(provider)
try: try:
@ -63,7 +67,7 @@ def oauth_callback(provider):
raise wz_exceptions.Forbidden() raise wz_exceptions.Forbidden()
if oauth_user.id is None: if oauth_user.id is None:
log.debug('Authentication failed for user with {}'.format(provider)) log.debug('Authentication failed for user with {}'.format(provider))
return redirect(url_for('main.homepage')) return redirect(next_after_login)
# Find or create user # Find or create user
user_info = {'id': oauth_user.id, 'email': oauth_user.email, 'full_name': ''} user_info = {'id': oauth_user.id, 'email': oauth_user.email, 'full_name': ''}
@ -88,11 +92,8 @@ def oauth_callback(provider):
# Check with Blender ID to update certain user roles. # Check with Blender ID to update certain user roles.
update_subscription() update_subscription()
next_after_login = session.pop('next_after_login', None)
if next_after_login:
log.debug('Redirecting user to %s', next_after_login) log.debug('Redirecting user to %s', next_after_login)
return redirect(next_after_login) return redirect(next_after_login)
return redirect(url_for('main.homepage'))
@blueprint.route('/login') @blueprint.route('/login')

View File

@ -0,0 +1,2 @@
Gulp will transpile everything in this folder. Every sub folder containing a init.js file exporting functions/classes
will be packed into a module in tutti.js under the namespace pillar.FOLDER_NAME.

View File

@ -1,3 +1,7 @@
/**
* Functions for communicating with the pillar server api
*/
export { thenMarkdownToHtml } from './markdown' export { thenMarkdownToHtml } from './markdown'
export { thenGetProject } from './projects' export { thenGetProject } from './projects'
export { thenGetNodes } from './nodes' export { thenGetNodes, thenGetNode, thenGetNodeActivities, thenUpdateNode, thenDeleteNode } from './nodes'
export { thenGetProjectUsers } from './users'

View File

@ -3,7 +3,80 @@ function thenGetNodes(where, embedded={}, sort='') {
let encodedEmbedded = encodeURIComponent(JSON.stringify(embedded)); let encodedEmbedded = encodeURIComponent(JSON.stringify(embedded));
let encodedSort = encodeURIComponent(sort); let encodedSort = encodeURIComponent(sort);
return $.get(`/api/nodes?where=${encodedWhere}&embedded=${encodedEmbedded}&sort=${encodedSort}`); return $.ajax({
url: `/api/nodes?where=${encodedWhere}&embedded=${encodedEmbedded}&sort=${encodedSort}`,
cache: false,
});
} }
export { thenGetNodes } function thenGetNode(nodeId) {
return $.ajax({
url: `/api/nodes/${nodeId}`,
cache: false,
});
}
function thenGetNodeActivities(nodeId, sort='[("_created", -1)]', max_results=20, page=1) {
let encodedSort = encodeURIComponent(sort);
return $.ajax({
url: `/api/nodes/${nodeId}/activities?sort=${encodedSort}&max_results=${max_results}&page=${page}`,
cache: false,
});
}
function thenUpdateNode(node) {
let id = node['_id'];
let etag = node['_etag'];
let nodeToSave = removePrivateKeys(node);
let data = JSON.stringify(nodeToSave);
return $.ajax({
url: `/api/nodes/${id}`,
type: 'PUT',
data: data,
dataType: 'json',
contentType: 'application/json; charset=UTF-8',
headers: {'If-Match': etag},
}).then(updatedInfo => {
return thenGetNode(updatedInfo['_id'])
.then(node => {
pillar.events.Nodes.triggerUpdated(node);
return node;
})
});
}
function thenDeleteNode(node) {
let id = node['_id'];
let etag = node['_etag'];
return $.ajax({
url: `/api/nodes/${id}`,
type: 'DELETE',
headers: {'If-Match': etag},
}).then(() => {
pillar.events.Nodes.triggerDeleted(id);
});
}
function removePrivateKeys(doc) {
function doRemove(d) {
for (const key in d) {
if (key.startsWith('_')) {
delete d[key];
continue;
}
let val = d[key];
if(typeof val === 'object') {
doRemove(val);
}
}
}
let docCopy = JSON.parse(JSON.stringify(doc));
doRemove(docCopy);
delete docCopy['allowed_methods']
return docCopy;
}
export { thenGetNodes, thenGetNode, thenGetNodeActivities, thenUpdateNode, thenDeleteNode }

View File

@ -0,0 +1,7 @@
function thenGetProjectUsers(projectId) {
return $.ajax({
url: `/api/p/users?project_id=${projectId}`,
});
}
export { thenGetProjectUsers }

View File

@ -1,3 +1,16 @@
/**
* Helper class to trigger/listen to global events on new/updated/deleted nodes.
*
* @example
* function myCallback(event) {
* console.log('Updated node:', event.detail);
* }
* // Register a callback:
* Nodes.onUpdated('5c1cc4a5a013573d9787164b', myCallback);
* // When changing the node, notify the listeners:
* Nodes.triggerUpdated(myUpdatedNode);
*/
class EventName { class EventName {
static parentCreated(parentId, node_type) { static parentCreated(parentId, node_type) {
return `pillar:node:${parentId}:created-${node_type}`; return `pillar:node:${parentId}:created-${node_type}`;
@ -14,79 +27,141 @@ 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) {
document.dispatchEvent(new CustomEvent(eventName, {detail: data}));
}
function on(eventName, cb) {
document.addEventListener(eventName, cb);
}
function off(eventName, cb) {
document.removeEventListener(eventName, cb);
} }
class Nodes { class Nodes {
/**
* Trigger events that node has been created
* @param {Object} node
*/
static triggerCreated(node) { static triggerCreated(node) {
if (node.parent) { if (node.parent) {
$('body').trigger( trigger(
EventName.parentCreated(node.parent, node.node_type), EventName.parentCreated(node.parent, node.node_type),
node); node);
} }
$('body').trigger( trigger(
EventName.globalCreated(node.node_type), EventName.globalCreated(node.node_type),
node); node);
} }
/**
* Get notified when new nodes where parent === parentId and node_type === node_type
* @param {String} parentId
* @param {String} node_type
* @param {Function(Event)} cb
*/
static onParentCreated(parentId, node_type, cb){ static onParentCreated(parentId, node_type, cb){
$('body').on( on(
EventName.parentCreated(parentId, node_type), EventName.parentCreated(parentId, node_type),
cb); cb);
} }
static offParentCreated(parentId, node_type, cb){ static offParentCreated(parentId, node_type, cb){
$('body').off( off(
EventName.parentCreated(parentId, node_type), EventName.parentCreated(parentId, node_type),
cb); cb);
} }
/**
* Get notified when new nodes where node_type === node_type is created
* @param {String} node_type
* @param {Function(Event)} cb
*/
static onCreated(node_type, cb){ static onCreated(node_type, cb){
$('body').on( on(
EventName.globalCreated(node_type), EventName.globalCreated(node_type),
cb); cb);
} }
static offCreated(node_type, cb){ static offCreated(node_type, cb){
$('body').off( off(
EventName.globalCreated(node_type), EventName.globalCreated(node_type),
cb); cb);
} }
static triggerUpdated(node) { static triggerUpdated(node) {
$('body').trigger( trigger(
EventName.updated(node._id), EventName.updated(node._id),
node); node);
} }
/**
* Get notified when node with _id === nodeId is updated
* @param {String} nodeId
* @param {Function(Event)} cb
*/
static onUpdated(nodeId, cb) { static onUpdated(nodeId, cb) {
$('body').on( on(
EventName.updated(nodeId), EventName.updated(nodeId),
cb); cb);
} }
static offUpdated(nodeId, cb) { static offUpdated(nodeId, cb) {
$('body').off( off(
EventName.updated(nodeId), EventName.updated(nodeId),
cb); cb);
} }
/**
* Notify that node has been deleted.
* @param {String} nodeId
*/
static triggerDeleted(nodeId) { static triggerDeleted(nodeId) {
$('body').trigger( trigger(
EventName.deleted(nodeId), EventName.deleted(nodeId),
nodeId); nodeId);
} }
/**
* Listen to events of nodes being deleted where _id === nodeId
* @param {String} nodeId
* @param {Function(Event)} cb
*/
static onDeleted(nodeId, cb) { static onDeleted(nodeId, cb) {
$('body').on( on(
EventName.deleted(nodeId), EventName.deleted(nodeId),
cb); cb);
} }
static offDeleted(nodeId, cb) { static offDeleted(nodeId, cb) {
$('body').off( off(
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 }

View File

@ -1 +1,4 @@
/**
* Collecting Custom Pillar events here
*/
export {Nodes} from './Nodes' export {Nodes} from './Nodes'

View File

@ -0,0 +1,2 @@
This module is used to render nodes/users dynamically. It was written before we introduced vue.js into the project.
Current best practice is to use vue for this type of work.

View File

@ -2,25 +2,50 @@ import { ComponentCreatorInterface } from './ComponentCreatorInterface'
const REGISTERED_CREATORS = [] const REGISTERED_CREATORS = []
/**
* Create a jQuery renderable element from a mongo document using registered creators.
* @deprecated use vue instead
*/
export class Component extends ComponentCreatorInterface { export class Component extends ComponentCreatorInterface {
/**
*
* @param {Object} doc
* @returns {$element}
*/
static create$listItem(doc) { static create$listItem(doc) {
let creator = Component.getCreator(doc); let creator = Component.getCreator(doc);
return creator.create$listItem(doc); return creator.create$listItem(doc);
} }
/**
* @param {Object} doc
* @returns {$element}
*/
static create$item(doc) { static create$item(doc) {
let creator = Component.getCreator(doc); let creator = Component.getCreator(doc);
return creator.create$item(doc); return creator.create$item(doc);
} }
/**
* @param {Object} candidate
* @returns {Boolean}
*/
static canCreate(candidate) { static canCreate(candidate) {
return !!Component.getCreator(candidate); return !!Component.getCreator(candidate);
} }
/**
* Register component creator to handle a node type
* @param {ComponentCreatorInterface} creator
*/
static regiseterCreator(creator) { static regiseterCreator(creator) {
REGISTERED_CREATORS.push(creator); REGISTERED_CREATORS.push(creator);
} }
/**
* @param {Object} doc
* @returns {ComponentCreatorInterface}
*/
static getCreator(doc) { static getCreator(doc) {
if (doc) { if (doc) {
for (let candidate of REGISTERED_CREATORS) { for (let candidate of REGISTERED_CREATORS) {

View File

@ -1,6 +1,10 @@
/**
* @deprecated use vue instead
*/
export class ComponentCreatorInterface { export class ComponentCreatorInterface {
/** /**
* @param {JSON} doc * Create a $element to render document in a list
* @param {Object} doc
* @returns {$element} * @returns {$element}
*/ */
static create$listItem(doc) { static create$listItem(doc) {
@ -8,8 +12,8 @@ export class ComponentCreatorInterface {
} }
/** /**
* * Create a $element to render the full doc
* @param {JSON} doc * @param {Object} doc
* @returns {$element} * @returns {$element}
*/ */
static create$item(doc) { static create$item(doc) {
@ -17,8 +21,7 @@ export class ComponentCreatorInterface {
} }
/** /**
* * @param {Object} candidate
* @param {JSON} candidate
* @returns {boolean} * @returns {boolean}
*/ */
static canCreate(candidate) { static canCreate(candidate) {

View File

@ -1,6 +1,10 @@
import { NodesBase } from "./NodesBase"; import { NodesBase } from "./NodesBase";
import { thenLoadVideoProgress } from '../utils'; import { thenLoadVideoProgress } from '../utils';
/**
* Create $element from a node of type asset
* @deprecated use vue instead
*/
export class Assets extends NodesBase{ export class Assets extends NodesBase{
static create$listItem(node) { static create$listItem(node) {
var markIfPublic = true; var markIfPublic = true;

View File

@ -3,6 +3,10 @@ import { ComponentCreatorInterface } from '../component/ComponentCreatorInterfac
let CREATE_NODE_ITEM_MAP = {} let CREATE_NODE_ITEM_MAP = {}
/**
* Create $element from node object
* @deprecated use vue instead
*/
export class Nodes extends ComponentCreatorInterface { export class Nodes extends ComponentCreatorInterface {
/** /**
* Creates a small list item out of a node document * Creates a small list item out of a node document

View File

@ -1,6 +1,9 @@
import { prettyDate } from '../../utils/prettydate'; import { prettyDate } from '../../utils/prettydate';
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface' import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
/**
* @deprecated use vue instead
*/
export class NodesBase extends ComponentCreatorInterface { export class NodesBase extends ComponentCreatorInterface {
static create$listItem(node) { static create$listItem(node) {
let nid = (node._id || node.objectID); // To support both mongo and elastic nodes let nid = (node._id || node.objectID); // To support both mongo and elastic nodes

View File

@ -1,5 +1,9 @@
import { NodesBase } from "./NodesBase"; import { NodesBase } from "./NodesBase";
/**
* Create $element from a node of type post
* @deprecated use vue instead
*/
export class Posts extends NodesBase { export class Posts extends NodesBase {
static create$item(post) { static create$item(post) {
let content = []; let content = [];

View File

@ -1,5 +1,9 @@
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface' import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
/**
* Create $elements from user objects
* @deprecated use vue instead
*/
export class Users extends ComponentCreatorInterface { export class Users extends ComponentCreatorInterface {
static create$listItem(userDoc) { static create$listItem(userDoc) {
let roles = userDoc.roles || []; let roles = userDoc.roles || [];

View File

@ -0,0 +1,35 @@
# Vue components
[Vue.js](https://vuejs.org/) is a javascript framework for writing interactive ui components.
Vue.js is packed into tutti.js, and hence available site wide.
### Absolute must read
- https://vuejs.org/v2/api/#Options-Data
- https://vuejs.org/v2/api/#v-bind
- https://vuejs.org/v2/api/#v-model
- https://vuejs.org/v2/guide/conditional.html
- https://vuejs.org/v2/guide/list.html#v-for-with-an-Object
- https://vuejs.org/v2/api/#vm-emit
- https://vuejs.org/v2/api/#v-on
### Styling and animation of components
- https://vuejs.org/v2/guide/class-and-style.html#Binding-HTML-Classes
- https://vuejs.org/v2/guide/transitions.html
### More advanced, but important topics
- https://vuejs.org/v2/api/#is
- https://vuejs.org/v2/guide/components-slots.html#Slot-Content
- https://vuejs.org/v2/guide/mixins.html
### Rule of thumbs
- [Have a dash in your component name](https://vuejs.org/v2/guide/components-registration.html#Component-Names)
- Have one prop binding per line in component templates.
~~~
// Good!
<my-component
:propA="propX"
:propB="propY"
/>
// Bad!
<my-component :propA="propX" :propB="propY"/>
~~~

View File

@ -0,0 +1,52 @@
const TEMPLATE = `
<div class='breadcrumbs' v-if="breadcrumbs.length">
<ul>
<li v-for="crumb in breadcrumbs">
<a :href="crumb.url" v-if="!crumb._self" @click.prevent="navigateToNode(crumb._id)">{{ crumb.name }}</a>
<span v-else>{{ crumb.name }}</span>
</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");
})
;
},
navigateToNode(nodeId) {
this.$emit('navigate', nodeId);
},
},
});

View File

@ -202,7 +202,10 @@ Vue.component('comment-editor', {
this.unitOfWork( this.unitOfWork(
this.thenSubmit() this.thenSubmit()
.fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'Failed to submit comment')}) .fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'Failed to submit comment')})
); )
.then(() => {
EventBus.$emit(Events.EDIT_DONE, this.comment._id);
});
}, },
thenSubmit() { thenSubmit() {
if (this.mode === 'reply') { if (this.mode === 'reply') {
@ -220,7 +223,6 @@ Vue.component('comment-editor', {
return thenCreateComment(this.parentId, this.msg, this.attachmentsAsObject) return thenCreateComment(this.parentId, this.msg, this.attachmentsAsObject)
.then((newComment) => { .then((newComment) => {
EventBus.$emit(Events.NEW_COMMENT, newComment); EventBus.$emit(Events.NEW_COMMENT, newComment);
EventBus.$emit(Events.EDIT_DONE, newComment.id );
this.cleanUp(); this.cleanUp();
}) })
}, },
@ -228,7 +230,6 @@ Vue.component('comment-editor', {
return thenUpdateComment(this.comment.parent, this.comment.id, this.msg, this.attachmentsAsObject) return thenUpdateComment(this.comment.parent, this.comment.id, this.msg, this.attachmentsAsObject)
.then((updatedComment) => { .then((updatedComment) => {
EventBus.$emit(Events.UPDATED_COMMENT, updatedComment); EventBus.$emit(Events.UPDATED_COMMENT, updatedComment);
EventBus.$emit(Events.EDIT_DONE, updatedComment.id);
this.cleanUp(); this.cleanUp();
}) })
}, },

View File

@ -91,6 +91,9 @@ Vue.component('comments-tree', {
} else { } else {
$(document).trigger('pillar:workStop'); $(document).trigger('pillar:workStop');
} }
},
parentId() {
this.fetchComments();
} }
}, },
created() { created() {
@ -98,6 +101,20 @@ Vue.component('comments-tree', {
EventBus.$on(Events.EDIT_DONE, this.showReplyComponent); EventBus.$on(Events.EDIT_DONE, this.showReplyComponent);
EventBus.$on(Events.NEW_COMMENT, this.onNewComment); EventBus.$on(Events.NEW_COMMENT, this.onNewComment);
EventBus.$on(Events.UPDATED_COMMENT, this.onCommentUpdated); EventBus.$on(Events.UPDATED_COMMENT, this.onCommentUpdated);
this.fetchComments()
},
beforeDestroy() {
EventBus.$off(Events.BEFORE_SHOW_EDITOR, this.doHideEditors);
EventBus.$off(Events.EDIT_DONE, this.showReplyComponent);
EventBus.$off(Events.NEW_COMMENT, this.onNewComment);
EventBus.$off(Events.UPDATED_COMMENT, this.onCommentUpdated);
if(this.isBusyWorking) {
$(document).trigger('pillar:workStop');
}
},
methods: {
fetchComments() {
this.showLoadingPlaceholder = true;
this.unitOfWork( this.unitOfWork(
thenGetComments(this.parentId) thenGetComments(this.parentId)
.then((commentsTree) => { .then((commentsTree) => {
@ -109,13 +126,6 @@ Vue.component('comments-tree', {
.always(()=>this.showLoadingPlaceholder = false) .always(()=>this.showLoadingPlaceholder = false)
); );
}, },
beforeDestroy() {
EventBus.$off(Events.BEFORE_SHOW_EDITOR, this.doHideEditors);
EventBus.$off(Events.EDIT_DONE, this.showReplyComponent);
EventBus.$off(Events.NEW_COMMENT, this.onNewComment);
EventBus.$off(Events.UPDATED_COMMENT, this.onCommentUpdated);
},
methods: {
doHideEditors() { doHideEditors() {
this.replyHidden = true; this.replyHidden = true;
}, },
@ -134,6 +144,7 @@ Vue.component('comments-tree', {
parentArray = parentComment.replies; parentArray = parentComment.replies;
} }
parentArray.unshift(newComment); parentArray.unshift(newComment);
this.$emit('new-comment');
}, },
onCommentUpdated(updatedComment) { onCommentUpdated(updatedComment) {
let commentInTree = this.findComment(this.comments, (comment) => { let commentInTree = this.findComment(this.comments, (comment) => {

View File

@ -1,23 +1,39 @@
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'
import { PillarTable } from './table/Table' import { BrowserHistoryState, StateSaveMode } from './mixins/BrowserHistoryState'
import { PillarTable, TableState } from './table/Table'
import { CellPrettyDate } from './table/cells/renderer/CellPrettyDate' import { CellPrettyDate } from './table/cells/renderer/CellPrettyDate'
import { CellDefault } from './table/cells/renderer/CellDefault' import { CellDefault } from './table/cells/renderer/CellDefault'
import { ColumnBase } from './table/columns/ColumnBase' import { ColumnBase } from './table/columns/ColumnBase'
import { Created } from './table/columns/Created'
import { Updated } from './table/columns/Updated'
import { DateColumnBase } from './table/columns/DateColumnBase'
import { ColumnFactoryBase } from './table/columns/ColumnFactoryBase' import { ColumnFactoryBase } from './table/columns/ColumnFactoryBase'
import { RowObjectsSourceBase } from './table/rows/RowObjectsSourceBase' import { RowObjectsSourceBase } from './table/rows/RowObjectsSourceBase'
import { RowBase } from './table/rows/RowObjectBase' import { RowBase } from './table/rows/RowObjectBase'
import { RowFilter } from './table/filter/RowFilter' import { RowFilter } from './table/rows/filter/RowFilter'
import { EnumFilter } from './table/rows/filter/EnumFilter'
import { StatusFilter } from './table/rows/filter/StatusFilter'
import { TextFilter } from './table/rows/filter/TextFilter'
import { NameFilter } from './table/rows/filter/NameFilter'
import { UserAvatar } from './user/Avatar'
let mixins = { let mixins = {
UnitOfWorkTracker UnitOfWorkTracker,
BrowserHistoryState,
StateSaveMode
} }
let table = { let table = {
PillarTable, PillarTable,
TableState,
columns: { columns: {
ColumnBase, ColumnBase,
Created,
Updated,
DateColumnBase,
ColumnFactoryBase, ColumnFactoryBase,
}, },
cells: { cells: {
@ -27,12 +43,20 @@ let table = {
} }
}, },
rows: { rows: {
RowObjectsSourceBase,
RowBase
},
filter: { filter: {
RowFilter RowFilter,
} EnumFilter,
StatusFilter,
TextFilter,
NameFilter
},
RowObjectsSourceBase,
RowBase,
},
} }
export { mixins, table } let user = {
UserAvatar
}
export { mixins, table, user }

View File

@ -1,6 +1,6 @@
const TEMPLATE =` const TEMPLATE =`
<div class="pillar-dropdown"> <div class="pillar-dropdown">
<div class="pillar-dropdown-button" <div class="pillar-dropdown-button action"
:class="buttonClasses" :class="buttonClasses"
@click="toggleShowMenu" @click="toggleShowMenu"
> >

View File

@ -0,0 +1,111 @@
/**
* Vue helper mixin to push app state into browser history.
*
* How to use:
* Override browserHistoryState so it return the state you want to store
* Override historyStateUrl so it return the url you want to store with your state
* Override applyHistoryState to apply your state
*/
const StateSaveMode = Object.freeze({
IGNORE: Symbol("ignore"),
PUSH: Symbol("push"),
REPLACE: Symbol("replace")
});
let BrowserHistoryState = {
created() {
window.onpopstate = this._popHistoryState;
},
data() {
return {
_lastApplyedHistoryState: undefined
}
},
computed: {
/**
* Override and return state object
* @returns {Object} state object
*/
browserHistoryState() {
return {};
},
/**
* Override and return url to this state
* @returns {String} url to state
*/
historyStateUrl() {
return ''
}
},
watch: {
browserHistoryState(newState) {
if(JSON.stringify(newState) === JSON.stringify(window.history.state)) return; // Don't save state on apply
let mode = this.stateSaveMode(newState, window.history.state);
switch(mode) {
case StateSaveMode.IGNORE: break;
case StateSaveMode.PUSH:
this._pushHistoryState();
break;
case StateSaveMode.REPLACE:
this._replaceHistoryState();
break;
default:
console.log('Unknown state save mode', mode);
}
}
},
methods: {
/**
* Override to apply your state
* @param {Object} newState The state object you returned in @function browserHistoryState
*/
applyHistoryState(newState) {
},
/**
* Override to
* @param {Object} newState
* @param {Object} oldState
* @returns {StateSaveMode} Enum value to instruct how state change should be handled
*/
stateSaveMode(newState, oldState) {
if (!oldState) {
// Initial state. Replace what we have so we can go back to this state
return StateSaveMode.REPLACE;
}
return StateSaveMode.PUSH;
},
_pushHistoryState() {
let currentState = this.browserHistoryState;
if (!currentState) return;
let url = this.historyStateUrl;
window.history.pushState(
currentState,
undefined,
url
);
},
_replaceHistoryState() {
let currentState = this.browserHistoryState;
if (!currentState) return;
let url = this.historyStateUrl;
window.history.replaceState(
currentState,
undefined,
url
);
},
_popHistoryState(event) {
let newState = event.state;
if (!newState) return;
this.applyHistoryState(newState);
},
},
}
export { BrowserHistoryState, StateSaveMode }

View File

@ -39,6 +39,11 @@ var UnitOfWorkTracker = {
} }
} }
}, },
beforeDestroy() {
if(this.unitOfWorkCounter !== 0) {
this.$emit('unit-of-work', -this.unitOfWorkCounter);
}
},
methods: { methods: {
unitOfWork(promise) { unitOfWork(promise) {
this.unitOfWorkBegin(); this.unitOfWorkBegin();

View File

@ -1,8 +1,41 @@
import './rows/renderer/Head' import './rows/renderer/Head'
import './rows/renderer/Row' import './rows/renderer/Row'
import './filter/ColumnFilter' import './columns/filter/ColumnFilter'
import './filter/RowFilter' import './rows/filter/RowFilter'
import {UnitOfWorkTracker} from '../mixins/UnitOfWorkTracker' import {UnitOfWorkTracker} from '../mixins/UnitOfWorkTracker'
import {RowFilter} from './rows/filter/RowFilter'
/**
* Table State
*
* Used to restore a table to a given state.
*/
class TableState {
constructor(selectedIds) {
this.selectedIds = selectedIds || [];
}
/**
* Apply state to row
* @param {RowBase} rowObject
*/
applyRowState(rowObject) {
rowObject.isSelected = this.selectedIds.includes(rowObject.getId());
}
}
class ComponentState {
/**
* Serializable state of this component.
*
* @param {Object} rowFilter
* @param {Object} columnFilter
*/
constructor(rowFilter, columnFilter) {
this.rowFilter = rowFilter;
this.columnFilter = columnFilter
}
}
const TEMPLATE =` const TEMPLATE =`
<div class="pillar-table-container" <div class="pillar-table-container"
@ -10,13 +43,20 @@ const TEMPLATE =`
> >
<div class="pillar-table-menu"> <div class="pillar-table-menu">
<pillar-table-row-filter <pillar-table-row-filter
:rowObjects="rowObjects" :rowObjects="sortedRowObjects"
:config="rowFilterConfig"
:componentState="(componentState || {}).rowFilter"
@visibleRowObjectsChanged="onVisibleRowObjectsChanged" @visibleRowObjectsChanged="onVisibleRowObjectsChanged"
@componentStateChanged="onRowFilterStateChanged"
/>
<pillar-table-actions
@item-clicked="onItemClicked"
/> />
<pillar-table-actions/>
<pillar-table-column-filter <pillar-table-column-filter
:columns="columns" :columns="columns"
:componentState="(componentState || {}).columnFilter"
@visibleColumnsChanged="onVisibleColumnsChanged" @visibleColumnsChanged="onVisibleColumnsChanged"
@componentStateChanged="onColumnFilterStateChanged"
/> />
</div> </div>
<div class="pillar-table"> <div class="pillar-table">
@ -30,60 +70,220 @@ const TEMPLATE =`
:columns="visibleColumns" :columns="visibleColumns"
:rowObject="rowObject" :rowObject="rowObject"
:key="rowObject.getId()" :key="rowObject.getId()"
@item-clicked="onItemClicked"
/> />
</transition-group> </transition-group>
</div> </div>
</div> </div>
`; `;
/**
* The table renders RowObject instances for the rows, and ColumnBase instances for the Columns.
* Extend the table to fit your needs.
*
* Usage:
* Extend RowBase to wrap the data you want in your row
* Extend ColumnBase once per column type you need
* Extend RowObjectsSourceBase to fetch and initialize your rows
* Extend ColumnFactoryBase to create the rows for your table
* Extend This Table with your ColumnFactory and RowSource
*
* @emits isInitialized When all rows has been fetched, and are initialized.
* @emits selectItemsChanged(selectedItems) When selected rows has changed.
* @emits componentStateChanged(newState) When table state changed. Filtered rows, visible columns...
*/
let PillarTable = Vue.component('pillar-table-base', { let PillarTable = Vue.component('pillar-table-base', {
template: TEMPLATE, template: TEMPLATE,
mixins: [UnitOfWorkTracker], mixins: [UnitOfWorkTracker],
// columnFactory,
// rowsSource,
props: { props: {
projectId: String selectedIds: {
type: Array,
default: []
},
canChangeSelectionCB: {
type: Function,
default: () => true
},
canMultiSelect: {
type: Boolean,
default: true
},
componentState: {
// Instance of ComponentState
type: Object,
default: undefined
}
}, },
data: function() { data: function() {
return { return {
columns: [], columns: [],
visibleColumns: [], visibleColumns: [],
visibleRowObjects: [], visibleRowObjects: [],
rowsSource: {} rowsSource: undefined, // Override with your implementations of RowSource
columnFactory: undefined, // Override with your implementations of ColumnFactoryBase
rowFilterConfig: undefined,
isInitialized: false,
rowFilterState: (this.componentState || {}).rowFilter,
columnFilterState: (this.componentState || {}).columnFilter,
compareRowsCB: (row1, row2) => 0
} }
}, },
computed: { computed: {
rowObjects() { rowObjects() {
return this.rowsSource.rowObjects || []; return this.rowsSource.rowObjects || [];
},
/**
* Rows sorted with a column sorter
*/
sortedRowObjects() {
return this.rowObjects.concat().sort(this.compareRowsCB);
},
rowAndChildObjects() {
let all = [];
for (const row of this.rowObjects) {
all.push(row, ...row.getChildObjects());
}
return all;
},
selectedItems() {
return this.rowAndChildObjects.filter(it => it.isSelected)
.map(it => it.underlyingObject);
},
currentComponentState() {
if (this.isInitialized) {
return new ComponentState(
this.rowFilterState,
this.columnFilterState
);
}
return undefined;
}
},
watch: {
selectedIds(newValue) {
this.rowAndChildObjects.forEach(item => {
item.isSelected = newValue.includes(item.getId());
});
},
selectedItems(newValue, oldValue) {
// Deep compare to avoid spamming un needed events
let hasChanged = JSON.stringify(newValue ) !== JSON.stringify(oldValue);
if (hasChanged) {
this.$emit('selectItemsChanged', newValue);
}
},
isInitialized(newValue) {
if (newValue) {
this.$emit('isInitialized');
}
},
currentComponentState(newValue, oldValue) {
if (this.isInitialized) {
// Deep compare to avoid spamming un needed events
let hasChanged = JSON.stringify(newValue ) !== JSON.stringify(oldValue);
if (hasChanged) {
this.$emit('componentStateChanged', newValue);
}
}
} }
}, },
created() { created() {
let columnFactory = new this.$options.columnFactory(this.projectId); let tableState = new TableState(this.selectedIds);
this.rowsSource = new this.$options.rowsSource(this.projectId);
this.unitOfWork( this.unitOfWork(
Promise.all([ Promise.all([
columnFactory.thenGetColumns(), this.columnFactory.thenGetColumns(),
this.rowsSource.thenInit() this.rowsSource.thenGetRowObjects()
]) ])
.then((resp) => { .then((resp) => {
this.columns = resp[0]; this.columns = resp[0];
return this.rowsSource.thenInit();
}) })
.then(() => {
let currentlySelectedIds = this.selectedItems.map(it => it._id);
if (currentlySelectedIds.length > 0) {
// User has clicked on a row while we inited the rows. Keep that selection!
tableState.selectedIds = currentlySelectedIds;
}
this.rowAndChildObjects.forEach(tableState.applyRowState.bind(tableState));
this.isInitialized = true;
})
.catch((err) => {toastr.error(pillar.utils.messageFromError(err), 'Loading table failed')})
); );
}, },
methods: { methods: {
onVisibleColumnsChanged(visibleColumns) { onVisibleColumnsChanged(visibleColumns) {
this.visibleColumns = visibleColumns; this.visibleColumns = visibleColumns;
}, },
onColumnFilterStateChanged(newComponentState) {
this.columnFilterState = newComponentState;
},
onVisibleRowObjectsChanged(visibleRowObjects) { onVisibleRowObjectsChanged(visibleRowObjects) {
this.visibleRowObjects = visibleRowObjects; this.visibleRowObjects = visibleRowObjects;
}, },
onRowFilterStateChanged(newComponentState) {
this.rowFilterState = newComponentState;
},
onSort(column, direction) { onSort(column, direction) {
function compareRows(r1, r2) { function compareRows(r1, r2) {
return column.compareRows(r1, r2) * direction; return column.compareRows(r1, r2) * direction;
} }
this.rowObjects.sort(compareRows); this.compareRowsCB = compareRows;
}, },
onItemClicked(clickEvent, itemId) {
if(!this.canChangeSelectionCB()) return;
if(this.isMultiToggleClick(clickEvent) && this.canMultiSelect) {
let slectedIdsWithoutClicked = this.selectedIds.filter(id => id !== itemId);
if (slectedIdsWithoutClicked.length < this.selectedIds.length) {
this.selectedIds = slectedIdsWithoutClicked;
} else {
this.selectedIds = [itemId, ...this.selectedIds];
}
} else if(this.isSelectBetweenClick(clickEvent) && this.canMultiSelect) {
if (this.selectedIds.length > 0) {
let betweenA = this.selectedIds[this.selectedIds.length -1];
let betweenB = itemId;
this.selectedIds = this.rowsBetween(betweenA, betweenB).map(it => it.getId());
} else {
this.selectedIds = [itemId];
}
}
else {
this.selectedIds = [itemId];
}
},
isSelectBetweenClick(clickEvent) {
return clickEvent.shiftKey;
},
isMultiToggleClick(clickEvent) {
return clickEvent.ctrlKey ||
clickEvent.metaKey; // Mac command key
},
/**
* Get visible rows between id1 and id2
* @param {String} id1
* @param {String} id2
* @returns {Array(RowObjects)}
*/
rowsBetween(id1, id2) {
let hasFoundFirst = false;
let hasFoundLast = false;
return this.visibleRowObjects.filter((it) => {
if (hasFoundLast) return false;
if (!hasFoundFirst) {
hasFoundFirst = [id1, id2].includes(it.getId());
return hasFoundFirst;
}
hasFoundLast = [id1, id2].includes(it.getId());
return true;
})
}
},
components: {
'pillar-table-row-filter': RowFilter
} }
}); });
export { PillarTable } export { PillarTable, TableState }

View File

@ -4,6 +4,10 @@ const TEMPLATE =`
</div> </div>
`; `;
/**
* Default cell renderer. Takes raw cell value and formats it.
* Override for custom formatting of value.
*/
let CellDefault = Vue.component('pillar-cell-default', { let CellDefault = Vue.component('pillar-cell-default', {
template: TEMPLATE, template: TEMPLATE,
props: { props: {

View File

@ -1,5 +1,9 @@
import { CellDefault } from './CellDefault' import { CellDefault } from './CellDefault'
/**
* Formats raw values as "pretty date".
* Expects rawCellValue to be a date.
*/
let CellPrettyDate = Vue.component('pillar-cell-pretty-date', { let CellPrettyDate = Vue.component('pillar-cell-pretty-date', {
extends: CellDefault, extends: CellDefault,
computed: { computed: {

View File

@ -6,25 +6,43 @@ const TEMPLATE =`
:rowObject="rowObject" :rowObject="rowObject"
:column="column" :column="column"
:rawCellValue="rawCellValue" :rawCellValue="rawCellValue"
@item-clicked="$emit('item-clicked', ...arguments)"
/> />
`; `;
/**
* Renders the cell that the column requests.
*
* @emits item-clicked(mouseEvent,itemId) Re-emits if real cell is emitting it
*/
let CellProxy = Vue.component('pillar-cell-proxy', { let CellProxy = Vue.component('pillar-cell-proxy', {
template: TEMPLATE, template: TEMPLATE,
props: { props: {
column: Object, column: Object, // ColumnBase
rowObject: Object rowObject: Object // RowObject
}, },
computed: { computed: {
/**
* Raw unformated cell value
*/
rawCellValue() { rawCellValue() {
return this.column.getRawCellValue(this.rowObject) || ''; return this.column.getRawCellValue(this.rowObject) || '';
}, },
/**
* Name of the cell render component to be rendered
*/
cellRenderer() { cellRenderer() {
return this.column.getCellRenderer(this.rowObject); return this.column.getCellRenderer(this.rowObject);
}, },
/**
* Css classes to apply to the cell
*/
cellClasses() { cellClasses() {
return this.column.getCellClasses(this.rawCellValue, this.rowObject); return this.column.getCellClasses(this.rawCellValue, this.rowObject);
}, },
/**
* Cell tooltip
*/
cellTitle() { cellTitle() {
return this.column.getCellTitle(this.rawCellValue, this.rowObject); return this.column.getCellTitle(this.rawCellValue, this.rowObject);
} }

View File

@ -5,7 +5,11 @@ const TEMPLATE =`
@mouseleave="onMouseLeave" @mouseleave="onMouseLeave"
> >
<div class="cell-content"> <div class="cell-content">
<div class="header-label"
:title="column.displayName"
>
{{ column.displayName }} {{ column.displayName }}
</div>
<div class="column-sort" <div class="column-sort"
v-if="column.isSortable" v-if="column.isSortable"
> >
@ -22,6 +26,11 @@ const TEMPLATE =`
</div> </div>
`; `;
/**
* A cell in the Header of the table
*
* @emits sort(column,direction) When user clicks column sort arrows.
*/
Vue.component('pillar-head-cell', { Vue.component('pillar-head-cell', {
template: TEMPLATE, template: TEMPLATE,
props: { props: {

View File

@ -1,25 +1,33 @@
import { CellDefault } from '../cells/renderer/CellDefault' import { CellDefault } from '../cells/renderer/CellDefault'
let nextColumnId = 0; /**
* Column logic
*/
export class ColumnBase { export class ColumnBase {
constructor(displayName, columnType) { constructor(displayName, columnType) {
this._id = nextColumnId++;
this.displayName = displayName; this.displayName = displayName;
this.columnType = columnType; this.columnType = columnType;
this.isMandatory = false; this.isMandatory = false;
this.includedByDefault = true;
this.isSortable = true; this.isSortable = true;
this.isHighLighted = 0; this.isHighLighted = 0;
} }
/** /**
* *
* @param {*} rowObject * @param {RowObject} rowObject
* @returns {String} Name of the Cell renderer component * @returns {String} Name of the Cell renderer component
*/ */
getCellRenderer(rowObject) { getCellRenderer(rowObject) {
return CellDefault.options.name; return CellDefault.options.name;
} }
/**
*
* @param {RowObject} rowObject
* @returns {*} Raw unformated value
*/
getRawCellValue(rowObject) { getRawCellValue(rowObject) {
// Should be overridden // Should be overridden
throw Error('Not implemented'); throw Error('Not implemented');
@ -37,16 +45,25 @@ export class ColumnBase {
} }
/** /**
* Object with css classes to use on the header cell * Object with css classes to use on the column
* @returns {Any} Object with css classes * @returns {Object} Object with css classes
*/ */
getHeaderCellClasses() { getColumnClasses() {
// Should be overridden // Should be overridden
let classes = {} let classes = {}
classes[this.columnType] = true; classes[this.columnType] = true;
return classes; return classes;
} }
/**
* Object with css classes to use on the header cell
* @returns {Object} Object with css classes
*/
getHeaderCellClasses() {
// Should be overridden
return this.getColumnClasses();
}
/** /**
* Object with css classes to use on the cell * Object with css classes to use on the cell
* @param {*} rawCellValue * @param {*} rawCellValue
@ -55,8 +72,7 @@ export class ColumnBase {
*/ */
getCellClasses(rawCellValue, rowObject) { getCellClasses(rawCellValue, rowObject) {
// Should be overridden // Should be overridden
let classes = {} let classes = this.getColumnClasses();
classes[this.columnType] = true;
classes['highlight'] = !!this.isHighLighted; classes['highlight'] = !!this.isHighLighted;
return classes; return classes;
} }

View File

@ -1,21 +1,15 @@
/**
* Provides the columns that are available in a table.
*/
class ColumnFactoryBase{ class ColumnFactoryBase{
constructor(projectId) { /**
this.projectId = projectId; * To be overridden for your purposes
this.projectPromise; * @returns {Promise(ColumnBase)} The columns that are available in the table.
} */
// Override this
thenGetColumns() { thenGetColumns() {
throw Error('Not implemented') throw Error('Not implemented')
} }
thenGetProject() {
if (this.projectPromise) {
return this.projectPromise;
}
this.projectPromise = pillar.api.thenGetProject(this.projectId);
return this.projectPromise;
}
} }
export { ColumnFactoryBase } export { ColumnFactoryBase }

View File

@ -0,0 +1,19 @@
import {DateColumnBase} from './DateColumnBase'
/**
* Column showing the objects _created prettyfied
*/
export class Created extends DateColumnBase{
constructor() {
super('Created', 'row-created');
this.includedByDefault = false;
}
/**
*
* @param {RowObject} rowObject
* @returns {DateString}
*/
getRawCellValue(rowObject) {
return rowObject.underlyingObject['_created'];
}
}

View File

@ -0,0 +1,41 @@
import { CellPrettyDate } from '../cells/renderer/CellPrettyDate'
import { ColumnBase } from './ColumnBase'
/**
* Column showing a pretty date
*/
export class DateColumnBase extends ColumnBase{
/**
*
* @param {RowObject} rowObject
* @returns {String} Name of the Cell renderer component
*/
getCellRenderer(rowObject) {
return CellPrettyDate.options.name;
}
/**
* Cell tooltip
* @param {Any} rawCellValue
* @param {RowObject} rowObject
* @returns {String}
*/
getCellTitle(rawCellValue, rowObject) {
return rawCellValue;
}
/**
* @param {RowObject} rowObject1
* @param {RowObject} rowObject2
* @returns {Number} -1, 0, 1
*/
compareRows(rowObject1, rowObject2) {
let dueDateStr1 = this.getRawCellValue(rowObject1);
let dueDateStr2 = this.getRawCellValue(rowObject2);
if (dueDateStr1 === dueDateStr2) return 0;
if (dueDateStr1 && dueDateStr2) {
return new Date(dueDateStr1) < new Date(dueDateStr2) ? -1 : 1;
}
return dueDateStr1 ? -1 : 1;
}
}

View File

@ -0,0 +1,19 @@
import {DateColumnBase} from './DateColumnBase'
/**
* Column showing the objects _updated prettyfied
*/
export class Updated extends DateColumnBase{
constructor() {
super('Updated', 'row-updated');
this.includedByDefault = false;
}
/**
*
* @param {RowObject} rowObject
* @returns {DateString}
*/
getRawCellValue(rowObject) {
return rowObject.underlyingObject['_updated'];
}
}

View File

@ -0,0 +1,130 @@
import '../../../menu/DropDown'
const TEMPLATE =`
<div class="pillar-table-column-filter">
<pillar-dropdown>
<i class="pi-cog"
slot="button"
title="Table Settings"/>
<ul class="settings-menu"
slot="menu"
>
Columns:
<li class="attract-column-select action"
v-for="c in columnStates"
:key="c.displayName"
@click="toggleColumn(c)"
>
<input type="checkbox"
v-model="c.isVisible"
/>
{{ c.displayName }}
</li>
</ul>
</pillar-dropdown>
</div>
`;
class ColumnState{
constructor() {
this.displayName;
this.isVisible;
this.isMandatory;
}
static createDefault(column) {
let state = new ColumnState;
state.displayName = column.displayName;
state.isVisible = !!column.includedByDefault;
state.isMandatory = !!column.isMandatory;
return state;
}
}
class ComponentState {
/**
* Serializable state of this component.
*
* @param {Array} selected The columns that should be visible
*/
constructor(selected) {
this.selected = selected;
}
}
/**
* Component to select what columns to render in the table.
*
* @emits visibleColumnsChanged(columns) When visible columns has changed
* @emits componentStateChanged(newState) When column filter state changed.
*/
let Filter = Vue.component('pillar-table-column-filter', {
template: TEMPLATE,
props: {
columns: Array, // Instances of ColumnBase
componentState: Object, // Instance of ComponentState
},
data() {
return {
columnStates: this.createInitialColumnStates(), // Instances of ColumnState
}
},
computed: {
visibleColumns() {
return this.columns.filter((candidate) => {
return candidate.isMandatory || this.isColumnStateVisible(candidate);
});
},
columnFilterState() {
return new ComponentState(this.visibleColumns.map(it => it.displayName));
}
},
watch: {
columns() {
this.columnStates = this.createInitialColumnStates();
},
visibleColumns(visibleColumns) {
this.$emit('visibleColumnsChanged', visibleColumns);
},
columnFilterState(newValue) {
this.$emit('componentStateChanged', newValue);
}
},
created() {
this.$emit('visibleColumnsChanged', this.visibleColumns);
},
methods: {
createInitialColumnStates() {
let columnStateCB = ColumnState.createDefault;
if (this.componentState && this.componentState.selected) {
let selected = this.componentState.selected;
columnStateCB = (column) => {
let state = ColumnState.createDefault(column);
state.isVisible = selected.includes(column.displayName);
return state;
}
}
return this.columns.reduce((states, c) => {
if(!c.isMandatory) {
states.push(columnStateCB(c));
}
return states;
}, []);
},
isColumnStateVisible(column) {
for (let state of this.columnStates) {
if (state.displayName === column.displayName) {
return state.isVisible;
}
}
return false;
},
toggleColumn(column) {
column.isVisible = !column.isVisible;
}
},
});
export { Filter }

View File

@ -1,10 +0,0 @@
const TEMPLATE =`
<div class="pillar-table-column"/>
`;
Vue.component('pillar-table-column', {
template: TEMPLATE,
props: {
column: Object
},
});

View File

@ -1,80 +0,0 @@
import '../../menu/DropDown'
const TEMPLATE =`
<div class="pillar-table-column-filter">
<pillar-dropdown>
<i class="pi-cog"
slot="button"
title="Table Settings"/>
<ul class="settings-menu"
slot="menu"
>
Columns:
<li class="attract-column-select"
v-for="c in columnStates"
:key="c._id"
>
<input type="checkbox"
v-model="c.isVisible"
/>
{{ c.displayName }}
</li>
</ul>
</pillar-dropdown>
</div>
`;
let Filter = Vue.component('pillar-table-column-filter', {
template: TEMPLATE,
props: {
columns: Array,
},
data() {
return {
columnStates: [],
}
},
computed: {
visibleColumns() {
return this.columns.filter((candidate) => {
return candidate.isMandatory || this.isColumnStateVisible(candidate);
});
}
},
watch: {
columns() {
this.columnStates = this.setColumnStates();
},
visibleColumns(visibleColumns) {
this.$emit('visibleColumnsChanged', visibleColumns);
}
},
created() {
this.$emit('visibleColumnsChanged', this.visibleColumns);
},
methods: {
setColumnStates() {
return this.columns.reduce((states, c) => {
if (!c.isMandatory) {
states.push({
_id: c._id,
displayName: c.displayName,
isVisible: true,
});
}
return states;
}, [])
},
isColumnStateVisible(column) {
for (let state of this.columnStates) {
if (state._id === column._id) {
return state.isVisible;
}
}
return false;
},
},
});
export { Filter }

View File

@ -1,45 +0,0 @@
const TEMPLATE =`
<div class="pillar-table-row-filter">
<input
placeholder="Filter by name"
v-model="nameQuery"
/>
</div>
`;
let RowFilter = Vue.component('pillar-table-row-filter', {
template: TEMPLATE,
props: {
rowObjects: Array
},
data() {
return {
nameQuery: '',
}
},
computed: {
nameQueryLoweCase() {
return this.nameQuery.toLowerCase();
},
visibleRowObjects() {
return this.rowObjects.filter((row) => {
return this.filterByName(row);
});
}
},
watch: {
visibleRowObjects(visibleRowObjects) {
this.$emit('visibleRowObjectsChanged', visibleRowObjects);
}
},
created() {
this.$emit('visibleRowObjectsChanged', this.visibleRowObjects);
},
methods: {
filterByName(rowObject) {
return rowObject.getName().toLowerCase().indexOf(this.nameQueryLoweCase) !== -1;
},
},
});
export { RowFilter }

View File

@ -1,11 +1,33 @@
/**
* Each object to be visualized in the table is wrapped in a RowBase object. Column cells interact with it,
*/
class RowBase { class RowBase {
constructor(underlyingObject) { constructor(underlyingObject) {
this.underlyingObject = underlyingObject; this.underlyingObject = underlyingObject;
this.isInitialized = false; this.isInitialized = false;
this.isCorrupt = false;
this.isSelected = false;
} }
/**
* Called after the row has been created to initalize async properties. Fetching child objects for instance
*/
thenInit() { thenInit() {
this.isInitialized = true return this._thenInitImpl()
.then(() => {
this.isInitialized = true;
})
.catch((err) => {
console.warn(err);
this.isCorrupt = true;
throw err;
})
}
/**
* Override to initialize async properties such as fetching child objects.
*/
_thenInitImpl() {
return Promise.resolve(); return Promise.resolve();
} }
@ -21,11 +43,23 @@ class RowBase {
return this.underlyingObject.properties; return this.underlyingObject.properties;
} }
/**
* The css classes that should be applied to the row in the table
*/
getRowClasses() { getRowClasses() {
return { return {
"is-busy": !this.isInitialized "active": this.isSelected,
"is-busy": !this.isInitialized,
"is-corrupt": this.isCorrupt
} }
} }
/**
* A row could have children (shots has tasks for example). Children should also be instances of RowObject
*/
getChildObjects() {
return [];
}
} }
export { RowBase } export { RowBase }

View File

@ -1,13 +1,28 @@
/**
* The provider of RowObjects to a table.
* Extend to fit your purpose.
*/
class RowObjectsSourceBase { class RowObjectsSourceBase {
constructor(projectId) { constructor() {
this.projectId = projectId;
this.rowObjects = []; this.rowObjects = [];
} }
// Override this /**
thenInit() { * Should be overriden to fetch and create the row objects to we rendered in the table. The Row objects should be stored in
* this.rowObjects
*/
thenGetRowObjects() {
throw Error('Not implemented'); throw Error('Not implemented');
} }
/**
* Inits all its row objects.
*/
thenInit() {
return Promise.all(
this.rowObjects.map(it => it.thenInit())
);
}
} }
export { RowObjectsSourceBase } export { RowObjectsSourceBase }

View File

@ -0,0 +1,153 @@
const TEMPLATE =`
<pillar-dropdown>
<i class="pi-filter"
slot="button"
:class="enumButtonClasses"
title="Filter rows"
/>
<ul class="settings-menu"
slot="menu"
>
<li>
{{ label }}:
</li>
<li class="action"
@click="toggleAll"
>
<input type="checkbox"
:checked="includesRows"
/> Toggle All
</li>
<li class="input-group-separator"/>
<li v-for="val in enumVisibilities"
class="action"
:key="val.value"
@click="toggleEnum(val.value)"
>
<input type="checkbox"
v-model="enumVisibilities[val.value].isVisible"
/> {{ val.displayName }}
</li>
</ul>
</pillar-dropdown>
`;
class EnumState{
constructor(displayName, value, isVisible) {
this.displayName = displayName;
this.value = value;
this.isVisible = isVisible;
}
}
class ComponentState {
/**
* Serializable state of this component.
*
* @param {Array} selected The enums that should be visible
*/
constructor(selected) {
this.selected = selected;
}
}
/**
* Filter row objects based on enumeratable values.
*
* @emits visibleRowObjectsChanged(rowObjects) When the objects to be visible has changed.
* @emits componentStateChanged(newState) When row filter state changed.
*/
let EnumFilter = {
template: TEMPLATE,
props: {
label: String,
availableValues: Array, // Array with valid values [{value: abc, displayName: xyz},...]
componentState: Object, // Instance of ComponentState.
valueExtractorCB: {
// Callback to extract enumvalue from a rowObject
type: Function,
default: (rowObject) => {throw Error("Not Implemented")}
},
rowObjects: Array,
},
data() {
return {
enumVisibilities: this.initEnumVisibilities(),
}
},
computed: {
visibleRowObjects() {
return this.rowObjects.filter((row) => {
return this.shouldBeVisible(row);
});
},
includesRows() {
for (const key in this.enumVisibilities) {
if(!this.enumVisibilities[key].isVisible) return false;
}
return true;
},
enumButtonClasses() {
return {
'filter-active': !this.includesRows
}
},
currentComponentState() {
let visibleEnums = [];
for (const key in this.enumVisibilities) {
const enumState = this.enumVisibilities[key];
if (enumState.isVisible) {
visibleEnums.push(enumState.value);
}
}
return new ComponentState(visibleEnums);
}
},
watch: {
visibleRowObjects(visibleRowObjects) {
this.$emit('visibleRowObjectsChanged', visibleRowObjects);
},
currentComponentState(newValue) {
this.$emit('componentStateChanged', newValue);
}
},
created() {
this.$emit('visibleRowObjectsChanged', this.visibleRowObjects);
},
methods: {
shouldBeVisible(rowObject) {
let value = this.valueExtractorCB(rowObject);
if (typeof this.enumVisibilities[value] === 'undefined') {
console.warn(`RowObject ${rowObject.getId()} has an invalid ${this.label} enum: ${value}`)
return true;
}
return this.enumVisibilities[value].isVisible;
},
initEnumVisibilities() {
let initialValueCB = () => true;
if (this.componentState && this.componentState.selected) {
initialValueCB = (val) => {
return this.componentState.selected.includes(val.value);
};
}
return this.availableValues.reduce((agg, val)=> {
agg[val.value] = new EnumState(val.displayName, val.value, initialValueCB(val));
return agg;
}, {});
},
toggleEnum(value) {
this.enumVisibilities[value].isVisible = !this.enumVisibilities[value].isVisible;
},
toggleAll() {
let newValue = !this.includesRows;
for (const key in this.enumVisibilities) {
this.enumVisibilities[key].isVisible = newValue;
}
}
},
};
export { EnumFilter }

View File

@ -0,0 +1,35 @@
import {TextFilter} from './TextFilter'
const TEMPLATE =`
<text-filter
label="Name"
:componentState="componentState"
:rowObjects="rowObjects"
:valueExtractorCB="extractName"
@visibleRowObjectsChanged="$emit('visibleRowObjectsChanged', ...arguments)"
@componentStateChanged="$emit('componentStateChanged', ...arguments)"
>
`;
/**
* Filter row objects based on there name.
*
* @emits visibleRowObjectsChanged(rowObjects) When the objects to be visible has changed.
* @emits componentStateChanged(newState) When row filter state changed.
*/
let NameFilter = {
template: TEMPLATE,
props: {
componentState: Object, // Instance of object that componentStateChanged emitted. To restore previous state.
rowObjects: Array,
},
methods: {
extractName(rowObject) {
return rowObject.getName();
},
},
components: {
'text-filter': TextFilter,
},
};
export { NameFilter }

View File

@ -0,0 +1,25 @@
import {NameFilter} from './NameFilter'
const TEMPLATE =`
<div class="pillar-table-row-filter">
<name-filter
:rowObjects="rowObjects"
:componentState="componentState"
@visibleRowObjectsChanged="$emit('visibleRowObjectsChanged', ...arguments)"
@componentStateChanged="$emit('componentStateChanged', ...arguments)"
/>
</div>
`;
let RowFilter = {
template: TEMPLATE,
props: {
rowObjects: Array,
componentState: Object
},
components: {
'name-filter': NameFilter
}
};
export { RowFilter }

View File

@ -0,0 +1,48 @@
import {EnumFilter} from './EnumFilter'
const TEMPLATE =`
<enum-filter
label="Status"
:availableValues="availableEnumValues"
:componentState="componentState"
:rowObjects="rowObjects"
:valueExtractorCB="extractStatus"
@visibleRowObjectsChanged="$emit('visibleRowObjectsChanged', ...arguments)"
@componentStateChanged="$emit('componentStateChanged', ...arguments)"
>
`;
/**
* Filter row objects based on there status.
*
* @emits visibleRowObjectsChanged(rowObjects) When the objects to be visible has changed.
* @emits componentStateChanged(newState) When row filter state changed.
*/
let StatusFilter = {
template: TEMPLATE,
props: {
availableStatuses: Array, // Array with valid values ['abc', 'xyz']
componentState: Object, // Instance of object that componentStateChanged emitted. To restore previous state.
rowObjects: Array,
},
computed: {
availableEnumValues() {
let statusCopy = this.availableStatuses.concat().sort()
return statusCopy.map(status =>{
return {
value: status,
displayName: status.replace(/-|_/g, ' ') // Replace -(dash) and _(underscore) with space
}
});
}
},
methods: {
extractStatus(rowObject) {
return rowObject.getStatus();
},
},
components: {
'enum-filter': EnumFilter,
},
};
export { StatusFilter }

View File

@ -0,0 +1,86 @@
const TEMPLATE =`
<input
:class="textInputClasses"
:placeholder="placeholderText"
v-model="textQuery"
/>
`;
class ComponentState {
/**
* Serializable state of this component.
*
* @param {String} textQuery
*/
constructor(textQuery) {
this.textQuery = textQuery;
}
}
/**
* Component to filter rowobjects by a text value
*
* @emits visibleRowObjectsChanged(rowObjects) When the objects to be visible has changed.
* @emits componentStateChanged(newState) When row filter state changed. Filter query...
*/
let TextFilter = {
template: TEMPLATE,
props: {
label: String,
rowObjects: Array,
componentState: {
// Instance of ComponentState
type: Object,
default: undefined
},
valueExtractorCB: {
// Callback to extract text to filter from a rowObject
type: Function,
default: (rowObject) => {throw Error("Not Implemented")}
}
},
data() {
return {
textQuery: (this.componentState || {}).textQuery || '',
}
},
computed: {
textQueryLoweCase() {
return this.textQuery.toLowerCase();
},
visibleRowObjects() {
return this.rowObjects.filter((row) => {
return this.filterByText(row);
});
},
textInputClasses() {
return {
'filter-active': this.textQuery.length > 0
};
},
currentComponentState() {
return new ComponentState(this.textQuery);
},
placeholderText() {
return `Filter by ${this.label}`;
}
},
watch: {
visibleRowObjects(visibleRowObjects) {
this.$emit('visibleRowObjectsChanged', visibleRowObjects);
},
currentComponentState(newValue) {
this.$emit('componentStateChanged', newValue);
}
},
created() {
this.$emit('visibleRowObjectsChanged', this.visibleRowObjects);
},
methods: {
filterByText(rowObject) {
return (this.valueExtractorCB(rowObject) || '').toLowerCase().indexOf(this.textQueryLoweCase) !== -1;
},
},
};
export { TextFilter }

View File

@ -9,7 +9,9 @@ const TEMPLATE =`
/> />
</div> </div>
`; `;
/**
* @emits sort(column,direction) When a column head has been clicked
*/
Vue.component('pillar-table-head', { Vue.component('pillar-table-head', {
template: TEMPLATE, template: TEMPLATE,
props: { props: {

View File

@ -1,18 +1,23 @@
import '../../cells/renderer/CellProxy' import '../../cells/renderer/CellProxy'
const TEMPLATE =` const TEMPLATE =`
<div class="pillar-table-row" <div class="pillar-table-row"
:class="rowClasses" :class="rowClasses"
@click.prevent.stop="$emit('item-clicked', arguments[0], rowObject.getId())"
> >
<pillar-cell-proxy <pillar-cell-proxy
v-for="c in columns" v-for="c in columns"
:rowObject="rowObject" :rowObject="rowObject"
:column="c" :column="c"
:key="c._id" :key="c._id"
@item-clicked="$emit('item-clicked', ...arguments)"
/> />
</div> </div>
`; `;
/**
* @emits item-clicked(mouseEvent,itemId) When a RowObject has been clicked
*/
Vue.component('pillar-table-row', { Vue.component('pillar-table-row', {
template: TEMPLATE, template: TEMPLATE,
props: { props: {
@ -23,5 +28,5 @@ Vue.component('pillar-table-row', {
rowClasses() { rowClasses() {
return this.rowObject.getRowClasses(); return this.rowObject.getRowClasses();
} }
} },
}); });

View File

@ -6,7 +6,9 @@ const TEMPLATE = `
</div> </div>
`; `;
Vue.component('user-avatar', { let UserAvatar = Vue.component('user-avatar', {
template: TEMPLATE, template: TEMPLATE,
props: {user: Object}, props: {user: Object},
}); });
export {UserAvatar}

View File

@ -0,0 +1,2 @@
Gulp will transpile everything in this folder. Every sub folder containing a init.js file will become a file named
FOLDER_NAME.js containing the exported functions/classes under the namespace pillar.FOLDER_NAME.

View File

@ -68,7 +68,8 @@ function containerResizeY(window_height){
var container_offset = project_container.offsetTop; var container_offset = project_container.offsetTop;
var container_height = window_height - container_offset.top; var container_height = window_height - container_offset.top;
var container_height_wheader = window_height - container_offset; var container_height_wheader = window_height - container_offset;
var window_height_minus_nav = window_height - container_offset; var breadcrumbs_height = $('.breadcrumbs-container').first().height();
var window_height_minus_nav = (window_height - container_offset);
if ($(window).width() > 768) { if ($(window).width() > 768) {
$('#project-container').css( $('#project-container').css(
@ -76,6 +77,10 @@ function containerResizeY(window_height){
'height': window_height_minus_nav + 'px'} 'height': window_height_minus_nav + 'px'}
); );
$('#project_context, #project_context-header').css(
{'top' : breadcrumbs_height}
);
$('#project_nav-container, #project_tree').css( $('#project_nav-container, #project_tree').css(
{'max-height': (window_height_minus_nav) + 'px', {'max-height': (window_height_minus_nav) + 'px',
'height': (window_height_minus_nav) + 'px'} 'height': (window_height_minus_nav) + 'px'}
@ -93,3 +98,41 @@ function containerResizeY(window_height){
}; };
}; };
function loadProjectSidebar(){
var bcloud_ui = Cookies.getJSON('bcloud_ui');
if (bcloud_ui && bcloud_ui.hide_project_sidebar) {
hideProjectSidebar();
} else {
showProjectSidebar();
};
}
function showProjectSidebar(){
Cookies.remove('bcloud_ui', 'hide_project_sidebar');
$('#project-container').addClass('is-sidebar-visible');
// Hide the toggle button.
$('.breadcrumbs-container .project-sidebar-toggle').hide();
}
function hideProjectSidebar(){
setJSONCookie('bcloud_ui', 'hide_project_sidebar', true);
$('#project-container').removeClass('is-sidebar-visible');
// Show the toggle button.
$('.breadcrumbs-container .project-sidebar-toggle').show();
}
function toggleProjectSidebar(){
let $projectContainer = $('#project-container');
if ($projectContainer.hasClass('is-sidebar-visible')) {
hideProjectSidebar();
} else {
showProjectSidebar();
};
}

View File

@ -16,22 +16,41 @@ body.svnman, body.edit_node_types, body.search-project
overflow-y: auto overflow-y: auto
z-index: $z-index-base z-index: $z-index-base
&.is-sidebar-visible
#project-side-container
@extend .d-flex
.breadcrumbs-container
+media-xs +media-xs
flex-direction: column-reverse left: $project_nav-width-xs
min-height: auto +media-sm
left: $project_nav-width-sm
+media-md
left: $project_nav-width-md
+media-lg
left: $project_nav-width-lg
+media-xl
left: $project_nav-width-xl
#project-side-container #project-side-container
display: flex display: none
+media-xs +media-xs
flex-direction: column-reverse position: fixed
bottom: 0
right: 0
top: $project_header-height
left: 0
z-index: 1
height: 100%
#project_nav, #project_nav,
#project_tree, #project_tree,
#project_nav-container #project_nav-container
+media-xs +media-xs
width: $project_nav-width-xs height: 100vh
width: 100%
+media-sm +media-sm
width: $project_nav-width-sm width: $project_nav-width-sm
+media-md +media-md
@ -44,14 +63,13 @@ body.svnman, body.edit_node_types, body.search-project
width: $project_nav-width width: $project_nav-width
#project_nav-container #project_nav-container
+media-xs
display: block
height: initial !important
position: relative
position: fixed position: fixed
z-index: $z-index-base + 5 z-index: $z-index-base + 5
.project-sidebar-toggle
right: 5px
z-index: 1
#project_sidebar #project_sidebar
box-shadow: inset -1px 0 0 0 $color-background box-shadow: inset -1px 0 0 0 $color-background
flex-shrink: 0 flex-shrink: 0
@ -111,6 +129,8 @@ body.svnman, body.edit_node_types, body.search-project
right: 0 right: 0
z-index: $z-index-base + 3 z-index: $z-index-base + 3
+media-xs
bottom: 0
/* Edit Asset buttons */ /* Edit Asset buttons */
.project-mode-view, .project-mode-view,
@ -237,11 +257,21 @@ ul.project-edit-tools
min-height: 800px min-height: 800px
border: none border: none
.breadcrumbs-container
top: $project_header-height + 1
#project_context
.node-details-description
font:
size: 1.2em
weight: 200
img
margin-bottom: 2rem
margin-top: 2rem
/* The actual navigation tree container */ /* The actual navigation tree container */
#project_tree #project_tree
+media-xs
margin-top: 0
overflow-y: auto // show vertical scrollbars when needed. overflow-y: auto // show vertical scrollbars when needed.
padding: 5px 0 // some padding on top/bottom of jsTree. padding: 5px 0 // some padding on top/bottom of jsTree.
position: relative position: relative
@ -1978,3 +2008,7 @@ a.learn-more
padding: 5px 35px padding: 5px 35px
text-align: center text-align: center
// Node Type: Page
.page
.node-details-description
font-size: 1.3em

View File

@ -69,6 +69,7 @@
@import components/tooltip @import components/tooltip
@import components/checkbox @import components/checkbox
@import components/overlay @import components/overlay
@import components/pillar_table
/* Top level, standalone stylesheets (not starting with _ so not meant for importing) /* Top level, standalone stylesheets (not starting with _ so not meant for importing)
* should not have pure styling here. * should not have pure styling here.

View File

@ -51,6 +51,16 @@
@import _comments @import _comments
@import _notifications @import _notifications
body.blog
.node-details-description
font:
size: 1.3em
weight: 200
img
margin-bottom: 2rem
margin-top: 2rem
#blog_post-edit-form #blog_post-edit-form
padding: 20px padding: 20px

View File

@ -0,0 +1,43 @@
.breadcrumbs
@extend .bg-dark
@extend .text-secondary
flex: 1
font-size: $font-size-xs
ul
@extend .d-flex
@extend .list-unstyled
@extend .m-0
@extend .align-items-center
li
@extend .position-relative
@extend .pr-1
// Triangle indicator on the right of items.
&:after
content: '\e83a'
font-family: "pillar-font"
position: absolute
right: 0
top: $font-size-xs / 2.2
// Remove indicator on the last item.
&:last-child
&:after
display: none
a, span
@extend .d-block
@extend .py-1
@extend .px-2
a
@extend .text-white
span
cursor: default
// .breadcrumbs-container
&-container
@extend .d-flex

View File

@ -0,0 +1,163 @@
$thumbnail-max-width: 110px
$thumbnail-max-height: calc(110px * (9/16))
.pillar-table-container
background-color: white
height: 100%
.pillar-table
display: flex
flex-direction: column
width: 100%
height: 95% // TODO: Investigate why some rows are outside screen if 100%
.pillar-table-head
display: flex
flex-direction: row
position: relative
box-shadow: 0 5 $color-background-dark
.cell-content
display: flex
flex-direction: row
align-items: center
height: 100%
font-size: .9em
.column-sort
display: flex
opacity: 0
flex-direction: column
.sort-action
&:hover
background-color: $color-background-active
&:hover
.column-sort
opacity: 1
.pillar-table-row-group
display: block
overflow-y: auto
height: 100%
.pillar-cell
padding-left: 0.3em
.pillar-table-row:nth-child(odd)
background-color: $color-background-dark
.pillar-table-row
display: flex
flex-direction: row
transition: all 250ms ease-in-out
background-color: $color-background-light
cursor: pointer
&:hover
background-color: $color-background-active
&.is-busy
+stripes-animate
+stripes(transparent, rgba($color-background-active, .6), -45deg, 4em)
animation-duration: 4s
&.is-corrupt
background-color: $color-warning
&.active
border-left: 0.5em solid $color-background-active
.pillar-cell
display: flex
flex-direction: column
flex-grow: 1
flex-basis: 0
overflow: hidden
white-space: nowrap
text-overflow: ellipsis
justify-content: center
min-width: 2em
&.highlight
background-color: rgba($color-background-active, .4)
&.warning
background-color: rgba($color-warning, .4)
&.header-cell
text-transform: capitalize
color: $color-text-dark-secondary
.header-label
overflow: hidden
&.thumbnail
flex: 0
flex-basis: $thumbnail-max-width
text-align: center
img
max-width: $thumbnail-max-width
height: auto
a
overflow: hidden
text-overflow: ellipsis
@include status-color-property(background-color, '', '')
.pillar-table-menu
display: flex
flex-direction: row
.action
cursor: pointer
.settings-menu
display: flex
flex-direction: column
position: absolute
background-color: white
list-style: none
margin: 0
padding: 0
text-transform: capitalize
z-index: $z-index-base + 1
box-shadow: 0 2px 5px rgba(black, .4)
user-select: none
.pillar-table-row-filter
display: flex
flex-direction: row
input.filter-active
background-color: rgba($color-info, .50)
.pi-filter.filter-active
color: $color-info
.pillar-table-actions
margin-left: auto
.action
cursor: pointer
vertical-align: middle
color: $color-primary
border: none
background: none
&:hover
text-decoration-line: underline
.pillar-table-column-filter
margin-left: auto
.settings-menu
right: 0em
.pillar-table-row-item
display: inline-block
.pillar-table-row-enter, .pillar-table-row-leave-to
opacity: 0

View File

@ -1,12 +1,18 @@
.timeline .timeline
// Shown briefly while the timeline is loading.
&.placeholder
color: $color-text-dark-hint
height: 75vh // Timelines are usually long, so scale the placeholder to almost viewport height.
.group .group
opacity: 0 opacity: 0
animation: fade-in 500ms forwards animation: fade-in 500ms forwards
.group-date &-date // .group-date
color: rgba($color-text, .4) color: $color-text-dark-hint
.group-title &-title // .group-title
@extend .border-bottom @extend .border-bottom
@extend .bg-white @extend .bg-white
@extend .text-uppercase @extend .text-uppercase
@ -14,6 +20,15 @@
a a
color: $color-text color: $color-text
.node-details-description
font:
size: 1.2em
weight: 200
img
margin-bottom: 2rem
margin-top: 2rem
body.homepage body.homepage
.timeline .timeline
.sticky-top .sticky-top
@ -23,3 +38,22 @@ body.is-mobile
.timeline .timeline
.js-asset-list .js-asset-list
@extend .card-deck-vertical @extend .card-deck-vertical
// Overrides for when the timeline is against a dark background.
.timeline-dark
.group
animation: fade-in 500ms forwards
opacity: 0
&-date // .group-date
color: $color-text-light-hint
&-title // .group-title
@extend .bg-transparent
border-bottom-color: rgba($color-background, .2) !important
color: rgba($color-background, .6)
blockquote
background-color: rgba($color-background, .1)
box-shadow: inset 5px 0 0 rgba($color-background, .2)

View File

@ -20,12 +20,11 @@ $tree-color-highlight-background-text: $primary
&[data-node-type="texture"], &[data-node-type="texture"],
&[data-node-type="hdri"] &[data-node-type="hdri"]
.jstree-anchor .jstree-anchor
padding-right: 20px padding-right: 20px // Make room for the angle-right icon.
&[is_free='true'] &[is_free='true']
.jstree-anchor .jstree-anchor
padding-right: initial padding-right: initial
&:after &:after
color: $tree-color-highlight color: $tree-color-highlight
content: '\e84e' !important content: '\e84e' !important
@ -36,7 +35,7 @@ $tree-color-highlight-background-text: $primary
font-weight: bold font-weight: bold
.jstree-anchor .jstree-anchor
padding: 0 6px padding: 0 5px
&:after &:after
top: 3px !important top: 3px !important
@ -61,35 +60,22 @@ $tree-color-highlight-background-text: $primary
&.jstree-open &.jstree-open
/* Text of children for an open tree (like a folder) */ /* Text of children for an open tree (like a folder) */
.jstree-children > .jstree-node .jstree-children > .jstree-node
padding-left: 16px !important padding-left: 25px !important
&:before
box-shadow: inset 5px 0 0 rgba($tree-color-text, .1)
content: ' '
height: 100%
width: 2px
position: absolute
left: 8px
.jstree-icon:empty .jstree-icon:empty
left: 20px !important
// Tweaks for specific icons
&.pi-file-archive
left: 25px !important left: 25px !important
&.pi-folder
left: 20px !important
font-size: .9em !important
&.pi-splay
left: 20px !important
font-size: .85em !important
.jstree-anchor
// box-shadow: inset 1px 0 0 0 $color-background
/* Closed Folder */ /* Closed Folder */
// &.jstree-closed // &.jstree-closed
&.jstree-open .jstree-icon.jstree-ocl,
&.jstree-closed .jstree-icon.jstree-ocl
float: left
min-width: 30px
opacity: 0
position: absolute
z-index: 1
/* The text of the last level item */ /* The text of the last level item */
.jstree-anchor .jstree-anchor
+media-xs +media-xs
@ -97,11 +83,8 @@ $tree-color-highlight-background-text: $primary
width: 98% width: 98%
border: none border: none
font-size: 13px font-size: 13px
height: inherit
line-height: 24px
overflow: hidden overflow: hidden
padding-left: 28px padding-left: 25px
padding-right: 10px
text-overflow: ellipsis text-overflow: ellipsis
white-space: nowrap white-space: nowrap
width: 100% width: 100%
@ -150,9 +133,7 @@ $tree-color-highlight-background-text: $primary
.jstree-clicked > .jstree-ocl .jstree-clicked > .jstree-ocl
color: $tree-color-highlight-background-text !important color: $tree-color-highlight-background-text !important
background-color: transparent !important background-color: transparent !important
border-radius: 0
box-shadow: none box-shadow: none
border-bottom: thin solid transparent
.jstree-ocl:before .jstree-ocl:before
+media-xs +media-xs
@ -177,14 +158,13 @@ $tree-color-highlight-background-text: $primary
&:empty &:empty
line-height: 24px line-height: 24px
left: 3px left: 1px
&.is_subscriber &.is_subscriber
.jstree-node .jstree-node
&[is_free='true'] &[is_free='true']
.jstree-anchor .jstree-anchor
padding-right: initial padding-right: initial
&:after &:after
display: none !important display: none !important
@ -204,7 +184,7 @@ $tree-color-highlight-background-text: $primary
.jstree-default .jstree-node.jstree-closed .jstree-icon.jstree-ocl + .jstree-anchor, .jstree-default .jstree-node.jstree-closed .jstree-icon.jstree-ocl + .jstree-anchor,
.jstree-default .jstree-node.jstree-open .jstree-icon.jstree-ocl + .jstree-anchor .jstree-default .jstree-node.jstree-open .jstree-icon.jstree-ocl + .jstree-anchor
padding-left: 24px !important padding-left: 25px !important
/* hovered text */ /* hovered text */
.jstree-default .jstree-hovered, .jstree-default .jstree-hovered,

View File

@ -11,7 +11,7 @@ mixin jumbotron(title, text, image, url)
href=url)&attributes(attributes) href=url)&attributes(attributes)
.container .container
.row .row
.col-md-9 .col-md-8
if title if title
.display-4.text-uppercase.font-weight-bold .display-4.text-uppercase.font-weight-bold
=title =title
@ -24,7 +24,7 @@ mixin jumbotron(title, text, image, url)
.jumbotron.text-white(style='background-image: url(' + image + ');')&attributes(attributes) .jumbotron.text-white(style='background-image: url(' + image + ');')&attributes(attributes)
.container .container
.row .row
.col-md-9 .col-md-6
if title if title
.display-4.text-uppercase.font-weight-bold .display-4.text-uppercase.font-weight-bold
=title =title
@ -73,15 +73,31 @@ mixin list-asset(name, url, image, type, date)
if block if block
block block
// used together with timeline.js
//- Used together with timeline.js
mixin timeline(projectid, sortdirection) mixin timeline(projectid, sortdirection)
section.timeline.placeholder( section.timeline.placeholder(
data-project-id=projectid, data-project-id=projectid,
data-sort-dir=sortdirection, data-sort-dir=sortdirection,
) )
// TODO: Make nicer reuseable placeholder .d-flex.w-100.h-100
.h3.text-center.text-secondary.p-5.border-bottom //- TODO: Make nicer reuseable placeholder
.h3.text-muted.m-auto
i.pi-spin.spin i.pi-spin.spin
//- Category listing (Learn, Libraries, etc).
mixin category_list_item(title, text, url, image)
.row.pb-2.my-2
.col-md-3
a(href=url, title=title)
img.img-fluid(alt=title, src=image)
.col-md-9
a(href=url, title=title)
h3.font-weight-bold
=title
.lead
=text
if block
block

View File

@ -3,9 +3,20 @@ include ../../../mixins/components
| {% set title = node.properties.url %} | {% set title = node.properties.url %}
//- Remove custom classes applied by the landing template (that turn background black).
| {% block bodyclasses %}page{% endblock %}
| {% block body %} | {% block body %}
.expand-image-links.imgs-fluid | {% if project and project.has_method('PUT') %}
+nav-secondary(class="bg-light border-bottom")
+nav-secondary-link(
href="{{ url_for('nodes.edit', node_id=node._id) }}")
i.pi-edit.pr-2
span Edit Post
| {% endif %}
| {% if node.picture %} | {% if node.picture %}
.expand-image-links.imgs-fluid
+jumbotron( +jumbotron(
"{{ node.name }}", "{{ node.name }}",
null, null,
@ -13,6 +24,14 @@ include ../../../mixins/components
"{{ node.url }}") "{{ node.url }}")
| {% endif %} | {% endif %}
.container.pt-5
.row
.col-12.text-center
h2.text-uppercase.font-weight-bold
| {{ node.name }}
hr.pb-2
.container.pb-5 .container.pb-5
.row .row
.col-8.mx-auto .col-8.mx-auto

View File

@ -78,7 +78,7 @@
| {% endif %} | {% endif %}
| {% else %} | {% else %}
| {{ field(class='hidden') }} | {{ field(class='d-none') }}
| {% endif %} | {% endif %}
| {% endif %} | {% endif %}

View File

@ -631,21 +631,25 @@ class RequireRolesTest(AbstractPillarTest):
def test_some_roles_required(self): def test_some_roles_required(self):
from pillar.api.utils.authorization import require_login from pillar.api.utils.authorization import require_login
called = [False] called = False
@require_login(require_roles={'admin'}) @require_login(require_roles={'admin'})
def call_me(): def call_me():
called[0] = True nonlocal called
called = True
return None
with self.app.test_request_context(): with self.app.test_request_context():
self.login_api_as(ObjectId(24 * 'a'), ['succubus']) self.login_api_as(ObjectId(24 * 'a'), ['succubus'])
self.assertRaises(Forbidden, call_me) resp = call_me()
self.assertFalse(called[0]) self.assertEqual(403, resp.status_code)
self.assertFalse(called, 'Forbidden function should not have been called')
with self.app.test_request_context(): with self.app.test_request_context():
self.login_api_as(ObjectId(24 * 'a'), ['admin']) self.login_api_as(ObjectId(24 * 'a'), ['admin'])
call_me() resp = call_me()
self.assertTrue(called[0]) self.assertIsNone(resp)
self.assertTrue(called)
def test_all_roles_required(self): def test_all_roles_required(self):
from pillar.api.utils.authorization import require_login from pillar.api.utils.authorization import require_login
@ -659,17 +663,20 @@ class RequireRolesTest(AbstractPillarTest):
with self.app.test_request_context(): with self.app.test_request_context():
self.login_api_as(ObjectId(24 * 'a'), ['admin']) self.login_api_as(ObjectId(24 * 'a'), ['admin'])
self.assertRaises(Forbidden, call_me) resp = call_me()
self.assertEqual(403, resp.status_code)
self.assertFalse(called[0]) self.assertFalse(called[0])
with self.app.test_request_context(): with self.app.test_request_context():
self.login_api_as(ObjectId(24 * 'a'), ['service']) self.login_api_as(ObjectId(24 * 'a'), ['service'])
self.assertRaises(Forbidden, call_me) resp = call_me()
self.assertEqual(403, resp.status_code)
self.assertFalse(called[0]) self.assertFalse(called[0])
with self.app.test_request_context(): with self.app.test_request_context():
self.login_api_as(ObjectId(24 * 'a'), ['badger']) self.login_api_as(ObjectId(24 * 'a'), ['badger'])
self.assertRaises(Forbidden, call_me) resp = call_me()
self.assertEqual(403, resp.status_code)
self.assertFalse(called[0]) self.assertFalse(called[0])
with self.app.test_request_context(): with self.app.test_request_context():
@ -702,7 +709,8 @@ class RequireRolesTest(AbstractPillarTest):
with self.app.test_request_context(): with self.app.test_request_context():
self.login_api_as(ObjectId(24 * 'a'), ['succubus']) self.login_api_as(ObjectId(24 * 'a'), ['succubus'])
self.assertRaises(Forbidden, call_me) resp = call_me()
self.assertEqual(403, resp.status_code)
self.assertFalse(called[0]) self.assertFalse(called[0])
with self.app.test_request_context(): with self.app.test_request_context():

View File

View File

@ -0,0 +1,127 @@
from pillar.tests import AbstractPillarTest
import flask
import werkzeug.wrappers as wz_wrappers
import werkzeug.exceptions as wz_exceptions
class CorsWrapperTest(AbstractPillarTest):
def test_noncors_request(self):
from pillar.auth.cors import allow
@allow()
def wrapped(a, b):
return f'{a} and {b}'
with self.app.test_request_context():
resp = wrapped('x', 'y')
self.assertEqual('x and y', resp, 'Non-CORS request should not be modified')
def test_string_response(self):
from pillar.auth.cors import allow
@allow()
def wrapped(a, b):
return f'{a} and {b}'
with self.app.test_request_context(headers={'Origin': 'http://jemoeder.nl:1234/'}):
resp = wrapped('x', 'y')
self.assertIsInstance(resp, wz_wrappers.Response)
self.assertEqual(b'x and y', resp.data)
self.assertEqual(200, resp.status_code)
self.assertEqual('http://jemoeder.nl:1234/', resp.headers['Access-Control-Allow-Origin'])
self.assertEqual('x-requested-with', resp.headers['Access-Control-Allow-Headers'])
self.assertNotIn('Access-Control-Allow-Credentials', resp.headers)
def test_string_with_code_response(self):
from pillar.auth.cors import allow
@allow()
def wrapped(a, b):
return f'{a} and {b}', 403
with self.app.test_request_context(headers={'Origin': 'http://jemoeder.nl:1234/'}):
resp = wrapped('x', 'y')
self.assertIsInstance(resp, wz_wrappers.Response)
self.assertEqual(b'x and y', resp.data)
self.assertEqual(403, resp.status_code)
self.assertEqual('http://jemoeder.nl:1234/', resp.headers['Access-Control-Allow-Origin'])
self.assertEqual('x-requested-with', resp.headers['Access-Control-Allow-Headers'])
self.assertNotIn('Access-Control-Allow-Credentials', resp.headers)
def test_flask_response_object(self):
from pillar.auth.cors import allow
@allow()
def wrapped(a, b):
return flask.Response(f'{a} and {b}', status=147, headers={'op-je': 'hoofd'})
with self.app.test_request_context(headers={'Origin': 'http://jemoeder.nl:1234/'}):
resp = wrapped('x', 'y')
self.assertIsInstance(resp, wz_wrappers.Response)
self.assertEqual(b'x and y', resp.data)
self.assertEqual(147, resp.status_code)
self.assertEqual('hoofd', resp.headers['Op-Je'])
self.assertEqual('http://jemoeder.nl:1234/', resp.headers['Access-Control-Allow-Origin'])
self.assertEqual('x-requested-with', resp.headers['Access-Control-Allow-Headers'])
self.assertNotIn('Access-Control-Allow-Credentials', resp.headers)
def test_wz_exception(self):
from pillar.auth.cors import allow
@allow()
def wrapped(a, b):
raise wz_exceptions.NotImplemented('nee')
with self.app.test_request_context(headers={'Origin': 'http://jemoeder.nl:1234/'}):
resp = wrapped('x', 'y')
self.assertIsInstance(resp, wz_wrappers.Response)
self.assertIn(b'nee', resp.data)
self.assertEqual(501, resp.status_code)
self.assertEqual('http://jemoeder.nl:1234/', resp.headers['Access-Control-Allow-Origin'])
self.assertEqual('x-requested-with', resp.headers['Access-Control-Allow-Headers'])
self.assertNotIn('Access-Control-Allow-Credentials', resp.headers)
def test_flask_abort(self):
from pillar.auth.cors import allow
@allow()
def wrapped(a, b):
raise flask.abort(401)
with self.app.test_request_context(headers={'Origin': 'http://jemoeder.nl:1234/'}):
resp = wrapped('x', 'y')
self.assertIsInstance(resp, wz_wrappers.Response)
self.assertEqual(401, resp.status_code)
self.assertEqual('http://jemoeder.nl:1234/', resp.headers['Access-Control-Allow-Origin'])
self.assertEqual('x-requested-with', resp.headers['Access-Control-Allow-Headers'])
self.assertNotIn('Access-Control-Allow-Credentials', resp.headers)
def test_with_credentials(self):
from pillar.auth.cors import allow
@allow(allow_credentials=True)
def wrapped(a, b):
return f'{a} and {b}'
with self.app.test_request_context(headers={'Origin': 'http://jemoeder.nl:1234/'}):
resp = wrapped('x', 'y')
self.assertIsInstance(resp, wz_wrappers.Response)
self.assertEqual(b'x and y', resp.data)
self.assertEqual(200, resp.status_code)
self.assertEqual('http://jemoeder.nl:1234/', resp.headers['Access-Control-Allow-Origin'])
self.assertEqual('x-requested-with', resp.headers['Access-Control-Allow-Headers'])
self.assertEqual('true', resp.headers['Access-Control-Allow-Credentials'])

View File

@ -1,5 +1,3 @@
from unittest import mock
from bson import ObjectId from bson import ObjectId
from dateutil.parser import parse from dateutil.parser import parse
from flask import Markup from flask import Markup

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')