kopia lustrzana https://github.com/OpenDroneMap/WebODM
commit
78c62a6131
|
@ -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 && \
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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 []
|
|
@ -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:
|
||||
|
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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:
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
/>;
|
||||
};
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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">
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>«</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>»</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
<ul className="pagination pagination-sm">
|
||||
<li className={currentPage === 1 ? "disabled" : ""}>
|
||||
<Link to={{search: this.getQueryForPage(1)}}>
|
||||
<span>«</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>»</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);
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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}
|
||||
/> : ""}
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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> </div>
|
||||
)}
|
||||
<div className="inputText" contentEditable="true" ref={(domNode) => this.inputText = domNode}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onBlur={this.addTag}></div>
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
||||
export default TagsField;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
})
|
||||
});
|
|
@ -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);
|
||||
})
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -1,2 +1,5 @@
|
|||
.task-list{
|
||||
.task-bar{
|
||||
text-align: right;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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() {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
|
@ -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": {
|
||||
|
|
Ładowanie…
Reference in New Issue