Merge pull request #1297 from pierotofy/tags

Better Project and Task Management
pull/1303/head
Piero Toffanin 2023-03-14 14:04:51 -04:00 zatwierdzone przez GitHub
commit 78c62a6131
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
35 zmienionych plików z 1312 dodań i 78 usunięć

Wyświetl plik

@ -15,7 +15,7 @@ RUN printf "deb http://old-releases.ubuntu.com/ubuntu/ hirsute main restricted\n
# Install Node.js
RUN apt-get -qq update && apt-get -qq install -y --no-install-recommends wget curl && \
wget --no-check-certificate https://deb.nodesource.com/setup_12.x -O /tmp/node.sh && bash /tmp/node.sh && \
wget --no-check-certificate https://deb.nodesource.com/setup_14.x -O /tmp/node.sh && bash /tmp/node.sh && \
apt-get -qq update && apt-get -qq install -y nodejs && \
# Install Python3, GDAL, PDAL, nginx, letsencrypt, psql
apt-get -qq update && apt-get -qq install -y --no-install-recommends python3 python3-pip python3-setuptools python3-wheel git g++ python3-dev python2.7-dev libpq-dev binutils libproj-dev gdal-bin pdal libgdal-dev python3-gdal nginx certbot grass-core gettext-base cron postgresql-client-13 gettext tzdata && \

Wyświetl plik

@ -1,13 +1,19 @@
import re
from guardian.shortcuts import get_perms, get_users_with_perms, assign_perm, remove_perm
from rest_framework import serializers, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework import status
from django_filters import rest_framework as filters
from django.db import transaction
from django.contrib.auth.models import User
from django.contrib.postgres.search import SearchQuery, SearchVector
from django.contrib.postgres.aggregates import StringAgg
from django.db.models import Q
from app import models
from .tasks import TaskIDsSerializer
from .tags import TagsField, parse_tags_input
from .common import get_and_check_project
from django.utils.translation import gettext as _
@ -21,6 +27,7 @@ class ProjectSerializer(serializers.ModelSerializer):
)
created_at = serializers.ReadOnlyField()
permissions = serializers.SerializerMethodField()
tags = TagsField(required=False)
def get_permissions(self, obj):
if 'request' in self.context:
@ -34,6 +41,49 @@ class ProjectSerializer(serializers.ModelSerializer):
exclude = ('deleting', )
class ProjectFilter(filters.FilterSet):
search = filters.CharFilter(method='filter_search')
def filter_search(self, qs, name, value):
value = value.replace(":", "#")
tag_pattern = re.compile("#[^\s]+")
tags = set(re.findall(tag_pattern, value))
task_tags = set([t for t in tags if t.startswith("##")])
project_tags = tags - task_tags
task_tags = [t.replace("##", "") for t in task_tags]
project_tags = [t.replace("#", "") for t in project_tags]
names = re.sub("\s+", " ", re.sub(tag_pattern, "", value)).strip()
if len(names) > 0:
project_name_vec = SearchVector("name")
task_name_vec = SearchVector(StringAgg("task__name", delimiter=' '))
name_query = SearchQuery(names, search_type="plain")
qs = qs.annotate(n_search=project_name_vec + task_name_vec).filter(n_search=name_query)
if len(task_tags) > 0:
task_tags_vec = SearchVector("task__tags")
tags_query = SearchQuery(task_tags[0])
for t in task_tags[1:]:
tags_query = tags_query & SearchQuery(t)
qs = qs.annotate(tt_search=task_tags_vec).filter(tt_search=tags_query)
if len(project_tags) > 0:
project_tags_vec = SearchVector("tags")
tags_query = SearchQuery(project_tags[0])
for t in project_tags[1:]:
tags_query = tags_query & SearchQuery(t)
qs = qs.annotate(pt_search=project_tags_vec).filter(pt_search=tags_query)
return qs.distinct()
class Meta:
model = models.Project
fields = ['search', 'id', 'name', 'description', 'created_at']
class ProjectViewSet(viewsets.ModelViewSet):
"""
Project get/add/delete/update
@ -45,6 +95,7 @@ class ProjectViewSet(viewsets.ModelViewSet):
filter_fields = ('id', 'name', 'description', 'created_at')
serializer_class = ProjectSerializer
queryset = models.Project.objects.prefetch_related('task_set').filter(deleting=False).order_by('-created_at')
filterset_class = ProjectFilter
ordering_fields = '__all__'
# Disable pagination when not requesting any page
@ -52,7 +103,7 @@ class ProjectViewSet(viewsets.ModelViewSet):
if self.paginator and self.request.query_params.get(self.paginator.page_query_param, None) is None:
return None
return super().paginate_queryset(queryset)
@action(detail=True, methods=['post'])
def duplicate(self, request, pk=None):
"""
@ -89,6 +140,7 @@ class ProjectViewSet(viewsets.ModelViewSet):
with transaction.atomic():
project.name = request.data.get('name', '')
project.description = request.data.get('description', '')
project.tags = TagsField().to_internal_value(parse_tags_input(request.data.get('tags', [])))
project.save()
form_perms = request.data.get('permissions')

27
app/api/tags.py 100644
Wyświetl plik

@ -0,0 +1,27 @@
from rest_framework import serializers
import json
class TagsField(serializers.JSONField):
def to_representation(self, tags):
return [t for t in tags.split(" ") if t != ""]
def to_internal_value(self, tags):
return " ".join([t.strip() for t in tags])
def parse_tags_input(tags):
if tags is None:
return []
if isinstance(tags, str):
try:
r = json.loads(tags)
if isinstance(r, list):
return r
else:
raise Exception("Invalid tags string")
except:
return []
elif isinstance(tags, list):
return list(map(str, tags))
else:
return []

Wyświetl plik

@ -20,6 +20,7 @@ from nodeodm import status_codes
from nodeodm.models import ProcessingNode
from worker import tasks as worker_tasks
from .common import get_and_check_project, get_asset_download_filename
from .tags import TagsField
from app.security import path_traversal_check
from django.utils.translation import gettext_lazy as _
@ -41,6 +42,7 @@ class TaskSerializer(serializers.ModelSerializer):
processing_node_name = serializers.SerializerMethodField()
can_rerun_from = serializers.SerializerMethodField()
statistics = serializers.SerializerMethodField()
tags = TagsField(required=False)
def get_processing_node_name(self, obj):
if obj.processing_node is not None:

Wyświetl plik

@ -0,0 +1,23 @@
# Generated by Django 2.2.27 on 2023-03-07 15:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('app', '0032_task_epsg'),
]
operations = [
migrations.AddField(
model_name='project',
name='tags',
field=models.TextField(blank=True, db_index=True, default='', help_text='Project tags', verbose_name='Tags'),
),
migrations.AddField(
model_name='task',
name='tags',
field=models.TextField(blank=True, db_index=True, default='', help_text='Task tags', verbose_name='Tags'),
),
]

Wyświetl plik

