Compare commits
123 Commits
last-py27
...
cryptograp
Author | SHA1 | Date | |
---|---|---|---|
![]() |
94c63d9b4e | ||
![]() |
bbf0f791b5 | ||
![]() |
f37fcd3765 | ||
864d0002f6 | |||
50782556f6 | |||
73fd86e28c | |||
c5722d1316 | |||
e33297e3f5 | |||
1e1420d92b | |||
14530d76a9 | |||
e2dc9b8d33 | |||
e38c577bcb | |||
a376beb143 | |||
383feaa4d0 | |||
784265715f | |||
977a9e2640 | |||
ec4cad5e5b | |||
f1354b9837 | |||
23e0e55de9 | |||
120ea251bd | |||
b43bb8a696 | |||
a7c1f5aa39 | |||
67d1e05d10 | |||
d2459c451c | |||
c86503e165 | |||
0c96d3eda1 | |||
b2801492fe | |||
479b844174 | |||
4f5eee6705 | |||
434cdb35a0 | |||
bae39ce01d | |||
f4c7101427 | |||
d1713f93b3 | |||
fccf6eb7a6 | |||
fbe4e53e50 | |||
ac8a6284d4 | |||
5e73720d91 | |||
66212ec5fa | |||
763866787d | |||
11652dd5cf | |||
755091f4e5 | |||
552c05d031 | |||
03a94271ae | |||
765ccaa8c9 | |||
ed457a125c | |||
70f49ed5bf | |||
3e4eb91668 | |||
0aa609817e | |||
1ae23c7ce9 | |||
3e8e465c7f | |||
5b578b58d8 | |||
3dd3006452 | |||
bbf21f614d | |||
1be31bdb22 | |||
b3e21d4b02 | |||
6f9cb1fe38 | |||
65aba61465 | |||
bc47cf3f15 | |||
bfec958b70 | |||
08c2fbc517 | |||
43668e43d2 | |||
c6fb4c3184 | |||
cf41599e20 | |||
39a23a80c9 | |||
47e0e6bc42 | |||
5ff0c9fde5 | |||
13dc6fea8e | |||
ca393af1b3 | |||
337e2db558 | |||
0b3ea29d48 | |||
f7665a4060 | |||
7fd4649a56 | |||
da0f606110 | |||
5b96aa4fdb | |||
a866008be1 | |||
e84e952169 | |||
b40b6dadd2 | |||
4fa18d4454 | |||
5c58ced224 | |||
412bd3a935 | |||
eb954208b2 | |||
c7b83d2d8b | |||
c4071c1e03 | |||
0612ed15a6 | |||
b918151c94 | |||
25fcfea62f | |||
59505d3233 | |||
c69aeb03dc | |||
62795b4007 | |||
50ae411575 | |||
f4a06c3271 | |||
bf9a73ff00 | |||
9ea75c30e3 | |||
725f93175c | |||
ab72357336 | |||
0868449209 | |||
f6d2a477eb | |||
28edb86aeb | |||
c38203ba63 | |||
9d59aefd80 | |||
f05ad37037 | |||
23a2a8fd64 | |||
12c51fb3f5 | |||
a13ba17545 | |||
01973a2471 | |||
a1391a6d1c | |||
30397fc12f | |||
dc4cf6aecc | |||
9d302d5124 | |||
48ad75c461 | |||
2a88d9c309 | |||
19f35e6713 | |||
025b44bfac | |||
fc08ab2bca | |||
b5cbbad1ba | |||
b3bbb5e68b | |||
e45f35f6f4 | |||
3efb21484a | |||
ce00665cb2 | |||
9376e40575 | |||
fa306c2821 | |||
4e8c735f6b | |||
cd17236428 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,11 +4,14 @@
|
||||
*.pyc
|
||||
__pycache__
|
||||
|
||||
/config_local.py
|
||||
/build
|
||||
/.cache
|
||||
/docs/site/
|
||||
/*.egg-info/
|
||||
/.eggs/
|
||||
/node_modules/
|
||||
/attract/templates/
|
||||
/attract/static/assets/css/
|
||||
/attract/static/assets/js/generated/
|
||||
/poetry.lock
|
||||
|
@@ -9,20 +9,30 @@ from pillar.web.nodes.routes import url_for_node
|
||||
|
||||
import pillarsdk
|
||||
|
||||
import attract.auth
|
||||
import attract.tasks
|
||||
import attract.shots_and_assets
|
||||
|
||||
EXTENSION_NAME = 'attract'
|
||||
|
||||
# Roles required to view task or shot details.
|
||||
ROLES_REQUIRED_TO_VIEW_ITEMS = {u'demo', u'subscriber', u'admin'}
|
||||
|
||||
|
||||
class AttractExtension(PillarExtension):
|
||||
has_project_settings = True
|
||||
user_roles = {'org-attract'}
|
||||
user_roles_indexable = {'org-attract'}
|
||||
|
||||
user_caps = {
|
||||
'org-attract': {'attract-view', 'attract-use'},
|
||||
'subscriber': {'attract-view', 'attract-use'},
|
||||
'demo': {'attract-view', 'attract-use'},
|
||||
'admin': {'attract-view', 'attract-use'},
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self._log = logging.getLogger('%s.AttractExtension' % __name__)
|
||||
self.task_manager = attract.tasks.TaskManager()
|
||||
self.shot_manager = attract.shots_and_assets.ShotAssetManager()
|
||||
self.auth = attract.auth.Auth()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@@ -149,22 +159,45 @@ class AttractExtension(PillarExtension):
|
||||
import pprint
|
||||
self._log.debug('Project: %s', pprint.pformat(project.to_dict()))
|
||||
return False
|
||||
except KeyError:
|
||||
# Not set up for Attract
|
||||
return False
|
||||
|
||||
if pprops is None:
|
||||
self._log.warning("is_attract_project: Project url=%r doesn't have Attract"
|
||||
" extension properties.", project['url'])
|
||||
self._log.debug("is_attract_project: Project url=%r doesn't have Attract"
|
||||
" extension properties.", project['url'])
|
||||
return False
|
||||
return True
|
||||
|
||||
def sidebar_links(self, project):
|
||||
from pillar.api.utils import str2id
|
||||
|
||||
if not self.is_attract_project(project):
|
||||
return ''
|
||||
|
||||
# Temporarily disabled until Attract is nicer to look at.
|
||||
return ''
|
||||
# return flask.render_template('attract/sidebar.html',
|
||||
# project=project)
|
||||
if not self.auth.current_user_may(auth.Actions.VIEW, str2id(project['_id'])):
|
||||
return ''
|
||||
|
||||
return flask.render_template('attract/sidebar.html',
|
||||
project=project)
|
||||
|
||||
@property
|
||||
def has_project_settings(self) -> bool:
|
||||
return self.auth.current_user_is_attract_user()
|
||||
|
||||
def project_settings(self, project: pillarsdk.Project, **template_args: dict) -> flask.Response:
|
||||
"""Renders the project settings page for this extension.
|
||||
|
||||
Set YourExtension.has_project_settings = True and Pillar will call this function.
|
||||
|
||||
:param project: the project for which to render the settings.
|
||||
:param template_args: additional template arguments.
|
||||
:returns: a Flask HTTP response
|
||||
"""
|
||||
|
||||
from attract.routes import project_settings
|
||||
|
||||
return project_settings(project, **template_args)
|
||||
|
||||
def activities_for_node(self, node_id, max_results=20, page=1):
|
||||
"""Returns a page of activities for the given task or shot.
|
||||
@@ -185,7 +218,7 @@ class AttractExtension(PillarExtension):
|
||||
'context_object': node_id},
|
||||
],
|
||||
},
|
||||
'sort': [('_created', -1)],
|
||||
'sort': [('_id', 1)], # Sort by creation, _id is incremental.
|
||||
'max_results': max_results,
|
||||
'page': page,
|
||||
}, api=api)
|
||||
@@ -221,11 +254,11 @@ class AttractExtension(PillarExtension):
|
||||
return url_for_node(node_id=act.object)
|
||||
|
||||
|
||||
def _get_current_attract():
|
||||
def _get_current_attract() -> AttractExtension:
|
||||
"""Returns the Attract extension of the current application."""
|
||||
|
||||
return flask.current_app.pillar_extensions[EXTENSION_NAME]
|
||||
|
||||
|
||||
current_attract = LocalProxy(_get_current_attract)
|
||||
current_attract: AttractExtension = LocalProxy(_get_current_attract)
|
||||
"""Attract extension of the current app."""
|
||||
|
103
attract/auth.py
Normal file
103
attract/auth.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import enum
|
||||
import flask
|
||||
|
||||
import attr
|
||||
import bson
|
||||
|
||||
from pillar import attrs_extra
|
||||
|
||||
# Having any of these methods on a project means you can use Attract.
|
||||
# Prerequisite: the project is set up for Attract and has a Manager assigned to it.
|
||||
PROJECT_METHODS_TO_USE_ATTRACT = {'PUT'}
|
||||
|
||||
|
||||
class Actions(enum.Enum):
|
||||
VIEW = 'view'
|
||||
USE = 'use'
|
||||
|
||||
|
||||
# Required capability for a given action.
|
||||
req_cap = {
|
||||
Actions.VIEW: 'attract-view',
|
||||
Actions.USE: 'attract-use',
|
||||
}
|
||||
|
||||
|
||||
@attr.s
|
||||
class Auth(object):
|
||||
"""Handles authorization for Attract."""
|
||||
|
||||
_log = attrs_extra.log('%s.Auth' % __name__)
|
||||
Actions = Actions # this allows using current_attract.auth.Actions
|
||||
|
||||
def current_user_is_attract_user(self) -> bool:
|
||||
"""Returns True iff the current user has Attract User role."""
|
||||
|
||||
from pillar.auth import current_user
|
||||
|
||||
return current_user.has_cap('attract-use')
|
||||
|
||||
def user_is_attract_user(self, user_id: bson.ObjectId) -> bool:
|
||||
"""Returns True iff the user has Attract User role."""
|
||||
|
||||
from pillar import current_app
|
||||
from pillar.auth import UserClass
|
||||
|
||||
assert isinstance(user_id, bson.ObjectId)
|
||||
|
||||
# TODO: move role checking code to Pillar.
|
||||
users_coll = current_app.db('users')
|
||||
db_user = users_coll.find_one({'_id': user_id}, {'roles': 1})
|
||||
if not db_user:
|
||||
self._log.debug('user_is_attract_user: User %s not found', user_id)
|
||||
return False
|
||||
|
||||
user = UserClass.construct('', db_user)
|
||||
return user.has_cap('attract-use')
|
||||
|
||||
def current_user_may(self, action: Actions, project_id: bson.ObjectId = None) -> bool:
|
||||
"""Returns True iff the user is authorised to use/view Attract on the current project.
|
||||
|
||||
Requires that determine_user_rights() was called before.
|
||||
"""
|
||||
|
||||
try:
|
||||
attract_rights = flask.g.attract_rights
|
||||
except AttributeError:
|
||||
if not project_id:
|
||||
self._log.error('current_user_may() called without previous call '
|
||||
'to current_user_rights()')
|
||||
return False
|
||||
|
||||
self.determine_user_rights(project_id)
|
||||
attract_rights = flask.g.attract_rights
|
||||
|
||||
return action in attract_rights
|
||||
|
||||
def determine_user_rights(self, project_id: bson.ObjectId):
|
||||
"""Updates g.attract_rights to reflect the current user's usage rights.
|
||||
|
||||
g.attract_rights is a frozenset that contains zero or more Actions.
|
||||
"""
|
||||
|
||||
from pillar.auth import current_user
|
||||
from pillar.api.projects.utils import user_rights_in_project
|
||||
|
||||
if current_user.is_anonymous:
|
||||
self._log.debug('Anonymous user never has access to Attract.')
|
||||
flask.g.attract_rights = frozenset()
|
||||
return
|
||||
|
||||
rights = set()
|
||||
for action in Actions:
|
||||
cap = req_cap[action]
|
||||
if current_user.has_cap(cap):
|
||||
rights.add(action)
|
||||
|
||||
# TODO Sybren: possibly split this up into a manager-fetching func + authorisation func.
|
||||
# TODO: possibly store the user rights on the current project in the current_user object?
|
||||
allowed_on_proj = user_rights_in_project(project_id)
|
||||
if not allowed_on_proj.intersection(PROJECT_METHODS_TO_USE_ATTRACT):
|
||||
rights.discard(Actions.USE)
|
||||
|
||||
flask.g.attract_rights = frozenset(rights)
|
@@ -5,7 +5,8 @@ import logging
|
||||
from flask import current_app
|
||||
from flask_script import Manager
|
||||
|
||||
from pillar.cli import manager, create_service_account
|
||||
from pillar.cli import manager
|
||||
from pillar.cli.setup import create_service_account
|
||||
from pillar.api.utils import authentication
|
||||
|
||||
import attract.setup
|
||||
@@ -46,7 +47,9 @@ def create_svner_account(email, project_url):
|
||||
log.error('Unable to find project url=%s', project_url)
|
||||
return 1
|
||||
|
||||
account, token = create_service_account(email, [u'svner'], {'svner': {'project': proj['_id']}})
|
||||
proj_id = proj['_id']
|
||||
account, token = create_service_account(email, ['svner'], {'svner': {'project': proj_id}},
|
||||
full_name=f'SVNer for project {proj_id}')
|
||||
return account, token
|
||||
|
||||
manager.add_command("attract", manager_attract)
|
||||
|
@@ -2,7 +2,7 @@ import logging
|
||||
|
||||
import flask
|
||||
|
||||
from pillar.api.nodes import only_for_node_type_decorator
|
||||
from pillar.api.nodes.eve_hooks import only_for_node_type_decorator
|
||||
import pillar.api.activities
|
||||
import pillar.api.utils.authentication
|
||||
|
||||
|
@@ -4,7 +4,7 @@ import logging
|
||||
|
||||
import flask
|
||||
|
||||
from pillar.api.nodes import only_for_node_type_decorator
|
||||
from pillar.api.nodes.eve_hooks import only_for_node_type_decorator
|
||||
from .node_types import NODE_TYPES
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
@@ -16,7 +16,7 @@ def find_for_shot(project, node):
|
||||
@register_node_finder(node_type_task['name'])
|
||||
def find_for_task(project, node):
|
||||
|
||||
parent = node.get(u'parent') if isinstance(node, dict) else node.parent
|
||||
parent = node.get('parent') if isinstance(node, dict) else node.parent
|
||||
if parent:
|
||||
endpoint = 'attract.shots.perproject.with_task'
|
||||
else:
|
||||
|
@@ -1,18 +1,22 @@
|
||||
import functools
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for
|
||||
from flask import Blueprint, render_template, redirect, url_for, request, jsonify, session
|
||||
import flask_login
|
||||
import werkzeug.exceptions as wz_exceptions
|
||||
|
||||
from pillar.web.utils import attach_project_pictures
|
||||
from pillar.auth import current_user as current_user
|
||||
from pillar.api.utils import str2id
|
||||
from pillar.web.utils import mass_attach_project_pictures
|
||||
import pillar.web.subquery
|
||||
from pillar.web.system_util import pillar_api
|
||||
from pillar.web.projects.routes import project_view, project_navigation_links
|
||||
import pillarsdk
|
||||
|
||||
from attract import current_attract
|
||||
from attract.node_types.task import node_type_task
|
||||
from attract.node_types.shot import node_type_shot
|
||||
from attract.node_types.asset import node_type_asset
|
||||
|
||||
blueprint = Blueprint('attract', __name__)
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -32,13 +36,14 @@ def index():
|
||||
# TODO: add projections.
|
||||
projects = current_attract.attract_projects()
|
||||
|
||||
for project in projects['_items']:
|
||||
attach_project_pictures(project, api)
|
||||
if current_user.is_anonymous:
|
||||
# Headers are only shown in index_anon_left_column.pug
|
||||
mass_attach_project_pictures(projects['_items'], square=False, api=api)
|
||||
|
||||
projs_with_summaries = [
|
||||
(proj, current_attract.shot_manager.shot_status_summary(proj['_id']))
|
||||
for proj in projects['_items']
|
||||
]
|
||||
]
|
||||
|
||||
# Fetch all activities for all Attract projects.
|
||||
id_to_proj = {p['_id']: p for p in projects['_items']}
|
||||
@@ -46,7 +51,7 @@ def index():
|
||||
'where': {
|
||||
'project': {'$in': list(id_to_proj.keys())},
|
||||
},
|
||||
'sort': [('_created', -1)],
|
||||
'sort': [('_id', 1)], # Sort by creation, _id is incremental.
|
||||
'max_results': 20,
|
||||
}, api=api)
|
||||
|
||||
@@ -59,17 +64,20 @@ def index():
|
||||
except (ValueError, wz_exceptions.NotFound):
|
||||
act.link = None
|
||||
|
||||
project = session.get('attract_last_project')
|
||||
return render_template('attract/index.html',
|
||||
tasks=tasks,
|
||||
projs_with_summaries=projs_with_summaries,
|
||||
activities=activities)
|
||||
activities=activities,
|
||||
project=project)
|
||||
|
||||
|
||||
def error_project_not_setup_for_attract():
|
||||
return render_template('attract/errors/project_not_setup.html')
|
||||
|
||||
|
||||
def attract_project_view(extra_project_projections=None, extension_props=False):
|
||||
def attract_project_view(extra_project_projections: dict=None, extension_props=False, *,
|
||||
full_project=False):
|
||||
"""Decorator, replaces the first parameter project_url with the actual project.
|
||||
|
||||
Assumes the first parameter to the decorated function is 'project_url'. It then
|
||||
@@ -81,14 +89,11 @@ def attract_project_view(extra_project_projections=None, extension_props=False):
|
||||
|
||||
:param extra_project_projections: extra projections to use on top of the ones already
|
||||
used by this decorator.
|
||||
:type extra_project_projections: dict
|
||||
:param extension_props: whether extension properties should be included. Includes them
|
||||
in the projections, and verifies that they are there.
|
||||
:type extension_props: bool
|
||||
:param full_project: skip projections altogether, fetching the whole project.
|
||||
"""
|
||||
|
||||
from . import EXTENSION_NAME
|
||||
|
||||
if callable(extra_project_projections):
|
||||
raise TypeError('Use with @attract_project_view() <-- note the parentheses')
|
||||
|
||||
@@ -96,14 +101,14 @@ def attract_project_view(extra_project_projections=None, extension_props=False):
|
||||
'_id': 1,
|
||||
'name': 1,
|
||||
'node_types': 1,
|
||||
'nodes_featured': 1,
|
||||
'extension_props': 1,
|
||||
# We don't need this here, but this way the wrapped function has access
|
||||
# to the orignal URL passed to it.
|
||||
'url': 1,
|
||||
}
|
||||
if extra_project_projections:
|
||||
projections.update(extra_project_projections)
|
||||
if extension_props:
|
||||
projections['extension_props.%s' % EXTENSION_NAME] = 1
|
||||
|
||||
def decorator(wrapped):
|
||||
@functools.wraps(wrapped)
|
||||
@@ -114,11 +119,16 @@ def attract_project_view(extra_project_projections=None, extension_props=False):
|
||||
# just pass everything along.
|
||||
return wrapped(project_url, *args, **kwargs)
|
||||
|
||||
if current_user.is_anonymous:
|
||||
log.debug('attract_project_view: Anonymous user never has access to Attract.')
|
||||
raise wz_exceptions.Forbidden()
|
||||
|
||||
api = pillar_api()
|
||||
|
||||
projection_param = None if full_project else {'projection': projections}
|
||||
project = pillarsdk.Project.find_by_url(
|
||||
project_url,
|
||||
{'projection': projections},
|
||||
projection_param,
|
||||
api=api)
|
||||
|
||||
is_attract = current_attract.is_attract_project(project,
|
||||
@@ -126,6 +136,15 @@ def attract_project_view(extra_project_projections=None, extension_props=False):
|
||||
if not is_attract:
|
||||
return error_project_not_setup_for_attract()
|
||||
|
||||
session['attract_last_project'] = project.to_dict()
|
||||
|
||||
# Check user access.
|
||||
auth = current_attract.auth
|
||||
auth.determine_user_rights(str2id(project['_id']))
|
||||
if not auth.current_user_may(auth.Actions.VIEW):
|
||||
log.info('User %s not allowed to use Attract', current_user)
|
||||
raise wz_exceptions.Forbidden()
|
||||
|
||||
if extension_props:
|
||||
pprops = project.extension_props.attract
|
||||
return wrapped(project, pprops, *args, **kwargs)
|
||||
@@ -137,8 +156,8 @@ def attract_project_view(extra_project_projections=None, extension_props=False):
|
||||
|
||||
|
||||
@blueprint.route('/<project_url>')
|
||||
@attract_project_view(extension_props=True)
|
||||
def project_index(project, attract_props):
|
||||
@attract_project_view(extension_props=False)
|
||||
def project_index(project):
|
||||
return redirect(url_for('attract.shots.perproject.index', project_url=project.url))
|
||||
|
||||
|
||||
@@ -152,3 +171,91 @@ def help(project):
|
||||
nt_shot['dyn_schema']['status']['allowed'])
|
||||
|
||||
return render_template('attract/help.html', statuses=statuses)
|
||||
|
||||
|
||||
def project_settings(project: pillarsdk.Project, **template_args: dict):
|
||||
"""Renders the project settings page for Attract projects."""
|
||||
|
||||
from . import EXTENSION_NAME
|
||||
|
||||
if not current_attract.auth.current_user_is_attract_user():
|
||||
raise wz_exceptions.Forbidden()
|
||||
|
||||
# Based on the project state, we can render a different template.
|
||||
if not current_attract.is_attract_project(project):
|
||||
return render_template('attract/project_settings/offer_setup.html',
|
||||
project=project, **template_args)
|
||||
|
||||
ntn_shot = node_type_shot['name']
|
||||
ntn_asset = node_type_asset['name']
|
||||
|
||||
try:
|
||||
attract_props = project['extension_props'][EXTENSION_NAME]
|
||||
except KeyError:
|
||||
# Not set up for attract, can happen.
|
||||
shot_task_types = []
|
||||
asset_task_types = []
|
||||
else:
|
||||
shot_task_types = attract_props['task_types'][ntn_shot]
|
||||
asset_task_types = attract_props['task_types'][ntn_asset]
|
||||
|
||||
return render_template('attract/project_settings/settings.html',
|
||||
project=project,
|
||||
asset_node_type_name=ntn_asset,
|
||||
shot_node_type_name=ntn_shot,
|
||||
shot_task_types=shot_task_types,
|
||||
asset_task_types=asset_task_types,
|
||||
**template_args)
|
||||
|
||||
|
||||
@blueprint.route('/<project_url>/<node_type_name>/set-task-types', methods=['POST'])
|
||||
@attract_project_view(extension_props=True, full_project=True)
|
||||
def save_task_types(project, attract_props, node_type_name: str):
|
||||
from . import EXTENSION_NAME
|
||||
from . import setup
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
|
||||
valid_name_re = re.compile(r'^[0-9a-zA-Z &+\-.,_]+$')
|
||||
|
||||
if (not node_type_name.startswith('%s_' % EXTENSION_NAME)
|
||||
or node_type_name not in attract_props['task_types']
|
||||
or not valid_name_re.match(node_type_name)):
|
||||
log.info('%s: received invalid node type name %r', request.endpoint, node_type_name)
|
||||
raise wz_exceptions.BadRequest('Invalid node type name')
|
||||
|
||||
task_types_field = request.form.get('task_types')
|
||||
if not task_types_field:
|
||||
raise wz_exceptions.BadRequest('No task types given')
|
||||
|
||||
task_types = [
|
||||
tt for tt in (tt.strip()
|
||||
for tt in task_types_field.split('\n'))
|
||||
if tt
|
||||
]
|
||||
task_types = list(OrderedDict.fromkeys(task_types)) # removes duplicates, maintains order.
|
||||
if not all(valid_name_re.match(tt) for tt in task_types):
|
||||
raise wz_exceptions.BadRequest('Invalid task type given')
|
||||
|
||||
setup.set_task_types(project.to_dict(), node_type_name, task_types)
|
||||
|
||||
return jsonify(task_types=task_types)
|
||||
|
||||
|
||||
@blueprint.route('/<project_url>/setup-for-attract', methods=['POST'])
|
||||
@flask_login.login_required
|
||||
@project_view()
|
||||
def setup_for_attract(project: pillarsdk.Project):
|
||||
import attract.setup
|
||||
|
||||
project_id = project._id
|
||||
|
||||
if not project.has_method('PUT'):
|
||||
log.warning('User %s tries to set up project %s for Attract, but has no PUT rights.',
|
||||
current_user, project_id)
|
||||
raise wz_exceptions.Forbidden()
|
||||
|
||||
log.info('User %s sets up project %s for Attract', current_user, project_id)
|
||||
attract.setup.setup_for_attract(project.url)
|
||||
|
||||
return '', 204
|
||||
|
@@ -1,13 +1,7 @@
|
||||
"""Setting up projects for Attract.
|
||||
"""Setting up projects for Attract."""
|
||||
|
||||
This is intended to be used by the CLI and unittests only, not tested
|
||||
for live/production situations.
|
||||
"""
|
||||
|
||||
from __future__ import print_function, division
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from bson import ObjectId
|
||||
from eve.methods.put import put_internal
|
||||
@@ -110,3 +104,13 @@ def setup_for_attract(project_url, replace=False, svn_url=None):
|
||||
log.info('Project %s was updated for Attract.', project_url)
|
||||
|
||||
return project
|
||||
|
||||
|
||||
def set_task_types(project: dict, node_type_name: str, task_types: typing.List[str]):
|
||||
eprops = project['extension_props']
|
||||
attract_props = eprops[EXTENSION_NAME]
|
||||
attract_props['task_types'][node_type_name] = task_types
|
||||
|
||||
log.info('Updating project %s, setting %s task_types to [%s]',
|
||||
project['url'], node_type_name, ', '.join(task_types))
|
||||
_update_project(project)
|
||||
|
@@ -5,7 +5,6 @@ import logging
|
||||
|
||||
import attr
|
||||
import flask
|
||||
import flask_login
|
||||
|
||||
from eve.methods.put import put_internal
|
||||
from werkzeug import exceptions as wz_exceptions
|
||||
@@ -16,30 +15,31 @@ from pillar.web.system_util import pillar_api
|
||||
from pillar.api.nodes.custom import register_patch_handler
|
||||
from pillar.api.utils import node_setattr
|
||||
from pillar import attrs_extra
|
||||
from pillar.auth import current_user
|
||||
|
||||
from attract.node_types import node_type_shot, node_type_task, node_type_asset
|
||||
|
||||
# From patch operation name to fields that operation may edit.
|
||||
VALID_SHOT_PATCH_FIELDS = {
|
||||
u'from-blender': {
|
||||
u'name',
|
||||
u'picture',
|
||||
u'properties.trim_start_in_frames',
|
||||
u'properties.trim_end_in_frames',
|
||||
u'properties.duration_in_edit_in_frames',
|
||||
u'properties.cut_in_timeline_in_frames',
|
||||
u'properties.status',
|
||||
u'properties.used_in_edit',
|
||||
'from-blender': {
|
||||
'name',
|
||||
'picture',
|
||||
'properties.trim_start_in_frames',
|
||||
'properties.trim_end_in_frames',
|
||||
'properties.duration_in_edit_in_frames',
|
||||
'properties.cut_in_timeline_in_frames',
|
||||
'properties.status',
|
||||
'properties.used_in_edit',
|
||||
},
|
||||
u'from-web': {
|
||||
u'properties.status',
|
||||
u'properties.notes',
|
||||
u'description',
|
||||
'from-web': {
|
||||
'properties.status',
|
||||
'properties.notes',
|
||||
'description',
|
||||
},
|
||||
}
|
||||
|
||||
VALID_SHOT_PATCH_OPERATIONS = {
|
||||
u'from-blender', u'from-web', u'unlink', u'relink',
|
||||
'from-blender', 'from-web', 'unlink', 'relink',
|
||||
}
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -101,7 +101,7 @@ class ShotAssetManager(object):
|
||||
node_props = dict(
|
||||
name='New %s' % typename,
|
||||
project=project_id,
|
||||
user=flask_login.current_user.objectid,
|
||||
user=current_user.objectid,
|
||||
node_type=node_type['name'],
|
||||
properties={
|
||||
'status': node_type['dyn_schema']['status']['default'],
|
||||
@@ -110,7 +110,7 @@ class ShotAssetManager(object):
|
||||
|
||||
node = pillarsdk.Node(node_props)
|
||||
node.create(api=api)
|
||||
return node
|
||||
return pillarsdk.Node.find(node._id, api=api)
|
||||
|
||||
def create_shot(self, project):
|
||||
"""Creates a new shot, owned by the current user.
|
||||
@@ -282,15 +282,15 @@ def patch_shot(node_id, patch):
|
||||
node_setattr(node, key, value)
|
||||
else:
|
||||
# Remaining operations are for marking as 'in use' or 'not in use'.
|
||||
if node.get('_deleted', False) and op == u'unlink':
|
||||
if node.get('_deleted', False) and op == 'unlink':
|
||||
# We won't undelete a node in response to an unlink request.
|
||||
return pillar.api.utils.jsonify({'_deleted': True,
|
||||
'_etag': node['_etag'],
|
||||
'_id': node['_id']})
|
||||
|
||||
used_in_edit = {
|
||||
u'unlink': False,
|
||||
u'relink': True,
|
||||
'unlink': False,
|
||||
'relink': True,
|
||||
}[op]
|
||||
node['properties']['used_in_edit'] = used_in_edit
|
||||
|
||||
@@ -308,8 +308,8 @@ def assert_is_valid_patch(patch):
|
||||
raise wz_exceptions.BadRequest("PATCH should have a key 'op' indicating the operation.")
|
||||
|
||||
if op not in VALID_SHOT_PATCH_OPERATIONS:
|
||||
valid_ops = u', '.join(sorted(VALID_SHOT_PATCH_OPERATIONS))
|
||||
raise wz_exceptions.BadRequest(u'Operation should be one of %s' % valid_ops)
|
||||
valid_ops = ', '.join(sorted(VALID_SHOT_PATCH_OPERATIONS))
|
||||
raise wz_exceptions.BadRequest('Operation should be one of %s' % valid_ops)
|
||||
|
||||
if op not in VALID_SHOT_PATCH_FIELDS:
|
||||
# Valid operation, and we don't have to check the fields.
|
||||
@@ -324,7 +324,7 @@ def assert_is_valid_patch(patch):
|
||||
|
||||
disallowed_fields = fields - allowed_fields
|
||||
if disallowed_fields:
|
||||
raise wz_exceptions.BadRequest(u"Operation '%s' does not allow you to set fields %s" % (
|
||||
raise wz_exceptions.BadRequest("Operation '%s' does not allow you to set fields %s" % (
|
||||
op, disallowed_fields
|
||||
))
|
||||
|
||||
|
@@ -10,7 +10,7 @@ import logging
|
||||
|
||||
from attract.node_types.shot import node_type_shot, human_readable_properties
|
||||
from attract.node_types.asset import node_type_asset
|
||||
from pillar.api.nodes import only_for_node_type_decorator
|
||||
from pillar.api.nodes.eve_hooks import only_for_node_type_decorator
|
||||
import pillar.api.activities
|
||||
import pillar.api.utils.authentication
|
||||
import pillar.api.utils
|
||||
@@ -72,8 +72,8 @@ def activity_after_replacing_shot_asset(shot_or_asset, original):
|
||||
descr = 'changed the thumbnail of %s "%s"' % (typename, shot_or_asset['name'])
|
||||
elif key == 'properties.status':
|
||||
val_shot = pillar.web.jinja.format_undertitle(val_shot)
|
||||
elif isinstance(val_shot, basestring) and len(val_shot) > 80:
|
||||
val_shot = val_shot[:80] + u'…'
|
||||
elif isinstance(val_shot, str) and len(val_shot) > 80:
|
||||
val_shot = val_shot[:80] + '…'
|
||||
|
||||
if descr is None:
|
||||
# A name change activity contains both the old and the new name.
|
||||
|
@@ -1,18 +1,17 @@
|
||||
import logging
|
||||
|
||||
import flask_login
|
||||
from flask import Blueprint, render_template, request
|
||||
import flask
|
||||
import werkzeug.exceptions as wz_exceptions
|
||||
|
||||
import pillarsdk
|
||||
import pillar.api.utils
|
||||
from pillar import current_app
|
||||
from pillar.web.projects.routes import project_navigation_links
|
||||
from pillar.web.system_util import pillar_api
|
||||
|
||||
from attract.routes import attract_project_view
|
||||
from attract.node_types.asset import node_type_asset, task_types
|
||||
from attract import current_attract, ROLES_REQUIRED_TO_VIEW_ITEMS
|
||||
from pillar.web.utils import get_file
|
||||
from attract.node_types.asset import node_type_asset
|
||||
from attract import current_attract
|
||||
|
||||
from . import routes_common
|
||||
|
||||
@@ -25,19 +24,20 @@ log = logging.getLogger(__name__)
|
||||
@perproject_blueprint.route('/with-task/<task_id>', endpoint='with_task')
|
||||
@attract_project_view(extension_props=True)
|
||||
def for_project(project, attract_props, task_id=None, asset_id=None):
|
||||
assets, tasks_for_assets, task_types_for_template = routes_common.for_project(
|
||||
node_type_asset['name'],
|
||||
task_types,
|
||||
project, attract_props, task_id, asset_id)
|
||||
can_use_attract = current_attract.auth.current_user_may(current_attract.auth.Actions.USE)
|
||||
navigation_links = project_navigation_links(project, pillar_api())
|
||||
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
||||
selected_id = asset_id or task_id
|
||||
|
||||
return render_template('attract/assets/for_project.html',
|
||||
assets=assets,
|
||||
tasks_for_assets=tasks_for_assets,
|
||||
task_types=task_types_for_template,
|
||||
open_task_id=task_id,
|
||||
open_asset_id=asset_id,
|
||||
selected_id=selected_id,
|
||||
project=project,
|
||||
attract_props=attract_props)
|
||||
can_use_attract=can_use_attract,
|
||||
can_create_task=can_use_attract,
|
||||
can_create_asset=can_use_attract,
|
||||
navigation_links=navigation_links,
|
||||
extension_sidebar_links=extension_sidebar_links,
|
||||
)
|
||||
|
||||
|
||||
@perproject_blueprint.route('/<asset_id>')
|
||||
@@ -48,12 +48,17 @@ def view_asset(project, attract_props, asset_id):
|
||||
|
||||
asset, node_type = routes_common.view_node(project, asset_id, node_type_asset['name'])
|
||||
|
||||
auth = current_attract.auth
|
||||
can_use_attract = auth.current_user_may(auth.Actions.USE)
|
||||
can_edit = can_use_attract and 'PUT' in asset.allowed_methods
|
||||
|
||||
return render_template('attract/assets/view_asset_embed.html',
|
||||
asset=asset,
|
||||
project=project,
|
||||
asset_node_type=node_type,
|
||||
attract_props=attract_props,
|
||||
can_edit='PUT' in asset.allowed_methods)
|
||||
can_use_attract=can_use_attract,
|
||||
can_edit=can_edit)
|
||||
|
||||
|
||||
@perproject_blueprint.route('/<asset_id>', methods=['POST'])
|
||||
@@ -81,7 +86,7 @@ def create_asset(project):
|
||||
project_url=project['url'],
|
||||
asset_id=asset['_id'])
|
||||
resp.status_code = 201
|
||||
return flask.make_response(flask.jsonify({'asset_id': asset['_id']}), 201)
|
||||
return flask.make_response(flask.jsonify(asset.to_dict()), 201)
|
||||
|
||||
|
||||
@perproject_blueprint.route('/<asset_id>/activities')
|
||||
|
@@ -1,14 +1,14 @@
|
||||
import logging
|
||||
|
||||
import flask
|
||||
import flask_login
|
||||
import werkzeug.exceptions as wz_exceptions
|
||||
|
||||
import pillarsdk
|
||||
from pillar.web.system_util import pillar_api
|
||||
from pillar.web.utils import get_file
|
||||
from pillar.auth import current_user
|
||||
|
||||
from attract import current_attract, ROLES_REQUIRED_TO_VIEW_ITEMS
|
||||
from attract import current_attract
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,12 +53,10 @@ def for_project(node_type_name, task_types_for_nt, project, attract_props,
|
||||
|
||||
def view_node(project, node_id, node_type_name):
|
||||
"""Returns the node if the user has access.
|
||||
|
||||
Uses attract.ROLES_REQUIRED_TO_VIEW_ITEMS to check permissions.
|
||||
"""
|
||||
|
||||
# asset list is public, asset details are not.
|
||||
if not flask_login.current_user.has_role(*ROLES_REQUIRED_TO_VIEW_ITEMS):
|
||||
if not current_user.has_cap('attract-view'):
|
||||
raise wz_exceptions.Forbidden()
|
||||
|
||||
api = pillar_api()
|
||||
|
@@ -1,18 +1,18 @@
|
||||
import logging
|
||||
|
||||
import flask_login
|
||||
from flask import Blueprint, render_template, request
|
||||
import flask
|
||||
import werkzeug.exceptions as wz_exceptions
|
||||
|
||||
import pillarsdk
|
||||
import pillar.api.utils
|
||||
from pillar import current_app
|
||||
from pillar.web.projects.routes import project_navigation_links
|
||||
from pillar.web.system_util import pillar_api
|
||||
|
||||
from attract.routes import attract_project_view
|
||||
from attract.node_types.shot import node_type_shot, task_types
|
||||
from attract import current_attract, ROLES_REQUIRED_TO_VIEW_ITEMS
|
||||
from pillar.web.utils import get_file
|
||||
from attract.node_types.shot import node_type_shot
|
||||
from attract import current_attract
|
||||
|
||||
from . import routes_common
|
||||
|
||||
@@ -25,9 +25,11 @@ log = logging.getLogger(__name__)
|
||||
@perproject_blueprint.route('/with-task/<task_id>', endpoint='with_task')
|
||||
@attract_project_view(extension_props=True)
|
||||
def for_project(project, attract_props, task_id=None, shot_id=None):
|
||||
node_type_name = node_type_shot['name']
|
||||
|
||||
shots, tasks_for_shots, task_types_for_template = routes_common.for_project(
|
||||
node_type_shot['name'],
|
||||
task_types,
|
||||
node_type_name,
|
||||
attract_props['task_types'][node_type_name],
|
||||
project, attract_props, task_id, shot_id)
|
||||
|
||||
# Some aggregated stats
|
||||
@@ -37,16 +39,24 @@ def for_project(project, attract_props, task_id=None, shot_id=None):
|
||||
for shot in shots
|
||||
if shot.properties.used_in_edit),
|
||||
}
|
||||
can_use_attract = current_attract.auth.current_user_may(current_attract.auth.Actions.USE)
|
||||
navigation_links = project_navigation_links(project, pillar_api())
|
||||
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
||||
|
||||
selected_id = shot_id or task_id
|
||||
|
||||
return render_template('attract/shots/for_project.html',
|
||||
shots=shots,
|
||||
tasks_for_shots=tasks_for_shots,
|
||||
task_types=task_types_for_template,
|
||||
open_task_id=task_id,
|
||||
open_shot_id=shot_id,
|
||||
selected_id=selected_id,
|
||||
project=project,
|
||||
attract_props=attract_props,
|
||||
stats=stats)
|
||||
stats=stats,
|
||||
can_use_attract=can_use_attract,
|
||||
can_create_task=can_use_attract,
|
||||
navigation_links=navigation_links,
|
||||
extension_sidebar_links=extension_sidebar_links)
|
||||
|
||||
|
||||
@perproject_blueprint.route('/<shot_id>')
|
||||
@@ -56,13 +66,15 @@ def view_shot(project, attract_props, shot_id):
|
||||
return for_project(project, attract_props, shot_id=shot_id)
|
||||
|
||||
shot, node_type = routes_common.view_node(project, shot_id, node_type_shot['name'])
|
||||
can_use_attract = current_attract.auth.current_user_may(current_attract.auth.Actions.USE)
|
||||
|
||||
return render_template('attract/shots/view_shot_embed.html',
|
||||
shot=shot,
|
||||
project=project,
|
||||
shot_node_type=node_type,
|
||||
attract_props=attract_props,
|
||||
can_edit='PUT' in shot.allowed_methods)
|
||||
can_use_attract=can_use_attract,
|
||||
can_edit=can_use_attract and 'PUT' in shot.allowed_methods)
|
||||
|
||||
|
||||
@perproject_blueprint.route('/<shot_id>', methods=['POST'])
|
||||
@@ -71,6 +83,9 @@ def save(project, shot_id):
|
||||
log.info('Saving shot %s', shot_id)
|
||||
log.debug('Form data: %s', request.form)
|
||||
|
||||
if not current_attract.auth.current_user_may(current_attract.auth.Actions.USE):
|
||||
raise wz_exceptions.Forbidden()
|
||||
|
||||
shot_dict = request.form.to_dict()
|
||||
current_attract.shot_manager.edit_shot(shot_id, **shot_dict)
|
||||
|
||||
@@ -84,6 +99,9 @@ def save(project, shot_id):
|
||||
@perproject_blueprint.route('/create', methods=['POST', 'GET'])
|
||||
@attract_project_view()
|
||||
def create_shot(project):
|
||||
if not current_attract.auth.current_user_may(current_attract.auth.Actions.USE):
|
||||
raise wz_exceptions.Forbidden()
|
||||
|
||||
shot = current_attract.shot_manager.create_shot(project)
|
||||
|
||||
resp = flask.make_response()
|
||||
|
2
attract/static/assets/js/vendor/jquery-resizable-0.20.min.js
vendored
Normal file
2
attract/static/assets/js/vendor/jquery-resizable-0.20.min.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
!function(e,n){"function"==typeof define&&define.amd?define(["jquery"],e):"object"==typeof module&&"object"==typeof module.exports?module.exports=e(require("jquery")):e(jQuery)}(function(e,n){function t(n,t){return n&&">"===n.trim()[0]?(n=n.trim().replace(/^>\s*/,""),t.find(n)):n?e(n):t}e.fn.resizable||(e.fn.resizable=function(n){var o={handleSelector:null,resizeWidth:!0,resizeHeight:!0,resizeWidthFrom:"right",resizeHeightFrom:"bottom",onDragStart:null,onDragEnd:null,onDrag:null,touchActionNone:!0};return"object"==typeof n&&(o=e.extend(o,n)),this.each(function(){function n(e){e.stopPropagation(),e.preventDefault()}function i(t){t.preventDefault&&t.preventDefault(),s=c(t),s.width=parseInt(d.width(),10),s.height=parseInt(d.height(),10),a=d.css("transition"),d.css("transition","none"),o.onDragStart&&o.onDragStart(t,d,o)===!1||(o.dragFunc=r,e(document).bind("mousemove.rsz",o.dragFunc),e(document).bind("mouseup.rsz",u),(window.Touch||navigator.maxTouchPoints)&&(e(document).bind("touchmove.rsz",o.dragFunc),e(document).bind("touchend.rsz",u)),e(document).bind("selectstart.rsz",n))}function r(e){var n,t,i=c(e);n="left"===o.resizeWidthFrom?s.width-i.x+s.x:s.width+i.x-s.x,t="top"===o.resizeHeightFrom?s.height-i.y+s.y:s.height+i.y-s.y,o.onDrag&&o.onDrag(e,d,n,t,o)===!1||(o.resizeHeight&&d.height(t),o.resizeWidth&&d.width(n))}function u(t){return t.stopPropagation(),t.preventDefault(),e(document).unbind("mousemove.rsz",o.dragFunc),e(document).unbind("mouseup.rsz",u),(window.Touch||navigator.maxTouchPoints)&&(e(document).unbind("touchmove.rsz",o.dragFunc),e(document).unbind("touchend.rsz",u)),e(document).unbind("selectstart.rsz",n),d.css("transition",a),o.onDragEnd&&o.onDragEnd(t,d,o),!1}function c(e){var n={x:0,y:0,width:0,height:0};if("number"==typeof e.clientX)n.x=e.clientX,n.y=e.clientY;else{if(!e.originalEvent.touches)return null;n.x=e.originalEvent.touches[0].clientX,n.y=e.originalEvent.touches[0].clientY}return n}var s,a,d=e(this),h=t(o.handleSelector,d);o.touchActionNone&&h.css("touch-action","none"),d.addClass("resizable"),h.bind("mousedown.rsz touchstart.rsz",i)})})});
|
||||
//# sourceMappingURL=jquery-resizable.min.js.map
|
@@ -1,7 +1,5 @@
|
||||
"""Subversion interface."""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import collections
|
||||
import dateutil.parser
|
||||
import re
|
||||
@@ -9,7 +7,7 @@ import re
|
||||
import attr
|
||||
import blinker
|
||||
import svn.remote
|
||||
import svn.common
|
||||
import svn.exception
|
||||
from pillar import attrs_extra
|
||||
|
||||
task_logged = blinker.NamedSignal('task_logged')
|
||||
@@ -19,10 +17,11 @@ signals = {
|
||||
'T': task_logged,
|
||||
}
|
||||
|
||||
# Copy of namedtuple defined in svn.common.log_default().
|
||||
# Copy of namedtuple defined in svn.common.log_default(),
|
||||
# extended with our project_id.
|
||||
LogEntry = collections.namedtuple(
|
||||
'LogEntry',
|
||||
['date', 'msg', 'revision', 'author', 'changelist']
|
||||
['date', 'msg', 'revision', 'author', 'changelist', 'project_id']
|
||||
)
|
||||
|
||||
|
||||
@@ -56,6 +55,7 @@ class CommitLogObserver(object):
|
||||
def fetch_and_observe(self):
|
||||
"""Obtains task IDs from SVN logs."""
|
||||
|
||||
# FIXME: this code is unaware of the fact that task markers are only unique per project.
|
||||
self._log.debug('%s: fetch_and_observe()', self)
|
||||
|
||||
try:
|
||||
@@ -72,7 +72,7 @@ class CommitLogObserver(object):
|
||||
|
||||
self.process_log(log_entry)
|
||||
|
||||
except svn.common.SvnException:
|
||||
except svn.exception.SvnException:
|
||||
# The SVN library just raises a SvnException when something goes wrong,
|
||||
# without any structured indication of the error. There isn't much else
|
||||
# we can do, except to log the error and return.
|
||||
|
@@ -36,7 +36,7 @@ def subversion_kick(project, attract_props):
|
||||
|
||||
|
||||
@api_blueprint.route('/<project_url>/subversion/log', methods=['POST'])
|
||||
@authorization.require_login(require_roles={u'service', u'svner'}, require_all=True)
|
||||
@authorization.require_login(require_roles={'service', 'svner'}, require_all=True)
|
||||
def subversion_log(project_url):
|
||||
if request.mimetype != 'application/json':
|
||||
log.warning('Received %s instead of application/json', request.mimetype)
|
||||
@@ -87,12 +87,13 @@ def subversion_log(project_url):
|
||||
except KeyError:
|
||||
return 'Not set up for Attract', 400
|
||||
|
||||
svn_server_url = attract_props['svn_url']
|
||||
svn_server_url = attract_props.get('svn_url', '-unknown-')
|
||||
log.debug('Receiving commit from SVN server %s', svn_server_url)
|
||||
log_entry = subversion.create_log_entry(revision=revision,
|
||||
msg=commit_message,
|
||||
author=commit_author,
|
||||
date_text=commit_date)
|
||||
date_text=commit_date,
|
||||
project_id=project['_id'])
|
||||
observer = subversion.CommitLogObserver()
|
||||
log.debug('Processing %s via %s', log_entry, observer)
|
||||
observer.process_log(log_entry)
|
||||
|
@@ -4,6 +4,7 @@ import attr
|
||||
import flask
|
||||
import flask_login
|
||||
from dateutil import parser
|
||||
import bson
|
||||
|
||||
import pillarsdk
|
||||
from pillar import attrs_extra
|
||||
@@ -48,7 +49,7 @@ class TaskManager(object):
|
||||
|
||||
task = pillarsdk.Node(node_props)
|
||||
task.create(api=api)
|
||||
return task
|
||||
return pillarsdk.Node.find(task._id, api=api)
|
||||
|
||||
def edit_task(self, task_id, **fields):
|
||||
"""Edits a task.
|
||||
@@ -66,7 +67,7 @@ class TaskManager(object):
|
||||
task.description = fields.pop('description')
|
||||
task.properties.status = fields.pop('status')
|
||||
task.properties.task_type = fields.pop('task_type', None)
|
||||
if isinstance(task.properties.task_type, basestring):
|
||||
if isinstance(task.properties.task_type, str):
|
||||
task.properties.task_type = task.properties.task_type.strip() or None
|
||||
|
||||
due_date = fields.pop('due_date', None)
|
||||
@@ -128,7 +129,7 @@ class TaskManager(object):
|
||||
}}, api=api)
|
||||
return tasks
|
||||
|
||||
def api_task_for_shortcode(self, shortcode):
|
||||
def api_task_for_shortcode(self, project_id: bson.ObjectId, shortcode: str) -> dict:
|
||||
"""Returns the task for the given shortcode.
|
||||
|
||||
:returns: the task Node, or None if not found.
|
||||
@@ -137,6 +138,7 @@ class TaskManager(object):
|
||||
db = flask.current_app.db()
|
||||
task = db['nodes'].find_one({
|
||||
'properties.shortcode': shortcode,
|
||||
'project': project_id,
|
||||
'node_type': node_type_task['name'],
|
||||
})
|
||||
|
||||
@@ -150,21 +152,22 @@ class TaskManager(object):
|
||||
:type log_entry: attract.subversion.LogEntry
|
||||
"""
|
||||
|
||||
self._log.info(u"Task '%s' logged in SVN by %s: %s...",
|
||||
shortcode, log_entry.author, log_entry.msg[:30].replace('\n', ' // '))
|
||||
self._log.info("Project %s, task '%s' logged in SVN by %s: %s...",
|
||||
log_entry.project_id, shortcode,
|
||||
log_entry.author, log_entry.msg[:30].replace('\n', ' // '))
|
||||
|
||||
# Find the task
|
||||
task = self.api_task_for_shortcode(shortcode)
|
||||
task = self.api_task_for_shortcode(log_entry.project_id, shortcode)
|
||||
if not task:
|
||||
self._log.warning(u'Task %s not found, ignoring SVN commit.', shortcode)
|
||||
self._log.warning('Task %s not found, ignoring SVN commit.', shortcode)
|
||||
return
|
||||
|
||||
# Find the author
|
||||
db = flask.current_app.db()
|
||||
proj = db['projects'].find_one({'_id': task['project']},
|
||||
projection={'extension_props.attract': 1})
|
||||
projection={'extension_props.attract': 1})
|
||||
if not proj:
|
||||
self._log.warning(u'Project %s for task %s not found, ignoring SVN commit.',
|
||||
self._log.warning('Project %s for task %s not found, ignoring SVN commit.',
|
||||
task['project'], task['_id'])
|
||||
return
|
||||
|
||||
@@ -175,7 +178,7 @@ class TaskManager(object):
|
||||
if user_id:
|
||||
msg = 'committed SVN revision %s: %s' % (log_entry.revision, log_entry.msg)
|
||||
else:
|
||||
self._log.warning(u'No Pillar user mapped for SVN user %s, using SVNer account.',
|
||||
self._log.warning('No Pillar user mapped for SVN user %s, using SVNer account.',
|
||||
log_entry.author)
|
||||
user_id = authentication.current_user_id()
|
||||
msg = 'committed SVN revision %s authored by SVN user %s: %s' % (
|
||||
|
@@ -5,7 +5,7 @@ import logging
|
||||
import itertools
|
||||
from flask import current_app, g
|
||||
|
||||
from pillar.api.nodes import only_for_node_type_decorator
|
||||
from pillar.api.nodes.eve_hooks import only_for_node_type_decorator
|
||||
import pillar.api.activities
|
||||
import pillar.api.utils.authentication
|
||||
import pillar.web.jinja
|
||||
@@ -112,7 +112,7 @@ def register_task_activity(task, descr):
|
||||
|
||||
def get_user_list(user_list):
|
||||
if not user_list:
|
||||
return u'-nobody-'
|
||||
return '-nobody-'
|
||||
|
||||
user_coll = current_app.db()['users']
|
||||
users = user_coll.find(
|
||||
@@ -123,7 +123,7 @@ def get_user_list(user_list):
|
||||
)
|
||||
|
||||
names = [user['full_name'] for user in users]
|
||||
return u', '.join(names)
|
||||
return ', '.join(names)
|
||||
|
||||
|
||||
@only_for_task
|
||||
@@ -150,8 +150,8 @@ def activity_after_replacing_task(task, original):
|
||||
human_key = 'assigned users'
|
||||
val_task = get_user_list(val_task)
|
||||
descr = 'assigned task "%s" to %s' % (task['name'], val_task)
|
||||
elif isinstance(val_task, basestring) and len(val_task) > 80:
|
||||
val_task = val_task[:80] + u'…'
|
||||
elif isinstance(val_task, str) and len(val_task) > 80:
|
||||
val_task = val_task[:80] + '…'
|
||||
|
||||
if descr is None:
|
||||
# A name change activity contains both the old and the new name.
|
||||
@@ -181,13 +181,13 @@ def activity_after_deleting_task(task):
|
||||
def set_defaults(task):
|
||||
from attract import shortcodes
|
||||
|
||||
shortcode = shortcodes.generate_shortcode(task['project'], task['node_type'], u'T')
|
||||
shortcode = shortcodes.generate_shortcode(task['project'], task['node_type'], 'T')
|
||||
task_properties = task.setdefault('properties', {})
|
||||
task_properties['shortcode'] = shortcode
|
||||
|
||||
# When the task is assigned to a user, this prevents a change of 'assigned_to' to a dict.
|
||||
# Instead, the activity will be registered on 'assigned_to.users', which is nicer.
|
||||
task_properties.setdefault('assigned_to', {u'users': []})
|
||||
task_properties.setdefault('assigned_to', {'users': []})
|
||||
|
||||
|
||||
def nodes_set_defaults(nodes):
|
||||
|
@@ -1,20 +1,22 @@
|
||||
import logging
|
||||
from dateutil import parser
|
||||
|
||||
from flask import Blueprint, render_template, request, current_app
|
||||
from flask import Blueprint, render_template, request, current_app, session
|
||||
import flask
|
||||
import flask_login
|
||||
import werkzeug.exceptions as wz_exceptions
|
||||
|
||||
import pillarsdk
|
||||
from pillar.web.projects.routes import project_navigation_links
|
||||
from pillar.web.system_util import pillar_api
|
||||
import pillar.api.utils
|
||||
import pillar.web.subquery
|
||||
from pillar.auth import current_user
|
||||
|
||||
from attract.routes import attract_project_view
|
||||
from attract.node_types.task import node_type_task
|
||||
from attract.node_types.shot import node_type_shot
|
||||
from attract import current_attract, ROLES_REQUIRED_TO_VIEW_ITEMS, EXTENSION_NAME
|
||||
from attract import current_attract, EXTENSION_NAME
|
||||
|
||||
blueprint = Blueprint('attract.tasks', __name__, url_prefix='/tasks')
|
||||
perproject_blueprint = Blueprint('attract.tasks.perproject', __name__,
|
||||
@@ -28,13 +30,16 @@ def index():
|
||||
if not user.is_authenticated:
|
||||
return render_template('attract/tasks/index.html')
|
||||
|
||||
project = session.get('attract_last_project')
|
||||
tasks = current_attract.task_manager.tasks_for_user(user.objectid)
|
||||
return render_template('attract/tasks/for_user.html',
|
||||
tasks=tasks['_items'],
|
||||
project=project,
|
||||
task_count=tasks['_meta']['total'])
|
||||
|
||||
|
||||
@blueprint.route('/<task_id>', methods=['DELETE'])
|
||||
@flask_login.login_required
|
||||
def delete(task_id):
|
||||
log.info('Deleting task %s', task_id)
|
||||
|
||||
@@ -45,13 +50,21 @@ def delete(task_id):
|
||||
|
||||
|
||||
@perproject_blueprint.route('/', endpoint='index')
|
||||
@attract_project_view()
|
||||
@attract_project_view(extension_props=False)
|
||||
def for_project(project, task_id=None):
|
||||
tasks = current_attract.task_manager.tasks_for_project(project['_id'])
|
||||
can_use_attract = current_attract.auth.current_user_may(current_attract.auth.Actions.USE)
|
||||
navigation_links = project_navigation_links(project, pillar_api())
|
||||
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
||||
|
||||
return render_template('attract/tasks/for_project.html',
|
||||
tasks=tasks['_items'],
|
||||
open_task_id=task_id,
|
||||
project=project)
|
||||
selected_id=task_id,
|
||||
project=project,
|
||||
can_use_attract=can_use_attract,
|
||||
can_create_task=can_use_attract,
|
||||
navigation_links=navigation_links,
|
||||
extension_sidebar_links=extension_sidebar_links)
|
||||
|
||||
|
||||
@perproject_blueprint.route('/<task_id>')
|
||||
@@ -61,7 +74,7 @@ def view_task(project, attract_props, task_id):
|
||||
return for_project(project, task_id=task_id)
|
||||
|
||||
# Task list is public, task details are not.
|
||||
if not flask_login.current_user.has_role(*ROLES_REQUIRED_TO_VIEW_ITEMS):
|
||||
if not current_user.has_cap('attract-view'):
|
||||
raise wz_exceptions.Forbidden()
|
||||
|
||||
api = pillar_api()
|
||||
@@ -76,7 +89,10 @@ def view_task(project, attract_props, task_id):
|
||||
task.properties.due_date = parser.parse('%s' % task.properties.due_date)
|
||||
|
||||
# Fetch project users so that we can assign them tasks
|
||||
can_edit = 'PUT' in task.allowed_methods
|
||||
auth = current_attract.auth
|
||||
can_use_attract = auth.current_user_may(auth.Actions.USE)
|
||||
can_edit = 'PUT' in task.allowed_methods and can_use_attract
|
||||
|
||||
if can_edit:
|
||||
users = project.get_users(api=api)
|
||||
project.users = users['_items']
|
||||
@@ -96,6 +112,7 @@ def view_task(project, attract_props, task_id):
|
||||
task_types=task_types,
|
||||
attract_props=attract_props.to_dict(),
|
||||
attract_context=request.args.get('context'),
|
||||
can_use_attract=can_use_attract,
|
||||
can_edit=can_edit)
|
||||
|
||||
|
||||
@@ -166,7 +183,7 @@ def create_task(project):
|
||||
task_id=task['_id'])
|
||||
resp.status_code = 201
|
||||
|
||||
return flask.make_response(flask.jsonify({'task_id': task['_id']}), 201)
|
||||
return flask.make_response(flask.jsonify(task.to_dict()), 201)
|
||||
|
||||
|
||||
@perproject_blueprint.route('/<task_id>/activities')
|
||||
|
@@ -1,8 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo
|
||||
echo "==========================================================================="
|
||||
echo "Dummy deploy script for people with a 'git pp' alias to push to production."
|
||||
echo "Run deploy script on your server project."
|
||||
echo "When done, press [ENTER] to stop this script."
|
||||
read dummy
|
6
deploy_docs.sh
Executable file
6
deploy_docs.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
cd docs
|
||||
command -v mkdocs 2>/dev/null 2>&1 || { echo >&2 "Command mkdocs not found. Are you in the right venv?"; exit 1; }
|
||||
mkdocs build
|
||||
rsync -auv ./site/* armadillica@attract.studio:/home/armadillica/attract.studio/docs
|
12
docs/docs/developer_docs/roadmap.md
Normal file
12
docs/docs/developer_docs/roadmap.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Roadmap
|
||||
The day-to-day planning for development is available on
|
||||
[developer.blender.org](https://developer.blender.org/project/board/72/). In this section we summarize
|
||||
the high level goals for the projects.
|
||||
|
||||
## Self-provisionable Server
|
||||
Make it possible for developers to run the full stack in a local environment. In similar way to
|
||||
Flamenco, the challenge is to get the Server (and its Pillar core) disconnected from Blender Cloud.
|
||||
|
||||
## Data filtering and sorting
|
||||
Provide basic filtering and sorting functionality for assets, tasks, and assets. For example, make it
|
||||
possible to find all tasks assigned to a specific user, or in a specific set of statuses.
|
74
docs/docs/img/logo_attract_white.svg
Normal file
74
docs/docs/img/logo_attract_white.svg
Normal file
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="512"
|
||||
height="512"
|
||||
id="svg2"
|
||||
version="1.1"
|
||||
inkscape:version="0.48.4 r9939"
|
||||
sodipodi:docname="logo_attract.svg"
|
||||
inkscape:export-filename="/shared/software/attract3/logo/attract_logo_bw_1080.png"
|
||||
inkscape:export-xdpi="90"
|
||||
inkscape:export-ydpi="90">
|
||||
<defs
|
||||
id="defs4" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.98994949"
|
||||
inkscape:cx="222.50239"
|
||||
inkscape:cy="247.16912"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
fit-margin-top="10"
|
||||
fit-margin-left="10"
|
||||
fit-margin-right="10"
|
||||
fit-margin-bottom="10"
|
||||
inkscape:showpageshadow="false"
|
||||
inkscape:window-width="1418"
|
||||
inkscape:window-height="855"
|
||||
inkscape:window-x="424"
|
||||
inkscape:window-y="590"
|
||||
inkscape:window-maximized="0" />
|
||||
<metadata
|
||||
id="metadata7">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-81.656231,-11.7557)">
|
||||
<g
|
||||
id="g2995"
|
||||
transform="matrix(0.66925035,0,0,0.66925035,371.3471,-72.927844)"
|
||||
style="fill:white;fill-opacity:1;stroke:none;fill-rule:nonzero">
|
||||
<path
|
||||
style="fill:white;fill-opacity:1;stroke:none;fill-rule:nonzero"
|
||||
d="m 166.06342,857.36804 c -2.0241,-1.63901 -2.11292,-2.89269 -2.98911,-42.18649 -1.56775,-70.30787 -3.12503,-148.81192 -3.94749,-198.99728 -0.74743,-45.6073 -0.92521,-48.94732 -2.9808,-56 -8.92469,-30.62035 -34.68288,-56.26535 -93.79792,-93.38574 -71.4521403,-44.86728 -217.86949,-119.37839 -308.62723,-157.05897 -11.53416,-4.78873 -14.07545,-5.52908 -15.12259,-4.40564 -0.95964,1.02956 -1.65295,20.23647 -2.91842,80.85035 -0.91287,43.725 -2.11692,81.49904 -2.67566,83.94232 -4.76367,20.83065 -32.50574,59.96134 -61.53114,86.79078 -22.87237,21.14193 -43.26077,35.03351 -52.17813,35.55142 l -3.71013,0.21548 -0.59028,-5.5 c -0.7529,-7.01532 6.55125,-231.1601 8.09781,-248.5 1.54288,-17.29859 4.40736,-33.86113 7.10175,-41.06252 2.60264,-6.9562 13.58837,-23.48911 31.94188,-48.07076 16.29143,-21.81981 31.85794,-37.75226 58.44884,-59.82286 30.66053,-25.44841 34.3329,-27.45527 50.34292,-27.51121 12.67088,-0.0443 25.74008,2.76304 48.65708,10.45172 79.57398,26.69719 271.10729,122.71606 363.96686,182.4627 53.1755,34.21358 78.51413,59.37766 87.39716,86.79513 2.37747,7.33804 5.6679,26.42936 7.11089,41.2578 1.13125,11.62495 8.05544,216.85126 8.19865,243 0.0845,15.42373 -0.0729,16.89815 -2.41221,22.60399 -11.11776,27.11712 -47.59735,68.13655 -82.76135,93.06091 -11.85316,8.40156 -21.59148,13.91534 -27.71765,15.69357 -4.78422,1.38871 -5.39093,1.37419 -7.30373,-0.1747 z M -53.75396,623.06914 c -16.11333,-1.99066 -30.39323,-10.66862 -38.76393,-23.55704 -5.77741,-8.89551 -8.7432,-19.27201 -8.81115,-30.82783 -0.0945,-16.06423 4.45371,-26.8127 16.30168,-38.52498 12.04616,-11.9082 24.23682,-16.62772 40.61216,-15.72271 26.10197,1.44258 45.6435897,18.04702 51.0008797,43.33525 2.75772,13.01736 1.11109,24.63774 -5.19616,36.66971 -10.56115,20.14689 -32.3266697,31.44641 -55.1434797,28.6276 z"
|
||||
id="path2999"
|
||||
inkscape:connector-curvature="0" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 3.9 KiB |
17
docs/docs/index.md
Normal file
17
docs/docs/index.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Attract Docs
|
||||
|
||||
Welcome to the Attract documentation pages! Here we collect both user and development docs. Attract
|
||||
is the production tracking and management software used at Blender Animation Studio.
|
||||
|
||||
## Main features
|
||||
|
||||
* Shot list, asset list and task list
|
||||
* Extensible design, supporting custom task types with custom attributes
|
||||
* Integration of SVN activity in a task activity list
|
||||
* Completely Free and Open Source software
|
||||
|
||||
## Status of the documentation
|
||||
|
||||
Documentation is an ongoing effort. We are currently focusing on user documentation, aimed at
|
||||
Blender Cloud subscribers. If you are interested in installing Attract on your own infrastructure,
|
||||
consider checking out the sources and README.md files.
|
10
docs/docs/user_manual/installation.md
Normal file
10
docs/docs/user_manual/installation.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Installation & Configuration
|
||||
|
||||
!!! note
|
||||
This section of the manual is work in progress.
|
||||
|
||||
The following video shows how to set up a Blender Cloud project with Attract, which is currently
|
||||
the only way officially supported. A step-by-step text version will follow.
|
||||
|
||||
<iframe width="750" height="350" src="https://www.youtube.com/embed/FoUua_Jlmpc?rel=0" frameborder="0"
|
||||
gesture="media" allow="encrypted-media" allowfullscreen></iframe>
|
6
docs/docs/user_manual/introduction.md
Normal file
6
docs/docs/user_manual/introduction.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Introduction
|
||||
|
||||
!!! note
|
||||
This section of the manual is work in progress.
|
||||
|
||||
This manual aims at describing the features of Attract and providing examples on how to use them.
|
28
docs/docs/user_manual/subversion.md
Normal file
28
docs/docs/user_manual/subversion.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Subversion Integration
|
||||
|
||||
By hooking your Subversion server to Attract, tasks can be automatically updated based on tags in
|
||||
the commit message. This requires a post-commit hook to be installed on the Subversion server.
|
||||
|
||||
Example hook:
|
||||
|
||||
REPOS="$1"
|
||||
REV="$2"
|
||||
TXN_NAME="$3"
|
||||
|
||||
/usr/bin/python3 "$REPOS"/hooks/notify_attract.py "$REPOS" "$REV"
|
||||
|
||||
The
|
||||
[`notify_attract.py`](https://developer.blender.org/source/attract/browse/master/notify_attract.py)
|
||||
file is bundled with Attract's source code, and needs to be copied to the Subversion repository's
|
||||
`hook` directory. After copying, modify the code to include an authentication token and the mapping from the Subversion repository name to the project URL. For example:
|
||||
|
||||
AUTH_TOKEN = 'SRVxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
|
||||
PILLAR_URL = 'https://cloud.blender.org/'
|
||||
PROJECT_URLS = { # Mapping from SVN repository name to Attract project URL.
|
||||
'svnreponame': 'p-123456789',
|
||||
}
|
||||
|
||||
The authentication token must be created on the server using `manage.py attract create_svner_account {emailaddress} {project-url}`
|
||||
|
||||
The project should have an `svn_url` extension property in the MongoDB database, which points to
|
||||
the Subversion server URL. This is for logging use only.
|
42
docs/mkdocs.yml
Normal file
42
docs/mkdocs.yml
Normal file
@@ -0,0 +1,42 @@
|
||||
# Project information
|
||||
site_name: 'Attract'
|
||||
site_description: 'Free and Open Source production tracking for film makers'
|
||||
site_author: 'Blender Institute'
|
||||
site_url: 'https://attract.studio/'
|
||||
|
||||
# Repository
|
||||
repo_name: 'Developed on blender.org'
|
||||
repo_url: 'https://developer.blender.org/project/view/72/'
|
||||
|
||||
# Copyright
|
||||
copyright: 'Copyright © 2016 Blender Institute - CC-BY-SA v4.0.'
|
||||
|
||||
theme:
|
||||
name: 'material'
|
||||
logo: 'img/logo_attract_white.svg'
|
||||
palette:
|
||||
primary: 'blue grey'
|
||||
accent: 'deep orange'
|
||||
social:
|
||||
- type: 'github'
|
||||
link: 'https://github.com/armadillica'
|
||||
- type: 'twitter'
|
||||
link: 'https://twitter.com/Blender_Cloud'
|
||||
|
||||
pages:
|
||||
- Home: 'index.md'
|
||||
- User Manual:
|
||||
- 'user_manual/introduction.md'
|
||||
- 'user_manual/installation.md'
|
||||
- 'user_manual/subversion.md'
|
||||
- Developer Docs:
|
||||
- 'developer_docs/roadmap.md'
|
||||
|
||||
|
||||
# Google Analytics
|
||||
google_analytics:
|
||||
- 'UA-13043630-10'
|
||||
- 'auto'
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
156
gulpfile.js
156
gulpfile.js
@@ -1,29 +1,43 @@
|
||||
var argv = require('minimist')(process.argv.slice(2));
|
||||
var autoprefixer = require('gulp-autoprefixer');
|
||||
var chmod = require('gulp-chmod');
|
||||
var concat = require('gulp-concat');
|
||||
var gulp = require('gulp');
|
||||
var gulpif = require('gulp-if');
|
||||
var jade = require('gulp-jade');
|
||||
var livereload = require('gulp-livereload');
|
||||
var plumber = require('gulp-plumber');
|
||||
var rename = require('gulp-rename');
|
||||
var sass = require('gulp-sass');
|
||||
var sourcemaps = require('gulp-sourcemaps');
|
||||
var uglify = require('gulp-uglify');
|
||||
var cache = require('gulp-cached');
|
||||
let argv = require('minimist')(process.argv.slice(2));
|
||||
let autoprefixer = require('gulp-autoprefixer');
|
||||
let cache = require('gulp-cached');
|
||||
let chmod = require('gulp-chmod');
|
||||
let concat = require('gulp-concat');
|
||||
let git = require('gulp-git');
|
||||
let gulp = require('gulp');
|
||||
let gulpif = require('gulp-if');
|
||||
let plumber = require('gulp-plumber');
|
||||
let pug = require('gulp-pug');
|
||||
let rename = require('gulp-rename');
|
||||
let sass = require('gulp-sass');
|
||||
let sourcemaps = require('gulp-sourcemaps');
|
||||
let uglify = require('gulp-uglify-es').default;
|
||||
let browserify = require('browserify');
|
||||
let babelify = require('babelify');
|
||||
let sourceStream = require('vinyl-source-stream');
|
||||
let glob = require('glob');
|
||||
let es = require('event-stream');
|
||||
let path = require('path');
|
||||
let buffer = require('vinyl-buffer');
|
||||
|
||||
var enabled = {
|
||||
uglify: argv.production,
|
||||
maps: argv.production,
|
||||
let enabled = {
|
||||
chmod: argv.production,
|
||||
cleanup: argv.production,
|
||||
failCheck: argv.production,
|
||||
maps: argv.production,
|
||||
prettyPug: !argv.production,
|
||||
liveReload: !argv.production
|
||||
uglify: argv.production,
|
||||
};
|
||||
|
||||
let destination = {
|
||||
css: 'attract/static/assets/css',
|
||||
pug: 'attract/templates',
|
||||
js: 'attract/static/assets/js/generated',
|
||||
}
|
||||
|
||||
|
||||
/* CSS */
|
||||
gulp.task('styles', function() {
|
||||
gulp.task('styles', function(done) {
|
||||
gulp.src('src/styles/**/*.sass')
|
||||
.pipe(gulpif(enabled.failCheck, plumber()))
|
||||
.pipe(gulpif(enabled.maps, sourcemaps.init()))
|
||||
@@ -32,26 +46,26 @@ gulp.task('styles', function() {
|
||||
))
|
||||
.pipe(autoprefixer("last 3 versions"))
|
||||
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
|
||||
.pipe(gulp.dest('attract/static/assets/css'))
|
||||
.pipe(gulpif(enabled.liveReload, livereload()));
|
||||
.pipe(gulp.dest(destination.css));
|
||||
done();
|
||||
});
|
||||
|
||||
|
||||
/* Templates - Jade */
|
||||
gulp.task('templates', function() {
|
||||
gulp.src('src/templates/**/*.jade')
|
||||
/* Templates - Pug */
|
||||
gulp.task('templates', function(done) {
|
||||
gulp.src('src/templates/**/*.pug')
|
||||
.pipe(gulpif(enabled.failCheck, plumber()))
|
||||
.pipe(cache('templating'))
|
||||
.pipe(jade({
|
||||
.pipe(pug({
|
||||
pretty: enabled.prettyPug
|
||||
}))
|
||||
.pipe(gulp.dest('attract/templates/'))
|
||||
.pipe(gulpif(enabled.liveReload, livereload()));
|
||||
.pipe(gulp.dest(destination.pug));
|
||||
done();
|
||||
});
|
||||
|
||||
|
||||
/* Individual Uglified Scripts */
|
||||
gulp.task('scripts', function() {
|
||||
gulp.task('scripts', function(done) {
|
||||
gulp.src('src/scripts/*.js')
|
||||
.pipe(gulpif(enabled.failCheck, plumber()))
|
||||
.pipe(cache('scripting'))
|
||||
@@ -59,40 +73,92 @@ gulp.task('scripts', function() {
|
||||
.pipe(gulpif(enabled.uglify, uglify()))
|
||||
.pipe(rename({suffix: '.min'}))
|
||||
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
|
||||
.pipe(chmod(644))
|
||||
.pipe(gulp.dest('attract/static/assets/js/generated/'))
|
||||
.pipe(gulpif(enabled.liveReload, livereload()));
|
||||
.pipe(gulpif(enabled.chmod, chmod(0o644)))
|
||||
.pipe(gulp.dest(destination.js));
|
||||
done();
|
||||
});
|
||||
|
||||
function browserify_base(entry) {
|
||||
let pathSplited = path.dirname(entry).split(path.sep);
|
||||
let moduleName = pathSplited[pathSplited.length - 1];
|
||||
return browserify({
|
||||
entries: [entry],
|
||||
standalone: 'attract.' + moduleName,
|
||||
})
|
||||
.transform(babelify, { "presets": ["@babel/preset-env"] })
|
||||
.bundle()
|
||||
.pipe(gulpif(enabled.failCheck, plumber()))
|
||||
.pipe(sourceStream(path.basename(entry)))
|
||||
.pipe(buffer())
|
||||
.pipe(rename({
|
||||
basename: moduleName,
|
||||
extname: '.min.js'
|
||||
}));
|
||||
}
|
||||
|
||||
function browserify_common() {
|
||||
return glob.sync('src/scripts/js/es6/common/**/init.js').map(browserify_base);
|
||||
}
|
||||
|
||||
gulp.task('scripts_browserify', function(done) {
|
||||
glob('src/scripts/js/es6/individual/**/init.js', function(err, files) {
|
||||
if(err) done(err);
|
||||
|
||||
let tasks = files.map(function(entry) {
|
||||
return browserify_base(entry)
|
||||
.pipe(gulpif(enabled.maps, sourcemaps.init()))
|
||||
.pipe(gulpif(enabled.uglify, uglify()))
|
||||
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
|
||||
.pipe(gulp.dest(destination.js));
|
||||
});
|
||||
|
||||
es.merge(tasks).on('end', done);
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js */
|
||||
/* Since it's always loaded, it's only for functions that we want site-wide */
|
||||
gulp.task('scripts_tutti', function() {
|
||||
gulp.src('src/scripts/tutti/**/*.js')
|
||||
gulp.task('scripts_tutti', function(done) {
|
||||
let toUglify = ['src/scripts/tutti/**/*.js']
|
||||
|
||||
es.merge(gulp.src(toUglify), ...browserify_common())
|
||||
.pipe(gulpif(enabled.failCheck, plumber()))
|
||||
.pipe(gulpif(enabled.maps, sourcemaps.init()))
|
||||
.pipe(concat("tutti.min.js"))
|
||||
.pipe(gulpif(enabled.uglify, uglify()))
|
||||
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
|
||||
.pipe(chmod(644))
|
||||
.pipe(gulp.dest('attract/static/assets/js/generated/'))
|
||||
.pipe(gulpif(enabled.liveReload, livereload()));
|
||||
.pipe(gulpif(enabled.chmod, chmod(0o644)))
|
||||
.pipe(gulp.dest(destination.js));
|
||||
done();
|
||||
});
|
||||
|
||||
|
||||
// While developing, run 'gulp watch'
|
||||
gulp.task('watch',function() {
|
||||
// Only listen for live reloads if ran with --livereload
|
||||
if (argv.livereload){
|
||||
livereload.listen();
|
||||
gulp.task('watch',function(done) {
|
||||
gulp.watch('src/styles/**/*.sass', gulp.series('styles'));
|
||||
gulp.watch('src/templates/**/*.pug', gulp.series('templates'));
|
||||
gulp.watch('src/scripts/*.js', gulp.series('scripts'));
|
||||
gulp.watch('src/scripts/tutti/*.js', gulp.series('scripts_tutti'));
|
||||
gulp.watch('src/scripts/js/**/*.js', gulp.series('scripts_browserify', 'scripts_tutti'));
|
||||
done();
|
||||
});
|
||||
|
||||
// Erases all generated files in output directories.
|
||||
gulp.task('cleanup', function(done) {
|
||||
let paths = [];
|
||||
for (attr in destination) {
|
||||
paths.push(destination[attr]);
|
||||
}
|
||||
|
||||
gulp.watch('src/styles/**/*.sass',['styles']);
|
||||
gulp.watch('src/templates/**/*.jade',['templates']);
|
||||
gulp.watch('src/scripts/*.js',['scripts']);
|
||||
gulp.watch('src/scripts/tutti/*.js',['scripts_tutti']);
|
||||
git.clean({ args: '-f -X ' + paths.join(' ') }, function (err) {
|
||||
if(err) throw err;
|
||||
});
|
||||
done();
|
||||
});
|
||||
|
||||
|
||||
// Run 'gulp' to build everything at once
|
||||
gulp.task('default', ['styles', 'templates', 'scripts', 'scripts_tutti']);
|
||||
let tasks = [];
|
||||
if (enabled.cleanup) tasks.push('cleanup');
|
||||
gulp.task('default', gulp.parallel(tasks.concat(['styles', 'templates', 'scripts', 'scripts_tutti'])));
|
||||
|
7
manage.py
Executable file
7
manage.py
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
from pillar import cli
|
||||
from runserver import app
|
||||
|
||||
cli.manager.app = app
|
||||
cli.manager.run()
|
8780
package-lock.json
generated
Normal file
8780
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
package.json
47
package.json
@@ -1,25 +1,40 @@
|
||||
{
|
||||
"name": "attract",
|
||||
"license": "GPL",
|
||||
"license": "GPL-2.0+",
|
||||
"author": "Blender Institute",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://git.blender.org/attract-server.git"
|
||||
"url": "git://git.blender.org/attract.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"gulp": "~3.9.1",
|
||||
"gulp-autoprefixer": "~2.3.1",
|
||||
"gulp-cached": "~1.1.0",
|
||||
"gulp-chmod": "~1.3.0",
|
||||
"gulp-concat": "~2.6.0",
|
||||
"gulp-if": "^2.0.1",
|
||||
"gulp-jade": "~1.1.0",
|
||||
"gulp-livereload": "~3.8.1",
|
||||
"gulp-plumber": "~1.1.0",
|
||||
"gulp-rename": "~1.2.2",
|
||||
"gulp-sass": "~2.3.1",
|
||||
"gulp-sourcemaps": "~1.6.0",
|
||||
"gulp-uglify": "~1.5.3",
|
||||
"minimist": "^1.2.0"
|
||||
"@babel/core": "7.1.6",
|
||||
"@babel/preset-env": "7.1.6",
|
||||
"acorn": "5.7.3",
|
||||
"babel-core": "7.0.0-bridge.0",
|
||||
"babelify": "10.0.0",
|
||||
"browserify": "16.2.3",
|
||||
"gulp": "^4.0",
|
||||
"gulp-autoprefixer": "^6.0.0",
|
||||
"gulp-cached": "^1.1.1",
|
||||
"gulp-chmod": "^2.0.0",
|
||||
"gulp-concat": "^2.6.1",
|
||||
"gulp-if": "^2.0.2",
|
||||
"gulp-git": "^2.8.0",
|
||||
"gulp-plumber": "^1.2.0",
|
||||
"gulp-pug": "^4.0.1",
|
||||
"gulp-rename": "^1.4.0",
|
||||
"gulp-sass": "^4.1.0",
|
||||
"gulp-sourcemaps": "^2.6.4",
|
||||
"gulp-uglify-es": "^1.0.4",
|
||||
"minimist": "^1.2.0",
|
||||
"vinyl-buffer": "1.0.1",
|
||||
"vinyl-source-stream": "2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^4.3.1",
|
||||
"event-stream": "^4.0.1",
|
||||
"jquery": "^3.4.1",
|
||||
"natives": "^1.1.6",
|
||||
"popper.js": "^1.14.4"
|
||||
}
|
||||
}
|
||||
|
25
pyproject.toml
Normal file
25
pyproject.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[tool.poetry]
|
||||
name = "attract"
|
||||
version = "1.1dev0"
|
||||
description = ""
|
||||
authors = [
|
||||
"Francesco Siddi <francesco@blender.org>",
|
||||
"Pablo Vazquez <pablo@blender.studio>",
|
||||
"Sybren Stüvel <sybren@blender.studio>",
|
||||
]
|
||||
include = ["readme.md", "LICENSE.txt"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "~3.6"
|
||||
cryptography = "2.7"
|
||||
pillar = {path = "../pillar"}
|
||||
svn = "~0.3"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pillar-devdeps = {path = "../pillar/devdeps"}
|
||||
mkdocs = "~1.0"
|
||||
mkdocs-material = "~4.2"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry==1.0","cryptography==2.7","setuptools==51.0.0","wheel==0.35.1"]
|
||||
build-backend = "poetry.masonry.api"
|
11
readme.md
11
readme.md
@@ -2,3 +2,14 @@
|
||||
|
||||
This project contains Attract, a task management extension for the Pillar
|
||||
platform.
|
||||
|
||||
## Development
|
||||
|
||||
Dependencies are managed via [Poetry](https://poetry.eustace.io/).
|
||||
|
||||
```
|
||||
git clone git@git.blender.org:pillar-python-sdk.git ../pillar-python-sdk
|
||||
git clone git@git.blender.org:pillar.git ../pillar
|
||||
pip install -U --user poetry
|
||||
poetry install
|
||||
```
|
||||
|
@@ -1,13 +0,0 @@
|
||||
# Primary requirements:
|
||||
# pillarsdk
|
||||
# pillar
|
||||
|
||||
attrs==16.2.0
|
||||
svn==0.3.43
|
||||
python-dateutil==2.5.3
|
||||
|
||||
# Testing requirements:
|
||||
pytest==3.0.1
|
||||
responses==0.5.1
|
||||
pytest-cov==2.3.1
|
||||
mock==2.0.0
|
44
rsync_ui.sh
44
rsync_ui.sh
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e # error out when one of the commands in the script errors.
|
||||
|
||||
# macOS does not support readlink -f, so we use greadlink instead
|
||||
if [[ `uname` == 'Darwin' ]]; then
|
||||
command -v greadlink 2>/dev/null 2>&1 || { echo >&2 "Install greadlink using brew."; exit 1; }
|
||||
readlink='greadlink'
|
||||
else
|
||||
readlink='readlink'
|
||||
fi
|
||||
|
||||
ATTRACT_DIR="$(dirname "$($readlink -f "$0")")"
|
||||
if [ ! -d "$ATTRACT_DIR" ]; then
|
||||
echo "Unable to find Attract dir '$ATTRACT_DIR'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ASSETS="$ATTRACT_DIR/attract/static/assets/"
|
||||
TEMPLATES="$ATTRACT_DIR/attract/templates/attract"
|
||||
|
||||
if [ ! -d "$ASSETS" ]; then
|
||||
echo "Unable to find assets dir $ASSETS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd $ATTRACT_DIR
|
||||
if [ $(git rev-parse --abbrev-ref HEAD) != "production" ]; then
|
||||
echo "You are NOT on the production branch, refusing to rsync_ui." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "*** GULPA GULPA ***"
|
||||
./gulp --production
|
||||
|
||||
echo
|
||||
echo "*** SYNCING ASSETS ***"
|
||||
# Exclude files managed by Git.
|
||||
rsync -avh $ASSETS --exclude js/vendor/ root@cloud.blender.org:/data/git/attract/attract/static/assets/
|
||||
|
||||
echo
|
||||
echo "*** SYNCING TEMPLATES ***"
|
||||
rsync -avh $TEMPLATES root@cloud.blender.org:/data/git/attract/attract/templates/
|
@@ -1,5 +1,5 @@
|
||||
[tool:pytest]
|
||||
addopts = -v --cov attract --cov-report term-missing --ignore node_modules -x
|
||||
addopts = -v --cov attract --cov-report term-missing --ignore node_modules
|
||||
|
||||
[pep8]
|
||||
max-line-length = 100
|
||||
|
21
setup.py
21
setup.py
@@ -1,21 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""Setup file for the Attract extension."""
|
||||
|
||||
import setuptools
|
||||
|
||||
setuptools.setup(
|
||||
name='attract',
|
||||
version='1.0',
|
||||
packages=setuptools.find_packages('.', exclude=['test']),
|
||||
install_requires=[
|
||||
'pillar>=2.0',
|
||||
],
|
||||
tests_require=[
|
||||
'pytest>=2.9.1',
|
||||
'responses>=0.5.1',
|
||||
'pytest-cov>=2.2.1',
|
||||
'mock>=2.0.0',
|
||||
],
|
||||
zip_safe=False,
|
||||
)
|
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 attract.FOLDER_NAME.
|
9
src/scripts/js/es6/common/api/assets.js
Normal file
9
src/scripts/js/es6/common/api/assets.js
Normal file
@@ -0,0 +1,9 @@
|
||||
function thenGetProjectAssets(projectId) {
|
||||
let where = {
|
||||
project: projectId,
|
||||
node_type: 'attract_asset'
|
||||
}
|
||||
return pillar.api.thenGetNodes(where);
|
||||
}
|
||||
|
||||
export { thenGetProjectAssets }
|
3
src/scripts/js/es6/common/api/init.js
Normal file
3
src/scripts/js/es6/common/api/init.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export {thenGetProjectAssets} from './assets'
|
||||
export {thenGetProjectShots} from './shots'
|
||||
export {thenGetTasks, thenGetProjectTasks} from './tasks'
|
10
src/scripts/js/es6/common/api/shots.js
Normal file
10
src/scripts/js/es6/common/api/shots.js
Normal file
@@ -0,0 +1,10 @@
|
||||
function thenGetProjectShots(projectId) {
|
||||
let where = {
|
||||
project: projectId,
|
||||
node_type: 'attract_shot'
|
||||
};
|
||||
let sort = '-properties.used_in_edit,properties.cut_in_timeline_in_frames';
|
||||
return pillar.api.thenGetNodes(where, {}, sort);
|
||||
}
|
||||
|
||||
export { thenGetProjectShots }
|
21
src/scripts/js/es6/common/api/tasks.js
Normal file
21
src/scripts/js/es6/common/api/tasks.js
Normal file
@@ -0,0 +1,21 @@
|
||||
function thenGetTasks(parentId) {
|
||||
let where = {
|
||||
parent: parentId,
|
||||
node_type: 'attract_task'
|
||||
};
|
||||
return pillar.api.thenGetNodes(where);
|
||||
}
|
||||
|
||||
function thenGetProjectTasks(projectId) {
|
||||
let where = {
|
||||
project: projectId,
|
||||
node_type: 'attract_task'
|
||||
}
|
||||
let embedded = {
|
||||
parent: 1
|
||||
}
|
||||
let sort = 'parent';
|
||||
return pillar.api.thenGetNodes(where, embedded, sort);
|
||||
}
|
||||
|
||||
export { thenGetTasks, thenGetProjectTasks }
|
52
src/scripts/js/es6/common/auth/auth.js
Normal file
52
src/scripts/js/es6/common/auth/auth.js
Normal file
@@ -0,0 +1,52 @@
|
||||
class ProjectAuth {
|
||||
constructor() {
|
||||
this.canCreateTask = false;
|
||||
this.canCreateAsset = false;
|
||||
this.canUseAttract = false;
|
||||
}
|
||||
}
|
||||
|
||||
class Auth {
|
||||
constructor() {
|
||||
this.perProjectAuth = {}
|
||||
}
|
||||
|
||||
canUserCreateTask(projectId) {
|
||||
let projectAuth = this.getProjectAuth(projectId);
|
||||
return projectAuth.canCreateTask;
|
||||
}
|
||||
|
||||
canUserCreateAsset(projectId) {
|
||||
let projectAuth = this.getProjectAuth(projectId);
|
||||
return projectAuth.canCreateAsset;
|
||||
}
|
||||
|
||||
canUserCanUseAttract(projectId) {
|
||||
let projectAuth = this.getProjectAuth(projectId);
|
||||
return projectAuth.canUseAttract;
|
||||
}
|
||||
|
||||
setUserCanUseAttract(projectId, canUseAttract) {
|
||||
let projectAuth = this.getProjectAuth(projectId);
|
||||
projectAuth.canUseAttract = canUseAttract;
|
||||
}
|
||||
|
||||
setUserCanCreateTask(projectId, canCreateTask) {
|
||||
let projectAuth = this.getProjectAuth(projectId);
|
||||
projectAuth.canCreateTask = canCreateTask;
|
||||
}
|
||||
|
||||
setUserCanCreateAsset(projectId, canCreateAsset) {
|
||||
let projectAuth = this.getProjectAuth(projectId);
|
||||
projectAuth.canCreateAsset = canCreateAsset;
|
||||
}
|
||||
|
||||
getProjectAuth(projectId) {
|
||||
this.perProjectAuth[projectId] = this.perProjectAuth[projectId] || new ProjectAuth();
|
||||
return this.perProjectAuth[projectId];
|
||||
}
|
||||
}
|
||||
|
||||
let AttractAuth = new Auth();
|
||||
|
||||
export {AttractAuth}
|
1
src/scripts/js/es6/common/auth/init.js
Normal file
1
src/scripts/js/es6/common/auth/init.js
Normal file
@@ -0,0 +1 @@
|
||||
export { AttractAuth } from './auth'
|
211
src/scripts/js/es6/common/vuecomponents/App.js
Normal file
211
src/scripts/js/es6/common/vuecomponents/App.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import { AssetsTable } from './assetstable/Table'
|
||||
import { TasksTable } from './taskstable/Table'
|
||||
import { ShotsTable } from './shotstable/Table'
|
||||
import './detailedview/Viewer'
|
||||
const BrowserHistoryState = pillar.vuecomponents.mixins.BrowserHistoryState;
|
||||
const StateSaveMode = pillar.vuecomponents.mixins.StateSaveMode;
|
||||
|
||||
const TEMPLATE =`
|
||||
<div class="attract-app">
|
||||
<div id="col_main">
|
||||
<component
|
||||
:is="tableComponentName"
|
||||
:project="project"
|
||||
:selectedIds="currentSelectedIds"
|
||||
:canChangeSelectionCB="canChangeSelectionCB"
|
||||
:componentState="initialTableState"
|
||||
@selected-items-changed="onSelectItemsChanged"
|
||||
@is-initialized="onTableInitialized"
|
||||
@component-state-changed="onTableStateChanged"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-splitter"/>
|
||||
<attract-detailed-view id="col_right"
|
||||
:items="selectedItems"
|
||||
:project="project"
|
||||
:contextType="contextType"
|
||||
@objects-are-edited="onEditingObjects"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
class ComponentState {
|
||||
/**
|
||||
* Serializable state of this component.
|
||||
*
|
||||
* @param {Object} tableState
|
||||
*/
|
||||
constructor(tableState) {
|
||||
this.tableState = tableState;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Component wrapping a table for selecting attract_task/asset/shot nodes, and a editor to edit the selected node(s).
|
||||
* Selected row filters and visible columns are stored in localStorage per project/context. This makes the settings
|
||||
* sticky between sessions in the same browser.
|
||||
* Selected nodes are stored in window.history. This makes it possible to move back/forward in browser and the selection
|
||||
* will change accordingly.
|
||||
*/
|
||||
Vue.component('attract-app', {
|
||||
template: TEMPLATE,
|
||||
mixins: [BrowserHistoryState],
|
||||
props: {
|
||||
projectId: String,
|
||||
selectedIds: {
|
||||
type: Array,
|
||||
default: () => {return []}
|
||||
},
|
||||
contextType: {
|
||||
type: String,
|
||||
default: 'shots',
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentSelectedIds: this.selectedIds,
|
||||
selectedItems: [],
|
||||
isEditing: false,
|
||||
isTableInited: false,
|
||||
project: null
|
||||
}
|
||||
},
|
||||
created() {
|
||||
pillar.api.thenGetProject(this.projectId)
|
||||
.then((project) =>{
|
||||
this.project = project;
|
||||
});
|
||||
},
|
||||
computed: {
|
||||
selectedNames() {
|
||||
return this.selectedItems.map(it => it.name);
|
||||
},
|
||||
tableComponentName() {
|
||||
if(!this.project) return '';
|
||||
switch (this.contextType) {
|
||||
case 'assets': return AssetsTable.options.name;
|
||||
case 'tasks': return TasksTable.options.name;
|
||||
case 'shots': return ShotsTable.options.name;
|
||||
default:
|
||||
console.log('Unknown context type', this.contextType);
|
||||
return ShotsTable.$options.name;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @override BrowserHistoryState
|
||||
*/
|
||||
browserHistoryState() {
|
||||
if(this.isTableInited) {
|
||||
return {
|
||||
'selectedIds': this.currentSelectedIds
|
||||
};
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @override BrowserHistoryState
|
||||
*/
|
||||
historyStateUrl() {
|
||||
let projectUrl = ProjectUtils.projectUrl();
|
||||
if(this.selectedItems.length !== 1) {
|
||||
return `/attract/${projectUrl}/${this.contextType}/`;
|
||||
} else {
|
||||
let selected = this.selectedItems[0];
|
||||
let node_type = selected.node_type;
|
||||
if (node_type === 'attract_task' && this.contextType !== 'tasks') {
|
||||
return `/attract/${projectUrl}/${this.contextType}/with-task/${selected._id}`;
|
||||
} else {
|
||||
return `/attract/${projectUrl}/${this.contextType}/${selected._id}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
stateStorageKey() {
|
||||
return `attract.${this.projectId}.${this.contextType}`;
|
||||
},
|
||||
initialAppState() {
|
||||
let stateJsonStr;
|
||||
try {
|
||||
stateJsonStr = localStorage.getItem(this.stateStorageKey);
|
||||
} catch (error) {
|
||||
// Log and ignore.
|
||||
console.warn('Unable to restore state:', error);
|
||||
}
|
||||
return stateJsonStr ? JSON.parse(stateJsonStr) : undefined;
|
||||
},
|
||||
initialTableState() {
|
||||
return this.initialAppState ? this.initialAppState.tableState : undefined;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
selectedItems(newValue) {
|
||||
function equals(arrA, arrB) {
|
||||
if (arrA.length === arrB.length) {
|
||||
return arrA.every(it => arrB.includes(it)) &&
|
||||
arrB.every(it => arrA.includes(it))
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let newSelectedIds = newValue.map(item => item._id);
|
||||
// They will be equal for instance when we pop browser history
|
||||
if (equals(newSelectedIds, this.currentSelectedIds)) return;
|
||||
|
||||
this.currentSelectedIds = newSelectedIds;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onSelectItemsChanged(selectedItems) {
|
||||
this.selectedItems = selectedItems;
|
||||
},
|
||||
onEditingObjects(isEditing) {
|
||||
this.isEditing = !!isEditing;
|
||||
},
|
||||
onTableInitialized() {
|
||||
this.isTableInited = true;
|
||||
},
|
||||
/**
|
||||
* Save table state to localStorage per project and context
|
||||
* @param {Object} newState
|
||||
*/
|
||||
onTableStateChanged(newState) {
|
||||
let appState = new ComponentState(newState);
|
||||
let stateJsonStr = JSON.stringify(appState);
|
||||
try {
|
||||
localStorage.setItem(this.stateStorageKey, stateJsonStr);
|
||||
} catch (error) {
|
||||
// Log and ignore.
|
||||
console.warn('Unable to save state:', error);
|
||||
}
|
||||
},
|
||||
canChangeSelectionCB() {
|
||||
if(this.isEditing) {
|
||||
let retval = confirm("You have unsaved data. Do you want to discard it?");
|
||||
return retval;
|
||||
}
|
||||
return true
|
||||
},
|
||||
/**
|
||||
* @override BrowserHistoryState
|
||||
*/
|
||||
stateSaveMode(newState, oldState) {
|
||||
if (!this.isTableInited) {
|
||||
return StateSaveMode.IGNORE;
|
||||
}
|
||||
|
||||
if (!oldState) {
|
||||
// Initial state. Replace what we have so we can go back to this state
|
||||
return StateSaveMode.REPLACE;
|
||||
}
|
||||
if (newState.selectedIds.length > 1 && oldState.selectedIds.length > 1) {
|
||||
// To not spam history when multiselecting items
|
||||
return StateSaveMode.REPLACE;
|
||||
}
|
||||
return StateSaveMode.PUSH;
|
||||
},
|
||||
/**
|
||||
* @override BrowserHistoryState
|
||||
*/
|
||||
applyHistoryState(newState) {
|
||||
this.currentSelectedIds = newState.selectedIds || this.currentSelectedIds;
|
||||
}
|
||||
},
|
||||
});
|
@@ -0,0 +1,51 @@
|
||||
import './Activity'
|
||||
|
||||
const TEMPLATE =`
|
||||
<div class="d-activity">
|
||||
<ul>
|
||||
<attract-activity
|
||||
v-for="a in activities"
|
||||
:key="a._id"
|
||||
:activity="a"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
Vue.component('attract-activities', {
|
||||
template: TEMPLATE,
|
||||
props: {
|
||||
objectId: String,
|
||||
outdated: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activities: [],
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
objectId() {
|
||||
this.fetchActivities();
|
||||
},
|
||||
outdated(isOutDated) {
|
||||
if(isOutDated) {
|
||||
this.fetchActivities();
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchActivities()
|
||||
},
|
||||
methods: {
|
||||
fetchActivities() {
|
||||
pillar.api.thenGetNodeActivities(this.objectId)
|
||||
.then(it => {
|
||||
this.activities = it['_items'];
|
||||
this.$emit('activities-updated');
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
@@ -0,0 +1,29 @@
|
||||
const TEMPLATE =`
|
||||
<li>
|
||||
<img class="actor-avatar"
|
||||
:src="activity.actor_user.avatar"
|
||||
/>
|
||||
<span class="date"
|
||||
:title="activity._created">
|
||||
{{ prettyCreated }}
|
||||
</span>
|
||||
<span class="actor">
|
||||
{{ activity.actor_user.full_name }}
|
||||
</span>
|
||||
<span class="verb">
|
||||
{{ activity.verb }}
|
||||
</span>
|
||||
</li>
|
||||
`;
|
||||
|
||||
Vue.component('attract-activity', {
|
||||
template: TEMPLATE,
|
||||
props: {
|
||||
activity: Object,
|
||||
},
|
||||
computed: {
|
||||
prettyCreated() {
|
||||
return pillar.utils.prettyDate(this.activity._created, true);
|
||||
}
|
||||
},
|
||||
});
|
64
src/scripts/js/es6/common/vuecomponents/assetstable/Table.js
Normal file
64
src/scripts/js/es6/common/vuecomponents/assetstable/Table.js
Normal file
@@ -0,0 +1,64 @@
|
||||
let PillarTable = pillar.vuecomponents.table.PillarTable;
|
||||
import {AssetColumnFactory} from './columns/AssetColumnFactory'
|
||||
import {AssetRowsSource} from './rows/AssetRowsSource'
|
||||
import {RowFilter} from '../attracttable/rows/filter/RowFilter'
|
||||
|
||||
const TEMPLATE =`
|
||||
<div class="pillar-table-actions">
|
||||
<button class="action"
|
||||
v-if="canAddAsset"
|
||||
@click="createNewAsset"
|
||||
>
|
||||
<i class="pi-plus">New Asset</i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let TableActions = {
|
||||
template: TEMPLATE,
|
||||
computed: {
|
||||
canAddAsset() {
|
||||
let projectId = ProjectUtils.projectId();
|
||||
return attract.auth.AttractAuth.canUserCreateAsset(projectId);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
createNewAsset(event) {
|
||||
thenCreateAsset(ProjectUtils.projectUrl())
|
||||
.then((asset) => {
|
||||
this.$emit('item-clicked', event, asset._id);
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
let AssetsTable = Vue.component('attract-assets-table', {
|
||||
extends: PillarTable,
|
||||
props: {
|
||||
project: Object
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
columnFactory: new AssetColumnFactory(this.project),
|
||||
rowsSource: new AssetRowsSource(this.project._id),
|
||||
rowFilterConfig: {validStatuses: this.getValidStatuses()}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getValidStatuses() {
|
||||
for (const it of this.project.node_types) {
|
||||
if(it.name === 'attract_asset'){
|
||||
return it.dyn_schema.status.allowed;
|
||||
}
|
||||
}
|
||||
console.warn('Did not find allowed statuses for node type attract_shot');
|
||||
return [];
|
||||
}
|
||||
},
|
||||
components: {
|
||||
'pillar-table-actions': TableActions,
|
||||
'pillar-table-row-filter': RowFilter,
|
||||
}
|
||||
});
|
||||
|
||||
export { AssetsTable };
|
@@ -0,0 +1,30 @@
|
||||
import { TaskColumn } from '../../attracttable/columns/Tasks';
|
||||
import { FirstTaskDueDate, NextTaskDueDate, LastTaskDueDate } from '../../attracttable/columns/TaskDueDate';
|
||||
import { Status } from '../../attracttable/columns/Status';
|
||||
import { RowObject } from '../../attracttable/columns/RowObject'
|
||||
let ColumnFactoryBase = pillar.vuecomponents.table.columns.ColumnFactoryBase;
|
||||
let Created = pillar.vuecomponents.table.columns.Created;
|
||||
let Updated = pillar.vuecomponents.table.columns.Updated;
|
||||
|
||||
|
||||
class AssetColumnFactory extends ColumnFactoryBase{
|
||||
constructor(project) {
|
||||
super();
|
||||
this.project = project;
|
||||
}
|
||||
|
||||
thenGetColumns() {
|
||||
let taskTypes = this.project.extension_props.attract.task_types.attract_asset;
|
||||
let taskColumns = taskTypes.map((tType) => {
|
||||
return new TaskColumn(tType, 'asset-task');
|
||||
})
|
||||
|
||||
return Promise.resolve(
|
||||
[new Status(), new RowObject()]
|
||||
.concat(taskColumns)
|
||||
.concat([new NextTaskDueDate(), new Created(), new Updated()])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { AssetColumnFactory }
|
@@ -0,0 +1,38 @@
|
||||
import { AttractRowBase } from '../../attracttable/rows/AttractRowBase'
|
||||
import { TaskEventListener } from '../../attracttable/rows/TaskEventListener';
|
||||
import { TaskRow } from '../../taskstable/rows/TaskRow'
|
||||
|
||||
class AssetRow extends AttractRowBase {
|
||||
constructor(asset) {
|
||||
super(asset);
|
||||
this.tasks = [];
|
||||
}
|
||||
|
||||
_thenInitImpl() {
|
||||
return attract.api.thenGetTasks(this.getId())
|
||||
.then((response) => {
|
||||
this.tasks = response._items.map(it => new TaskRow(it));
|
||||
this.registerTaskEventListeners();
|
||||
|
||||
return Promise.all(
|
||||
this.tasks.map(t => t.thenInit())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
registerTaskEventListeners() {
|
||||
new TaskEventListener(this).register();
|
||||
}
|
||||
|
||||
getTasksOfType(taskType) {
|
||||
return this.tasks.filter((t) => {
|
||||
return t.getProperties().task_type === taskType;
|
||||
})
|
||||
}
|
||||
|
||||
getChildObjects() {
|
||||
return this.tasks;
|
||||
}
|
||||
}
|
||||
|
||||
export { AssetRow }
|
@@ -0,0 +1,18 @@
|
||||
import { AttractRowsSourceBase } from '../../attracttable/rows/AttractRowsSourceBase'
|
||||
import { AssetRow } from './AssetRow'
|
||||
|
||||
class AssetRowsSource extends AttractRowsSourceBase {
|
||||
constructor(projectId) {
|
||||
super(projectId, 'attract_asset', AssetRow);
|
||||
}
|
||||
|
||||
thenGetRowObjects() {
|
||||
return attract.api.thenGetProjectAssets(this.projectId)
|
||||
.then((result) => {
|
||||
let assets = result._items;
|
||||
this.initRowObjects(assets);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { AssetRowsSource }
|
@@ -0,0 +1,37 @@
|
||||
let CellDefault = pillar.vuecomponents.table.cells.renderer.CellDefault;
|
||||
|
||||
const TEMPLATE =`
|
||||
<div>
|
||||
<a
|
||||
@click="ignoreDefault"
|
||||
:href="cellLink"
|
||||
:title="cellValue"
|
||||
>
|
||||
{{ cellValue }}
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let CellRowObject = Vue.component('pillar-cell-row-object', {
|
||||
extends: CellDefault,
|
||||
template: TEMPLATE,
|
||||
computed: {
|
||||
cellLink() {
|
||||
let project_url = ProjectUtils.projectUrl();
|
||||
let item_type = this.itemType();
|
||||
return `/attract/${project_url}/${item_type}s/${this.rowObject.getId()}`;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
itemType() {
|
||||
let node_type = this.rowObject.underlyingObject.node_type;
|
||||
return node_type.replace('attract_', ''); // eg. attract_task to tasks
|
||||
},
|
||||
ignoreDefault(event) {
|
||||
// Don't follow link, let the event bubble and the row handles it
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export { CellRowObject }
|
@@ -0,0 +1,12 @@
|
||||
let CellDefault = pillar.vuecomponents.table.cells.renderer.CellDefault;
|
||||
|
||||
let CellStatus = Vue.component('attract-cell-Status', {
|
||||
extends: CellDefault,
|
||||
computed: {
|
||||
cellValue() {
|
||||
return '';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export { CellStatus }
|
@@ -0,0 +1,53 @@
|
||||
let CellDefault = pillar.vuecomponents.table.cells.renderer.CellDefault;
|
||||
import './CellTasksLink'
|
||||
|
||||
const TEMPLATE =`
|
||||
<div>
|
||||
<div class="tasks">
|
||||
<attract-cell-task-link
|
||||
v-for="t in tasks"
|
||||
:task="t"
|
||||
:itemType="itemType"
|
||||
:key="t._id"
|
||||
@item-clicked="$emit('item-clicked', ...arguments)"
|
||||
/>
|
||||
</div>
|
||||
<button class="add-task-link"
|
||||
v-if="canAddTask"
|
||||
@click.prevent.stop="onAddTask"
|
||||
>
|
||||
<i class="pi-plus">Task</i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let CellTasks = Vue.component('attract-cell-tasks', {
|
||||
extends: CellDefault,
|
||||
template: TEMPLATE,
|
||||
computed: {
|
||||
tasks() {
|
||||
return this.rawCellValue;
|
||||
},
|
||||
canAddTask() {
|
||||
if (this.tasks.length < 1 ) {
|
||||
let projectId = ProjectUtils.projectId();
|
||||
return attract.auth.AttractAuth.canUserCreateTask(projectId);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
itemType() {
|
||||
let node_type = this.rowObject.underlyingObject.node_type;
|
||||
return node_type.replace('attract_', '') + 's'; // eg. attract_asset to assets
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onAddTask(event) {
|
||||
thenCreateTask(this.rowObject.getId(), this.column.taskType)
|
||||
.then((task) => {
|
||||
this.$emit('item-clicked', event, task._id);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export { CellTasks }
|
@@ -0,0 +1,33 @@
|
||||
const TEMPLATE =`
|
||||
<a class="task"
|
||||
:class="taskClass"
|
||||
:href="taskLink"
|
||||
:title="taskTitle"
|
||||
@click.prevent.stop="$emit('item-clicked', arguments[0], task.getId())"
|
||||
/>
|
||||
`;
|
||||
|
||||
let CellTasksLink = Vue.component('attract-cell-task-link', {
|
||||
template: TEMPLATE,
|
||||
props: {
|
||||
task: Object,
|
||||
itemType: String,
|
||||
},
|
||||
computed: {
|
||||
taskClass() {
|
||||
let classes = {'active': this.task.isSelected};
|
||||
classes[`status-${this.task.getProperties().status}`] = true;
|
||||
return classes;
|
||||
},
|
||||
taskLink() {
|
||||
let project_url = ProjectUtils.projectUrl();
|
||||
return `/attract/${project_url}/${this.itemType}/with-task/${this.task.getId()}`;
|
||||
},
|
||||
taskTitle() {
|
||||
let status = (this.task.getProperties().status || '').replace('_', ' ');
|
||||
return `Task: ${this.task.getName()}\nStatus: ${status}`
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export { CellTasksLink }
|
@@ -0,0 +1,19 @@
|
||||
let ColumnBase = pillar.vuecomponents.table.columns.ColumnBase;
|
||||
import { CellRowObject } from '../cells/renderer/CellRowObject'
|
||||
|
||||
class RowObject extends ColumnBase {
|
||||
constructor() {
|
||||
super('Name', 'row-object');
|
||||
this.isMandatory = true;
|
||||
}
|
||||
|
||||
getCellRenderer(rowObject) {
|
||||
return CellRowObject.options.name;
|
||||
}
|
||||
|
||||
getRawCellValue(rowObject) {
|
||||
return rowObject.getName() || '<No Name>';
|
||||
}
|
||||
}
|
||||
|
||||
export { RowObject }
|
@@ -0,0 +1,47 @@
|
||||
import {CellStatus} from '../cells/renderer/CellStatus'
|
||||
let ColumnBase = pillar.vuecomponents.table.columns.ColumnBase;
|
||||
|
||||
export class Status extends ColumnBase {
|
||||
constructor() {
|
||||
super('', 'attract-status');
|
||||
this.isMandatory = true;
|
||||
}
|
||||
getCellRenderer(rowObject) {
|
||||
return CellStatus.options.name;
|
||||
}
|
||||
getRawCellValue(rowObject) {
|
||||
return rowObject.getProperties().status;
|
||||
}
|
||||
getCellTitle(rawCellValue, rowObject) {
|
||||
function capitalize(str) {
|
||||
if(str.length === 0) return str;
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
let formatedStatus = capitalize(rawCellValue).replace('_', ' ');
|
||||
return `Status: ${formatedStatus}`;
|
||||
}
|
||||
getCellClasses(rawCellValue, rowObject) {
|
||||
let classes = super.getCellClasses(rawCellValue, rowObject);
|
||||
classes[`status-${rawCellValue}`] = true;
|
||||
return classes;
|
||||
}
|
||||
compareRows(rowObject1, rowObject2) {
|
||||
let sortNbr1 = this.getSortNumber(rowObject1);
|
||||
let sortNbr2 = this.getSortNumber(rowObject2);
|
||||
if (sortNbr1 === sortNbr2) return 0;
|
||||
return sortNbr1 < sortNbr2 ? -1 : 1;
|
||||
}
|
||||
getSortNumber(rowObject) {
|
||||
let statusStr = rowObject.getProperties().status;
|
||||
switch (statusStr) {
|
||||
case 'on_hold': return 10;
|
||||
case 'todo': return 20;
|
||||
case 'in_progress': return 30;
|
||||
case 'review': return 40;
|
||||
case 'cbb': return 50;
|
||||
case 'approved': return 60;
|
||||
case 'final': return 70;
|
||||
default: return 9999; // invalid status
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,81 @@
|
||||
let DateColumnBase = pillar.vuecomponents.table.columns.DateColumnBase;
|
||||
|
||||
function firstDate(prevDate, task) {
|
||||
let candidate = task.properties.due_date;
|
||||
if (prevDate && candidate) {
|
||||
if (prevDate !== candidate) {
|
||||
return new Date(candidate) < new Date(prevDate) ? candidate : prevDate;
|
||||
}
|
||||
}
|
||||
return prevDate || candidate;
|
||||
}
|
||||
|
||||
function lastDate(prevDate, task) {
|
||||
let candidate = task.properties.due_date;
|
||||
if (prevDate && candidate) {
|
||||
if (prevDate !== candidate) {
|
||||
return new Date(candidate) > new Date(prevDate) ? candidate : prevDate;
|
||||
}
|
||||
}
|
||||
return prevDate || candidate;
|
||||
}
|
||||
|
||||
function nextDate(prevDate, task) {
|
||||
let candidate = task.properties.due_date;
|
||||
if(candidate && new Date(candidate) >= new Date()) {
|
||||
return firstDate(prevDate, task);
|
||||
}
|
||||
return prevDate;
|
||||
}
|
||||
|
||||
class DueDate extends DateColumnBase {
|
||||
getCellClasses(dueDate, rowObject) {
|
||||
let classes = super.getCellClasses(dueDate, rowObject);
|
||||
let isPostDueDate = false;
|
||||
if (dueDate) {
|
||||
isPostDueDate = new Date(dueDate) < new Date();
|
||||
}
|
||||
classes['warning'] = isPostDueDate;
|
||||
return classes;
|
||||
}
|
||||
}
|
||||
|
||||
export class FirstTaskDueDate extends DueDate {
|
||||
constructor() {
|
||||
super('First Due Date', 'first-duedate');
|
||||
}
|
||||
getRawCellValue(rowObject) {
|
||||
let tasks = (rowObject.tasks || []).map(task => task.underlyingObject);
|
||||
return tasks.reduce(firstDate, undefined) || '';
|
||||
}
|
||||
}
|
||||
|
||||
export class LastTaskDueDate extends DueDate {
|
||||
constructor() {
|
||||
super('Last Due Date', 'last-duedate');
|
||||
}
|
||||
getRawCellValue(rowObject) {
|
||||
let tasks = (rowObject.tasks || []).map(task => task.underlyingObject);
|
||||
return tasks.reduce(lastDate, undefined) || '';
|
||||
}
|
||||
}
|
||||
|
||||
export class NextTaskDueDate extends DueDate {
|
||||
constructor() {
|
||||
super('Next Due Date', 'next-duedate');
|
||||
}
|
||||
getRawCellValue(rowObject) {
|
||||
let tasks = (rowObject.tasks || []).map(task => task.underlyingObject);
|
||||
return tasks.reduce(nextDate, undefined) || '';
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskDueDate extends DueDate {
|
||||
constructor() {
|
||||
super('Due Date', 'duedate');
|
||||
}
|
||||
getRawCellValue(rowObject) {
|
||||
let task = rowObject.getTask();
|
||||
return task.properties.due_date || '';
|
||||
}
|
||||
}
|
@@ -0,0 +1,30 @@
|
||||
let ColumnBase = pillar.vuecomponents.table.columns.ColumnBase;
|
||||
import { CellTasks } from '../cells/renderer/CellTasks'
|
||||
|
||||
export class TaskColumn extends ColumnBase {
|
||||
constructor(taskType, columnType) {
|
||||
super(taskType, columnType);
|
||||
this.taskType = taskType;
|
||||
}
|
||||
|
||||
getCellRenderer(rowObject) {
|
||||
return CellTasks.options.name;
|
||||
}
|
||||
|
||||
getRawCellValue(rowObject) {
|
||||
return rowObject.getTasksOfType(this.taskType);
|
||||
}
|
||||
|
||||
compareRows(rowObject1, rowObject2) {
|
||||
let numTasks1 = this.getRawCellValue(rowObject1).length;
|
||||
let numTasks2 = this.getRawCellValue(rowObject2).length;
|
||||
if (numTasks1 === numTasks2) return 0;
|
||||
return numTasks1 < numTasks2 ? -1 : 1;
|
||||
}
|
||||
|
||||
getColumnClasses() {
|
||||
let classes = super.getColumnClasses();
|
||||
classes[this.taskType] = true;
|
||||
return classes;
|
||||
}
|
||||
}
|
@@ -0,0 +1,18 @@
|
||||
let RowBase = pillar.vuecomponents.table.rows.RowBase;
|
||||
|
||||
class AttractRowBase extends RowBase {
|
||||
constructor(underlyingObject) {
|
||||
super(underlyingObject);
|
||||
pillar.events.Nodes.onUpdated(this.getId(), this.onRowUpdated.bind(this));
|
||||
}
|
||||
|
||||
onRowUpdated(event) {
|
||||
this.underlyingObject = event.detail;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return this.underlyingObject.properties.status;
|
||||
}
|
||||
}
|
||||
|
||||
export { AttractRowBase }
|
@@ -0,0 +1,43 @@
|
||||
let RowObjectsSourceBase = pillar.vuecomponents.table.rows.RowObjectsSourceBase;
|
||||
|
||||
/**
|
||||
* Base for all attract tables. Listens to events on create/delete events and keeps the the source up to date
|
||||
* accordingly.
|
||||
*/
|
||||
class AttractRowsSourceBase extends RowObjectsSourceBase {
|
||||
constructor(projectId, node_type, rowClass) {
|
||||
super();
|
||||
this.projectId = projectId;
|
||||
this.node_type = node_type;
|
||||
this.rowClass = rowClass;
|
||||
}
|
||||
|
||||
createRow(node) {
|
||||
let row = new this.rowClass(node);
|
||||
this.registerListeners(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
initRowObjects(nodes) {
|
||||
this.rowObjects = nodes.map(this.createRow.bind(this));
|
||||
pillar.events.Nodes.onCreated(this.node_type, this.onNodeCreated.bind(this));
|
||||
}
|
||||
|
||||
registerListeners(rowObject) {
|
||||
pillar.events.Nodes.onDeleted(rowObject.getId(), this.onNodeDeleted.bind(this));
|
||||
}
|
||||
|
||||
onNodeDeleted(event) {
|
||||
this.rowObjects = this.rowObjects.filter((rowObj) => {
|
||||
return rowObj.getId() !== event.detail;
|
||||
});
|
||||
}
|
||||
|
||||
onNodeCreated(event) {
|
||||
let rowObj = this.createRow(event.detail);
|
||||
rowObj.thenInit();
|
||||
this.rowObjects = this.rowObjects.concat(rowObj);
|
||||
}
|
||||
}
|
||||
|
||||
export { AttractRowsSourceBase }
|
@@ -0,0 +1,44 @@
|
||||
import { TaskRow } from '../../taskstable/rows/TaskRow'
|
||||
/**
|
||||
* Helper class that listens to events triggered when a RowObject task is updated/created/deleted and keep the tasks
|
||||
* array of a RowObject up to date accordingly.
|
||||
*/
|
||||
|
||||
export class TaskEventListener {
|
||||
constructor(rowWithTasks) {
|
||||
this.rowObject = rowWithTasks;
|
||||
}
|
||||
|
||||
register() {
|
||||
pillar.events.Nodes.onParentCreated(this.rowObject.getId(), 'attract_task', this.onTaskCreated.bind(this));
|
||||
this.rowObject.tasks.forEach(this.registerEventListeners.bind(this));
|
||||
}
|
||||
|
||||
registerEventListeners(task) {
|
||||
pillar.events.Nodes.onUpdated(task.getId(), this.onTaskUpdated.bind(this));
|
||||
pillar.events.Nodes.onDeleted(task.getId(), this.onTaskDeleted.bind(this));
|
||||
}
|
||||
|
||||
onTaskCreated(event) {
|
||||
let task = new TaskRow(event.detail);
|
||||
task.thenInit();
|
||||
this.registerEventListeners(task);
|
||||
this.rowObject.tasks = this.rowObject.tasks.concat(task);
|
||||
}
|
||||
|
||||
onTaskUpdated(event) {
|
||||
let updatedTask = event.detail;
|
||||
for (const task of this.rowObject.tasks) {
|
||||
if (task.getId() === updatedTask._id) {
|
||||
task.underlyingObject = updatedTask;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onTaskDeleted(event) {
|
||||
this.rowObject.tasks = this.rowObject.tasks.filter((t) => {
|
||||
return t.getId() !== event.detail;
|
||||
});
|
||||
}
|
||||
}
|
@@ -0,0 +1,67 @@
|
||||
let NameFilter = pillar.vuecomponents.table.rows.filter.NameFilter;
|
||||
let StatusFilter = pillar.vuecomponents.table.rows.filter.StatusFilter;
|
||||
|
||||
const TEMPLATE =`
|
||||
<div class="pillar-table-row-filter">
|
||||
<name-filter
|
||||
:rowObjects="rowObjects"
|
||||
:componentState="(componentState || {}).nameFilter"
|
||||
@visible-row-objects-changed="onNameFiltered"
|
||||
@component-state-changed="onNameFilterStateChanged"
|
||||
/>
|
||||
<status-filter
|
||||
:availableStatuses="availableStatuses"
|
||||
:rowObjects="nameFilteredRowObjects"
|
||||
:componentState="(componentState || {}).statusFilter"
|
||||
@visible-row-objects-changed="$emit('visible-row-objects-changed', ...arguments)"
|
||||
@component-state-changed="onStatusFilterStateChanged"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let RowFilter = {
|
||||
template: TEMPLATE,
|
||||
props: {
|
||||
rowObjects: Array,
|
||||
componentState: Object,
|
||||
config: Object
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
availableStatuses: this.config.validStatuses,
|
||||
nameFilteredRowObjects: [],
|
||||
nameFilterState: (this.componentState || {}).nameFilter,
|
||||
statusFilterState: (this.componentState || {}).statusFilter,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onNameFiltered(visibleRowObjects) {
|
||||
this.nameFilteredRowObjects = visibleRowObjects;
|
||||
},
|
||||
onNameFilterStateChanged(stateObj) {
|
||||
this.nameFilterState = stateObj;
|
||||
},
|
||||
onStatusFilterStateChanged(stateObj) {
|
||||
this.statusFilterState = stateObj;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentComponentState() {
|
||||
return {
|
||||
nameFilter: this.nameFilterState,
|
||||
statusFilter: this.statusFilterState,
|
||||
};
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentComponentState(newValue) {
|
||||
this.$emit('component-state-changed', newValue);
|
||||
}
|
||||
},
|
||||
components: {
|
||||
'name-filter': NameFilter,
|
||||
'status-filter': StatusFilter
|
||||
}
|
||||
};
|
||||
|
||||
export { RowFilter }
|
@@ -0,0 +1,12 @@
|
||||
const TEMPLATE =`
|
||||
<div class="attract-box item-details-empty">Select Something</div>
|
||||
`;
|
||||
|
||||
/**
|
||||
* For when nothing is selected in the table
|
||||
*/
|
||||
let Empty = Vue.component('attract-editor-empty', {
|
||||
template: TEMPLATE,
|
||||
});
|
||||
|
||||
export {Empty}
|
@@ -0,0 +1,14 @@
|
||||
const TEMPLATE =`
|
||||
<div class="attract-box multiple-types">
|
||||
Objects of different types selected
|
||||
</div>
|
||||
`;
|
||||
|
||||
/**
|
||||
* For when objects of different node_type is selected.
|
||||
*/
|
||||
let MultipleTypes = Vue.component('attract-editor-multiple-types', {
|
||||
template: TEMPLATE,
|
||||
});
|
||||
|
||||
export {MultipleTypes}
|
108
src/scripts/js/es6/common/vuecomponents/detailedview/Viewer.js
Normal file
108
src/scripts/js/es6/common/vuecomponents/detailedview/Viewer.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Empty } from './Empty'
|
||||
import { MultipleTypes } from './MultipleTypes'
|
||||
import { AssetEditor } from '../editor/AssetEditor'
|
||||
import { TaskEditor } from '../editor/TaskEditor'
|
||||
import { ShotEditor } from '../editor/ShotEditor'
|
||||
import '../activities/Activities'
|
||||
|
||||
const TEMPLATE =`
|
||||
<div class="attract-detailed-view">
|
||||
<div class="col_header">
|
||||
<span class="header_text">
|
||||
{{ headerText }}
|
||||
<i
|
||||
v-if="isMultiItemsView"
|
||||
class="pi-link"
|
||||
title="Multiple items selected"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<component
|
||||
:is="editorType"
|
||||
:items="items"
|
||||
:project="project"
|
||||
:contextType="contextType"
|
||||
@objects-are-edited="$emit('objects-are-edited', ...arguments)"
|
||||
@saved-items="activitiesIsOutdated"
|
||||
/>
|
||||
<attract-activities
|
||||
v-if="isSingleItemView"
|
||||
:objectId="singleObjectId"
|
||||
:outdated="isActivitiesOutdated"
|
||||
@activities-updated="activitiesIsUpToDate"
|
||||
/>
|
||||
<comments-tree
|
||||
v-if="isSingleItemView"
|
||||
:parentId="singleObjectId"
|
||||
@new-comment="activitiesIsOutdated"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
Vue.component('attract-detailed-view', {
|
||||
template: TEMPLATE,
|
||||
props: {
|
||||
items: Array,
|
||||
project: Object,
|
||||
contextType: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isActivitiesOutdated: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
headerText() {
|
||||
switch(this.items.length) {
|
||||
case 0: return 'Details';
|
||||
case 1: return `${this.itemsTypeFormated} Details`
|
||||
default: return `${this.itemsTypeFormated} Details (${this.items.length})`;
|
||||
}
|
||||
},
|
||||
itemsType() {
|
||||
let itemsType = this.items.reduce((prevType, it) => {
|
||||
if(prevType) {
|
||||
return prevType === it.node_type ? prevType : 'multiple_types';
|
||||
}
|
||||
return it.node_type;
|
||||
}, null);
|
||||
|
||||
return itemsType || 'empty';
|
||||
},
|
||||
itemsTypeFormated() {
|
||||
return this.itemsType.replace('attract_', '').replace('multiple_types', '');
|
||||
},
|
||||
editorType() {
|
||||
if(!this.project) {
|
||||
return Empty.options.name;
|
||||
}
|
||||
switch(this.itemsType) {
|
||||
case 'attract_asset': return AssetEditor.options.name;
|
||||
case 'attract_shot': return ShotEditor.options.name;
|
||||
case 'attract_task': return TaskEditor.options.name;
|
||||
case 'multiple_types': return MultipleTypes.options.name;
|
||||
case 'empty': return Empty.options.name;
|
||||
default:
|
||||
console.log('No editor for:', this.itemsType);
|
||||
return Empty.options.name;
|
||||
}
|
||||
},
|
||||
isMultiItemsView() {
|
||||
return this.items.length > 1;
|
||||
},
|
||||
isSingleItemView() {
|
||||
return this.items.length === 1;
|
||||
},
|
||||
singleObjectId() {
|
||||
return this.isSingleItemView ? this.items[0]._id : '';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
activitiesIsOutdated() {
|
||||
this.isActivitiesOutdated = true;
|
||||
},
|
||||
activitiesIsUpToDate() {
|
||||
this.isActivitiesOutdated = false
|
||||
}
|
||||
},
|
||||
});
|
114
src/scripts/js/es6/common/vuecomponents/editor/AssetEditor.js
Normal file
114
src/scripts/js/es6/common/vuecomponents/editor/AssetEditor.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import './base/TextArea'
|
||||
import './base/ConclusiveMark'
|
||||
import {EditorBase, BaseProps} from './base/EditorBase'
|
||||
|
||||
const TEMPLATE =`
|
||||
<div class="attract-box asset with-status"
|
||||
:class="editorClasses"
|
||||
>
|
||||
<div class="input-group">
|
||||
<attract-property-conslusive-mark
|
||||
v-if="isMultpleItems"
|
||||
:prop="nameProp"
|
||||
/>
|
||||
<input class="item-name" name="name" type="text" placeholder="Asset Name"
|
||||
:disabled="!canEdit"
|
||||
:class="classesForProperty(nameProp)"
|
||||
v-model="nameProp.value"/>
|
||||
<button class="copy-to-clipboard btn item-id" name="Copy to Clipboard" type="button" title="Copy ID to clipboard"
|
||||
v-if="!isMultpleItems"
|
||||
:data-clipboard-text="items[0]._id"
|
||||
>
|
||||
ID
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<attract-property-conslusive-mark
|
||||
v-if="isMultpleItems"
|
||||
:prop="descriptionProp"
|
||||
/>
|
||||
<attract-editor-text-area
|
||||
placeholder="Description"
|
||||
:disabled="!canEdit"
|
||||
:class="classesForProperty(descriptionProp)"
|
||||
v-model="descriptionProp.value"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<attract-property-conslusive-mark
|
||||
v-if="isMultpleItems"
|
||||
:prop="statusProp"
|
||||
/>
|
||||
<label for="item-status">Status:</label>
|
||||
<select class="input-transparent" id="item-status" name="status"
|
||||
:class="classesForProperty(statusProp)"
|
||||
:disabled="!canEdit"
|
||||
v-model="statusProp.value"
|
||||
>
|
||||
<option value=undefined disabled="true" v-if="!statusProp.isConclusive()"> *** </option>
|
||||
|
||||
<option v-for="it in allowedStatusesPair"
|
||||
:key="it.id"
|
||||
:value="it.id"
|
||||
>{{it.text}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<attract-property-conslusive-mark
|
||||
v-if="isMultpleItems"
|
||||
:prop="notesProp"
|
||||
/>
|
||||
<attract-editor-text-area
|
||||
placeholder="Notes"
|
||||
:class="classesForProperty(notesProp)"
|
||||
:disabled="!canEdit"
|
||||
v-model="notesProp.value"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group-separator"/>
|
||||
<div class="input-group"
|
||||
v-if="canEdit"
|
||||
>
|
||||
<button class="btn btn-outline-success btn-block" id="item-save" type="submit"
|
||||
@click="save"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
<i class="pi-check"/>Save Asset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
const AllProps = Object.freeze({
|
||||
...BaseProps,
|
||||
PROP_NOTES: 'properties.notes',
|
||||
});
|
||||
|
||||
let ALL_PROPERTIES = [];
|
||||
|
||||
for (const key in AllProps) {
|
||||
ALL_PROPERTIES.push(AllProps[key]);
|
||||
}
|
||||
|
||||
let AssetEditor = Vue.component('attract-editor-asset', {
|
||||
template: TEMPLATE,
|
||||
extends: EditorBase,
|
||||
data() {
|
||||
return {
|
||||
multiEditEngine: this.createEditorEngine(ALL_PROPERTIES),
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
items() {
|
||||
this.multiEditEngine = this.createEditorEngine(ALL_PROPERTIES);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
notesProp() {
|
||||
return this.multiEditEngine.getProperty(AllProps.PROP_NOTES);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export {AssetEditor}
|
247
src/scripts/js/es6/common/vuecomponents/editor/ShotEditor.js
Normal file
247
src/scripts/js/es6/common/vuecomponents/editor/ShotEditor.js
Normal file
@@ -0,0 +1,247 @@
|
||||
import './base/TextArea'
|
||||
import './base/ConclusiveMark'
|
||||
import {EditorBase, BaseProps} from './base/EditorBase'
|
||||
|
||||
const TEMPLATE =`
|
||||
<div>
|
||||
<div class="attract-box shot with-status"
|
||||
:class="editorClasses"
|
||||
>
|
||||
<div class="input-group">
|
||||
<attract-property-conslusive-mark
|
||||
v-if="isMultpleItems"
|
||||
:prop="nameProp"
|
||||
/>
|
||||
<span title="Shot names can only be updated from Blender." class="item-name">
|
||||
{{ valueOrNA(nameProp.value) }}
|
||||
</span>
|
||||
<button class="copy-to-clipboard btn item-id" name="Copy to Clipboard" type="button" title="Copy ID to clipboard"
|
||||
v-if="!isMultpleItems"
|
||||
:data-clipboard-text="items[0]._id"
|
||||
>
|
||||
ID
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<attract-property-conslusive-mark
|
||||
v-if="isMultpleItems"
|
||||
:prop="descriptionProp"
|
||||
/>
|
||||
<attract-editor-text-area
|
||||
placeholder="Description"
|
||||
:disabled="!canEdit"
|
||||
:class="classesForProperty(descriptionProp)"
|
||||
v-model="descriptionProp.value"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<attract-property-conslusive-mark
|
||||
v-if="isMultpleItems"
|
||||
:prop="statusProp"
|
||||
/>
|
||||
<label for="item-status">
|
||||
Status:
|
||||
</label>
|
||||
<select id="item-status" name="status" class="input-transparent"
|
||||
:class="classesForProperty(statusProp)"
|
||||
:disabled="!canEdit"
|
||||
v-model="statusProp.value"
|
||||
>
|
||||
<option value=undefined disabled="true" v-if="!statusProp.isConclusive()"> *** </option>
|
||||
<option v-for="it in allowedStatusesPair"
|
||||
:key="it.id"
|
||||
:value="it.id"
|
||||
>{{it.text}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<attract-property-conslusive-mark
|
||||
v-if="isMultpleItems"
|
||||
:prop="notesProp"
|
||||
/>
|
||||
<attract-editor-text-area
|
||||
placeholder="Notes"
|
||||
:class="classesForProperty(notesProp)"
|
||||
:disabled="!canEdit"
|
||||
v-model="notesProp.value"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group-separator"/>
|
||||
<div class="input-group"
|
||||
v-if="canEdit"
|
||||
>
|
||||
<button class="btn btn-outline-success btn-block" id="item-save" type="submit"
|
||||
@click="save"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
<i class="pi-check"/>Save Shot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="attract-box">
|
||||
<div class="table item-properties">
|
||||
<div class="table-body">
|
||||
<div class="table-row">
|
||||
<div class="table-cell">
|
||||
<attract-property-conslusive-mark
|
||||
v-if="isMultpleItems"
|
||||
:prop="updatedProp"
|
||||
/>
|
||||
Last Update
|
||||
</div>
|
||||
<div :title="updatedProp.value" class="table-cell">
|
||||
<span role="button" data-toggle="collapse" data-target="#task-time-creation" aria-expanded="false" aria-controls="#task-time-creation">
|
||||
{{ prettyDate(updatedProp.value) }}
|
||||
</span>
|
||||
<div id="task-time-creation" class="collapse">
|
||||
{{ prettyDate(createdProp.value) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-row">
|
||||
<div class="table-cell">
|
||||
<attract-property-conslusive-mark
|
||||
v-if="isMultpleItems"
|
||||
:prop="usedInEditProp"
|
||||
/>
|
||||
Used in Edit
|
||||
</div>
|
||||
<div title="Whether this shot is used in the edit." class="table-cell text-capitalize">
|
||||
{{ formatBool(usedInEditProp.value) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-row">
|
||||
<div class="table-cell">
|
||||
<attract-property-conslusive-mark
|
||||
v-if="isMultpleItems"
|
||||
:prop="cutInTimelineProp"
|
||||
/>
|
||||
Cut-in
|
||||
</div>
|
||||
<div title="Frame number of the first visible frame of this shot." class="table-cell">
|
||||
at frame {{ valueOrNA(cutInTimelineProp.value) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-row">
|
||||
<div class="table-cell">
|
||||
<attract-property-conslusive-mark
|
||||
v-if="isMultpleItems"
|
||||
:prop="trimStartProp"
|
||||
/>
|
||||
Trim Start
|
||||
</div>
|
||||
<div title="How many frames were trimmed off the start of the shot in the edit." class="table-cell">
|
||||
{{ valueOrNA(trimStartProp.value) }} frames
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-row">
|
||||
<div class="table-cell">
|
||||
<attract-property-conslusive-mark
|
||||
v-if="isMultpleItems"
|
||||
:prop="trimEndProp"
|
||||
/>
|
||||
Trim End
|
||||
</div>
|
||||
<div title="How many frames were trimmed off the end of the shot in the edit." class="table-cell">
|
||||
{{ valueOrNA(trimEndProp.value) }} frames
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-row">
|
||||
<div class="table-cell">
|
||||
<attract-property-conslusive-mark
|
||||
v-if="isMultpleItems"
|
||||
:prop="durationInEditProp"
|
||||
/>
|
||||
Duration in Edit
|
||||
</div>
|
||||
<div title="Duration of the visible part of this shot." class="table-cell">
|
||||
{{ valueOrNA(durationInEditProp.value) }} frames
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
const AllProps = Object.freeze({
|
||||
...BaseProps,
|
||||
UPDATED: '_updated',
|
||||
CREATED: '_created',
|
||||
NOTES: 'properties.notes',
|
||||
USED_IN_EDIT: 'properties.used_in_edit',
|
||||
CUT_IN_TIMELINE: 'properties.cut_in_timeline_in_frames',
|
||||
TRIM_START: 'properties.trim_start_in_frames',
|
||||
TRIM_END: 'properties.trim_end_in_frames',
|
||||
DURATION_IN_EDIT: 'properties.duration_in_edit_in_frames',
|
||||
});
|
||||
|
||||
let ALL_PROPERTIES = [];
|
||||
|
||||
for (const key in AllProps) {
|
||||
ALL_PROPERTIES.push(AllProps[key]);
|
||||
}
|
||||
|
||||
let ShotEditor = Vue.component('attract-editor-shot', {
|
||||
template: TEMPLATE,
|
||||
extends: EditorBase,
|
||||
data() {
|
||||
return {
|
||||
multiEditEngine: this.createEditorEngine(ALL_PROPERTIES),
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
items() {
|
||||
this.multiEditEngine = this.createEditorEngine(ALL_PROPERTIES);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
notesProp() {
|
||||
return this.multiEditEngine.getProperty(AllProps.NOTES);
|
||||
},
|
||||
updatedProp() {
|
||||
return this.multiEditEngine.getProperty(AllProps.UPDATED);
|
||||
},
|
||||
createdProp() {
|
||||
return this.multiEditEngine.getProperty(AllProps.CREATED);
|
||||
},
|
||||
usedInEditProp() {
|
||||
return this.multiEditEngine.getProperty(AllProps.USED_IN_EDIT);
|
||||
},
|
||||
cutInTimelineProp() {
|
||||
return this.multiEditEngine.getProperty(AllProps.CUT_IN_TIMELINE);
|
||||
},
|
||||
trimStartProp() {
|
||||
return this.multiEditEngine.getProperty(AllProps.TRIM_START);
|
||||
},
|
||||
trimEndProp() {
|
||||
return this.multiEditEngine.getProperty(AllProps.TRIM_END);
|
||||
},
|
||||
durationInEditProp() {
|
||||
return this.multiEditEngine.getProperty(AllProps.DURATION_IN_EDIT);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
valueOrNA(value) {
|
||||
return value ? value : 'N/A'
|
||||
},
|
||||
formatBool(value) {
|
||||
switch (value) {
|
||||
case true: return 'Yes';
|
||||
case false: return 'No';
|
||||
default: return 'N/A';
|
||||
}
|
||||
},
|
||||
prettyDate(value) {
|
||||
if(value) {
|
||||
return pillar.utils.prettyDate(value, true);
|
||||
}
|
||||
return 'N/A';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export {ShotEditor}
|
245
src/scripts/js/es6/common/vuecomponents/editor/TaskEditor.js
Normal file
245
src/scripts/js/es6/common/vuecomponents/editor/TaskEditor.js
Normal file
@@ -0,0 +1,245 @@
|
||||
import './base/TextArea'
|
||||
import './base/ConclusiveMark'
|
||||
import './base/Select2'
|
||||
import './base/DatePicker'
|
||||
import {EditorBase, BaseProps} from './base/EditorBase'
|
||||
|
||||
const TEMPLATE =`
|
||||
<div class="attract-box task with-status"
|
||||
:class="editorClasses"
|
||||
>
|
||||
<div class="input-group">
|
||||
<attract-property-conslusive-mark
|
||||
v-if="isMultpleItems"
|
||||
:prop="nameProp"
|
||||
/>
|
||||
<input class="item-name" name="name" type="text" placeholder="Task Title"
|
||||
:disabled="!canEdit"
|
||||
:class="classesForProperty(nameProp)"
|
||||
v-model="nameProp.value"/>
|
||||
<div class="dropdown" style="margin-left: auto"
|
||||
v-if="canEdit"
|
||||
>
|
||||
<button class="btn btn-outline-success dropdown-toggle" id="item-dropdown" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
|
||||
<i class="pi-more-vertical"/>
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="item-dropdown">
|
||||
<li class="copy-to-clipboard"
|
||||
v-if="!isMultpleItems"
|
||||
:data-clipboard-text="items[0]._id">
|
||||
<a href="javascript:void(0)">
|
||||
<i class="pi-clipboard-copy"/>
|
||||
Copy ID to Clipboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="copy-to-clipboard"
|
||||
v-if="!isMultpleItems"
|
||||
:data-clipboard-text="'[' + items[0].properties.shortcode + ']'">
|
||||
<a href="javascript:void(0)">
|
||||
<i class="pi-clipboard-copy"/>
|
||||
Copy Shortcode for SVN Commits to Clipboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="divider" role="separator"/>
|
||||
<li class="item-delete">
|
||||
<a href="javascript:void(0)"
|
||||
@click="deleteTasks"
|
||||
>
|
||||
<i class="pi-trash"/>
|
||||
{{ isMultpleItems ? "Delete Tasks" : "Delete Task" }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<attract-property-conslusive-mark
|
||||
v-if="isMultpleItems"
|
||||
:prop="descriptionProp"
|
||||
/>
|
||||
<attract-editor-text-area
|
||||
placeholder="Description"
|
||||
:disabled="!canEdit"
|
||||
:class="classesForProperty(descriptionProp)"
|
||||
v-model="descriptionProp.value"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group-flex">
|
||||
<div class="input-group field-type"
|
||||
v-if="(canChangeTaskType)"
|
||||
>
|
||||
<attract-property-conslusive-mark
|
||||
v-if="isMultpleItems"
|
||||
:prop="taskTypeProp"
|
||||
/>
|
||||
<label id="task-task_type">
|
||||
Type:
|
||||
</label>
|
||||
<select name="task_type" aria-describedby="task-task_type"
|
||||
:class="classesForProperty(taskTypeProp)"
|
||||
:disabled="!canEdit"
|
||||
v-model="taskTypeProp.value"
|
||||
>
|
||||
<option value=undefined disabled="true" v-if="!statusProp.isConclusive()"> *** </option>
|
||||
<option v-for="it in allowedTaskTypesPair"
|
||||
:key="it.id"
|
||||
:value="it.id"
|
||||
>{{it.text}}</option>
|
||||
</select></div>
|
||||
<div class="input-group">
|
||||
<attract-property-conslusive-mark
|
||||
v-if="isMultpleItems"
|
||||
:prop="statusProp"
|
||||
/>
|
||||
<label for="item-status">Status:</label>
|
||||
<select class="input-transparent" id="item-status" name="status"
|
||||
:class="classesForProperty(statusProp)"
|
||||
:disabled="!canEdit"
|
||||
v-model="statusProp.value"
|
||||
>
|
||||
<option value=undefined disabled="true" v-if="!statusProp.isConclusive()"> *** </option>
|
||||
<option v-for="it in allowedStatusesPair"
|
||||
:key="it.id"
|
||||
:value="it.id"
|
||||
>{{it.text}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group-separator"/>
|
||||
<div class="input-group select_multiple">
|
||||
<attract-property-conslusive-mark
|
||||
v-if="isMultpleItems"
|
||||
:prop="assignedToProp"
|
||||
/>
|
||||
<label>
|
||||
Assignees:
|
||||
</label>
|
||||
<attract-select2
|
||||
:class="classesForProperty(assignedToProp)"
|
||||
:options="users"
|
||||
:disabled="!canEdit"
|
||||
v-model="assignedToProp.value">
|
||||
<option value=undefined disabled="true" v-if="!assignedToProp.isConclusive()"> *** </option>
|
||||
</attract-select2>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<attract-property-conslusive-mark
|
||||
v-if="isMultpleItems"
|
||||
:prop="dueDateProp"
|
||||
/>
|
||||
<label>
|
||||
Due Date:
|
||||
</label>
|
||||
<attract-date-picker id="item-due_date" name="due_date" placeholder="Deadline for Task"
|
||||
:class="classesForProperty(dueDateProp)"
|
||||
:disabled="!canEdit"
|
||||
v-model="dueDateProp.value"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group-separator"/>
|
||||
<div class="input-group"
|
||||
v-if="canEdit"
|
||||
>
|
||||
<button class="btn btn-outline-success btn-block" id="item-save" type="submit"
|
||||
@click="save"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
<i class="pi-check"/>
|
||||
Save Task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
const AllProps = Object.freeze({
|
||||
...BaseProps,
|
||||
PARENT: 'parent',
|
||||
TASK_TYPE: 'properties.task_type',
|
||||
DUE_DATE: 'properties.due_date',
|
||||
ASSIGNED_TO: 'properties.assigned_to.users',
|
||||
SHORT_CODE: 'properties.short_code',
|
||||
});
|
||||
|
||||
let ALL_PROPERTIES = [];
|
||||
|
||||
for (const key in AllProps) {
|
||||
ALL_PROPERTIES.push(AllProps[key]);
|
||||
}
|
||||
|
||||
let TaskEditor = Vue.component('attract-editor-task', {
|
||||
template: TEMPLATE,
|
||||
extends: EditorBase,
|
||||
props: {
|
||||
contextType: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
multiEditEngine: this.createEditorEngine(ALL_PROPERTIES),
|
||||
users: [],
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchUsers()
|
||||
},
|
||||
watch: {
|
||||
items() {
|
||||
this.multiEditEngine = this.createEditorEngine(ALL_PROPERTIES);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
parentProp() {
|
||||
return this.multiEditEngine.getProperty(AllProps.PARENT);
|
||||
},
|
||||
taskTypeProp() {
|
||||
return this.multiEditEngine.getProperty(AllProps.TASK_TYPE);
|
||||
},
|
||||
allowedTaskTypes() {
|
||||
let shot_task_types = this.project.extension_props.attract.task_types.attract_shot;
|
||||
|
||||
return ['generic', ...shot_task_types];
|
||||
},
|
||||
canChangeTaskType() {
|
||||
return this.parentProp.isConclusive() && !this.parentProp.value;
|
||||
},
|
||||
allowedTaskTypesPair() {
|
||||
function format(status) {
|
||||
// hair_sim => Hair sim
|
||||
let first = status[0].toUpperCase();
|
||||
let last = status.substr(1).replace('_', ' ');
|
||||
return `${first}${last}`;
|
||||
}
|
||||
|
||||
return this.allowedTaskTypes.map(it => {
|
||||
return {
|
||||
id: it,
|
||||
text: format(it)
|
||||
}
|
||||
});
|
||||
},
|
||||
dueDateProp() {
|
||||
return this.multiEditEngine.getProperty(AllProps.DUE_DATE);
|
||||
},
|
||||
assignedToProp() {
|
||||
return this.multiEditEngine.getProperty(AllProps.ASSIGNED_TO);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fetchUsers() {
|
||||
pillar.api.thenGetProjectUsers(this.project._id)
|
||||
.then(users => {
|
||||
this.users = users._items.map(it =>{
|
||||
return {
|
||||
id: it._id,
|
||||
text: it.full_name,
|
||||
};
|
||||
});
|
||||
});
|
||||
},
|
||||
deleteTasks() {
|
||||
this.items.map(pillar.api.thenDeleteNode);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export {TaskEditor}
|
@@ -0,0 +1,45 @@
|
||||
|
||||
const TEMPLATE =`
|
||||
<i
|
||||
:class="classes"
|
||||
:title="toolTip"
|
||||
/>
|
||||
`;
|
||||
|
||||
/**
|
||||
* Draws a chain icon. If property is inconclusive it becomes a broken chain.
|
||||
*/
|
||||
Vue.component('attract-property-conslusive-mark', {
|
||||
template: TEMPLATE,
|
||||
props: {
|
||||
prop: Object, // MultiProperty
|
||||
},
|
||||
computed: {
|
||||
classes() {
|
||||
return this.prop.isConclusive() ? 'pi-link' : 'pi-unlink';
|
||||
},
|
||||
toolTip() {
|
||||
if (this.prop.isConclusive()) {
|
||||
return 'All objects has the same value'
|
||||
} else {
|
||||
let values = this.prop.getOriginalValues();
|
||||
let toolTip = 'Objects has diverging values:';
|
||||
let i = 0;
|
||||
for (const it of values) {
|
||||
if (i === 5) {
|
||||
toolTip += `\n...`;
|
||||
break;
|
||||
}
|
||||
toolTip += `\n${++i}: ${this.shorten(it)}`;
|
||||
}
|
||||
return toolTip;
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
shorten(value) {
|
||||
let s = `${value}`;
|
||||
return s.length < 30 ? s : `${s.substr(0, 27)}...`
|
||||
}
|
||||
},
|
||||
});
|
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Wrapper around Pikaday
|
||||
*/
|
||||
let TEMPLATE = `
|
||||
<input ref="datepicker" type="text">
|
||||
`;
|
||||
|
||||
Vue.component('attract-date-picker', {
|
||||
props: {
|
||||
value: String
|
||||
},
|
||||
template: TEMPLATE,
|
||||
data() {
|
||||
return {
|
||||
picker: null // inited in this.initDatePicker()
|
||||
}
|
||||
},
|
||||
mounted: function () {
|
||||
this.$nextTick(this.initDatePicker);
|
||||
},
|
||||
watch: {
|
||||
value(newValue, oldValue) {
|
||||
this.picker.setDate(newValue);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initDatePicker() {
|
||||
let vm = this;
|
||||
this.picker = new Pikaday(
|
||||
{
|
||||
field: this.$refs.datepicker,
|
||||
firstDay: 1,
|
||||
showTime: false,
|
||||
use24hour: true,
|
||||
format: 'dddd D, MMMM YYYY',
|
||||
disableWeekends: true,
|
||||
timeLabel: 'Time: ',
|
||||
autoClose: true,
|
||||
incrementMinuteBy: 15,
|
||||
yearRange: [new Date().getFullYear(),new Date().getFullYear() + 5],
|
||||
onSelect: function(date) {
|
||||
// This is a bit ugly. Can we solve this in a better way?
|
||||
let dateAsConfigedInEve = this.getMoment().format('ddd, DD MMM YYYY [00:00:00 GMT]')
|
||||
vm.$emit('input', dateAsConfigedInEve);
|
||||
}
|
||||
});
|
||||
this.picker.setDate(this.value);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
@@ -0,0 +1,140 @@
|
||||
import {MultiEditEngine} from './MultiEditEngine'
|
||||
let UnitOfWorkTracker = pillar.vuecomponents.mixins.UnitOfWorkTracker;
|
||||
|
||||
|
||||
/**
|
||||
* Properties to be edited and/or read using the multi editor engine.
|
||||
*/
|
||||
const BaseProps = Object.freeze({
|
||||
NAME: 'name',
|
||||
DESCRIPTION: 'description',
|
||||
STATUS: 'properties.status',
|
||||
NODE_TYPE: 'node_type',
|
||||
});
|
||||
|
||||
let ALL_BASE_PROPERTIES = [];
|
||||
|
||||
for (const key in BaseProps) {
|
||||
ALL_BASE_PROPERTIES.push(BaseProps[key]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The base implementation of node editor.
|
||||
* Extend to fit your needs.
|
||||
* @emits objects-are-edited(isEdited) When the user starts editing the objects.
|
||||
*/
|
||||
let EditorBase = Vue.component('attract-editor-Base', {
|
||||
mixins: [UnitOfWorkTracker],
|
||||
props: {
|
||||
items: Array, // Array of objects to be edited.
|
||||
project: Object,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
multiEditEngine: this.createEditorEngine(ALL_BASE_PROPERTIES),
|
||||
isSaving: false,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
items() {
|
||||
this.multiEditEngine = this.createEditorEngine(ALL_BASE_PROPERTIES); // MultiEditEngine
|
||||
},
|
||||
statusPropEdited(isEdited) {
|
||||
if(isEdited && this.items.length === 1) {
|
||||
// Auto save on status is convenient, but could lead to head ache in multi edit.
|
||||
this.save();
|
||||
}
|
||||
},
|
||||
isEdited(isEdited) {
|
||||
this.$emit('objects-are-edited', isEdited);
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isMultpleItems() {
|
||||
return this.items.length > 1;
|
||||
},
|
||||
nodeTypeProp() {
|
||||
return this.multiEditEngine.getProperty(BaseProps.NODE_TYPE);
|
||||
},
|
||||
allowedStatuses() {
|
||||
let tmp = this.project.node_types.filter((it) => it.name === this.nodeTypeProp.value);
|
||||
if(tmp.length === 1) {
|
||||
let nodeTypeDefinition = tmp[0];
|
||||
return nodeTypeDefinition.dyn_schema.status.allowed;
|
||||
}
|
||||
console.log('Failed to find allowed statused for node type:', this.nodeTypeProp.value);
|
||||
return [];
|
||||
},
|
||||
allowedStatusesPair() {
|
||||
function format(status) {
|
||||
// in_progress => In progress
|
||||
let first = status[0].toUpperCase();
|
||||
let last = status.substr(1).replace('_', ' ');
|
||||
return `${first}${last}`;
|
||||
}
|
||||
return this.allowedStatuses.map(it => {
|
||||
return {
|
||||
id: it,
|
||||
text: format(it)
|
||||
}
|
||||
});
|
||||
},
|
||||
nameProp() {
|
||||
return this.multiEditEngine.getProperty(BaseProps.NAME);
|
||||
},
|
||||
descriptionProp() {
|
||||
return this.multiEditEngine.getProperty(BaseProps.DESCRIPTION);
|
||||
},
|
||||
statusProp() {
|
||||
return this.multiEditEngine.getProperty(BaseProps.STATUS);
|
||||
},
|
||||
statusPropEdited() {
|
||||
return this.statusProp.isEdited();
|
||||
},
|
||||
editorClasses() {
|
||||
let status = this.statusProp.isConclusive() ? this.statusProp.value : 'inconclusive';
|
||||
let classes = {}
|
||||
classes[`status-${status}`] = true;
|
||||
return classes;
|
||||
},
|
||||
isEdited() {
|
||||
return this.multiEditEngine.isEdited();
|
||||
},
|
||||
canEdit() {
|
||||
let canUseAttract = attract.auth.AttractAuth.canUserCanUseAttract(ProjectUtils.projectId());
|
||||
return canUseAttract && this.multiEditEngine.allowedToEdit();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* @param {MultiProperty} prop
|
||||
* @returns {Object} Css classes for property
|
||||
*/
|
||||
classesForProperty(prop) {
|
||||
return {
|
||||
'inconclusive': !prop.isConclusive(),
|
||||
'edited': prop.isEdited(),
|
||||
}
|
||||
},
|
||||
createEditorEngine(props) {
|
||||
return new MultiEditEngine(this.items, ...props);
|
||||
},
|
||||
save() {
|
||||
let toBeSaved = this.multiEditEngine.createUpdatedItems();
|
||||
let promises = toBeSaved.map(pillar.api.thenUpdateNode);
|
||||
this.isSaving = true;
|
||||
this.unitOfWork(
|
||||
Promise.all(promises)
|
||||
.then(() => {
|
||||
this.$emit('saved-items');
|
||||
})
|
||||
.catch((err) => {toastr.error(pillar.utils.messageFromError(err), 'Save Failed')})
|
||||
.finally(() => this.isSaving = false)
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export {EditorBase, BaseProps}
|
||||
|
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* MultiEditEngine
|
||||
* n-MultiProperty
|
||||
* 1-PropertyCB
|
||||
*
|
||||
* Class to edit multiple objects at the same time.
|
||||
* It keeps track of what object properties has been edited, which properties that still diffs,
|
||||
*
|
||||
* @example
|
||||
* let myObjects = [{
|
||||
* 'name': 'Bob',
|
||||
* 'personal': {
|
||||
* 'hobby': 'Fishing'
|
||||
* }
|
||||
* },{
|
||||
* 'name': 'Greg',
|
||||
* 'personal': {
|
||||
* 'hobby': 'Movies'
|
||||
* }
|
||||
* }]
|
||||
* // Create engine with list of objects to edit, and the properties we want to be able to edit.
|
||||
* let engine = new MultiEditEngine(myObjects,'name', 'personal.hobby');
|
||||
*
|
||||
* engine.getProperty('personal.hobby').isConclusive(); // false since one is 'Fishing' and one 'Movies'
|
||||
* engine.getProperty('personal.hobby').isEdited(); // false
|
||||
*
|
||||
* engine.getProperty('personal.hobby').value = 'Fishing';
|
||||
* engine.getProperty('personal.hobby').isConclusive(); // true
|
||||
* engine.getProperty('personal.hobby').isEdited(); // true
|
||||
* engine.getProperty('personal.hobby').getOriginalValues(); // A set with the original values 'Fishing' and 'Movies'
|
||||
*
|
||||
* engine.getProperty('name').isConclusive(); // false since one is 'Bob' and one is 'Greg'
|
||||
* engine.getProperty('personal.hobby').isEdited(); // false since this property has not been edited
|
||||
*
|
||||
* let updatedObjects = engine.createUpdatedItems();
|
||||
* // updatedObjects is now: [{'name': 'Greg', 'hobby': 'Fishing'}]
|
||||
* // myObjects is still unchanged.
|
||||
*/
|
||||
|
||||
function areEqual(valA, valB) {
|
||||
if(Array.isArray(valB) && Array.isArray(valB)) {
|
||||
if(valA.length === valB.length) {
|
||||
for (let i = 0; i < valA.length; i++) {
|
||||
if(!areEqual(valA[i], valB[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
return valA === valB;
|
||||
}
|
||||
}
|
||||
|
||||
class UniqueValues {
|
||||
constructor() {
|
||||
this._values = new Set();
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this._values.size;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} valueCandidate
|
||||
*/
|
||||
addIfUnique(valueCandidate) {
|
||||
if (Array.isArray(valueCandidate)) {
|
||||
for (const uniqueValue of this._values) {
|
||||
if(!Array.isArray(uniqueValue)) continue;
|
||||
if(areEqual(valueCandidate, uniqueValue)) {
|
||||
// not a new value. Don't add
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._values.add(valueCandidate);
|
||||
} else {
|
||||
this._values.add(valueCandidate);
|
||||
}
|
||||
}
|
||||
|
||||
getValueOrInconclusive() {
|
||||
if (this.size === 1) {
|
||||
return this._values.values().next().value;
|
||||
}
|
||||
return INCONCLUSIVE;
|
||||
}
|
||||
|
||||
getValues() {
|
||||
return new Set([...this._values]);
|
||||
}
|
||||
|
||||
_areArraysEqual(arrA, arrB) {
|
||||
if(arrA.size === arrB.size) {
|
||||
for (let i = 0; i < arrA.length; i++) {
|
||||
if(arrA[i] !== arrB[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class PropertyCB {
|
||||
constructor(propertyPath) {
|
||||
this.name = propertyPath;
|
||||
this._propertyPath = propertyPath.split('.');
|
||||
this._propertyKey = this._propertyPath.pop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the property from the item
|
||||
* @param {Object} item
|
||||
* @returns {*} Property value
|
||||
*/
|
||||
getValue(item) {
|
||||
let tmp = item;
|
||||
for (const key of this._propertyPath) {
|
||||
tmp = (tmp || {})[key]
|
||||
}
|
||||
return (tmp || {})[this._propertyKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a new value to the property
|
||||
* @param {Object} item
|
||||
* @param {*} newValue
|
||||
*/
|
||||
setValue(item, newValue) {
|
||||
let tmp = item;
|
||||
for (const key of this._propertyPath) {
|
||||
tmp[key] = tmp[key] || {};
|
||||
tmp = tmp[key];
|
||||
}
|
||||
tmp[this._propertyKey] = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Dummy object to indicate that a property is unedited.
|
||||
const NOT_SET = Symbol('Not Set');
|
||||
const INCONCLUSIVE = Symbol('Inconclusive');
|
||||
|
||||
class MultiProperty {
|
||||
/**
|
||||
*
|
||||
* @param {String} propPath Dot separeted path to property
|
||||
*/
|
||||
constructor(propPath) {
|
||||
this.propCB = new PropertyCB(propPath);;
|
||||
this.originalValues = new UniqueValues();
|
||||
this.newValue = NOT_SET;
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.newValue !== NOT_SET ?
|
||||
this.newValue :
|
||||
this._getOriginalValue();
|
||||
}
|
||||
|
||||
set value(newValue) {
|
||||
if (areEqual(newValue, this._getOriginalValue())) {
|
||||
this.reset();
|
||||
} else {
|
||||
this.newValue = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Set with all values the different object has for the property.
|
||||
* @returns {Set}
|
||||
*/
|
||||
getOriginalValues() {
|
||||
return this.originalValues.getValues();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ture if property has been edited.
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
isEdited() {
|
||||
return this.newValue !== NOT_SET;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo changes to property.
|
||||
*/
|
||||
reset() {
|
||||
this.newValue = NOT_SET;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if all objects has the same value for this property.
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
isConclusive() {
|
||||
if (this.newValue !== NOT_SET) {
|
||||
return true;
|
||||
}
|
||||
return this.originalValues.size == 1;
|
||||
}
|
||||
|
||||
_applyNewValue(item) {
|
||||
if (this.isEdited()) {
|
||||
this.propCB.setValue(item, this.newValue);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
_getOriginalValue() {
|
||||
let origVal = this.originalValues.getValueOrInconclusive()
|
||||
return origVal !== INCONCLUSIVE ?
|
||||
origVal : undefined;
|
||||
}
|
||||
|
||||
_addValueFrom(item) {
|
||||
this.originalValues.addIfUnique(this.propCB.getValue(item));
|
||||
}
|
||||
}
|
||||
|
||||
class MultiEditEngine {
|
||||
/**
|
||||
* @param {Array<Object>} items An array with the objects to be edited.
|
||||
* @param {...String} propertyPaths Dot separeted paths to properties. 'name', 'properties.status'
|
||||
*/
|
||||
constructor(items, ...propertyPaths) {
|
||||
this.originalItems = items;
|
||||
this.properties = this._createMultiproperties(propertyPaths);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {String} propName
|
||||
* @returns {MultiProperty}
|
||||
*/
|
||||
getProperty(propName) {
|
||||
return this.properties[propName];
|
||||
}
|
||||
|
||||
/**
|
||||
* True if all the objects has the same value for all of its monitored properties.
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
isConclusive() {
|
||||
for (const key in this.properties) {
|
||||
const prop = this.properties[key];
|
||||
if (prop.isConclusive()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if at least one property has been edited.
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
isEdited() {
|
||||
for (const key in this.properties) {
|
||||
const prop = this.properties[key];
|
||||
if (prop.isEdited()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array with copies of the objects with there new values.
|
||||
* Only the updated objects are included in the array.
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
createUpdatedItems() {
|
||||
let updatedItems = [];
|
||||
for (const it of this.originalItems) {
|
||||
let itemCopy = JSON.parse(JSON.stringify(it));
|
||||
let hasChanged = false;
|
||||
for (const key in this.properties) {
|
||||
const prop = this.properties[key];
|
||||
hasChanged |= prop._applyNewValue(itemCopy);
|
||||
}
|
||||
if(hasChanged) {
|
||||
updatedItems.push(itemCopy);
|
||||
}
|
||||
}
|
||||
return updatedItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if all items has 'PUT' in 'allowed_methods'. If object has now 'allowed_methods' we return true
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
allowedToEdit() {
|
||||
for (const it of this.originalItems) {
|
||||
if(!it.allowed_methods) continue;
|
||||
if(!it.allowed_methods.includes('PUT')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo all edits on all properties.
|
||||
*/
|
||||
reset() {
|
||||
for (const key in this.properties) {
|
||||
this.properties[key].reset();
|
||||
}
|
||||
}
|
||||
|
||||
_createMultiproperties(propertyPaths) {
|
||||
let retval = {}
|
||||
for (const propPath of propertyPaths) {
|
||||
let prop = new MultiProperty(propPath);
|
||||
this.originalItems.forEach(prop._addValueFrom.bind(prop));
|
||||
retval[propPath] = prop;
|
||||
}
|
||||
return retval;
|
||||
}
|
||||
}
|
||||
|
||||
export { MultiEditEngine }
|
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Wrapper around jquery select2. Heavily inspired by: https://vuejs.org/v2/examples/select2.html
|
||||
*/
|
||||
|
||||
let TEMPLATE = `
|
||||
<div class="input-group attract-select2">
|
||||
<select multiple="" ref="select2" style="display: none;" id="apa"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<slot/>
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
|
||||
Vue.component('attract-select2', {
|
||||
props: {
|
||||
options: Array,
|
||||
value: Array,
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
template: TEMPLATE,
|
||||
mounted: function () {
|
||||
this.$nextTick(this.initSelect2);
|
||||
},
|
||||
watch: {
|
||||
value(value) {
|
||||
// update value
|
||||
$(this.$refs.select2)
|
||||
.val(value)
|
||||
.trigger('change.select2');
|
||||
},
|
||||
options(options) {
|
||||
// update options
|
||||
$(this.$refs.select2).empty().select2({ data: options });
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
$(this.$refs.select2).off().select2('destroy');
|
||||
},
|
||||
methods: {
|
||||
initSelect2() {
|
||||
$(this.$refs.select2)
|
||||
// init select2
|
||||
.select2({ data: this.options })
|
||||
.val(this.value)
|
||||
.trigger('change.select2')
|
||||
// emit event on change.
|
||||
.on('change', () => {
|
||||
this.$emit('input', $(this.$refs.select2).val());
|
||||
});
|
||||
}
|
||||
},
|
||||
})
|
@@ -0,0 +1,34 @@
|
||||
const TEMPLATE = `
|
||||
<textarea ref="inputField"
|
||||
v-bind:value="value"
|
||||
v-on:input="$emit('input', $event.target.value)"
|
||||
class="input-transparent"
|
||||
type="text"
|
||||
rows="2"/>
|
||||
`;
|
||||
/**
|
||||
* Wrapper around regular textarea to make it grow in length as you type.
|
||||
*/
|
||||
Vue.component('attract-editor-text-area', {
|
||||
template: TEMPLATE,
|
||||
props: {
|
||||
value: String,
|
||||
},
|
||||
watch:{
|
||||
value() {
|
||||
this.$nextTick(this.autoSizeInputField);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(this.autoSizeInputField);
|
||||
},
|
||||
methods: {
|
||||
autoSizeInputField() {
|
||||
let elInputField = this.$refs.inputField;
|
||||
elInputField.style.cssText = 'height:auto; padding:0';
|
||||
let newInputHeight = elInputField.scrollHeight + 20;
|
||||
elInputField.style.cssText = `height:${ newInputHeight }px`;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
6
src/scripts/js/es6/common/vuecomponents/init.js
Normal file
6
src/scripts/js/es6/common/vuecomponents/init.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import './assetstable/Table'
|
||||
import './taskstable/Table'
|
||||
import './shotstable/Table'
|
||||
import './App'
|
||||
import './activities/Activities'
|
||||
import './detailedview/Viewer'
|
34
src/scripts/js/es6/common/vuecomponents/shotstable/Table.js
Normal file
34
src/scripts/js/es6/common/vuecomponents/shotstable/Table.js
Normal file
@@ -0,0 +1,34 @@
|
||||
let PillarTable = pillar.vuecomponents.table.PillarTable;
|
||||
import {ShotsColumnFactory} from './columns/ShotsColumnFactory'
|
||||
import {ShotRowsSource} from './rows/ShotRowsSource'
|
||||
import {RowFilter} from '../attracttable/rows/filter/RowFilter'
|
||||
|
||||
let ShotsTable = Vue.component('attract-shots-table', {
|
||||
extends: PillarTable,
|
||||
props: {
|
||||
project: Object
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
columnFactory: new ShotsColumnFactory(this.project),
|
||||
rowsSource: new ShotRowsSource(this.project._id),
|
||||
rowFilterConfig: {validStatuses: this.getValidStatuses()}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getValidStatuses() {
|
||||
for (const it of this.project.node_types) {
|
||||
if(it.name === 'attract_shot'){
|
||||
return it.dyn_schema.status.allowed;
|
||||
}
|
||||
}
|
||||
console.warn('Did not find allowed statuses for node type attract_shot');
|
||||
return [];
|
||||
}
|
||||
},
|
||||
components: {
|
||||
'pillar-table-row-filter': RowFilter,
|
||||
},
|
||||
});
|
||||
|
||||
export { ShotsTable }
|
@@ -0,0 +1,63 @@
|
||||
let CellDefault = pillar.vuecomponents.table.cells.renderer.CellDefault;
|
||||
|
||||
const TEMPLATE =`
|
||||
<div>
|
||||
<img
|
||||
v-if="img.src"
|
||||
:src="img.src"
|
||||
:alt="img.alt"
|
||||
:height="img.height"
|
||||
:width="img.width"
|
||||
/>
|
||||
<generic-placeholder
|
||||
v-if="isLoading"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let CellPicture = Vue.component('pillar-cell-picture', {
|
||||
extends: CellDefault,
|
||||
template: TEMPLATE,
|
||||
data() {
|
||||
return {
|
||||
img: {},
|
||||
failed: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isLoading() {
|
||||
if(!this.failed) {
|
||||
return !!this.rawCellValue && !this.img.src;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (this.rawCellValue) {
|
||||
this.loadThumbnail(this.rawCellValue);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
rawCellValue(newValue) {
|
||||
this.loadThumbnail(newValue);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadThumbnail(imgId) {
|
||||
this.img = {};
|
||||
pillar.utils.thenLoadImage(imgId, 't')
|
||||
.then(fileDoc => {
|
||||
this.img = {
|
||||
src: fileDoc.link,
|
||||
alt: fileDoc.name,
|
||||
width: fileDoc.width,
|
||||
height: fileDoc.height,
|
||||
}
|
||||
}).fail(() => {
|
||||
this.failed = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export { CellPicture }
|
@@ -0,0 +1,15 @@
|
||||
import {CellPicture} from '../cells/renderer/Picture'
|
||||
let ColumnBase = pillar.vuecomponents.table.columns.ColumnBase;
|
||||
|
||||
export class Picture extends ColumnBase {
|
||||
constructor() {
|
||||
super('Thumbnail', 'thumbnail');
|
||||
this.isSortable = false;
|
||||
}
|
||||
getCellRenderer(rowObject) {
|
||||
return CellPicture.options.name;
|
||||
}
|
||||
getRawCellValue(rowObject) {
|
||||
return rowObject.underlyingObject.picture;
|
||||
}
|
||||
}
|
@@ -0,0 +1,31 @@
|
||||
import { TaskColumn } from '../../attracttable/columns/Tasks';
|
||||
import { FirstTaskDueDate, NextTaskDueDate, LastTaskDueDate } from '../../attracttable/columns/TaskDueDate';
|
||||
import { Status } from '../../attracttable/columns/Status';
|
||||
import { Picture } from '../columns/Picture'
|
||||
import { RowObject } from '../../attracttable/columns/RowObject'
|
||||
let ColumnFactoryBase = pillar.vuecomponents.table.columns.ColumnFactoryBase;
|
||||
let Created = pillar.vuecomponents.table.columns.Created;
|
||||
let Updated = pillar.vuecomponents.table.columns.Updated;
|
||||
|
||||
|
||||
class ShotsColumnFactory extends ColumnFactoryBase{
|
||||
constructor(project) {
|
||||
super();
|
||||
this.project = project;
|
||||
}
|
||||
|
||||
thenGetColumns() {
|
||||
let taskTypes = this.project.extension_props.attract.task_types.attract_shot;
|
||||
let taskColumns = taskTypes.map((tType) => {
|
||||
return new TaskColumn(tType, 'shot-task');
|
||||
})
|
||||
|
||||
return Promise.resolve(
|
||||
[new Status(), new Picture(), new RowObject()]
|
||||
.concat(taskColumns)
|
||||
.concat([new NextTaskDueDate(), new Created(), new Updated()])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { ShotsColumnFactory }
|
@@ -0,0 +1,46 @@
|
||||
import {AttractRowBase} from '../../attracttable/rows/AttractRowBase'
|
||||
import { TaskEventListener } from '../../attracttable/rows/TaskEventListener';
|
||||
import { TaskRow } from '../../taskstable/rows/TaskRow';
|
||||
|
||||
class ShotRow extends AttractRowBase {
|
||||
constructor(shot) {
|
||||
super(shot);
|
||||
this.tasks = [];
|
||||
}
|
||||
|
||||
_thenInitImpl() {
|
||||
return attract.api.thenGetTasks(this.getId())
|
||||
.then((response) => {
|
||||
this.tasks = response._items.map(t => new TaskRow(t));
|
||||
this.registerTaskEventListeners();
|
||||
|
||||
return Promise.all(
|
||||
this.tasks.map(t => t.thenInit())
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
registerTaskEventListeners() {
|
||||
new TaskEventListener(this).register();
|
||||
}
|
||||
|
||||
getTasksOfType(taskType) {
|
||||
return this.tasks.filter((t) => {
|
||||
return t.getProperties().task_type === taskType;
|
||||
})
|
||||
}
|
||||
|
||||
getRowClasses() {
|
||||
let classes = super.getRowClasses()
|
||||
if(this.isInitialized) {
|
||||
classes['shot-not-in-edit'] = !this.underlyingObject.properties.used_in_edit;
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
|
||||
getChildObjects() {
|
||||
return this.tasks;
|
||||
}
|
||||
}
|
||||
|
||||
export { ShotRow }
|
@@ -0,0 +1,18 @@
|
||||
import { AttractRowsSourceBase } from '../../attracttable/rows/AttractRowsSourceBase'
|
||||
import { ShotRow } from './ShotRow'
|
||||
|
||||
class ShotRowsSource extends AttractRowsSourceBase {
|
||||
constructor(projectId) {
|
||||
super(projectId, 'attract_asset', ShotRow);
|
||||
}
|
||||
|
||||
thenGetRowObjects() {
|
||||
return attract.api.thenGetProjectShots(this.projectId)
|
||||
.then((result) => {
|
||||
let shots = result._items;
|
||||
this.initRowObjects(shots);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { ShotRowsSource }
|
64
src/scripts/js/es6/common/vuecomponents/taskstable/Table.js
Normal file
64
src/scripts/js/es6/common/vuecomponents/taskstable/Table.js
Normal file
@@ -0,0 +1,64 @@
|
||||
let PillarTable = pillar.vuecomponents.table.PillarTable;
|
||||
import {TasksColumnFactory} from './columns/TasksColumnFactory'
|
||||
import {TaskRowsSource} from './rows/TaskRowsSource'
|
||||
import {RowFilter} from '../attracttable/rows/filter/RowFilter'
|
||||
|
||||
const TEMPLATE =`
|
||||
<div class="pillar-table-actions">
|
||||
<button class="action"
|
||||
v-if="canAddTask"
|
||||
@click="createNewTask"
|
||||
>
|
||||
<i class="pi-plus">New Task</i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let TableActions = {
|
||||
template: TEMPLATE,
|
||||
computed: {
|
||||
canAddTask() {
|
||||
let projectId = ProjectUtils.projectId();
|
||||
return attract.auth.AttractAuth.canUserCreateTask(projectId);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
createNewTask(event) {
|
||||
thenCreateTask(undefined, 'generic')
|
||||
.then((task) => {
|
||||
this.$emit('item-clicked', event, task._id);
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
let TasksTable = Vue.component('attract-tasks-table', {
|
||||
extends: PillarTable,
|
||||
props: {
|
||||
project: Object
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
columnFactory: new TasksColumnFactory(this.project),
|
||||
rowsSource: new TaskRowsSource(this.project._id),
|
||||
rowFilterConfig: {validStatuses: this.getValidStatuses()}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getValidStatuses() {
|
||||
for (const it of this.project.node_types) {
|
||||
if(it.name === 'attract_task'){
|
||||
return it.dyn_schema.status.allowed;
|
||||
}
|
||||
}
|
||||
console.warn('Did not find allowed statuses for node type attract_task');
|
||||
return [];
|
||||
}
|
||||
},
|
||||
components: {
|
||||
'pillar-table-actions': TableActions,
|
||||
'pillar-table-row-filter': RowFilter,
|
||||
}
|
||||
});
|
||||
|
||||
export {TasksTable}
|
@@ -0,0 +1,42 @@
|
||||
let CellDefault = pillar.vuecomponents.table.cells.renderer.CellDefault;
|
||||
|
||||
const TEMPLATE =`
|
||||
<div>
|
||||
<a
|
||||
v-if="rawCellValue"
|
||||
@click="onClick"
|
||||
:href="cellLink"
|
||||
>
|
||||
{{ cellValue }}
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let ParentNameCell = Vue.component('pillar-cell-parent-name', {
|
||||
extends: CellDefault,
|
||||
template: TEMPLATE,
|
||||
computed: {
|
||||
cellTitle() {
|
||||
return this.rawCellValue;
|
||||
},
|
||||
cellLink() {
|
||||
let project_url = ProjectUtils.projectUrl();
|
||||
let item_type = this.itemType();
|
||||
return `/attract/${project_url}/${item_type}s/${this.rowObject.getParent()._id}`;
|
||||
},
|
||||
embededLink() {
|
||||
return this.cellLink;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onClick(event) {
|
||||
event.preventDefault(); // Don't follow link, but let event bubble and the row will handle it
|
||||
},
|
||||
itemType() {
|
||||
let node_type = this.rowObject.getParent().node_type;
|
||||
return node_type.replace('attract_', ''); // eg. attract_task to task
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export { ParentNameCell }
|
@@ -0,0 +1,30 @@
|
||||
let ColumnBase = pillar.vuecomponents.table.columns.ColumnBase;
|
||||
import {ParentNameCell} from '../cells/ParentName'
|
||||
|
||||
class ParentName extends ColumnBase {
|
||||
constructor() {
|
||||
super('Parent', 'parent-name');
|
||||
}
|
||||
|
||||
getCellRenderer(rowObject) {
|
||||
return ParentNameCell.options.name;
|
||||
}
|
||||
|
||||
getRawCellValue(rowObject) {
|
||||
if(!rowObject.getParent()) return '';
|
||||
return rowObject.getParent().name || '<No Name>';
|
||||
}
|
||||
|
||||
compareRows(rowObject1, rowObject2) {
|
||||
let parent1 = rowObject1.getParent();
|
||||
let parent2 = rowObject2.getParent();
|
||||
if (parent1 && parent2) {
|
||||
if (parent1.name === parent2.name) {
|
||||
return parent1._id < parent2._id ? -1 : 1;
|
||||
}
|
||||
}
|
||||
return super.compareRows(rowObject1, rowObject2);
|
||||
}
|
||||
}
|
||||
|
||||
export { ParentName }
|
@@ -0,0 +1,13 @@
|
||||
let ColumnBase = pillar.vuecomponents.table.columns.ColumnBase;
|
||||
|
||||
class ShortCode extends ColumnBase {
|
||||
constructor() {
|
||||
super('Short Code', 'short-code');
|
||||
}
|
||||
|
||||
getRawCellValue(rowObject) {
|
||||
return rowObject.getTask().properties.shortcode || '';
|
||||
}
|
||||
}
|
||||
|
||||
export { ShortCode }
|
@@ -0,0 +1,13 @@
|
||||
let ColumnBase = pillar.vuecomponents.table.columns.ColumnBase;
|
||||
|
||||
class TaskType extends ColumnBase {
|
||||
constructor() {
|
||||
super('Type', 'task-type');
|
||||
}
|
||||
|
||||
getRawCellValue(rowObject) {
|
||||
return rowObject.getTask().properties.task_type || '';
|
||||
}
|
||||
}
|
||||
|
||||
export { TaskType }
|
@@ -0,0 +1,28 @@
|
||||
import { Status } from '../../attracttable/columns/Status'
|
||||
import { RowObject } from '../../attracttable/columns/RowObject'
|
||||
import { TaskDueDate } from '../../attracttable/columns/TaskDueDate'
|
||||
import { TaskType } from './TaskType'
|
||||
import { ShortCode } from './ShortCode'
|
||||
import { ParentName } from './ParentName'
|
||||
|
||||
let ColumnFactoryBase = pillar.vuecomponents.table.columns.ColumnFactoryBase;
|
||||
let Created = pillar.vuecomponents.table.columns.Created;
|
||||
let Updated = pillar.vuecomponents.table.columns.Updated;
|
||||
|
||||
|
||||
class TasksColumnFactory extends ColumnFactoryBase{
|
||||
thenGetColumns() {
|
||||
return Promise.resolve([
|
||||
new Status(),
|
||||
new ParentName(),
|
||||
new RowObject(),
|
||||
new ShortCode(),
|
||||
new TaskType(),
|
||||
new TaskDueDate(),
|
||||
new Created(),
|
||||
new Updated(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export { TasksColumnFactory }
|
@@ -0,0 +1,29 @@
|
||||
import {AttractRowBase} from '../../attracttable/rows/AttractRowBase'
|
||||
|
||||
class TaskRow extends AttractRowBase {
|
||||
constructor(task) {
|
||||
super(task);
|
||||
this.parent = undefined;
|
||||
if (task.parent && task.parent._id) {
|
||||
// Deattach parent from task to avoid parent to be overwritten when task is updated
|
||||
let parentId = task.parent._id;
|
||||
this.parent = task.parent;
|
||||
task.parent = parentId;
|
||||
pillar.events.Nodes.onUpdated(parentId, this.onParentUpdated.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
getTask() {
|
||||
return this.underlyingObject;
|
||||
}
|
||||
|
||||
getParent() {
|
||||
return this.parent;
|
||||
}
|
||||
|
||||
onParentUpdated(event) {
|
||||
this.parent = event.detail;
|
||||
}
|
||||
}
|
||||
|
||||
export { TaskRow }
|
@@ -0,0 +1,18 @@
|
||||
import { AttractRowsSourceBase } from '../../attracttable/rows/AttractRowsSourceBase'
|
||||
import { TaskRow } from './TaskRow'
|
||||
|
||||
class TaskRowsSource extends AttractRowsSourceBase {
|
||||
constructor(projectId) {
|
||||
super(projectId, 'attract_task', TaskRow);
|
||||
}
|
||||
|
||||
thenGetRowObjects() {
|
||||
return attract.api.thenGetProjectTasks(this.projectId)
|
||||
.then((result) => {
|
||||
let tasks = result._items;
|
||||
this.initRowObjects(tasks);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { TaskRowsSource }
|
@@ -145,8 +145,8 @@ $(window).on('load resize', function(){
|
||||
});
|
||||
|
||||
|
||||
/* Fix header of items list (like assets or shots */
|
||||
function itemsListFixHeader(toClone, toAppend){
|
||||
/* Clone children elements, used to fix the header of items_list (like assets or shots) */
|
||||
function cloneChildren(toClone, toAppend){
|
||||
var target = $(toClone);
|
||||
var target_children = target.children();
|
||||
var clone = target.clone();
|
||||
@@ -156,3 +156,45 @@ function itemsListFixHeader(toClone, toAppend){
|
||||
});
|
||||
$(toAppend).append(clone);
|
||||
}
|
||||
|
||||
/* Scroll fixed headers horizontally, used for the header of items_list (like assets or shots) */
|
||||
function scrollHeaderHorizontal(scrollableClassName, fixedContainer, offset){
|
||||
var $table_list = $(scrollableClassName);
|
||||
|
||||
$table_list.scroll(function(e) {
|
||||
// Scroll of the table scrollableClassName from the left minus offset, inverted (multiplied by -1)
|
||||
var table_header_offset = ($table_list.scrollLeft() - offset) * - 1
|
||||
$(fixedContainer).css('left', table_header_offset);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// For every column, set the width of the fixed header using the original columns width
|
||||
function setHeaderCellsWidth(tableHeaderRowOriginal, tableHeaderRowFixed) {
|
||||
var table_header = $(tableHeaderRowOriginal).children();
|
||||
var table_header_fixed = $(tableHeaderRowFixed).children();
|
||||
|
||||
table_header_fixed.width(function(i,val) {
|
||||
return table_header.eq(i).width();
|
||||
});
|
||||
}
|
||||
|
||||
/* Returns a more-or-less reasonable message given an error response object. */
|
||||
function xhrErrorResponseMessage(err) {
|
||||
if (typeof err.responseJSON == 'undefined')
|
||||
return err.statusText;
|
||||
|
||||
if (typeof err.responseJSON._error != 'undefined' && typeof err.responseJSON._error.message != 'undefined')
|
||||
return err.responseJSON._error.message;
|
||||
|
||||
if (typeof err.responseJSON._message != 'undefined')
|
||||
return err.responseJSON._message
|
||||
|
||||
return err.statusText;
|
||||
}
|
||||
|
||||
function xhrErrorResponseElement(err, prefix) {
|
||||
msg = xhrErrorResponseMessage(err);
|
||||
return $('<span>')
|
||||
.text(prefix + msg);
|
||||
}
|
||||
|
@@ -1,133 +1,7 @@
|
||||
/**
|
||||
* Removes the task from the task list and shot list, and show the 'task-add-link'
|
||||
* when this was the last task in its category.
|
||||
*/
|
||||
function _remove_task_from_list(task_id) {
|
||||
var $task_link = $('#task-' + task_id)
|
||||
var $task_link_parent = $task_link.parent();
|
||||
$task_link.hideAndRemove(300, function() {
|
||||
if ($task_link_parent.children('.task-link').length == 0) {
|
||||
$task_link_parent.find('.task-add-link').removeClass('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the 'active' class from any element whose ID starts with
|
||||
* shot-, asset-, or task-.
|
||||
*/
|
||||
function deactivateItemLinks()
|
||||
{
|
||||
$('[id^="shot-"]').removeClass('active');
|
||||
$('[id^="asset-"]').removeClass('active');
|
||||
$('[id^="task-"]').removeClass('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an item such as tasks/shots in the #item-details div
|
||||
*/
|
||||
function item_open(item_id, item_type, pushState, project_url)
|
||||
{
|
||||
if (item_id === undefined || item_type === undefined) {
|
||||
throw new ReferenceError("item_open(" + item_id + ", " + item_type + ") called.");
|
||||
}
|
||||
|
||||
if (typeof project_url === 'undefined') {
|
||||
project_url = ProjectUtils.projectUrl();
|
||||
if (typeof project_url === 'undefined') {
|
||||
throw new ReferenceError("ProjectUtils.projectUrl() undefined");
|
||||
}
|
||||
}
|
||||
|
||||
// Style elements starting with item_type and dash, e.g. "#shot-uuid"
|
||||
deactivateItemLinks();
|
||||
var current_item = $('#' + item_type + '-' + item_id);
|
||||
current_item.addClass('processing');
|
||||
|
||||
// Special case to highlight the shot row when opening task in shot or asset context
|
||||
var pu_ctx = ProjectUtils.context();
|
||||
var pc_ctx_shot_asset = (pu_ctx == 'shot' || pu_ctx == 'asset');
|
||||
if (pc_ctx_shot_asset && item_type == 'task'){
|
||||
$('[id^="shot-"]').removeClass('active');
|
||||
$('[id^="asset-"]').removeClass('active');
|
||||
$('#task-' + item_id).closest('.table-row').addClass('active');
|
||||
}
|
||||
|
||||
var item_url = '/attract/' + project_url + '/' + item_type + 's/' + item_id;
|
||||
var push_url = item_url;
|
||||
if (pc_ctx_shot_asset && item_type == 'task'){
|
||||
push_url = '/attract/' + project_url + '/' + pu_ctx + 's/with-task/' + item_id;
|
||||
}
|
||||
item_url += '?context=' + pu_ctx;
|
||||
|
||||
statusBarSet('default', 'Loading ' + item_type + '…');
|
||||
|
||||
$.get(item_url, function(item_data) {
|
||||
statusBarClear();
|
||||
$('#item-details').html(item_data);
|
||||
$('#col_right .col_header span.header_text').text(item_type + ' details');
|
||||
current_item
|
||||
.removeClass('processing newborn')
|
||||
.addClass('active');
|
||||
|
||||
}).fail(function(xhr) {
|
||||
if (console) {
|
||||
console.log('Error fetching task', item_id, 'from', item_url);
|
||||
console.log('XHR:', xhr);
|
||||
}
|
||||
|
||||
statusBarSet('error', 'Failed to open ' + item_type, 'pi-warning');
|
||||
|
||||
if (xhr.status) {
|
||||
$('#item-details').html(xhr.responseText);
|
||||
} else {
|
||||
$('#item-details').html('<p class="text-danger">Opening ' + item_type + ' failed. There possibly was ' +
|
||||
'an error connecting to the server. Please check your network connection and ' +
|
||||
'try again.</p>');
|
||||
}
|
||||
});
|
||||
|
||||
// Determine whether we should push the new state or not.
|
||||
pushState = (typeof pushState !== 'undefined') ? pushState : true;
|
||||
if (!pushState) return;
|
||||
|
||||
// Push the correct URL onto the history.
|
||||
var push_state = {itemId: item_id, itemType: item_type};
|
||||
|
||||
window.history.pushState(
|
||||
push_state,
|
||||
item_type + ': ' + item_id,
|
||||
push_url
|
||||
);
|
||||
}
|
||||
|
||||
// Fine if project_url is undefined, but that requires ProjectUtils.projectUrl().
|
||||
function task_open(task_id, project_url)
|
||||
{
|
||||
item_open(task_id, 'task', true, project_url);
|
||||
}
|
||||
|
||||
function shot_open(shot_id)
|
||||
{
|
||||
item_open(shot_id, 'shot');
|
||||
}
|
||||
|
||||
function asset_open(asset_id)
|
||||
{
|
||||
item_open(asset_id, 'asset');
|
||||
}
|
||||
|
||||
window.onpopstate = function(event)
|
||||
{
|
||||
var state = event.state;
|
||||
|
||||
item_open(state.itemId, state.itemType, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a asset and show it in the #item-details div.
|
||||
*/
|
||||
function asset_create(project_url)
|
||||
function thenCreateAsset(project_url)
|
||||
{
|
||||
if (project_url === undefined) {
|
||||
throw new ReferenceError("asset_create(" + project_url+ ") called.");
|
||||
@@ -138,8 +12,9 @@ function asset_create(project_url)
|
||||
project_url: project_url
|
||||
};
|
||||
|
||||
$.post(url, data, function(asset_data) {
|
||||
window.location.href = asset_data.asset_id;
|
||||
return $.post(url, data, function(asset_data) {
|
||||
pillar.events.Nodes.triggerCreated(asset_data);
|
||||
return asset_data;
|
||||
})
|
||||
.fail(function(xhr) {
|
||||
if (console) {
|
||||
@@ -150,58 +25,6 @@ function asset_create(project_url)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the task item to the shots/tasks list.
|
||||
*
|
||||
* 'shot_id' can be undefined if the task isn't attached to a shot.
|
||||
*/
|
||||
function task_add(shot_id, task_id, task_type)
|
||||
{
|
||||
if (task_id === undefined || task_type === undefined) {
|
||||
throw new ReferenceError("task_add(" + shot_id + ", " + task_id + ", " + task_type + ") called.");
|
||||
}
|
||||
|
||||
var project_url = ProjectUtils.projectUrl();
|
||||
var url = '/attract/' + project_url + '/tasks/' + task_id;
|
||||
var context = ProjectUtils.context();
|
||||
|
||||
if (context == 'task') {
|
||||
/* WARNING: This is a copy of an element of attract/tasks/for_project .item-list.col-list
|
||||
* If that changes, change this too. */
|
||||
$('.item-list.task').append('\
|
||||
<a class="col-list-item task-list-item status-todo task-link active"\
|
||||
href="' + url + '"\
|
||||
data-task-id="' + task_id + '"\
|
||||
id="task-' + task_id + '">\
|
||||
<span class="status-indicator"></span>\
|
||||
<span class="name">-save your task first-</span>\
|
||||
<span class="due_date">-</span>\
|
||||
</a>\
|
||||
');
|
||||
} else if (context == 'shot' || context == 'asset') {
|
||||
if (shot_id === undefined) {
|
||||
throw new ReferenceError("task_add(" + shot_id + ", " + task_id + ", " + task_type + ") called in " + context + " context.");
|
||||
}
|
||||
|
||||
var $list_cell = $('#' + context + '-' + shot_id + ' .table-cell.task-type.' + task_type);
|
||||
var url = '/attract/' + project_url + '/' + context + 's/with-task/' + task_id;
|
||||
|
||||
/* WARNING: This is a copy of an element of attract/shots/for_project .item-list.col-list
|
||||
* If that changes, change this too. */
|
||||
$list_cell.append('\
|
||||
<a class="status-todo task-link active newborn"\
|
||||
title="-save your task first-"\
|
||||
href="' + url + '"\
|
||||
data-task-id="' + task_id + '"\
|
||||
id="task-' + task_id + '">\
|
||||
</a>\
|
||||
');
|
||||
|
||||
$list_cell.find('.task-add.task-add-link').addClass('hidden');
|
||||
} else {
|
||||
if (console) console.log('task_add: not doing much in context', context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a task and show it in the #item-details div.
|
||||
@@ -209,7 +32,7 @@ function task_add(shot_id, task_id, task_type)
|
||||
* 'shot_id' may be undefined, in which case the task will not
|
||||
* be attached to a shot.
|
||||
*/
|
||||
function task_create(shot_id, task_type)
|
||||
function thenCreateTask(shot_id, task_type)
|
||||
{
|
||||
if (task_type === undefined) {
|
||||
throw new ReferenceError("task_create(" + shot_id + ", " + task_type + ") called.");
|
||||
@@ -224,10 +47,10 @@ function task_create(shot_id, task_type)
|
||||
};
|
||||
if (has_shot_id) data.parent = shot_id;
|
||||
|
||||
$.post(url, data, function(task_data) {
|
||||
return $.post(url, data, function(task_data) {
|
||||
if (console) console.log('Task created:', task_data);
|
||||
task_open(task_data.task_id);
|
||||
task_add(shot_id, task_data.task_id, task_type);
|
||||
pillar.events.Nodes.triggerCreated(task_data);
|
||||
return task_data;
|
||||
})
|
||||
.fail(function(xhr) {
|
||||
if (console) {
|
||||
@@ -235,235 +58,9 @@ function task_create(shot_id, task_type)
|
||||
console.log('XHR:', xhr);
|
||||
}
|
||||
$('#item-details').html(xhr.responseText);
|
||||
})
|
||||
.done(function(){
|
||||
$('#item-details input[name="name"]').focus();
|
||||
});
|
||||
}
|
||||
|
||||
function attract_form_save(form_id, item_id, item_save_url, options)
|
||||
{
|
||||
// Mandatory option.
|
||||
if (typeof options === 'undefined' || typeof options.type === 'undefined') {
|
||||
throw new ReferenceError('attract_form_save(): options.type is mandatory.');
|
||||
}
|
||||
|
||||
var $form = $('#' + form_id);
|
||||
var $button = $form.find("button[type='submit']");
|
||||
|
||||
var payload = $form.serialize();
|
||||
var $item = $('#' + item_id);
|
||||
|
||||
$button.attr('disabled', true);
|
||||
$item.addClass('processing');
|
||||
|
||||
statusBarSet('', 'Saving ' + options.type + '…');
|
||||
|
||||
if (console) console.log('Sending:', payload);
|
||||
|
||||
$.post(item_save_url, payload)
|
||||
.done(function(saved_item) {
|
||||
if (console) console.log('Done saving', saved_item);
|
||||
|
||||
statusBarSet('success', 'Saved ' + options.type + '. ' + saved_item._updated, 'pi-check');
|
||||
|
||||
$form.find("input[name='_etag']").val(saved_item._etag);
|
||||
|
||||
if (options.done) options.done($item, saved_item);
|
||||
})
|
||||
.fail(function(xhr_or_response_data) {
|
||||
// jQuery sends the response data (if JSON), or an XHR object (if not JSON).
|
||||
if (console) console.log('Failed saving', options.type, xhr_or_response_data);
|
||||
|
||||
$button.removeClass('btn-default').addClass('btn-danger');
|
||||
|
||||
statusBarSet('error', 'Failed saving. ' + xhr_or_response_data.status, 'pi-warning');
|
||||
|
||||
if (options.fail) options.fail($item, xhr_or_response_data);
|
||||
})
|
||||
.always(function() {
|
||||
$button.attr('disabled', false);
|
||||
$item.removeClass('processing');
|
||||
|
||||
if (options.always) options.always($item);
|
||||
})
|
||||
;
|
||||
|
||||
return false; // prevent synchronous POST to current page.
|
||||
}
|
||||
|
||||
function task_save(task_id, task_url) {
|
||||
return attract_form_save('item_form', 'task-' + task_id, task_url, {
|
||||
done: function($task, saved_task) {
|
||||
// Update the task list.
|
||||
// NOTE: this is tightly linked to the HTML of the task list in for_project.jade.
|
||||
$('.task-name-' + saved_task._id).text(saved_task.name).flashOnce();
|
||||
$task.find('span.name').text(saved_task.name);
|
||||
$task.find('span.status').text(saved_task.properties.status.replace('_', ' '));
|
||||
if (saved_task.properties.due_date){
|
||||
$task.find('span.due_date').text(moment().to(saved_task.properties.due_date));
|
||||
}
|
||||
|
||||
$task
|
||||
.removeClassPrefix('status-')
|
||||
.addClass('status-' + saved_task.properties.status)
|
||||
.flashOnce()
|
||||
;
|
||||
|
||||
task_open(task_id);
|
||||
},
|
||||
fail: function($item, xhr_or_response_data) {
|
||||
if (xhr_or_response_data.status == 412) {
|
||||
// TODO: implement something nice here. Just make sure we don't throw
|
||||
// away the user's edits. It's up to the user to handle this.
|
||||
} else {
|
||||
$('#item-details').html(xhr_or_response_data.responseText);
|
||||
}
|
||||
},
|
||||
type: 'task'
|
||||
});
|
||||
}
|
||||
|
||||
function shot_save(shot_id, shot_url) {
|
||||
return attract_form_save('item_form', 'shot-' + shot_id, shot_url, {
|
||||
done: function($shot, saved_shot) {
|
||||
// Update the shot list.
|
||||
$('.shot-name-' + saved_shot._id).text(saved_shot.name);
|
||||
$shot
|
||||
.removeClassPrefix('status-')
|
||||
.addClass('status-' + saved_shot.properties.status)
|
||||
.flashOnce()
|
||||
;
|
||||
shot_open(shot_id);
|
||||
},
|
||||
fail: function($item, xhr_or_response_data) {
|
||||
if (xhr_or_response_data.status == 412) {
|
||||
// TODO: implement something nice here. Just make sure we don't throw
|
||||
// away the user's edits. It's up to the user to handle this.
|
||||
} else {
|
||||
$('#item-details').html(xhr_or_response_data.responseText);
|
||||
}
|
||||
},
|
||||
type: 'shot'
|
||||
});
|
||||
}
|
||||
|
||||
function asset_save(asset_id, asset_url) {
|
||||
return attract_form_save('item_form', 'asset-' + asset_id, asset_url, {
|
||||
done: function($asset, saved_asset) {
|
||||
// Update the asset list.
|
||||
// NOTE: this is tightly linked to the HTML of the asset list in for_project.jade.
|
||||
$('.item-name-' + saved_asset._id).text(saved_asset.name).flashOnce();
|
||||
$asset.find('span.name').text(saved_asset.name);
|
||||
$asset.find('span.due_date').text(moment().to(saved_asset.properties.due_date));
|
||||
$asset.find('span.status').text(saved_asset.properties.status.replace('_', ' '));
|
||||
|
||||
$asset
|
||||
.removeClassPrefix('status-')
|
||||
.addClass('status-' + saved_asset.properties.status)
|
||||
.flashOnce()
|
||||
;
|
||||
|
||||
asset_open(asset_id);
|
||||
},
|
||||
fail: function($item, xhr_or_response_data) {
|
||||
if (xhr_or_response_data.status == 412) {
|
||||
// TODO: implement something nice here. Just make sure we don't throw
|
||||
// away the user's edits. It's up to the user to handle this.
|
||||
} else {
|
||||
$('#item-details').html(xhr_or_response_data.responseText);
|
||||
}
|
||||
},
|
||||
type: 'asset'
|
||||
});
|
||||
}
|
||||
|
||||
function task_delete(task_id, task_etag, task_delete_url) {
|
||||
if (task_id === undefined || task_etag === undefined || task_delete_url === undefined) {
|
||||
throw new ReferenceError("task_delete(" + task_id + ", " + task_etag + ", " + task_delete_url + ") called.");
|
||||
}
|
||||
|
||||
$('#task-' + task_id).addClass('processing');
|
||||
|
||||
$.ajax({
|
||||
type: 'DELETE',
|
||||
url: task_delete_url,
|
||||
data: {'etag': task_etag}
|
||||
})
|
||||
.done(function(e) {
|
||||
if (console) console.log('Task', task_id, 'was deleted.');
|
||||
$('#item-details').fadeOutAndClear();
|
||||
_remove_task_from_list(task_id);
|
||||
|
||||
statusBarSet('success', 'Task deleted successfully', 'pi-check');
|
||||
})
|
||||
.fail(function(xhr) {
|
||||
|
||||
statusBarSet('error', 'Unable to delete task, code ' + xhr.status, 'pi-warning');
|
||||
|
||||
if (xhr.status == 412) {
|
||||
alert('Someone else edited this task before you deleted it; refresh to try again.');
|
||||
// TODO: implement something nice here. Just make sure we don't throw
|
||||
// away the user's edits. It's up to the user to handle this.
|
||||
// TODO: refresh activity feed and point user to it.
|
||||
} else {
|
||||
// TODO: find a better place to put this error message, without overwriting the
|
||||
// task the user is looking at in-place.
|
||||
$('#task-view-feed').html(xhr.responseText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadActivities(url)
|
||||
{
|
||||
return $.get(url)
|
||||
.done(function(data) {
|
||||
if(console) console.log('Activities loaded OK');
|
||||
$('#activities').html(data);
|
||||
})
|
||||
.fail(function(xhr) {
|
||||
if (console) {
|
||||
console.log('Error fetching activities');
|
||||
console.log('XHR:', xhr);
|
||||
}
|
||||
|
||||
statusBarSet('error', 'Opening activity log failed.', 'pi-warning');
|
||||
|
||||
if (xhr.status) {
|
||||
$('#activities').html(xhr.responseText);
|
||||
} else {
|
||||
$('#activities').html('<p class="text-danger">Opening activity log failed. There possibly was ' +
|
||||
'an error connecting to the server. Please check your network connection and ' +
|
||||
'try again.</p>');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$(function() {
|
||||
$("a.shot-link[data-shot-id]").click(function(e) {
|
||||
e.preventDefault();
|
||||
// delegateTarget is the thing the event hander was attached to,
|
||||
// rather than the thing we clicked on.
|
||||
var shot_id = e.delegateTarget.dataset.shotId;
|
||||
shot_open(shot_id);
|
||||
});
|
||||
|
||||
$("a.asset-link[data-asset-id]").click(function(e) {
|
||||
e.preventDefault();
|
||||
// delegateTarget is the thing the event hander was attached to,
|
||||
// rather than the thing we clicked on.
|
||||
var asset_id = e.delegateTarget.dataset.assetId;
|
||||
asset_open(asset_id);
|
||||
});
|
||||
|
||||
$("a.task-link[data-task-id]").click(function(e) {
|
||||
e.preventDefault();
|
||||
var task_id = e.delegateTarget.dataset.taskId;
|
||||
var project_url = e.delegateTarget.dataset.projectUrl; // fine if undefined
|
||||
task_open(task_id, project_url);
|
||||
});
|
||||
});
|
||||
|
||||
var save_on_ctrl_enter = ['shot', 'asset', 'task'];
|
||||
$(document).on('keyup', function(e){
|
||||
if ($.inArray(save_on_ctrl_enter, ProjectUtils.context())) {
|
||||
|
@@ -53,6 +53,9 @@ nav.sidebar
|
||||
margin-bottom: 10px
|
||||
width: 100%
|
||||
|
||||
option
|
||||
@include status-color-property(color, '', 'dark')
|
||||
|
||||
button
|
||||
&#item-save
|
||||
+button($color-success, 3px)
|
||||
@@ -69,13 +72,15 @@ nav.sidebar
|
||||
.table-cell
|
||||
&.item-status
|
||||
width: 5px
|
||||
min-width: 5px
|
||||
height: 100%
|
||||
border-bottom: none
|
||||
.table-head
|
||||
&.is-fixed
|
||||
position: fixed
|
||||
top: 42px
|
||||
top: 84px
|
||||
z-index: 1
|
||||
pointer-events: none
|
||||
background-color: white
|
||||
|
||||
.table-cell
|
||||
@@ -240,6 +245,9 @@ nav.sidebar
|
||||
color: $color-text-dark-secondary
|
||||
margin-right: 10px
|
||||
|
||||
span.item-extra
|
||||
white-space: nowrap
|
||||
padding-right: 10px
|
||||
|
||||
/* Debug styles, such as status color legend on help */
|
||||
.debug-info
|
||||
@@ -279,67 +287,6 @@ nav.sidebar
|
||||
@include status-color-property(background-color, '', 'dark')
|
||||
|
||||
|
||||
/* General style for activities in all places */
|
||||
.d-activity
|
||||
font-size: .9em
|
||||
|
||||
$activity-highlight-color: #00cc9f
|
||||
ul
|
||||
cursor: default
|
||||
padding: 5px
|
||||
color: $color-text-dark-primary
|
||||
list-style: none
|
||||
|
||||
li
|
||||
padding: 0 10px 7px 10px
|
||||
position: relative
|
||||
|
||||
span.date
|
||||
color: darken($activity-highlight-color, 5%)
|
||||
|
||||
/* Left Dot */
|
||||
&:after
|
||||
content: ''
|
||||
display: block
|
||||
position: absolute
|
||||
top: 6px
|
||||
left: -3px
|
||||
width: 5px
|
||||
height: 5px
|
||||
border-radius: 50%
|
||||
background-color: $color-background-light
|
||||
border: thin solid $activity-highlight-color
|
||||
transition: all 250ms ease-in-out
|
||||
|
||||
/* Left Line */
|
||||
&:before
|
||||
content: ''
|
||||
display: block
|
||||
position: absolute
|
||||
top: 10px
|
||||
left: -1px
|
||||
width: 1px
|
||||
height: 100%
|
||||
background-color: $activity-highlight-color
|
||||
transition: all 250ms ease-in-out
|
||||
|
||||
&:last-child
|
||||
&:before
|
||||
background-color: transparent
|
||||
|
||||
span.actor
|
||||
padding: 0 5px
|
||||
color: $color-text-dark
|
||||
|
||||
img.actor-avatar
|
||||
width: 16px
|
||||
height: 16px
|
||||
border-radius: 50%
|
||||
margin-right: 5px
|
||||
position: relative
|
||||
top: -2px
|
||||
|
||||
|
||||
.attract-box
|
||||
.item-id
|
||||
padding: 8px
|
||||
@@ -354,6 +301,13 @@ nav.sidebar
|
||||
&:hover
|
||||
color: $color-text-dark-primary
|
||||
border-color: $color-text-dark-primary
|
||||
|
||||
textarea
|
||||
overflow-y: hidden // there is js in place to make them grow as needed instead
|
||||
resize: none
|
||||
|
||||
.edited
|
||||
background-color: $color-status-updated
|
||||
|
||||
|
||||
#item-details
|
||||
@@ -380,3 +334,15 @@ nav.sidebar
|
||||
|
||||
#comments-container
|
||||
margin-top: 0
|
||||
|
||||
.attract-app
|
||||
display: flex
|
||||
width: 100%
|
||||
|
||||
.attract-detailed-view
|
||||
overflow: scroll
|
||||
|
||||
.col_header
|
||||
position: sticky
|
||||
top: 0px
|
||||
z-index: $zindex-sticky
|
||||
|
@@ -1,615 +1 @@
|
||||
/* Collection of mixins that can be plugged everywhere */
|
||||
|
||||
=clearfix
|
||||
clear: both
|
||||
&:after
|
||||
// Basically same as .clearfix from bootstrap
|
||||
clear: both
|
||||
display: block
|
||||
content: ' '
|
||||
|
||||
|
||||
@mixin button($mixin-color, $roundness, $filled: false)
|
||||
font-family: $font-body
|
||||
text-transform: uppercase
|
||||
opacity: .9
|
||||
padding:
|
||||
left: 20px
|
||||
right: 20px
|
||||
border-radius: $roundness
|
||||
|
||||
@if $filled
|
||||
background: linear-gradient(lighten($mixin-color, 2%), $mixin-color)
|
||||
color: white
|
||||
border: thin solid darken($mixin-color, 5%)
|
||||
text-shadow: 1px 1px 0 rgba(black, .15)
|
||||
@else
|
||||
background-color: transparent
|
||||
color: $mixin-color
|
||||
border: thin solid $mixin-color
|
||||
text-shadow: none
|
||||
|
||||
transition: color 350ms ease-out, border 150ms ease-in-out, opacity 150ms ease-in-out, background-color 150ms ease-in-out
|
||||
|
||||
&:hover
|
||||
opacity: 1
|
||||
cursor: pointer
|
||||
text-decoration: none
|
||||
|
||||
@if $filled
|
||||
background: linear-gradient(lighten($mixin-color, 5%), lighten($mixin-color, 5%))
|
||||
color: white
|
||||
border-color: lighten($mixin-color, 5%)
|
||||
@else
|
||||
background-color: rgba($mixin-color, .1)
|
||||
color: $mixin-color
|
||||
border-color: $mixin-color
|
||||
|
||||
&:active, &:focus
|
||||
outline: none
|
||||
border-color: $mixin-color
|
||||
background-color: $mixin-color
|
||||
color: white
|
||||
|
||||
i
|
||||
margin-right: 10px
|
||||
small
|
||||
font-size: .6em
|
||||
|
||||
&:disabled
|
||||
cursor: not-allowed
|
||||
color: $color-text-dark-secondary
|
||||
border-color: $color-text-dark-hint
|
||||
|
||||
&:hover
|
||||
@if $filled
|
||||
background: rgba($color-text-dark-hint, .2)
|
||||
@else
|
||||
background-color: rgba($color-text-dark-hint, .1)
|
||||
|
||||
@if $filled
|
||||
background: rgba($color-text-dark-hint, .1)
|
||||
text-shadow: none
|
||||
|
||||
|
||||
@mixin overlay($from-color, $from-percentage, $to-color, $to-percentage)
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
right: 0
|
||||
bottom: 0
|
||||
background: linear-gradient(to bottom, $from-color $from-percentage, $to-color $to-percentage)
|
||||
|
||||
|
||||
@mixin stripes($color-light, $color-dark, $deg, $size)
|
||||
background-size: $size $size
|
||||
background-image: linear-gradient($deg, $color-light 25%, $color-dark 25%, $color-dark 50%, $color-light 50%, $color-light 75%, $color-dark 75%, $color-dark)
|
||||
|
||||
=stripes-animate
|
||||
animation:
|
||||
name: background-slide
|
||||
duration: 1s
|
||||
delay: 0s
|
||||
iteration-count: infinite
|
||||
timing-function: linear
|
||||
|
||||
=container-box
|
||||
position: relative
|
||||
background-color: white
|
||||
border-radius: 3px
|
||||
box-shadow: rgba(0, 0, 0, 0.298039) 0px 1px 4px -1px
|
||||
|
||||
=text-overflow-ellipsis
|
||||
overflow: hidden
|
||||
white-space: nowrap
|
||||
text-overflow: ellipsis
|
||||
|
||||
=position-center-translate
|
||||
position: absolute
|
||||
top: 50%
|
||||
left: 50%
|
||||
transform: translate(-50%, -50%)
|
||||
|
||||
=input-generic
|
||||
color: $color-text-dark
|
||||
box-shadow: none
|
||||
font-family: $font-body
|
||||
border-radius: 3px
|
||||
border-color: $color-background-dark
|
||||
background-color: $color-background-light
|
||||
|
||||
&:focus
|
||||
border-color: $color-info
|
||||
box-shadow: none
|
||||
|
||||
=label-generic
|
||||
color: $color-text-dark
|
||||
font-family: $font-body
|
||||
font-weight: 300
|
||||
|
||||
@mixin badge($mixin-color, $roundness)
|
||||
padding:
|
||||
left: 10px
|
||||
right: 10px
|
||||
|
||||
text-transform: uppercase
|
||||
|
||||
color: $mixin-color
|
||||
border: 1px solid $mixin-color
|
||||
border-radius: $roundness
|
||||
|
||||
i
|
||||
margin-right: 10px
|
||||
|
||||
/* Smallest, like phones on portrait.
|
||||
** Menu is collapsed, columns stack, no brand */
|
||||
=media-xs
|
||||
@media (max-width: #{$screen-tablet - 1px})
|
||||
@content
|
||||
|
||||
/* Small but wide: phablets, iPads
|
||||
** Menu is collapsed, columns stack, no brand */
|
||||
=media-sm
|
||||
@media (min-width: #{$screen-tablet}) and (max-width: #{$screen-desktop - 1px})
|
||||
@content
|
||||
|
||||
/* Tablets portrait.
|
||||
** Menu is expanded, but columns stack, brand is shown */
|
||||
=media-md
|
||||
@media (min-width: #{$screen-desktop})
|
||||
@content
|
||||
|
||||
=media-lg
|
||||
@media (min-width: #{$screen-lg-desktop})
|
||||
@content
|
||||
|
||||
=media-print
|
||||
@media print
|
||||
@content
|
||||
|
||||
=spin
|
||||
animation:
|
||||
name: spin-once
|
||||
duration: 1s
|
||||
delay: 0s
|
||||
fill-mode: forwards
|
||||
iteration-count: infinite
|
||||
timing-function: linear
|
||||
|
||||
=spin-once
|
||||
+spin
|
||||
animation:
|
||||
iteration-count: 1
|
||||
|
||||
=pulse
|
||||
animation:
|
||||
name: pulse
|
||||
duration: 1s
|
||||
delay: 0s
|
||||
fill-mode: forwards
|
||||
iteration-count: infinite
|
||||
|
||||
=pulse-75
|
||||
animation:
|
||||
name: pulse-75
|
||||
duration: 1s
|
||||
delay: 0
|
||||
fill-mode: forwards
|
||||
iteration-count: infinite
|
||||
|
||||
=animation-wiggle
|
||||
animation:
|
||||
name: wiggle
|
||||
duration: 1s
|
||||
delay: 0s
|
||||
fill-mode: forwards
|
||||
iteration-count: infinite
|
||||
timing-function: linear
|
||||
|
||||
.spin
|
||||
+spin
|
||||
&:before, &:after
|
||||
+spin
|
||||
|
||||
@keyframes spin-once
|
||||
from
|
||||
transform: rotate(0deg)
|
||||
to
|
||||
transform: rotate(360deg)
|
||||
|
||||
@keyframes wiggle
|
||||
0
|
||||
transform: rotate(0deg)
|
||||
25%
|
||||
transform: rotate(25deg)
|
||||
75%
|
||||
transform: rotate(-25deg)
|
||||
100%
|
||||
transform: rotate(0deg)
|
||||
|
||||
@keyframes pulse
|
||||
0
|
||||
opacity: 1
|
||||
50%
|
||||
opacity: 0
|
||||
100%
|
||||
opacity: 1
|
||||
|
||||
@keyframes pulse-75
|
||||
0
|
||||
opacity: 1
|
||||
50%
|
||||
opacity: .8
|
||||
100%
|
||||
opacity: 1
|
||||
|
||||
@keyframes background-fill-left-right
|
||||
from
|
||||
background-position: right bottom
|
||||
to
|
||||
background-position: left bottom
|
||||
|
||||
@keyframes grow-bounce-in
|
||||
0
|
||||
transform: scale(0.8)
|
||||
opacity: 0
|
||||
50%
|
||||
transform: scale(1.05)
|
||||
opacity: 1
|
||||
85%
|
||||
transform: scale(1.0)
|
||||
90%
|
||||
transform: scale(0.99)
|
||||
100%
|
||||
transform: scale(1.0)
|
||||
|
||||
@keyframes grow-bounce-out
|
||||
0
|
||||
transform: scale(1.0)
|
||||
opacity: 1
|
||||
100%
|
||||
transform: scale(0.9)
|
||||
opacity: 0
|
||||
|
||||
@keyframes background-slide
|
||||
from
|
||||
background-position: 0 0
|
||||
to
|
||||
background-position: 50px 50px
|
||||
|
||||
@keyframes grow-bounce
|
||||
0
|
||||
transform: scale(1.0)
|
||||
opacity: 1
|
||||
50%
|
||||
transform: scale(1.01)
|
||||
opacity: .9
|
||||
85%
|
||||
transform: scale(1.0)
|
||||
90%
|
||||
transform: scale(0.99)
|
||||
opacity: 1
|
||||
100%
|
||||
transform: scale(1.0)
|
||||
|
||||
@keyframes grow-bounce-heartbeat
|
||||
0
|
||||
transform: scale(1.0)
|
||||
85%
|
||||
transform: scale(1.0)
|
||||
90%
|
||||
transform: scale(1.15)
|
||||
94%
|
||||
transform: scale(0.9)
|
||||
96%
|
||||
transform: scale(1.05)
|
||||
100%
|
||||
transform: scale(1.0)
|
||||
|
||||
=list-bullets
|
||||
ul
|
||||
padding-left: 20px
|
||||
list-style: none
|
||||
|
||||
li:before
|
||||
content: '·'
|
||||
font-weight: 400
|
||||
position: relative
|
||||
left: -10px
|
||||
|
||||
|
||||
=node-details-description
|
||||
padding: 15px 0 25px 0
|
||||
color: darken($color-text-dark, 5%)
|
||||
font:
|
||||
family: $font-body
|
||||
weight: 300
|
||||
size: 1.2em
|
||||
|
||||
word-break: break-word
|
||||
clear: both
|
||||
+clearfix
|
||||
|
||||
+media-xs
|
||||
font-size: 1.1em
|
||||
|
||||
strong, b
|
||||
font-weight: 400
|
||||
|
||||
a:not([class])
|
||||
color: $color-text-dark-primary
|
||||
text-decoration: underline
|
||||
|
||||
&:hover
|
||||
color: $color-primary
|
||||
|
||||
p
|
||||
padding:
|
||||
left: 20px
|
||||
right: 20px
|
||||
margin-bottom: 20px
|
||||
line-height: 1.5em
|
||||
word-wrap: break-word
|
||||
|
||||
h1, h2, h3, h4, h5, h6
|
||||
padding:
|
||||
top: 20px
|
||||
left: 20px
|
||||
right: 20px
|
||||
|
||||
blockquote
|
||||
background-color: lighten($color-background, 5%)
|
||||
text-shadow: 1px 1px 0 rgba(white, .2)
|
||||
margin:
|
||||
left: 20px
|
||||
right: 20px
|
||||
bottom: 30px
|
||||
font-size: 1em
|
||||
|
||||
p
|
||||
padding: 0
|
||||
margin: 0
|
||||
ul li blockquote
|
||||
margin:
|
||||
left: 0
|
||||
top: 15px
|
||||
|
||||
img,
|
||||
p img,
|
||||
ul li img
|
||||
max-width: 100%
|
||||
padding:
|
||||
top: 25px
|
||||
// bottom: 10px
|
||||
bottom: 25px
|
||||
|
||||
h2
|
||||
margin-bottom: 15px
|
||||
|
||||
+media-xs
|
||||
font-size: 1.5em
|
||||
|
||||
/* e.g. YouTube embed */
|
||||
iframe
|
||||
margin-top: 20px
|
||||
width: 100%
|
||||
max-width: 100%
|
||||
height: auto
|
||||
min-height: 354px
|
||||
|
||||
+media-sm
|
||||
iframe
|
||||
min-height: 314px
|
||||
+media-xs
|
||||
iframe
|
||||
min-height: 314px
|
||||
|
||||
iframe[src^="https://w.soundcloud"]
|
||||
min-height: auto
|
||||
|
||||
+list-bullets
|
||||
|
||||
ul
|
||||
padding-left: 40px
|
||||
margin-bottom: 25px
|
||||
|
||||
li
|
||||
margin-bottom: 7px
|
||||
img
|
||||
display: block
|
||||
padding:
|
||||
top: 25px
|
||||
bottom: 10px
|
||||
|
||||
ul, ul li ul
|
||||
margin-top: 15px
|
||||
padding-left: 20px
|
||||
|
||||
code, kbd, pre, samp
|
||||
font-size: 1.3rem
|
||||
|
||||
pre
|
||||
background-color: lighten($color-background, 5%)
|
||||
border-color: $color-background
|
||||
border-radius: 3px
|
||||
color: $color-text
|
||||
|
||||
/* when <pre> is outside <p> */
|
||||
margin:
|
||||
left: 20px
|
||||
right: 20px
|
||||
pre+p
|
||||
margin-top: 30px
|
||||
|
||||
p+pre
|
||||
/* a <pre> right after a <p> usually are related, remove some spacing */
|
||||
margin-top: -10px
|
||||
|
||||
p
|
||||
pre
|
||||
/* We already have spacing on the sides inside <p> */
|
||||
margin:
|
||||
left: 0
|
||||
right: 0
|
||||
|
||||
|
||||
=markdown-preview-container
|
||||
border:
|
||||
top: 1px solid $color-background
|
||||
bottom: 1px solid $color-background
|
||||
position: relative
|
||||
margin: 40px auto 25px auto
|
||||
padding: 10px 10px 25px 10px
|
||||
color: $color-text-dark-primary
|
||||
cursor: default
|
||||
transition: all 150ms ease-in-out
|
||||
|
||||
+node-details-description
|
||||
|
||||
// Funny, normalize.css doesn't normalize when it's outside
|
||||
h1
|
||||
font-size: 2.8em
|
||||
h2
|
||||
margin-bottom: 15px
|
||||
|
||||
|
||||
=ribbon
|
||||
background-color: $color-success
|
||||
cursor: default
|
||||
overflow: hidden
|
||||
white-space: nowrap
|
||||
position: absolute
|
||||
right: -40px
|
||||
top: 10px
|
||||
-webkit-transform: rotate(45deg)
|
||||
-moz-transform: rotate(45deg)
|
||||
-ms-transform: rotate(45deg)
|
||||
-o-transform: rotate(45deg)
|
||||
transform: rotate(45deg)
|
||||
|
||||
span
|
||||
border: thin dashed rgba(white, .5)
|
||||
color: white
|
||||
display: block
|
||||
font-size: 70%
|
||||
margin: 1px 0
|
||||
padding: 3px 50px
|
||||
text:
|
||||
align: center
|
||||
transform: uppercase
|
||||
|
||||
@mixin text-background($text-color, $background-color, $roundness, $padding)
|
||||
border-radius: $roundness
|
||||
padding: $padding
|
||||
background-color: rgba($background-color, .9)
|
||||
box-shadow: 0.5em 0 0 rgba($background-color, .9),-0.5em 0 0 rgba($background-color, .9)
|
||||
box-decoration-break: clone
|
||||
color: $text-color
|
||||
|
||||
=list-meta
|
||||
margin: 0
|
||||
padding: 0
|
||||
list-style: none
|
||||
color: $color-text-dark-primary
|
||||
|
||||
li
|
||||
display: inline-block
|
||||
padding-left: 15px
|
||||
position: relative
|
||||
|
||||
&:before
|
||||
content: '·'
|
||||
position: relative
|
||||
top: 1px
|
||||
left: -7px
|
||||
color: $color-text-dark-secondary
|
||||
|
||||
&:first-child
|
||||
padding-left: 0
|
||||
|
||||
&:before
|
||||
content: ''
|
||||
a
|
||||
color: $color-text-dark-secondary
|
||||
&:hover
|
||||
color: $color-primary
|
||||
|
||||
/* Bootstrap's img-responsive class */
|
||||
=img-responsive
|
||||
display: block
|
||||
max-width: 100%
|
||||
height: auto
|
||||
|
||||
/* Set the color for a specified property
|
||||
* 1: $property: e.g. background-color
|
||||
* 2: $where: ':before', ' .class-name', etc.
|
||||
* 3: $variation: 'light', 'dark', or empty
|
||||
* e.g. @include status-color-property(background-color, ':before', 'light')
|
||||
*/
|
||||
@mixin status-color-property($property, $where: false, $variation: false)
|
||||
|
||||
@if not ($where)
|
||||
$where: ''
|
||||
|
||||
&.status
|
||||
&-invalid#{$where}
|
||||
@if ($variation == 'light')
|
||||
#{$property}: $color-status-invalid-light
|
||||
@else if ($variation == 'dark')
|
||||
#{$property}: $color-status-invalid-dark
|
||||
@else
|
||||
#{$property}: $color-status-invalid
|
||||
&-todo#{$where}
|
||||
@if ($variation == 'light')
|
||||
#{$property}: $color-status-todo-light
|
||||
@else if ($variation == 'dark')
|
||||
#{$property}: $color-status-todo-dark
|
||||
@else
|
||||
#{$property}: $color-status-todo
|
||||
&-in_progress#{$where}
|
||||
@if ($variation == 'light')
|
||||
#{$property}: $color-status-in_progress-light
|
||||
@else if ($variation == 'dark')
|
||||
#{$property}: $color-status-in_progress-dark
|
||||
@else
|
||||
#{$property}: $color-status-in_progress
|
||||
&-on_hold#{$where}
|
||||
@if ($variation == 'light')
|
||||
#{$property}: $color-status-on_hold-light
|
||||
@else if ($variation == 'dark')
|
||||
#{$property}: $color-status-on_hold-dark
|
||||
@else
|
||||
#{$property}: $color-status-on_hold
|
||||
&-approved#{$where}
|
||||
@if ($variation == 'light')
|
||||
#{$property}: $color-status-approved-light
|
||||
@else if ($variation == 'dark')
|
||||
#{$property}: $color-status-approved-dark
|
||||
@else
|
||||
#{$property}: $color-status-approved
|
||||
&-cbb#{$where}
|
||||
@if ($variation == 'light')
|
||||
#{$property}: $color-status-cbb-light
|
||||
@else if ($variation == 'dark')
|
||||
#{$property}: $color-status-cbb-dark
|
||||
@else
|
||||
#{$property}: $color-status-cbb
|
||||
&-final#{$where}
|
||||
@if ($variation == 'light')
|
||||
#{$property}: $color-status-final-light
|
||||
@else if ($variation == 'dark')
|
||||
#{$property}: $color-status-final-dark
|
||||
@else
|
||||
#{$property}: $color-status-final
|
||||
&-review#{$where}
|
||||
@if ($variation == 'light')
|
||||
#{$property}: $color-status-review-light
|
||||
@else if ($variation == 'dark')
|
||||
#{$property}: $color-status-review-dark
|
||||
@else
|
||||
#{$property}: $color-status-review
|
||||
|
||||
=sidebar-button-active
|
||||
background-color: $color-background-nav-light
|
||||
box-shadow: inset 2px 0 0 $color-primary
|
||||
color: white
|
||||
|
@@ -1,95 +1,3 @@
|
||||
$color-background: #eaebec
|
||||
$color-background-light: lighten($color-background, 5%)
|
||||
$color-background-dark: darken($color-background, 5%)
|
||||
$color-background-nav: hsl(hue($color-background), 20%, 25%)
|
||||
|
||||
$color-background-nav-light: hsl(hue($color-background), 20%, 35%)
|
||||
$color-background-nav-dark: hsl(hue($color-background), 20%, 15%)
|
||||
|
||||
$color-background-active: #dff5f6 // background colour for active items.
|
||||
|
||||
$font-body: 'Roboto'
|
||||
$font-headings: 'Lato'
|
||||
$font-size: 14px
|
||||
|
||||
$color-text: #4d4e53
|
||||
|
||||
$color-text-dark: $color-text
|
||||
$color-text-dark-primary: #646469 // rgba($color-text, .87)
|
||||
$color-text-dark-secondary: #9E9FA2 // rgba($color-text, .54)
|
||||
$color-text-dark-hint: #BBBBBD // rgba($color-text, .38)
|
||||
|
||||
$color-text-light: white
|
||||
$color-text-light-primary: rgba($color-text-light, .87)
|
||||
$color-text-light-secondary: rgba($color-text-light, .54)
|
||||
$color-text-light-hint: rgba($color-text-light, .38)
|
||||
|
||||
$color-primary: #68B3C8
|
||||
$color-primary-light: hsl(hue($color-primary), 30%, 90%)
|
||||
$color-primary-dark: hsl(hue($color-primary), 80%, 30%)
|
||||
$color-primary-accent: hsl(hue($color-primary), 100%, 50%)
|
||||
|
||||
$color-secondary: #f42942
|
||||
$color-secondary-light: hsl(hue($color-secondary), 30%, 90%)
|
||||
$color-secondary-dark: hsl(hue($color-secondary), 80%, 40%)
|
||||
$color-secondary-accent: hsl(hue($color-secondary), 100%, 50%)
|
||||
|
||||
$color-warning: #F3BB45 !default
|
||||
$color-info: #68B3C8 !default
|
||||
$color-success: #27AE60 !default
|
||||
$color-danger: #EB5E28 !default
|
||||
|
||||
/* Borrowed from dillo.space :) */
|
||||
$color_upvote: #ff8b60
|
||||
$color_downvote: #74a4ff
|
||||
|
||||
/* Label Status */
|
||||
$color-status-invalid: #999
|
||||
$color-status-invalid-light: lighten($color-status-invalid, 10%)
|
||||
$color-status-invalid-dark: darken($color-status-invalid, 10%)
|
||||
$color-status-todo: #ff8080
|
||||
$color-status-todo-light: hsl(hue($color-status-todo), 100%, 85%)
|
||||
$color-status-todo-dark: hsl(hue($color-status-todo), 100%, 65%)
|
||||
$color-status-on_hold: #cb9e15
|
||||
$color-status-on_hold-light: hsl(hue($color-status-on_hold), 50%, 70%)
|
||||
$color-status-on_hold-dark: hsl(hue($color-status-on_hold), 60%, 40%)
|
||||
$color-status-in_progress: #ffbe00
|
||||
$color-status-in_progress-light: hsl(hue($color-status-in_progress), 100%, 55%)
|
||||
$color-status-in_progress-dark: hsl(hue($color-status-in_progress), 100%, 45%)
|
||||
$color-status-review: #00ceff
|
||||
$color-status-review-light: hsl(hue($color-status-review), 100%, 75%)
|
||||
$color-status-review-dark: hsl(hue($color-status-review), 100%, 40%)
|
||||
$color-status-approved: #00cc9f
|
||||
$color-status-approved-light: hsl(hue($color-status-approved), 100%, 70%)
|
||||
$color-status-approved-dark: hsl(hue($color-status-approved), 100%, 35%)
|
||||
$color-status-cbb: #acbf92
|
||||
$color-status-cbb-light: hsl(hue($color-status-cbb), 40%, 75%)
|
||||
$color-status-cbb-dark: hsl(hue($color-status-cbb), 15%, 50%)
|
||||
$color-status-final: #b0ea10
|
||||
$color-status-final-light: hsl(hue($color-status-final), 100%, 70%)
|
||||
$color-status-final-dark: hsl(hue($color-status-final), 100%, 30%)
|
||||
|
||||
$color-status-active: #E6F3FD
|
||||
$color-status-updated: #e7f5d3
|
||||
|
||||
/* Mobile Stuff */
|
||||
$screen-xs: 480px !default
|
||||
$screen-xs-min: $screen-xs
|
||||
$screen-phone: $screen-xs-min
|
||||
$screen-sm: 768px !default
|
||||
$screen-sm-min: $screen-sm
|
||||
$screen-tablet: $screen-sm-min
|
||||
$screen-md: 1100px !default
|
||||
$screen-md-min: $screen-md
|
||||
$screen-desktop: $screen-md-min
|
||||
$screen-lg: 1270px !default
|
||||
$screen-lg-min: $screen-lg
|
||||
$screen-lg-desktop: $screen-lg-min
|
||||
$screen-xs-max: $screen-sm-min - 1
|
||||
$screen-sm-max: $screen-md-min - 1
|
||||
$screen-md-max: $screen-lg-min - 1
|
||||
|
||||
|
||||
$sidebar-width: 50px
|
||||
/* Attract specific configuration settings */
|
||||
|
||||
$items-list-thumbnail-width: 100px
|
||||
|
@@ -88,16 +88,21 @@
|
||||
/* Dashboard specific styles */
|
||||
.dashboard
|
||||
.d-stats
|
||||
padding: 0 10px
|
||||
|
||||
.d-stats-card
|
||||
+container-box
|
||||
padding: 10px
|
||||
|
||||
.progress
|
||||
margin-bottom: 0
|
||||
h4
|
||||
margin-top: 0
|
||||
margin-bottom: 10px
|
||||
height: 4px
|
||||
|
||||
.d-stats-card-legend
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
font-size: .95em
|
||||
|
||||
.d-stats-card-item
|
||||
@include status-color-property(color, '', 'dark')
|
||||
|
||||
.d-activity
|
||||
margin: 0 20px 15px
|
||||
|
@@ -9,6 +9,8 @@
|
||||
&.with-status
|
||||
border-top: thick solid $color-background-dark
|
||||
@include status-color-property(border-top-color, '', 'dark')
|
||||
@include status-color-property(color, ' select', 'dark')
|
||||
@include status-color-property(border-bottom-color, ' select', 'dark')
|
||||
|
||||
.item-name
|
||||
font-size: 1.6em
|
||||
|
62
src/styles/components/_attract_table.sass
Normal file
62
src/styles/components/_attract_table.sass
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
|
||||
.pillar-table-container.attract-tasks-table
|
||||
.pillar-table-row
|
||||
min-height: 1.4em
|
||||
|
||||
.pillar-table-container.attract-shots-table
|
||||
.pillar-table-row
|
||||
min-height: $thumbnail-max-height
|
||||
|
||||
&.shot-not-in-edit
|
||||
+stripes(transparent, rgba($color-warning, .2), -45deg, 4em)
|
||||
|
||||
.pillar-table-container.attract-assets-table
|
||||
.pillar-table-row
|
||||
min-height: 2.6em
|
||||
|
||||
.pillar-cell
|
||||
&.thumbnail
|
||||
padding-left: 0px
|
||||
|
||||
&.row-object
|
||||
flex-basis: 3em
|
||||
|
||||
&.attract-status
|
||||
flex: 0
|
||||
flex-basis: 1em
|
||||
min-width: 1em
|
||||
|
||||
&.task-type
|
||||
text-transform: capitalize
|
||||
|
||||
.add-task-link
|
||||
opacity: 0
|
||||
cursor: pointer
|
||||
vertical-align: middle
|
||||
color: $color-primary
|
||||
border: none
|
||||
background: none
|
||||
width: max-content
|
||||
padding: 0
|
||||
|
||||
&:hover
|
||||
text-decoration-line: underline
|
||||
|
||||
&:hover
|
||||
.add-task-link
|
||||
opacity: 1
|
||||
.tasks
|
||||
display: flex
|
||||
.task
|
||||
@include status-color-property(background-color, '', '')
|
||||
width: 1em
|
||||
height: 1em
|
||||
border-radius: 1em
|
||||
|
||||
&:hover
|
||||
box-shadow: inset 0px 0px 5px $color-background-active-dark
|
||||
|
||||
&.active
|
||||
border: 2px solid white
|
||||
box-shadow: 0 0 0 1px rgba($color-primary, .2), 1px 1px 0 rgba(black, .2)
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user