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() {
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) {
glob('src/scripts/js/es6/individual/**/init.js', function(err, files) {
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.
* It also includes jQuery and Bootstrap (and its dependency popper), since
* 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))
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
url = self.config['URLS'][resource]
@ -800,7 +800,7 @@ class PillarServer(BlinkerCompatibleEve):
def put_internal(self, resource: str, payload=None, concurrency_check=False,
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
url = self.config['URLS'][resource]
@ -811,7 +811,7 @@ class PillarServer(BlinkerCompatibleEve):
def patch_internal(self, resource: str, payload=None, concurrency_check=False,
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
url = self.config['URLS'][resource]
@ -822,7 +822,7 @@ class PillarServer(BlinkerCompatibleEve):
def delete_internal(self, resource: str, concurrency_check=False,
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
url = self.config['URLS'][resource]

View File

@ -161,13 +161,14 @@ class ValidateCustomFields(Validator):
"""
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
"""
my_log = log.getChild('_normalize_coerce_markdown')
mdown = self.document.get(markdown_field, '')
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

View File

@ -6,7 +6,7 @@ import pymongo.errors
import werkzeug.exceptions as wz_exceptions
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.authorization import check_permissions, require_login
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)
@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/<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.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']
def find_markdown_fields(schema, project):
"""Find and process all makrdown validated fields."""
for k, v in schema.items():
if not isinstance(v, dict):
"""Find and process all Markdown coerced fields.
- 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
if v.get('validator') == 'markdown':
# If there is a match with the validator: markdown pair, assign the sibling
# property (following the naming convention _<property>_html)
# the processed value.
if k in project:
html = pillar.markdown.markdown(project[k])
field_name = pillar.markdown.cache_field_name(k)
# Construct markdown source field name (strip the leading '_' and the trailing '_html')
source_field_name = field_name[1:-5]
html = pillar.markdown.markdown(project[source_field_name])
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)
return 'ok'
def parse_markdowns(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.
"""
private_keys = {'_id', '_etag', '_deleted', '_updated', '_created'}
def is_private(key):
return str(key).startswith('_')
def combine_key(some_key):
"""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):
for key in set(doc1.keys()).union(set(doc2.keys())):
if key in private_keys:
if is_private(key):
continue
val1 = doc1.get(key, DoesNotExist)

View File

@ -331,8 +331,9 @@ def require_login(*, require_roles=set(),
def render_error() -> Response:
if error_view is None:
abort(403)
resp: Response = error_view()
resp = Forbidden().get_response()
else:
resp = error_view()
resp.status_code = 403
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 json
import logging
from datetime import datetime
@ -18,15 +17,12 @@ from flask import request
from flask import jsonify
from flask import abort
from flask_login import current_user
from flask_wtf.csrf import validate_csrf
import werkzeug.exceptions as wz_exceptions
from wtforms import SelectMultipleField
from flask_login import login_required
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.web.nodes.forms import get_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
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'):
# Posts shouldn't be shown at this route (unless viewed embedded, tipically
# 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)
@blueprint.route("/<node_id>/breadcrumbs")
def breadcrumbs(node_id: str):
"""Return breadcrumbs for the given node, as JSON.
Note that a missing parent is still returned in the breadcrumbs,
but with `{_exists: false, name: '-unknown-'}`.
The breadcrumbs start with the top-level parent, and end with the node
itself (marked by {_self: true}). Returns JSON like this:
{breadcrumbs: [
...,
{_id: "parentID",
name: "The Parent Node",
node_type: "group",
url: "/p/project/parentID"},
{_id: "deadbeefbeefbeefbeeffeee",
_self: true,
name: "The Node Itself",
node_type: "asset",
url: "/p/project/nodeID"},
]}
When a parent node is missing, it has a breadcrumb like this:
{_id: "deadbeefbeefbeefbeeffeee",
_exists': false,
name': '-unknown-'}
"""
api = system_util.pillar_api()
is_self = True
def make_crumb(some_node: None) -> dict:
"""Construct a breadcrumb for this node."""
nonlocal is_self
crumb = {
'_id': some_node._id,
'name': some_node.name,
'node_type': some_node.node_type,
'url': finders.find_url_for_node(some_node),
}
if is_self:
crumb['_self'] = True
is_self = False
return crumb
def make_missing_crumb(some_node_id: None) -> dict:
"""Construct 'missing parent' breadcrumb."""
return {
'_id': some_node_id,
'_exists': False,
'name': '-unknown-',
}
# The first node MUST exist.
try:
node = Node.find(node_id, api=api)
except ResourceNotFound:
log.warning('breadcrumbs(node_id=%r): Unable to find node', node_id)
raise wz_exceptions.NotFound(f'Unable to find node {node_id}')
except ForbiddenAccess:
log.warning('breadcrumbs(node_id=%r): access denied to current user', node_id)
raise wz_exceptions.Forbidden(f'No access to node {node_id}')
crumbs = []
while True:
crumbs.append(make_crumb(node))
child_id = node._id
node_id = node.parent
if not node_id:
break
# If a subsequent node doesn't exist any more, include that in the breadcrumbs.
# Forbidden nodes are handled as if they don't exist.
try:
node = Node.find(node_id, api=api)
except (ResourceNotFound, ForbiddenAccess):
log.warning('breadcrumbs: Unable to find node %r but it is marked as parent of %r',
node_id, child_id)
crumbs.append(make_missing_crumb(node_id))
break
return jsonify({'breadcrumbs': list(reversed(crumbs))})
# Import of custom modules (using the same nodes decorator)
from .custom import groups, storage, posts

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>')
def oauth_authorize(provider):
if not current_user.is_anonymous:
return redirect(url_for('main.homepage'))
if current_user.is_authenticated:
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:
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 import utcnow
next_after_login = session.pop('next_after_login', None) or url_for('main.homepage')
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)
try:
@ -63,7 +67,7 @@ def oauth_callback(provider):
raise wz_exceptions.Forbidden()
if oauth_user.id is None:
log.debug('Authentication failed for user with {}'.format(provider))
return redirect(url_for('main.homepage'))
return redirect(next_after_login)
# Find or create user
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.
update_subscription()
next_after_login = session.pop('next_after_login', None)
if next_after_login:
log.debug('Redirecting user to %s', next_after_login)
return redirect(next_after_login)
return redirect(url_for('main.homepage'))
@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 { 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 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 {
static parentCreated(parentId, node_type) {
return `pillar:node:${parentId}:created-${node_type}`;
@ -14,79 +27,141 @@ class EventName {
static deleted(nodeId) {
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 {
/**
* Trigger events that node has been created
* @param {Object} node
*/
static triggerCreated(node) {
if (node.parent) {
$('body').trigger(
trigger(
EventName.parentCreated(node.parent, node.node_type),
node);
}
$('body').trigger(
trigger(
EventName.globalCreated(node.node_type),
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){
$('body').on(
on(
EventName.parentCreated(parentId, node_type),
cb);
}
static offParentCreated(parentId, node_type, cb){
$('body').off(
off(
EventName.parentCreated(parentId, node_type),
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){
$('body').on(
on(
EventName.globalCreated(node_type),
cb);
}
static offCreated(node_type, cb){
$('body').off(
off(
EventName.globalCreated(node_type),
cb);
}
static triggerUpdated(node) {
$('body').trigger(
trigger(
EventName.updated(node._id),
node);
}
/**
* Get notified when node with _id === nodeId is updated
* @param {String} nodeId
* @param {Function(Event)} cb
*/
static onUpdated(nodeId, cb) {
$('body').on(
on(
EventName.updated(nodeId),
cb);
}
static offUpdated(nodeId, cb) {
$('body').off(
off(
EventName.updated(nodeId),
cb);
}
/**
* Notify that node has been deleted.
* @param {String} nodeId
*/
static triggerDeleted(nodeId) {
$('body').trigger(
trigger(
EventName.deleted(nodeId),
nodeId);
}
/**
* Listen to events of nodes being deleted where _id === nodeId
* @param {String} nodeId
* @param {Function(Event)} cb
*/
static onDeleted(nodeId, cb) {
$('body').on(
on(
EventName.deleted(nodeId),
cb);
}
static offDeleted(nodeId, cb) {
$('body').off(
off(
EventName.deleted(nodeId),
cb);
}
static triggerLoaded(nodeId) {
trigger(EventName.loaded(), {nodeId: nodeId});
}
/**
* Listen to events of nodes being loaded for display
* @param {Function(Event)} cb
*/
static onLoaded(cb) {
on(EventName.loaded(), cb);
}
static offLoaded(cb) {
off(EventName.loaded(), cb);
}
}
export { Nodes }

View File

@ -1 +1,4 @@
/**
* Collecting Custom Pillar events here
*/
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 = []
/**
* Create a jQuery renderable element from a mongo document using registered creators.
* @deprecated use vue instead
*/
export class Component extends ComponentCreatorInterface {
/**
*
* @param {Object} doc
* @returns {$element}
*/
static create$listItem(doc) {
let creator = Component.getCreator(doc);
return creator.create$listItem(doc);
}
/**
* @param {Object} doc
* @returns {$element}
*/
static create$item(doc) {
let creator = Component.getCreator(doc);
return creator.create$item(doc);
}
/**
* @param {Object} candidate
* @returns {Boolean}
*/
static canCreate(candidate) {
return !!Component.getCreator(candidate);
}
/**
* Register component creator to handle a node type
* @param {ComponentCreatorInterface} creator
*/
static regiseterCreator(creator) {
REGISTERED_CREATORS.push(creator);
}
/**
* @param {Object} doc
* @returns {ComponentCreatorInterface}
*/
static getCreator(doc) {
if (doc) {
for (let candidate of REGISTERED_CREATORS) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,9 @@
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
/**
* Create $elements from user objects
* @deprecated use vue instead
*/
export class Users extends ComponentCreatorInterface {
static create$listItem(userDoc) {
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.thenSubmit()
.fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'Failed to submit comment')})
);
)
.then(() => {
EventBus.$emit(Events.EDIT_DONE, this.comment._id);
});
},
thenSubmit() {
if (this.mode === 'reply') {
@ -220,7 +223,6 @@ Vue.component('comment-editor', {
return thenCreateComment(this.parentId, this.msg, this.attachmentsAsObject)
.then((newComment) => {
EventBus.$emit(Events.NEW_COMMENT, newComment);
EventBus.$emit(Events.EDIT_DONE, newComment.id );
this.cleanUp();
})
},
@ -228,7 +230,6 @@ Vue.component('comment-editor', {
return thenUpdateComment(this.comment.parent, this.comment.id, this.msg, this.attachmentsAsObject)
.then((updatedComment) => {
EventBus.$emit(Events.UPDATED_COMMENT, updatedComment);
EventBus.$emit(Events.EDIT_DONE, updatedComment.id);
this.cleanUp();
})
},

View File

@ -91,6 +91,9 @@ Vue.component('comments-tree', {
} else {
$(document).trigger('pillar:workStop');
}
},
parentId() {
this.fetchComments();
}
},
created() {
@ -98,6 +101,20 @@ Vue.component('comments-tree', {
EventBus.$on(Events.EDIT_DONE, this.showReplyComponent);
EventBus.$on(Events.NEW_COMMENT, this.onNewComment);
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(
thenGetComments(this.parentId)
.then((commentsTree) => {
@ -109,13 +126,6 @@ Vue.component('comments-tree', {
.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() {
this.replyHidden = true;
},
@ -134,6 +144,7 @@ Vue.component('comments-tree', {
parentArray = parentComment.replies;
}
parentArray.unshift(newComment);
this.$emit('new-comment');
},
onCommentUpdated(updatedComment) {
let commentInTree = this.findComment(this.comments, (comment) => {

View File

@ -1,23 +1,39 @@
import './breadcrumbs/Breadcrumbs'
import './comments/CommentTree'
import './customdirectives/click-outside'
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 { CellDefault } from './table/cells/renderer/CellDefault'
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 { RowObjectsSourceBase } from './table/rows/RowObjectsSourceBase'
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 = {
UnitOfWorkTracker
UnitOfWorkTracker,
BrowserHistoryState,
StateSaveMode
}
let table = {
PillarTable,
TableState,
columns: {
ColumnBase,
Created,
Updated,
DateColumnBase,
ColumnFactoryBase,
},
cells: {
@ -27,12 +43,20 @@ let table = {
}
},
rows: {
RowObjectsSourceBase,
RowBase
},
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 =`
<div class="pillar-dropdown">
<div class="pillar-dropdown-button"
<div class="pillar-dropdown-button action"
:class="buttonClasses"
@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: {
unitOfWork(promise) {
this.unitOfWorkBegin();

View File

@ -1,8 +1,41 @@
import './rows/renderer/Head'
import './rows/renderer/Row'
import './filter/ColumnFilter'
import './filter/RowFilter'
import './columns/filter/ColumnFilter'
import './rows/filter/RowFilter'
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 =`
<div class="pillar-table-container"
@ -10,13 +43,20 @@ const TEMPLATE =`
>
<div class="pillar-table-menu">
<pillar-table-row-filter
:rowObjects="rowObjects"
:rowObjects="sortedRowObjects"
:config="rowFilterConfig"
:componentState="(componentState || {}).rowFilter"
@visibleRowObjectsChanged="onVisibleRowObjectsChanged"
@componentStateChanged="onRowFilterStateChanged"
/>
<pillar-table-actions
@item-clicked="onItemClicked"
/>
<pillar-table-actions/>
<pillar-table-column-filter
:columns="columns"
:componentState="(componentState || {}).columnFilter"
@visibleColumnsChanged="onVisibleColumnsChanged"
@componentStateChanged="onColumnFilterStateChanged"
/>
</div>
<div class="pillar-table">
@ -30,60 +70,220 @@ const TEMPLATE =`
:columns="visibleColumns"
:rowObject="rowObject"
:key="rowObject.getId()"
@item-clicked="onItemClicked"
/>
</transition-group>
</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', {
template: TEMPLATE,
mixins: [UnitOfWorkTracker],
// columnFactory,
// rowsSource,
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() {
return {
columns: [],
visibleColumns: [],
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: {
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() {
let columnFactory = new this.$options.columnFactory(this.projectId);
this.rowsSource = new this.$options.rowsSource(this.projectId);
let tableState = new TableState(this.selectedIds);
this.unitOfWork(
Promise.all([
columnFactory.thenGetColumns(),
this.rowsSource.thenInit()
this.columnFactory.thenGetColumns(),
this.rowsSource.thenGetRowObjects()
])
.then((resp) => {
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: {
onVisibleColumnsChanged(visibleColumns) {
this.visibleColumns = visibleColumns;
},
onColumnFilterStateChanged(newComponentState) {
this.columnFilterState = newComponentState;
},
onVisibleRowObjectsChanged(visibleRowObjects) {
this.visibleRowObjects = visibleRowObjects;
},
onRowFilterStateChanged(newComponentState) {
this.rowFilterState = newComponentState;
},
onSort(column, direction) {
function compareRows(r1, r2) {
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>
`;
/**
* Default cell renderer. Takes raw cell value and formats it.
* Override for custom formatting of value.
*/
let CellDefault = Vue.component('pillar-cell-default', {
template: TEMPLATE,
props: {

View File

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

View File

@ -6,25 +6,43 @@ const TEMPLATE =`
:rowObject="rowObject"
:column="column"
: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', {
template: TEMPLATE,
props: {
column: Object,
rowObject: Object
column: Object, // ColumnBase
rowObject: Object // RowObject
},
computed: {
/**
* Raw unformated cell value
*/
rawCellValue() {
return this.column.getRawCellValue(this.rowObject) || '';
},
/**
* Name of the cell render component to be rendered
*/
cellRenderer() {
return this.column.getCellRenderer(this.rowObject);
},
/**
* Css classes to apply to the cell
*/
cellClasses() {
return this.column.getCellClasses(this.rawCellValue, this.rowObject);
},
/**
* Cell tooltip
*/
cellTitle() {
return this.column.getCellTitle(this.rawCellValue, this.rowObject);
}

View File

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

View File

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

View File

@ -1,21 +1,15 @@
/**
* Provides the columns that are available in a table.
*/
class ColumnFactoryBase{
constructor(projectId) {
this.projectId = projectId;
this.projectPromise;
}
// Override this
/**
* To be overridden for your purposes
* @returns {Promise(ColumnBase)} The columns that are available in the table.
*/
thenGetColumns() {
throw Error('Not implemented')
}
thenGetProject() {
if (this.projectPromise) {
return this.projectPromise;
}
this.projectPromise = pillar.api.thenGetProject(this.projectId);
return this.projectPromise;
}
}
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 {
constructor(underlyingObject) {
this.underlyingObject = underlyingObject;
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() {
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();
}
@ -21,11 +43,23 @@ class RowBase {
return this.underlyingObject.properties;
}
/**
* The css classes that should be applied to the row in the table
*/
getRowClasses() {
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 }

View File

@ -1,13 +1,28 @@
/**
* The provider of RowObjects to a table.
* Extend to fit your purpose.
*/
class RowObjectsSourceBase {
constructor(projectId) {
this.projectId = projectId;
constructor() {
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');
}
/**
* Inits all its row objects.
*/
thenInit() {
return Promise.all(
this.rowObjects.map(it => it.thenInit())
);
}
}
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>
`;
/**
* @emits sort(column,direction) When a column head has been clicked
*/
Vue.component('pillar-table-head', {
template: TEMPLATE,
props: {

View File

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

View File

@ -6,7 +6,9 @@ const TEMPLATE = `
</div>
`;
Vue.component('user-avatar', {
let UserAvatar = Vue.component('user-avatar', {
template: TEMPLATE,
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_height = window_height - container_offset.top;
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) {
$('#project-container').css(
@ -76,6 +77,10 @@ function containerResizeY(window_height){
'height': window_height_minus_nav + 'px'}
);
$('#project_context, #project_context-header').css(
{'top' : breadcrumbs_height}
);
$('#project_nav-container, #project_tree').css(
{'max-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
z-index: $z-index-base
&.is-sidebar-visible
#project-side-container
@extend .d-flex
.breadcrumbs-container
+media-xs
flex-direction: column-reverse
min-height: auto
left: $project_nav-width-xs
+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
display: flex
display: none
+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_tree,
#project_nav-container
+media-xs
width: $project_nav-width-xs
height: 100vh
width: 100%
+media-sm
width: $project_nav-width-sm
+media-md
@ -44,14 +63,13 @@ body.svnman, body.edit_node_types, body.search-project
width: $project_nav-width
#project_nav-container
+media-xs
display: block
height: initial !important
position: relative
position: fixed
z-index: $z-index-base + 5
.project-sidebar-toggle
right: 5px
z-index: 1
#project_sidebar
box-shadow: inset -1px 0 0 0 $color-background
flex-shrink: 0
@ -111,6 +129,8 @@ body.svnman, body.edit_node_types, body.search-project
right: 0
z-index: $z-index-base + 3
+media-xs
bottom: 0
/* Edit Asset buttons */
.project-mode-view,
@ -237,11 +257,21 @@ ul.project-edit-tools
min-height: 800px
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 */
#project_tree
+media-xs
margin-top: 0
overflow-y: auto // show vertical scrollbars when needed.
padding: 5px 0 // some padding on top/bottom of jsTree.
position: relative
@ -1978,3 +2008,7 @@ a.learn-more
padding: 5px 35px
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/checkbox
@import components/overlay
@import components/pillar_table
/* Top level, standalone stylesheets (not starting with _ so not meant for importing)
* should not have pure styling here.

View File

@ -51,6 +51,16 @@
@import _comments
@import _notifications
body.blog
.node-details-description
font:
size: 1.3em
weight: 200
img
margin-bottom: 2rem
margin-top: 2rem
#blog_post-edit-form
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
// 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
opacity: 0
animation: fade-in 500ms forwards
.group-date
color: rgba($color-text, .4)
&-date // .group-date
color: $color-text-dark-hint
.group-title
&-title // .group-title
@extend .border-bottom
@extend .bg-white
@extend .text-uppercase
@ -14,6 +20,15 @@
a
color: $color-text
.node-details-description
font:
size: 1.2em
weight: 200
img
margin-bottom: 2rem
margin-top: 2rem
body.homepage
.timeline
.sticky-top
@ -23,3 +38,22 @@ body.is-mobile
.timeline
.js-asset-list
@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="hdri"]
.jstree-anchor
padding-right: 20px
padding-right: 20px // Make room for the angle-right icon.
&[is_free='true']
.jstree-anchor
padding-right: initial
&:after
color: $tree-color-highlight
content: '\e84e' !important
@ -36,7 +35,7 @@ $tree-color-highlight-background-text: $primary
font-weight: bold
.jstree-anchor
padding: 0 6px
padding: 0 5px
&:after
top: 3px !important
@ -61,35 +60,22 @@ $tree-color-highlight-background-text: $primary
&.jstree-open
/* Text of children for an open tree (like a folder) */
.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
left: 20px !important
// Tweaks for specific icons
&.pi-file-archive
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 */
// &.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 */
.jstree-anchor
+media-xs
@ -97,11 +83,8 @@ $tree-color-highlight-background-text: $primary
width: 98%
border: none
font-size: 13px
height: inherit
line-height: 24px
overflow: hidden
padding-left: 28px
padding-right: 10px
padding-left: 25px
text-overflow: ellipsis
white-space: nowrap
width: 100%
@ -150,9 +133,7 @@ $tree-color-highlight-background-text: $primary
.jstree-clicked > .jstree-ocl
color: $tree-color-highlight-background-text !important
background-color: transparent !important
border-radius: 0
box-shadow: none
border-bottom: thin solid transparent
.jstree-ocl:before
+media-xs
@ -177,14 +158,13 @@ $tree-color-highlight-background-text: $primary
&:empty
line-height: 24px
left: 3px
left: 1px
&.is_subscriber
.jstree-node
&[is_free='true']
.jstree-anchor
padding-right: initial
&:after
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-open .jstree-icon.jstree-ocl + .jstree-anchor
padding-left: 24px !important
padding-left: 25px !important
/* hovered text */
.jstree-default .jstree-hovered,

View File

@ -11,7 +11,7 @@ mixin jumbotron(title, text, image, url)
href=url)&attributes(attributes)
.container
.row
.col-md-9
.col-md-8
if title
.display-4.text-uppercase.font-weight-bold
=title
@ -24,7 +24,7 @@ mixin jumbotron(title, text, image, url)
.jumbotron.text-white(style='background-image: url(' + image + ');')&attributes(attributes)
.container
.row
.col-md-9
.col-md-6
if title
.display-4.text-uppercase.font-weight-bold
=title
@ -73,15 +73,31 @@ mixin list-asset(name, url, image, type, date)
if block
block
// used together with timeline.js
//- Used together with timeline.js
mixin timeline(projectid, sortdirection)
section.timeline.placeholder(
data-project-id=projectid,
data-sort-dir=sortdirection,
)
// TODO: Make nicer reuseable placeholder
.h3.text-center.text-secondary.p-5.border-bottom
.d-flex.w-100.h-100
//- TODO: Make nicer reuseable placeholder
.h3.text-muted.m-auto
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,15 +3,34 @@ include ../../../mixins/components
| {% set title = node.properties.url %}
//- Remove custom classes applied by the landing template (that turn background black).
| {% block bodyclasses %}page{% endblock %}
| {% block body %}
| {% 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 %}
.expand-image-links.imgs-fluid
| {% if node.picture %}
+jumbotron(
"{{ node.name }}",
null,
"{{ node.picture.thumbnail('h', api=api) }}",
"{{ 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
.row

View File

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

View File

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