@ -25,7 +25,8 @@ class Project(models.Model):
description = models.TextField(default="", blank=True, help_text=_("More in-depth description of the project"), verbose_name=_("Description"))
created_at = models.DateTimeField(default=timezone.now, help_text=_("Creation date"), verbose_name=_("Created at"))
deleting = models.BooleanField(db_index=True, default=False, help_text=_("Whether this project has been marked for deletion. Projects that have running tasks need to wait for tasks to be properly cleaned up before they can be deleted."), verbose_name=_("Deleting"))
tags = models.TextField(db_index=True, default="", blank=True, help_text=_("Project tags"), verbose_name=_("Tags"))
def delete(self, *args):
# No tasks?
if self.task_set.count() == 0:

Wyświetl plik

@ -276,7 +276,8 @@ class Task(models.Model):
partial = models.BooleanField(default=False, help_text=_("A flag indicating whether this task is currently waiting for information or files to be uploaded before being considered for processing."), verbose_name=_("Partial"))
potree_scene = fields.JSONField(default=dict, blank=True, help_text=_("Serialized potree scene information used to save/load measurements and camera view angle"), verbose_name=_("Potree Scene"))
epsg = models.IntegerField(null=True, default=None, blank=True, help_text=_("EPSG code of the dataset (if georeferenced)"), verbose_name="EPSG")
tags = models.TextField(db_index=True, default="", blank=True, help_text=_("Task tags"), verbose_name=_("Tags"))
class Meta:
verbose_name = _("Task")
verbose_name_plural = _("Tasks")

Wyświetl plik

@ -61,6 +61,9 @@ body,
.pagination li > a{
color: theme("primary");
}
.theme-border-secondary-07{
border-color: scaleby(theme("secondary"), 0.7) !important;
}
.btn-secondary, .btn-secondary:active, .btn-secondary.active, .open>.dropdown-toggle.btn-secondary{
background-color: theme("secondary");
@ -188,7 +191,7 @@ footer,
border-bottom-color: theme("border");
}
.theme-border{
border-color: theme("border");
border-color: theme("border") !important;
}
/* Highlight */
@ -261,4 +264,15 @@ pre.prettyprint,
color: complementary(theme("secondary")) !important;
}
}
}
.tag-badge{
background-color: theme("button_default");
border-color: theme("button_default");
color: theme("secondary");
a, a:hover{
color: theme("secondary");
}
}

Wyświetl plik

