Merge branch 'master' into dillo
This commit is contained in:
commit
32361a0e70
18
gulpfile.js
18
gulpfile.js
@ -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
12790
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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]
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
43
pillar/api/nodes/activities.py
Normal file
43
pillar/api/nodes/activities.py
Normal 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)
|
@ -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:
|
||||||
|
@ -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)
|
||||||
|
@ -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
48
pillar/auth/cors.py
Normal 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
|
@ -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
@ -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')
|
||||||
|
2
src/scripts/js/es6/common/README.md
Normal file
2
src/scripts/js/es6/common/README.md
Normal 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.
|
@ -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'
|
||||||
|
@ -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 }
|
||||||
|
7
src/scripts/js/es6/common/api/users.js
Normal file
7
src/scripts/js/es6/common/api/users.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
function thenGetProjectUsers(projectId) {
|
||||||
|
return $.ajax({
|
||||||
|
url: `/api/p/users?project_id=${projectId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { thenGetProjectUsers }
|
@ -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 }
|
||||||
|
@ -1 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* Collecting Custom Pillar events here
|
||||||
|
*/
|
||||||
export {Nodes} from './Nodes'
|
export {Nodes} from './Nodes'
|
||||||
|
2
src/scripts/js/es6/common/templates/DEPRECATED.md
Normal file
2
src/scripts/js/es6/common/templates/DEPRECATED.md
Normal 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.
|
@ -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) {
|
||||||
|
@ -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) {
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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 = [];
|
||||||
|
@ -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 || [];
|
||||||
|
35
src/scripts/js/es6/common/vuecomponents/README.md
Normal file
35
src/scripts/js/es6/common/vuecomponents/README.md
Normal 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"/>
|
||||||
|
~~~
|
@ -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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
@ -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();
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
@ -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) => {
|
||||||
|
@ -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 }
|
||||||
|
@ -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"
|
||||||
>
|
>
|
||||||
|
@ -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 }
|
@ -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();
|
||||||
|
@ -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 }
|
||||||
|
@ -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: {
|
||||||
|
@ -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: {
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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: {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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 }
|
||||||
|
|
||||||
|
@ -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'];
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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'];
|
||||||
|
}
|
||||||
|
}
|
@ -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 }
|
@ -1,10 +0,0 @@
|
|||||||
const TEMPLATE =`
|
|
||||||
<div class="pillar-table-column"/>
|
|
||||||
`;
|
|
||||||
|
|
||||||
Vue.component('pillar-table-column', {
|
|
||||||
template: TEMPLATE,
|
|
||||||
props: {
|
|
||||||
column: Object
|
|
||||||
},
|
|
||||||
});
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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 }
|
||||||
|
@ -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 }
|
||||||
|
@ -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 }
|
@ -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 }
|
@ -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 }
|
@ -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 }
|
@ -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 }
|
@ -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: {
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
@ -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}
|
||||||
|
2
src/scripts/js/es6/individual/README.md
Normal file
2
src/scripts/js/es6/individual/README.md
Normal 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.
|
@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
43
src/styles/components/_breadcrumbs.sass
Normal file
43
src/styles/components/_breadcrumbs.sass
Normal 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
|
163
src/styles/components/_pillar_table.sass
Normal file
163
src/styles/components/_pillar_table.sass
Normal 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
|
@ -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)
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -3,15 +3,34 @@ 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 %}
|
||||||
|
| {% 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
|
.expand-image-links.imgs-fluid
|
||||||
| {% if node.picture %}
|
|
||||||
+jumbotron(
|
+jumbotron(
|
||||||
"{{ node.name }}",
|
"{{ node.name }}",
|
||||||
null,
|
null,
|
||||||
"{{ node.picture.thumbnail('h', api=api) }}",
|
"{{ node.picture.thumbnail('h', api=api) }}",
|
||||||
"{{ 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
|
||||||
|
@ -78,7 +78,7 @@
|
|||||||
| {% endif %}
|
| {% endif %}
|
||||||
|
|
||||||
| {% else %}
|
| {% else %}
|
||||||
| {{ field(class='hidden') }}
|
| {{ field(class='d-none') }}
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
|
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
|
@ -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():
|
||||||
|
0
tests/test_auth/__init__.py
Normal file
0
tests/test_auth/__init__.py
Normal file
127
tests/test_auth/test_cors.py
Normal file
127
tests/test_auth/test_cors.py
Normal 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'])
|
@ -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
|
||||||
|
131
tests/test_web/test_nodes.py
Normal file
131
tests/test_web/test_nodes.py
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import typing
|
||||||
|
|
||||||
|
from bson import ObjectId
|
||||||
|
import flask
|
||||||
|
|
||||||
|
from pillar.tests import AbstractPillarTest
|
||||||
|
|
||||||
|
|
||||||
|
class BreadcrumbsTest(AbstractPillarTest):
|
||||||
|
def setUp(self, **kwargs):
|
||||||
|
super().setUp(**kwargs)
|
||||||
|
self.project_id, self.project = self.ensure_project_exists()
|
||||||
|
|
||||||
|
def _create_group(self,
|
||||||
|
parent_id: typing.Optional[ObjectId],
|
||||||
|
name: str) -> ObjectId:
|
||||||
|
node = {
|
||||||
|
'name': name,
|
||||||
|
'description': '',
|
||||||
|
'node_type': 'group',
|
||||||
|
'user': self.project['user'],
|
||||||
|
'properties': {'status': 'published'},
|
||||||
|
'project': self.project_id,
|
||||||
|
}
|
||||||
|
if parent_id:
|
||||||
|
node['parent'] = parent_id
|
||||||
|
return self.create_node(node)
|
||||||
|
|
||||||
|
def test_happy(self) -> ObjectId:
|
||||||
|
# Create the nodes we expect to be returned in the breadcrumbs.
|
||||||
|
top_group_node_id = self._create_group(None, 'Top-level node')
|
||||||
|
group_node_id = self._create_group(top_group_node_id, 'Group node')
|
||||||
|
|
||||||
|
fid, _ = self.ensure_file_exists()
|
||||||
|
node_id = self.create_node({
|
||||||
|
'name': 'Asset node',
|
||||||
|
'parent': group_node_id,
|
||||||
|
'description': '',
|
||||||
|
'node_type': 'asset',
|
||||||
|
'user': self.project['user'],
|
||||||
|
'properties': {'status': 'published', 'file': fid},
|
||||||
|
'project': self.project_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create some siblings that should not be returned.
|
||||||
|
self._create_group(None, 'Sibling of top node')
|
||||||
|
self._create_group(top_group_node_id, 'Sibling of group node')
|
||||||
|
self._create_group(group_node_id, 'Sibling of asset node')
|
||||||
|
|
||||||
|
expected = {'breadcrumbs': [
|
||||||
|
{'_id': str(top_group_node_id),
|
||||||
|
'name': 'Top-level node',
|
||||||
|
'node_type': 'group',
|
||||||
|
'url': f'/p/{self.project["url"]}/{top_group_node_id}'},
|
||||||
|
{'_id': str(group_node_id),
|
||||||
|
'name': 'Group node',
|
||||||
|
'node_type': 'group',
|
||||||
|
'url': f'/p/{self.project["url"]}/{group_node_id}'},
|
||||||
|
{'_id': str(node_id),
|
||||||
|
'_self': True,
|
||||||
|
'name': 'Asset node',
|
||||||
|
'node_type': 'asset',
|
||||||
|
'url': f'/p/{self.project["url"]}/{node_id}'},
|
||||||
|
]}
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
url = flask.url_for('nodes.breadcrumbs', node_id=str(node_id))
|
||||||
|
|
||||||
|
actual = self.get(url).json
|
||||||
|
self.assertEqual(expected, actual)
|
||||||
|
|
||||||
|
return node_id
|
||||||
|
|
||||||
|
def test_missing_parent(self):
|
||||||
|
# Note that this group node doesn't exist in the database:
|
||||||
|
group_node_id = ObjectId(3 * 'deadbeef')
|
||||||
|
|
||||||
|
fid, _ = self.ensure_file_exists()
|
||||||
|
node_id = self.create_node({
|
||||||
|
'name': 'Asset node',
|
||||||
|
'parent': group_node_id,
|
||||||
|
'description': '',
|
||||||
|
'node_type': 'asset',
|
||||||
|
'user': self.project['user'],
|
||||||
|
'properties': {'status': 'published', 'file': fid},
|
||||||
|
'project': self.project_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
expected = {'breadcrumbs': [
|
||||||
|
{'_id': str(group_node_id),
|
||||||
|
'_exists': False,
|
||||||
|
'name': '-unknown-'},
|
||||||
|
{'_id': str(node_id),
|
||||||
|
'_self': True,
|
||||||
|
'name': 'Asset node',
|
||||||
|
'node_type': 'asset',
|
||||||
|
'url': f'/p/{self.project["url"]}/{node_id}'},
|
||||||
|
]}
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
url = flask.url_for('nodes.breadcrumbs', node_id=str(node_id))
|
||||||
|
|
||||||
|
actual = self.get(url).json
|
||||||
|
self.assertEqual(expected, actual)
|
||||||
|
|
||||||
|
def test_missing_node(self):
|
||||||
|
with self.app.app_context():
|
||||||
|
url = flask.url_for('nodes.breadcrumbs', node_id=3 * 'deadbeef')
|
||||||
|
self.get(url, expected_status=404)
|
||||||
|
|
||||||
|
def test_permissions(self):
|
||||||
|
# Use the same test case as the happy case.
|
||||||
|
node_id = self.test_happy()
|
||||||
|
|
||||||
|
# Tweak the project to make it private.
|
||||||
|
with self.app.app_context():
|
||||||
|
proj_coll = self.app.db('projects')
|
||||||
|
result = proj_coll.update_one({'_id': self.project_id},
|
||||||
|
{'$set': {'permissions.world': []}})
|
||||||
|
self.assertEqual(1, result.modified_count)
|
||||||
|
self.project = self.fetch_project_from_db(self.project_id)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
url = flask.url_for('nodes.breadcrumbs', node_id=str(node_id))
|
||||||
|
|
||||||
|
# Anonymous access should be forbidden.
|
||||||
|
self.get(url, expected_status=403)
|
||||||
|
|
||||||
|
# Authorized access should work, though.
|
||||||
|
self.create_valid_auth_token(self.project['user'], token='user-token')
|
||||||
|
self.get(url, auth_token='user-token')
|
Loading…
x
Reference in New Issue
Block a user