@ -28,10 +28,12 @@ class Dashboard extends React.Component {
return $.ajax({
url: `/api/projects/`,
type: 'POST',
data: {
contentType: 'application/json',
data: JSON.stringify({
name: project.name,
description: project.descr
}
description: project.descr,
tags: project.tags
})
}).done(() => {
this.projectList.refresh();
});
@ -39,13 +41,15 @@ class Dashboard extends React.Component {
render() {
const projectList = ({ location, history }) => {
let q = Utils.queryParams(location),
page = parseInt(q.page !== undefined ? q.page : 1);
let q = Utils.queryParams(location);
if (q.page === undefined) q.page = 1;
else q.page = parseInt(q.page);
return <ProjectList
source={`/api/projects/?ordering=-created_at&page=${page}`}
source={`/api/projects/${Utils.toSearchQuery(q)}`}
ref={(domNode) => { this.projectList = domNode; }}
currentPage={page}
currentPage={q.page}
currentSearch={q.search}
history={history}
/>;
};

Wyświetl plik

@ -0,0 +1,21 @@
export default {
userTags: function(tags){
// Tags starting with a "." are considered hidden or system tags
// and should not be displayed to end users via the UI
if (Array.isArray(tags)){
return tags.filter(t => !t.startsWith("."));
}else return [];
},
systemTags: function(tags){
if (Array.isArray(tags)){
return tags.filter(t => t.startsWith("."));
}else return [];
},
combine: function(user, system){
if (Array.isArray(user) && Array.isArray(system)){
return user.concat(system);
}else throw Error("Invalid parameters");
}
}

Wyświetl plik

@ -1,8 +1,10 @@
import React from 'react';
import '../css/EditProjectDialog.scss';
import FormDialog from './FormDialog';
import PropTypes from 'prop-types';
import ErrorMessage from './ErrorMessage';
import EditPermissionsPanel from './EditPermissionsPanel';
import TagsField from './TagsField';
import { _ } from '../classes/gettext';
class EditProjectDialog extends React.Component {
@ -10,6 +12,7 @@ class EditProjectDialog extends React.Component {
projectName: "",
projectDescr: "",
projectId: -1,
projectTags: [],
title: _("New Project"),
saveLabel: _("Create Project"),
savingLabel: _("Creating project..."),
@ -25,6 +28,7 @@ class EditProjectDialog extends React.Component {
projectName: PropTypes.string,
projectDescr: PropTypes.string,
projectId: PropTypes.number,
projectTags: PropTypes.array,
saveAction: PropTypes.func.isRequired,
onShow: PropTypes.func,
deleteAction: PropTypes.func,
@ -46,7 +50,9 @@ class EditProjectDialog extends React.Component {
name: props.projectName,
descr: props.projectDescr !== null ? props.projectDescr : "",
duplicating: false,
error: ""
tags: props.projectTags,
error: "",
showTagsField: !!props.projectTags.length
};
this.reset = this.reset.bind(this);
@ -60,6 +66,8 @@ class EditProjectDialog extends React.Component {
name: this.props.projectName,
descr: this.props.projectDescr,
duplicating: false,
tags: this.props.projectTags,
showTagsField: !!this.props.projectTags.length,
error: ""
});
}
@ -68,6 +76,7 @@ class EditProjectDialog extends React.Component {
const res = {
name: this.state.name,
descr: this.state.descr,
tags: this.state.tags
};
if (this.editPermissionsPanel){
@ -128,7 +137,26 @@ class EditProjectDialog extends React.Component {
});
}
toggleTagsField = () => {
if (!this.state.showTagsField){
setTimeout(() => {
if (this.tagsField) this.tagsField.focus();
}, 0);
}
this.setState({showTagsField: !this.state.showTagsField});
}
render(){
let tagsField = "";
if (this.state.showTagsField){
tagsField = (<div className="form-group">
<label className="col-sm-2 control-label">{_("Tags")}</label>
<div className="col-sm-10">
<TagsField onUpdate={(tags) => this.state.tags = tags } tags={this.state.tags} ref={domNode => this.tagsField = domNode}/>
</div>
</div>);
}
return (
<FormDialog {...this.props}
getFormData={this.getFormData}
@ -137,12 +165,16 @@ class EditProjectDialog extends React.Component {
leftButtons={this.props.showDuplicate ? [<button key="duplicate" disabled={this.duplicating} onClick={this.handleDuplicate} className="btn btn-default"><i className={"fa " + (this.state.duplicating ? "fa-circle-notch fa-spin fa-fw" : "fa-copy")}></i> Duplicate</button>] : undefined}
ref={(domNode) => { this.dialog = domNode; }}>
<ErrorMessage bind={[this, "error"]} />
<div className="form-group">
<div className="form-group edit-project-dialog">
<label className="col-sm-2 control-label">{_("Name")}</label>
<div className="col-sm-10">
<div className="col-sm-10 name-fields">
<input type="text" className="form-control" ref={(domNode) => { this.nameInput = domNode; }} value={this.state.name} onChange={this.handleChange('name')} onKeyPress={e => this.dialog.handleEnter(e)} />
<button type="button" title={_("Add tags")} onClick={this.toggleTagsField} className="btn btn-sm btn-secondary toggle-tags">
<i className="fa fa-tag"></i>
</button>
</div>
</div>
{tagsField}
<div className="form-group">
<label className="col-sm-2 control-label">{_("Description (optional)")}</label>
<div className="col-sm-10">

Wyświetl plik

@ -5,6 +5,7 @@ import EditPresetDialog from './EditPresetDialog';
import ErrorMessage from './ErrorMessage';
import PropTypes from 'prop-types';
import Storage from '../classes/Storage';
import TagsField from './TagsField';
import $ from 'jquery';
import { _, interpolate } from '../classes/gettext';
@ -45,10 +46,13 @@ class EditTaskForm extends React.Component {
processingNodes: [],
selectedPreset: null,
presets: [],
tags: props.task !== null ? Utils.clone(props.task.tags) : [],
editingPreset: false,
loadingTaskName: false
loadingTaskName: false,
showTagsField: props.task !== null ? !!props.task.tags.length : false
};
this.handleNameChange = this.handleNameChange.bind(this);
@ -354,12 +358,13 @@ class EditTaskForm extends React.Component {
}
getTaskInfo(){
const { name, selectedNode, selectedPreset } = this.state;
const { name, selectedNode, selectedPreset, tags } = this.state;
return {
name: name !== "" ? name : this.namePlaceholder,
name: name !== "" ? name : this.state.namePlaceholder,
selectedNode: selectedNode,
options: this.getAvailableOptionsOnly(selectedPreset.options, selectedNode.options)
options: this.getAvailableOptionsOnly(selectedPreset.options, selectedNode.options),
tags
};
}
@ -485,6 +490,15 @@ class EditTaskForm extends React.Component {
}
}
toggleTagsField = () => {
if (!this.state.showTagsField){
setTimeout(() => {
if (this.tagsField) this.tagsField.focus();
}, 0);
}
this.setState({showTagsField: !this.state.showTagsField});
}
render() {
if (this.state.error){
return (<div className="edit-task-panel">
@ -513,10 +527,10 @@ class EditTaskForm extends React.Component {
{!this.state.presetActionPerforming ?
<div className="btn-group presets-dropdown">
<button type="button" className="btn btn-default" title={_("Edit Task Options")} onClick={this.handleEditPreset}>
<button type="button" className="btn btn-sm btn-default" title={_("Edit Task Options")} onClick={this.handleEditPreset}>
<i className="fa fa-sliders-h"></i> {_("Edit")}
</button>
<button type="button" className="btn btn-default dropdown-toggle" data-toggle="dropdown">
<button type="button" className="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
<span className="caret"></span>
</button>
<ul className="dropdown-menu">
@ -543,8 +557,19 @@ class EditTaskForm extends React.Component {
<ErrorMessage className="preset-error" bind={[this, 'presetError']} />
</div>);
let tagsField = "";
if (this.state.showTagsField){
tagsField = (<div className="form-group">
<label className="col-sm-2 control-label">{_("Tags")}</label>
<div className="col-sm-10">
<TagsField onUpdate={(tags) => this.state.tags = tags } tags={this.state.tags} ref={domNode => this.tagsField = domNode}/>
</div>
</div>);
}
taskOptions = (
<div>
{tagsField}
<div className="form-group">
<label className="col-sm-2 control-label">{_("Processing Node")}</label>
<div className="col-sm-10">
@ -588,7 +613,7 @@ class EditTaskForm extends React.Component {
<div className="edit-task-form">
<div className="form-group">
<label className="col-sm-2 control-label">{_("Name")}</label>
<div className="col-sm-10">
<div className="col-sm-10 name-fields">
{this.state.loadingTaskName ?
<i className="fa fa-circle-notch fa-spin fa-fw name-loading"></i>
: ""}
@ -596,8 +621,12 @@ class EditTaskForm extends React.Component {
onChange={this.handleNameChange}
className="form-control"
placeholder={this.state.namePlaceholder}
value={this.state.name}
value={this.state.name}
/>
<button type="button" title={_("Add tags")} onClick={this.toggleTagsField} className="btn btn-sm btn-secondary toggle-tags">
<i className="fa fa-tag"></i>
</button>
</div>
</div>
{taskOptions}

Wyświetl plik

@ -1,45 +1,182 @@
import React from 'react';
import { Link } from 'react-router-dom';
import '../css/Paginator.scss';
import { Link, withRouter } from 'react-router-dom';
import SortPanel from './SortPanel';
import Utils from '../classes/Utils';
import { _ } from '../classes/gettext';
let decodeSearch = (search) => {
return window.decodeURI(search.replace(/:/g, "#"));
};
class Paginator extends React.Component {
constructor(props){
super(props);
const q = Utils.queryParams(props.location);
this.state = {
searchText: decodeSearch(q.search || ""),
sortKey: q.ordering || "-created_at"
}
this.sortItems = [{
key: "created_at",
label: _("Created on")
},{
key: "name",
label: _("Name")
},{
key: "tags",
label: _("Tags")
},{
key: "owner",
label: _("Owner")
}];
}
componentDidMount(){
document.addEventListener("onProjectListTagClicked", this.addTagAndSearch);
}
componentWillUnmount(){
document.removeEventListener("onProjectListTagClicked", this.addTagAndSearch);
}
closeSearch = () => {
this.searchContainer.classList.remove("open");
}
toggleSearch = e => {
e.stopPropagation();
setTimeout(() => {
this.searchInput.focus();
}, 50);
}
handleSearchChange = e => {
this.setState({searchText: e.target.value});
}
handleSearchKeyDown = e => {
if (e.key === "Enter"){
this.search();
}
}
search = () => {
this.props.history.push({search: this.getQueryForPage(1)});
this.closeSearch();
}
clearSearch = () => {
this.setState({searchText: ""});
setTimeout(() => {
this.search();
}, 0);
}
sortChanged = key => {
this.setState({sortKey: key});
setTimeout(() => {
this.props.history.push({search: this.getQueryForPage(this.props.currentPage)});
}, 0);
}
getQueryForPage = (num) => {
return Utils.toSearchQuery({
page: num,
ordering: this.state.sortKey,
search: this.state.searchText.replace(/#/g, ":")
});
}
addTagAndSearch = e => {
const tag = e.detail;
if (tag === undefined) return;
let { searchText } = this.state;
if (searchText === "") searchText += "#" + tag;
else searchText += " #" + tag;
this.setState({searchText});
setTimeout(() => {
this.search();
}, 0);
}
render() {
const { itemsPerPage, totalItems, currentPage } = this.props;
const { searchText } = this.state;
let paginator = null;
let clearSearch = null;
let toolbar = (<ul className={"pagination pagination-sm toolbar " + (totalItems == 0 && !searchText ? "hidden " : " ") + (totalItems / itemsPerPage <= 1 ? "no-margin" : "")}>
<li className="btn-group" ref={domNode => { this.searchContainer = domNode; }}>
<a href="javascript:void(0);" className="dropdown-toggle"
data-toggle-outside
data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false"
onClick={this.toggleSearch}
title={_("Search")}><i className="fa fa-search"></i></a>
<ul className="dropdown-menu dropdown-menu-right search-popup">
<li>
<input type="text"
ref={(domNode) => { this.searchInput = domNode}}
className="form-control search theme-border-secondary-07"
placeholder={_("Search names or #tags")}
spellCheck="false"
autoComplete="false"
value={searchText}
onKeyDown={this.handleSearchKeyDown}
onChange={this.handleSearchChange} />
<button onClick={this.search} className="btn btn-sm btn-default"><i className="fa fa-search"></i></button>
</li>
</ul>
</li>
<li className="btn-group">
<a href="javascript:void(0);" className="dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"><i className="fa fa-sort-alpha-down" title={_("Sort")}></i></a>
<SortPanel selected={this.state.sortKey} items={this.sortItems} onChange={this.sortChanged} />
</li>
</ul>);
if (this.props.currentSearch){
let currentSearch = decodeSearch(this.props.currentSearch);
clearSearch = (<span className="clear-search">{_("Search results for:")} <span className="query">{currentSearch}</span> <a href="javascript:void(0);" onClick={this.clearSearch}>×</a></span>);
}
if (itemsPerPage && itemsPerPage && totalItems > itemsPerPage){
const numPages = Math.ceil(totalItems / itemsPerPage),
pages = [...Array(numPages).keys()]; // [0, 1, 2, ...numPages]
paginator = (
<div className={this.props.className}>
<ul className="pagination pagination-sm">
<li className={currentPage === 1 ? "disabled" : ""}>
<Link to={{search: "?page=1"}}>
<span>&laquo;</span>
</Link>
</li>
{pages.map(page => {
return (<li
key={page + 1}
className={currentPage === (page + 1) ? "active" : ""}
><Link to={{search: "?page=" + (page + 1)}}>{page + 1}</Link></li>);
})}
<li className={currentPage === numPages ? "disabled" : ""}>
<Link to={{search: "?page=" + numPages}}>
<span>&raquo;</span>
</Link>
</li>
</ul>
</div>
);
<ul className="pagination pagination-sm">
<li className={currentPage === 1 ? "disabled" : ""}>
<Link to={{search: this.getQueryForPage(1)}}>
<span>&laquo;</span>
</Link>
</li>
{pages.map(page => {
return (<li
key={page + 1}
className={currentPage === (page + 1) ? "active" : ""}
><Link to={{search: this.getQueryForPage(page + 1)}}>{page + 1}</Link></li>);
})}
<li className={currentPage === numPages ? "disabled" : ""}>
<Link to={{search: this.getQueryForPage(numPages)}}>
<span>&raquo;</span>
</Link>
</li>
</ul>
);
}
return (<div>
{paginator}
{this.props.children}
{paginator}
</div>);
return [
<div key="0" className="text-right paginator">{clearSearch}{toolbar}{paginator}</div>,
this.props.children,
<div key="2" className="text-right paginator">{paginator}</div>,
];
}
}
export default Paginator;
export default withRouter(Paginator);

Wyświetl plik

@ -8,6 +8,7 @@ import Paginator from './Paginator';
import ErrorMessage from './ErrorMessage';
import { _, interpolate } from '../classes/gettext';
import PropTypes from 'prop-types';
import Utils from '../classes/Utils';
class ProjectList extends Paginated {
static propTypes = {
@ -33,8 +34,23 @@ class ProjectList extends Paginated {
this.refresh();
}
getParametersHash(source){
if (!source) return "";
if (source.indexOf("?") === -1) return "";
let search = source.substr(source.indexOf("?"));
let q = Utils.queryParams({search});
// All parameters that can change via history.push without
// triggering a reload of the project list should go here
delete q.project_task_open;
delete q.project_task_expanded;
return JSON.stringify(q);
}
componentDidUpdate(prevProps){
if (prevProps.source !== this.props.source){
if (this.getParametersHash(prevProps.source) !== this.getParametersHash(this.props.source)){
this.refresh();
}
}
@ -101,8 +117,8 @@ class ProjectList extends Paginated {
}else{
return (<div className="project-list">
<ErrorMessage bind={[this, 'error']} />
<Paginator className="text-right" {...this.state.pagination} {...this.props}>
<ul className={"list-group project-list " + (this.state.refreshing ? "refreshing" : "")}>
<Paginator {...this.state.pagination} {...this.props}>
<ul key="1" className={"list-group project-list " + (this.state.refreshing ? "refreshing" : "")}>
{this.state.projects.map(p => (
<ProjectListItem
ref={(domNode) => { this["projectListItem_" + p.id] = domNode }}

Wyświetl plik

@ -7,11 +7,13 @@ import ImportTaskPanel from './ImportTaskPanel';
import UploadProgressBar from './UploadProgressBar';
import ErrorMessage from './ErrorMessage';
import EditProjectDialog from './EditProjectDialog';
import SortPanel from './SortPanel';
import Dropzone from '../vendor/dropzone';
import csrf from '../django/csrf';
import HistoryNav from '../classes/HistoryNav';
import PropTypes from 'prop-types';
import ResizeModes from '../classes/ResizeModes';
import Tags from '../classes/Tags';
import exifr from '../vendor/exifr';
import { _, interpolate } from '../classes/gettext';
import $ from 'jquery';
@ -37,9 +39,24 @@ class ProjectListItem extends React.Component {
data: props.data,
refreshing: false,
importing: false,
buttons: []
buttons: [],
sortKey: "-created_at",
filterTags: [],
selectedTags: [],
filterText: ""
};
this.sortItems = [{
key: "created_at",
label: _("Created on")
},{
key: "name",
label: _("Name")
},{
key: "tags",
label: _("Tags")
}];
this.toggleTaskList = this.toggleTaskList.bind(this);
this.closeUploadError = this.closeUploadError.bind(this);
this.cancelUpload = this.cancelUpload.bind(this);
@ -75,6 +92,13 @@ class ProjectListItem extends React.Component {
if (this.refreshRequest) this.refreshRequest.abort();
}
componentDidUpdate(prevProps, prevState){
if (prevState.filterText !== this.state.filterText ||
prevState.selectedTags.length !== this.state.selectedTags.length){
if (this.taskList) this.taskList.applyFilter(this.state.filterText, this.state.selectedTags);
}
}
getDefaultUploadState(){
return {
uploading: false,
@ -383,6 +407,7 @@ class ProjectListItem extends React.Component {
data: JSON.stringify({
name: project.name,
description: project.descr,
tags: project.tags,
permissions: project.permissions
}),
dataType: 'json',
@ -467,10 +492,66 @@ class ProjectListItem extends React.Component {
});
}
sortChanged = key => {
if (this.taskList){
this.setState({sortKey: key});
setTimeout(() => {
this.taskList.refresh();
}, 0);
}
}
handleTagClick = tag => {
return e => {
const evt = new CustomEvent("onProjectListTagClicked", { detail: tag });
document.dispatchEvent(evt);
}
}
tagsChanged = (filterTags) => {
this.setState({filterTags, selectedTags: []});
}
handleFilterTextChange = e => {
this.setState({filterText: e.target.value});
}
toggleTag = t => {
return () => {
if (this.state.selectedTags.indexOf(t) === -1){
this.setState(update(this.state, { selectedTags: {$push: [t]} }));
}else{
this.setState({selectedTags: this.state.selectedTags.filter(tag => tag !== t)});
}
}
}
selectTag = t => {
if (this.state.selectedTags.indexOf(t) === -1){
this.setState(update(this.state, { selectedTags: {$push: [t]} }));
}
}
clearFilter = () => {
this.setState({
filterText: "",
selectedTags: []
});
}
onOpenFilter = () => {
if (this.state.filterTags.length === 0){
setTimeout(() => {
this.filterTextInput.focus();
}, 0);
}
}
render() {
const { refreshing, data } = this.state;
const { refreshing, data, filterTags } = this.state;
const numTasks = data.tasks.length;
const canEdit = this.hasPermission("change");
const userTags = Tags.userTags(data.tags);
return (
<li className={"project-list-item list-group-item " + (refreshing ? "refreshing" : "")}
@ -490,6 +571,7 @@ class ProjectListItem extends React.Component {
projectName={data.name}
projectDescr={data.description}
projectId={data.id}
projectTags={data.tags}
saveAction={this.updateProject}
showPermissions={this.hasPermission("change")}
deleteAction={this.hasPermission("delete") ? this.handleDelete : undefined}
@ -524,14 +606,13 @@ class ProjectListItem extends React.Component {
<i className="glyphicon glyphicon-remove-circle"></i>
Cancel Upload
</button>
<button type="button" className="btn btn-default btn-sm" onClick={this.viewMap}>
<i className="fa fa-globe"></i> {_("View Map")}
</button>
</div>
<div className="project-name">
{data.name}
{userTags.length > 0 ?
userTags.map((t, i) => <div key={i} className="tag-badge small-badge" onClick={this.handleTagClick(t)}>{t}</div>)
: ""}
</div>
<div className="project-description">
{data.description}
@ -540,17 +621,65 @@ class ProjectListItem extends React.Component {
{numTasks > 0 ?
<span>
<i className='fa fa-tasks'></i>
<a href="javascript:void(0);" onClick={this.toggleTaskList}>
<a href="javascript:void(0);" onClick={this.toggleTaskList}>
{interpolate(_("%(count)s Tasks"), { count: numTasks})} <i className={'fa fa-caret-' + (this.state.showTaskList ? 'down' : 'right')}></i>
</a>
</span>
: ""}
{this.state.showTaskList && numTasks > 1 ?
<div className="task-filters">
<div className="btn-group">
{this.state.selectedTags.length || this.state.filterText !== "" ?
<a className="quick-clear-filter" href="javascript:void(0)" onClick={this.clearFilter}>×</a>
: ""}
<i className='fa fa-filter'></i>
<a href="javascript:void(0);" onClick={this.onOpenFilter} className="dropdown-toggle" data-toggle-outside data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{_("Filter")}
</a>
<ul className="dropdown-menu dropdown-menu-right filter-dropdown">
<li className="filter-text-container">
<input type="text" className="form-control filter-text theme-border-secondary-07"
value={this.state.filterText}
ref={domNode => {this.filterTextInput = domNode}}
placeholder=""
spellCheck="false"
autoComplete="false"
onChange={this.handleFilterTextChange} />
</li>
{filterTags.map(t => <li key={t} className="tag-selection">
<input type="checkbox"
className="filter-checkbox"
id={"filter-tag-" + data.id + "-" + t}
checked={this.state.selectedTags.indexOf(t) !== -1}
onChange={this.toggleTag(t)} /> <label className="filter-checkbox-label" htmlFor={"filter-tag-" + data.id + "-" + t}>{t}</label>
</li>)}
<li className="clear-container"><input type="button" onClick={this.clearFilter} className="btn btn-default btn-xs" value={_("Clear")}/></li>
</ul>
</div>
<div className="btn-group">
<i className='fa fa-sort-alpha-down'></i>
<a href="javascript:void(0);" className="dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{_("Sort")}
</a>
<SortPanel selected="-created_at" items={this.sortItems} onChange={this.sortChanged} />
</div>
</div> : ""}
{numTasks > 0 ?
[<i key="edit-icon" className='fa fa-globe'></i>
,<a key="edit-text" href="javascript:void(0);" onClick={this.viewMap}>
{_("View Map")}
</a>]
: ""}
{canEdit ?
[<i key="edit-icon" className='far fa-edit'></i>
,<a key="edit-text" href="javascript:void(0);" onClick={this.handleEditProject}> {_("Edit")}
</a>]
: ""}
</div>
</div>
<i className="drag-drop-icon fa fa-inbox"></i>
@ -586,10 +715,12 @@ class ProjectListItem extends React.Component {
{this.state.showTaskList ?
<TaskList
ref={this.setRef("taskList")}
source={`/api/projects/${data.id}/tasks/?ordering=-created_at`}
source={`/api/projects/${data.id}/tasks/?ordering=${this.state.sortKey}`}
onDelete={this.taskDeleted}
onTaskMoved={this.taskMoved}
hasPermission={this.hasPermission}
onTagsChanged={this.tagsChanged}
onTagClicked={this.selectTag}
history={this.props.history}
/> : ""}

Wyświetl plik

@ -0,0 +1,64 @@
import React from 'react';
import '../css/SortPanel.scss';
import PropTypes from 'prop-types';
import { _ } from '../classes/gettext';
class SortPanel extends React.Component {
static defaultProps = {
items: [],
onChange: () => {},
selected: null
};
static propTypes = {
items: PropTypes.arrayOf(PropTypes.object),
onChange: PropTypes.func,
selected: PropTypes.string
};
constructor(props){
super(props);
this.state = {
items: props.items
}
if (props.selected){
let normSortKey = props.selected.replace("-", "");
this.state.items.forEach(s => {
if (s.key === normSortKey) s.selected = props.selected[0] === "-" ? "desc" : "asc";
});
}
}
handleClick = (key, order) => {
return () => {
this.state.items.forEach(i => {
i.selected = i.key === key ? order : false;
});
this.setState({
items: this.state.items
})
this.props.onChange(order === "desc" ? "-" + key : key);
}
}
render() {
return (<ul className="dropdown-menu dropdown-menu-right sort-items">
<li className="sort-order-label">{_("Descending")}</li>
{this.state.items.map(i =>
<li key={i.key}><a onClick={this.handleClick(i.key, "desc")} className="sort-item">
{ i.label } {i.selected === "desc" ? <i className="fa fa-check"></i> : ""}
</a></li>
)}
<li className="sort-order-label">{_("Ascending")}</li>
{this.state.items.map(i =>
<li key={i.key}><a onClick={this.handleClick(i.key, "asc")} className="sort-item">
{ i.label } {i.selected === "asc" ? <i className="fa fa-check"></i> : ""}
</a></li>
)}
</ul>);
}
}
export default SortPanel;

Wyświetl plik

@ -0,0 +1,225 @@
import React from 'react';
import '../css/TagsField.scss';
import PropTypes from 'prop-types';
import update from 'immutability-helper';
import { _ } from '../classes/gettext';
import Tags from '../classes/Tags';
class TagsField extends React.Component {
static defaultProps = {
tags: [],
onUpdate: () => {}
};
static propTypes = {
tags: PropTypes.arrayOf(PropTypes.string),
onUpdate: PropTypes.func
};
constructor(props){
super(props);
this.state = {
userTags: Tags.userTags(props.tags),
systemTags: Tags.systemTags(props.tags)
}
this.dzList = [];
this.domTags = [];
}
componentDidUpdate(){
this.props.onUpdate(Tags.combine(this.state.userTags, this.state.systemTags));
}
componentWillUnmount(){
this.restoreDropzones();
}
disableDropzones(){
if (this.disabledDz) return;
let parent = this.domNode.parentElement;
while(parent){
if (parent.dropzone){
parent.dropzone.removeListeners();
this.dzList.push(parent.dropzone);
}
parent = parent.parentElement;
}
this.disabledDz = true;
}
restoreDropzones(){
if (!this.disabledDz) return;
this.dzList.forEach(dz => {
dz.restoreListeners();
});
this.dzList = [];
this.disabledDz = false;
}
handleKeyDown = e => {
if (e.key === "Tab" || e.key === "Enter" || e.key === "," || e.key === " "){
e.preventDefault();
e.stopPropagation();
this.addTag();
}else if (e.key === "Backspace" && this.inputText.innerText === ""){
this.removeTag(this.state.userTags.length - 1);
}
}
focus = () => {
this.inputText.focus();
}
stop = e => {
e.stopPropagation();
}
handleRemoveTag = idx => {
return e => {
e.stopPropagation();
this.removeTag(idx);
}
}
removeTag = idx => {
this.setState(update(this.state, { userTags: { $splice: [[idx, 1]] } }));
}
addTag = () => {
let text = this.inputText.innerText;
if (text !== ""){
// Do not allow system tags
if (!text.startsWith(".")){
// Only lower case text allowed
text = text.toLowerCase();
// Check for dulicates
if (this.state.userTags.indexOf(text) === -1){
this.setState(update(this.state, {
userTags: {$push: [text]}
}));
}
}
this.inputText.innerText = "";
}
}
handleDragStart = tag => {
return e => {
this.disableDropzones();
e.stopPropagation();
e.dataTransfer.setData("application/tag", tag);
e.dataTransfer.dropEffect = "move";
}
}
handleDrop = e => {
e.preventDefault();
const dragTag = e.dataTransfer.getData("application/tag");
const [moveTag, side] = this.findClosestTag(e.clientX, e.clientY);
const { userTags } = this.state;
if (moveTag){
const dragIdx = userTags.indexOf(dragTag);
const moveIdx = userTags.indexOf(moveTag);
if (dragIdx !== -1 && moveIdx !== -1){
if (dragIdx === moveIdx) return;
else{
// Put drag tag in front of move tag
let insertIdx = side === "right" ? moveIdx + 1 : moveIdx;
userTags.splice(insertIdx, 0, dragTag);
for (let i = 0; i < userTags.length; i++){
if (userTags[i] === dragTag && i !== insertIdx){
userTags.splice(i, 1);
break;
}
}
this.setState({userTags});
}
}
}
}
handleDragOver = e => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
}
handleDragEnter = e => {
e.preventDefault();
}
handleDragEnd = () => {
this.restoreDropzones();
}
findClosestTag = (clientX, clientY) => {
let closestTag = null;
let minDistX = Infinity, minDistY = Infinity;
let rowTagY = null;
const { userTags } = this.state;
// Find tags in closest row
this.domTags.forEach((domTag, i) => {
const b = domTag.getBoundingClientRect();
const tagY = b.y + (b.height / 2);
let dy = clientY - tagY,
sqDistY = dy*dy;
if (sqDistY < minDistY){
minDistY = sqDistY;
rowTagY = tagY;
}
});
if (!rowTagY) return [null, ""];
// From row, find closest in X
this.domTags.forEach((domTag, i) => {
const b = domTag.getBoundingClientRect();
const tagY = b.y + (b.height / 2);
if (Math.abs(tagY - rowTagY) < 0.001){
const tagX = b.x + b.width;
let dx = clientX - tagX,
sqDistX = dx*dx;
if (sqDistX < minDistX){
closestTag = userTags[i];
minDistX = sqDistX;
}
}
});
let side = "right";
if (closestTag){
const b = this.domTags[this.state.userTags.indexOf(closestTag)].getBoundingClientRect();
const centerX = b.x + b.width / 2.0;
if (clientX < centerX) side = "left";
}
return [closestTag, side];
}
render() {
return (<div
ref={domNode => this.domNode = domNode}
spellCheck="false"
autoComplete="off"
onClick={this.focus}
onDrop={this.handleDrop}
onDragOver={this.handleDragOver}
onDragEnter={this.handleDragEnter}
className="form-control tags-field">{this.state.userTags.map((tag, i) =>
<div draggable="true" className="tag-badge" key={i} ref={domNode => this.domTags[i] = domNode}
onClick={this.stop}
onDragStart={this.handleDragStart(tag)}
onDragEnd={this.handleDragEnd}>{tag} <a href="javascript:void(0)" onClick={this.handleRemoveTag(i)}>×</a>&nbsp;&nbsp;</div>
)}
<div className="inputText" contentEditable="true" ref={(domNode) => this.inputText = domNode}
onKeyDown={this.handleKeyDown}
onBlur={this.addTag}></div>
</div>);
}
}
export default TagsField;

Wyświetl plik

@ -11,7 +11,9 @@ class TaskList extends React.Component {
source: PropTypes.string.isRequired, // URL where to load task list
onDelete: PropTypes.func,
onTaskMoved: PropTypes.func,
hasPermission: PropTypes.func.isRequired
hasPermission: PropTypes.func.isRequired,
onTagsChanged: PropTypes.func,
onTagClicked: PropTypes.func
}
constructor(props){
@ -20,7 +22,9 @@ class TaskList extends React.Component {
this.state = {
tasks: [],
error: "",
loading: true
loading: true,
filterText: "",
filterTags: []
};
this.refresh = this.refresh.bind(this);
@ -41,12 +45,19 @@ class TaskList extends React.Component {
this.refresh();
}
applyFilter(text, tags){
this.setState({filterText: text, filterTags: tags});
}
loadTaskList(){
this.setState({loading: true});
this.taskListRequest =
$.getJSON(this.props.source, json => {
this.setState({
tasks: json
});
setTimeout(() => this.notifyTagsChanged(), 0);
})
.fail((jqXHR, textStatus, errorThrown) => {
this.setState({
@ -76,6 +87,49 @@ class TaskList extends React.Component {
if (this.props.onTaskMoved) this.props.onTaskMoved(task);
}
notifyTagsChanged = () => {
const { tasks } = this.state;
const tags = [];
if (tasks){
tasks.forEach(t => {
if (t.tags){
t.tags.forEach(x => {
if (tags.indexOf(x) === -1) tags.push(x);
});
}
});
}
tags.sort();
if (this.props.onTagsChanged) this.props.onTagsChanged(tags);
}
taskEdited = (task) => {
// Update
const { tasks } = this.state;
for (let i = 0; i < tasks.length; i++){
if (tasks[i].id === task.id){
tasks[i] = task;
break;
}
}
this.setState({tasks});
// Tags might have changed
setTimeout(() => this.notifyTagsChanged(), 0);
}
arrayContainsAll = (a, b) => {
let miss = false;
for (let i = 0; i < b.length; i++){
if (a.indexOf(b[i]) === -1){
miss = true;
break;
}
}
return !miss;
}
render() {
let message = "";
if (this.state.loading){
@ -88,9 +142,10 @@ class TaskList extends React.Component {
return (
<div className="task-list">
{message}
{this.state.tasks.map(task => (
{this.state.tasks.filter(t => {
return t.name.toLocaleLowerCase().indexOf(this.state.filterText.toLocaleLowerCase()) !== -1 &&
this.arrayContainsAll(t.tags, this.state.filterTags);
}).map(task => (
<TaskListItem
data={task}
key={task.id}
@ -98,9 +153,13 @@ class TaskList extends React.Component {
onDelete={this.deleteTask}
onMove={this.moveTask}
onDuplicate={this.refresh}
onEdited={this.taskEdited}
onTagClicked={this.props.onTagClicked}
hasPermission={this.props.hasPermission}
history={this.props.history} />
))}
{message}
</div>
);
}

Wyświetl plik

@ -12,6 +12,7 @@ import TaskPluginActionButtons from './TaskPluginActionButtons';
import MoveTaskDialog from './MoveTaskDialog';
import PipelineSteps from '../classes/PipelineSteps';
import Css from '../classes/Css';
import Tags from '../classes/Tags';
import Trans from './Trans';
import { _, interpolate } from '../classes/gettext';
@ -23,7 +24,9 @@ class TaskListItem extends React.Component {
onDelete: PropTypes.func,
onMove: PropTypes.func,
onDuplicate: PropTypes.func,
hasPermission: PropTypes.func
hasPermission: PropTypes.func,
onEdited: PropTypes.func,
onTagClicked: PropTypes.func
}
constructor(props){
@ -278,6 +281,7 @@ class TaskListItem extends React.Component {
handleEditTaskSave(task){
this.setState({task, editing: false});
if (this.props.onEdited) this.props.onEdited(task);
this.setAutoRefresh();
}
@ -401,6 +405,12 @@ class TaskListItem extends React.Component {
}else return false;
}
handleTagClick = t => {
return () => {
if (this.props.onTagClicked) this.props.onTagClicked(t);
}
}
render() {
const task = this.state.task;
const name = task.name !== null ? task.name : interpolate(_("Task #%(number)s"), { number: task.id });
@ -706,6 +716,7 @@ class TaskListItem extends React.Component {
let taskActionsIcon = "fa-ellipsis-h";
if (actionLoading) taskActionsIcon = "fa-circle-notch fa-spin fa-fw";
const userTags = Tags.userTags(task.tags);
return (
<div className="task-list-item">
@ -719,7 +730,10 @@ class TaskListItem extends React.Component {
: ""}
<div className="row">
<div className="col-sm-5 col-xs-12 name">
<i onClick={this.toggleExpanded} className={"clickable far " + (this.state.expanded ? "fa-minus-square" : " fa-plus-square")}></i> <a href="javascript:void(0);" onClick={this.toggleExpanded}>{name}</a>
<i onClick={this.toggleExpanded} className={"clickable far " + (this.state.expanded ? "fa-minus-square" : " fa-plus-square")}></i> <a href="javascript:void(0);" onClick={this.toggleExpanded} className="name-link">{name}</a>
{userTags.length > 0 ?
userTags.map((t, i) => <div key={i} className="tag-badge small-badge" onClick={this.handleTagClick(t)}>{t}</div>)
: ""}
</div>
<div className="col-sm-1 col-xs-5 details">
<i className="far fa-image"></i> {task.images_count}

Wyświetl plik

@ -0,0 +1,15 @@
import React from 'react';
import { shallow } from 'enzyme';
import SortPanel from '../SortPanel';
var sortItems = [{
key: "created_at",
label: "Created on"
}];
describe('<SortPanel />', () => {
it('renders without exploding', () => {
const wrapper = shallow(<SortPanel items={sortItems} selected="created_at" />);
expect(wrapper.exists()).toBe(true);
})
});

Wyświetl plik

@ -0,0 +1,10 @@
import React from 'react';
import { shallow } from 'enzyme';
import TagsField from '../TagsField';
describe('<TagsField />', () => {
it('renders without exploding', () => {
const wrapper = shallow(<TagsField tags={["abc"]} />);
expect(wrapper.exists()).toBe(true);
})
});

Wyświetl plik

@ -0,0 +1,15 @@
.edit-project-dialog{
.name-fields{
display: flex;
.btn.toggle-tags{
margin-top: 0;
margin-bottom: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
input[type="text"]{
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
}

Wyświetl plik

@ -28,8 +28,22 @@
.name-loading{
position: absolute;
right: 30px;
right: 60px;
top: 15px;
opacity: 0.5;
}
.name-fields{
display: flex;
.btn.toggle-tags{
margin-top: 2px;
margin-bottom: 2px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
input[type="text"]{
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
}

Wyświetl plik

@ -0,0 +1,59 @@
.paginator{
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
.toolbar{
i{
opacity: 0.8;
}
margin-right: 8px;
&.no-margin{
margin-right: 0;
}
}
.btn-group.open > .dropdown-menu{
top: 22px;
a{
border: none;
}
}
.search{
height: 25px;
margin-left: 7px;
margin-right: 4px;
padding-left: 4px;
padding-right: 4px;
border-width: 1px;
border-radius: 3px;
display: inline-block;
max-width: 210px;
}
.search-popup{
min-width: 256px;
li{
display: flex;
button{
width: 27px;
height: 25px;
i{
position: relative;
top: -4px;
left: -3px;
}
}
}
}
.clear-search{
margin-top: 1px;
font-weight: bold;
margin-right: 8px;
.query{
font-weight: normal;
}
}
}

Wyświetl plik

@ -12,6 +12,10 @@
}
}
.project-description{
min-height: 12px;
}
.drag-drop-icon{
display: none;
position: absolute;
@ -97,4 +101,71 @@
}
}
}
.task-filters{
float: right;
}
.tag-badge.small-badge {
display: inline-block;
width: auto;
padding-left: 6px;
padding-right: 6px;
padding-top: 0px;
padding-bottom: 0px;
margin-left: 4px;
margin-top: -2px;
border-radius: 6px;
font-size: 90%;
position: relative;
top: -1px;
&:hover{
cursor: pointer;
}
}
.filter-dropdown{
max-width: 320px;
padding-bottom: 6px;
}
.filter-text{
height: 25px;
margin-left: 7px;
margin-right: 6px;
margin-bottom: 4px;
padding-left: 4px;
padding-right: 4px;
border-width: 1px;
border-radius: 3px;
display: block;
width: 100%;
}
.filter-text-container,.tag-selection{
display: flex;
}
.filter-checkbox{
margin-left: 8px;
}
.filter-checkbox-label{
font-weight: normal;
position: relative;
top: 4px;
overflow: hidden;
text-overflow: ellipsis;
margin-left: 4px;
width: 100%;
}
.clear-container{
text-align: right;
margin-top: 2px;
margin-right: 6px;
}
.quick-clear-filter{
margin-right: 6px !important;
}
}

Wyświetl plik

@ -0,0 +1,20 @@
.sort-items{
.sort-order-label{
opacity: 0.7;
padding-left: 12px;
}
a{
margin-right: 0 !important;
padding-left: 24px !important;
}
a:hover{
cursor: pointer;
}
.fa-check{
font-size: 80%;
margin-left: 8px;
}
}

Wyświetl plik

@ -0,0 +1,40 @@
.tags-field{
height: auto;
padding-bottom: 2px;
&:hover{
cursor: text;
}
.tag-badge{
&:hover{
cursor: grab;
}
display: inline-block;
width: auto;
padding-left: 6px;
padding-top: 2px;
padding-bottom: 2px;
margin-top: -2px;
margin-right: 4px;
margin-bottom: 8px;
border-radius: 6px;
a{
margin-top: 2px;
font-weight: bold;
padding-bottom: 5px;
}
a:hover, a:focus, a:active{
cursor: pointer;
text-decoration: none !important;
}
}
.inputText{
display: inline-block;
outline: none;
border: none;
margin-bottom: 10px;
min-width: 1px;
}
}

Wyświetl plik

@ -1,2 +1,5 @@
.task-list{
.task-bar{
text-align: right;
}
}

Wyświetl plik

@ -119,4 +119,23 @@
.mb{
margin-bottom: 12px;
}
.tag-badge.small-badge {
display: inline-block;
width: auto;
padding-left: 6px;
padding-right: 6px;
padding-top: 0px;
padding-bottom: 0px;
margin-left: 4px;
margin-top: -2px;
border-radius: 6px;
font-size: 90%;
position: relative;
top: -1px;
}
.name-link{
margin-right: 4px;
}
}

Wyświetl plik

@ -1,5 +1,5 @@
/*!
* Bootstrap v3.3.1 (http://getbootstrap.com)
* Bootstrap v3.3.1 (http://getbootstrap.com) modified to allow "data-toggle-outside"
* Copyright 2011-2014 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*/
@ -838,6 +838,15 @@ if (typeof jQuery === 'undefined') {
if (!$parent.hasClass('open')) return
// Modification to allow toggling only with click outside
if ($this.attr('data-toggle-outside')){
if (e && e.target){
var sibiling = $this.get(0).nextSibling;
if (sibiling === e.target || sibiling.contains(e.target)) return
}
}
// End modification
$parent.trigger(e = $.Event('hide.bs.dropdown', relatedTarget))
if (e.isDefaultPrevented()) return

File diff suppressed because one or more lines are too long

Wyświetl plik

@ -1555,6 +1555,22 @@ var Dropzone = function (_Emitter) {
return _this4.cancelUpload(file);
});
}
}, {
key: "removeListeners",
value: function disable() {
this.clickableElements.forEach(function (element) {
return element.classList.remove("dz-clickable");
});
this.removeEventListeners();
}
}, {
key: "restoreListeners",
value: function disable() {
this.clickableElements.forEach(function (element) {
return element.classList.add("dz-clickable");
});
return this.setupEventListeners();
}
}, {
key: "enable",
value: function enable() {

Wyświetl plik

@ -33,7 +33,7 @@
<ul>
<li>{% trans 'You need at least 5 images, but 16-32 is typically the minimum.' %}</li>
<li>{% trans 'Images must overlap by 65% or more. Aim for 70-72%' %}</li>
<li>{% trans 'For great 3D, images must overlap by 83%' %}</li>
<li>{% trans 'For great 3D, images must overlap by 83%' %}</li>
<li>{% blocktrans with link_start='<a href="https://github.com/OpenDroneMap/OpenDroneMap/wiki/Running-OpenDroneMap#running-odm-with-ground-control" target="_blank">' link_end='</a>' %}A {{link_start}}GCP File{{link_end}} is optional, but can increase georeferencing accuracy{% endblocktrans %}</li>
</ul>
</p>

Wyświetl plik

@ -0,0 +1,93 @@
import logging
import json
from django.contrib.auth.models import User
from rest_framework import status
from rest_framework.test import APIClient
from app.models import Project, Task
from app.tests.classes import BootTestCase
logger = logging.getLogger('app.logger')
class TestApiPreset(BootTestCase):
def setUp(self):
super().setUp()
def test_tags(self):
client = APIClient()
client.login(username="testuser", password="test1234")
user = User.objects.get(username="testuser")
project = Project.objects.create(
owner=user,
name="test project",
tags="a b c .hidden"
)
# Can retrieve tags
res = client.get("/api/projects/{}/".format(project.id))
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertEqual(4, len(res.data['tags']))
# Can update tags
res = client.post("/api/projects/{}/edit/".format(project.id), {
'tags': ["b", "c", ".hidden"]
}, format="json")
self.assertEqual(res.status_code, status.HTTP_200_OK)
project.refresh_from_db()
self.assertEqual(project.tags, "b c .hidden")
# Can search projects by tag
project2 = Project.objects.create(
owner=user,
name="test project2",
tags="c d"
)
res = client.get("/api/projects/?search=:c")
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertEqual(2, len(res.data))
res = client.get("/api/projects/?search=:d")
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertEqual(1, len(res.data))
# Can search projects by name
res = client.get("/api/projects/?search=project2")
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertEqual(1, len(res.data))
Task.objects.create(project=project, name="TestTask0")
task = Task.objects.create(project=project, name="TestTask1", tags="d .hidden")
task2 = Task.objects.create(project=project2, name="TestTask2", tags="ee .hidden")
# Can retrieve task tags
res = client.get("/api/projects/{}/tasks/{}/".format(project.id, task.id))
self.assertEqual(2, len(res.data['tags']))
# Can update task tags
res = client.patch("/api/projects/{}/tasks/{}/".format(project.id, task.id), {
'tags': ["d", "e", ".hidden"]
}, format="json")
self.assertTrue(res.status_code == status.HTTP_200_OK)
task.refresh_from_db()
self.assertEqual(task.tags, "d e .hidden")
# Can search task tags
res = client.get("/api/projects/?search=::e")
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertEqual(1, len(res.data))
self.assertEqual(res.data[0]['tasks'][0], task.id)
res = client.get("/api/projects/?search=::hidden")
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertEqual(2, len(res.data))
# Can search task names
res = client.get("/api/projects/?search=TestTask2")
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertEqual(1, len(res.data))
self.assertEqual(res.data[0]['tasks'][0], task2.id)

Wyświetl plik

@ -1,6 +1,6 @@
{
"name": "WebODM",
"version": "1.9.19",
"version": "2.0.0",
"description": "User-friendly, extendable application and API for processing aerial imagery.",
"main": "index.js",
"scripts": {