From 657ddd67366dfe99e68744ac456948c8724bd7a6 Mon Sep 17 00:00:00 2001 From: Enrico Ferreguti Date: Tue, 19 Jun 2018 22:35:24 +0200 Subject: [PATCH 1/2] Search history new feature --- djangoql/admin.py | 35 +++- djangoql/migrations/0001_initial.py | 25 +++ djangoql/migrations/__init__.py | 0 djangoql/models.py | 6 + djangoql/static/djangoql/js/completion.js | 175 ++++++++++++++++-- .../static/djangoql/js/completion_admin.js | 28 +++ 6 files changed, 253 insertions(+), 16 deletions(-) create mode 100644 djangoql/migrations/0001_initial.py create mode 100644 djangoql/migrations/__init__.py create mode 100644 djangoql/models.py diff --git a/djangoql/admin.py b/djangoql/admin.py index f54b98a..e1798cd 100644 --- a/djangoql/admin.py +++ b/djangoql/admin.py @@ -4,6 +4,7 @@ from django.contrib import messages from django.contrib.admin.views.main import ChangeList from django.core.exceptions import FieldError, ValidationError +from django.http.response import JsonResponse from django.forms import Media from django.http import HttpResponse from django.views.generic import TemplateView @@ -12,6 +13,9 @@ from .exceptions import DjangoQLError from .queryset import apply_search from .schema import DjangoQLSchema +from .models import history + +import sys DJANGOQL_SEARCH_MARKER = 'q-l' @@ -57,10 +61,9 @@ def get_search_results(self, request, queryset, search_term): if not search_term: return queryset, use_distinct try: - return ( - apply_search(queryset, search_term, self.djangoql_schema), - use_distinct, - ) + qs = apply_search(queryset, search_term, self.djangoql_schema) + self.new_history_item(request.user, search_term) + return ( qs, use_distinct, ) except (DjangoQLError, ValueError, FieldError) as e: msg = text_type(e) except ValidationError as e: @@ -101,12 +104,17 @@ def get_urls(self): self.model._meta.model_name, ), ), + url( + r'^djangoql-history/$', + self.admin_site.admin_view(self.get_history), + name='history', + ), url( r'^djangoql-syntax/$', TemplateView.as_view( template_name=self.djangoql_syntax_help_template, ), - name='djangoql_syntax_help', + name='history', ), ] return custom_urls + super(DjangoQLSearchMixin, self).get_urls() @@ -117,3 +125,20 @@ def introspect(self, request): content=json.dumps(response, indent=2), content_type='application/json; charset=utf-8', ) + + def new_history_item(self,user,search_term): + history_item = history() + history_item.user = user + history_item.query = search_term + history_item.save() + + def get_history(self, request): + reverse_history_qs = history.objects.filter(user=request.user) + history_qs = reverse_history_qs.order_by('-pk') + lastQueries = [] + for i,q in enumerate(history_qs): + if i < 200: #limit history to 200 items per user + lastQueries.append(q.query) + else: + q.delete() + return JsonResponse({'history':lastQueries}) diff --git a/djangoql/migrations/0001_initial.py b/djangoql/migrations/0001_initial.py new file mode 100644 index 0000000..3e1f962 --- /dev/null +++ b/djangoql/migrations/0001_initial.py @@ -0,0 +1,25 @@ +# Generated by Django 2.0.6 on 2018-06-15 20:22 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='history', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('query', models.CharField(max_length=2000)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/djangoql/migrations/__init__.py b/djangoql/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/djangoql/models.py b/djangoql/models.py new file mode 100644 index 0000000..58bc428 --- /dev/null +++ b/djangoql/models.py @@ -0,0 +1,6 @@ +from django.db import models +from django.contrib.auth.models import User + +class history(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + query = models.CharField(max_length=2000) diff --git a/djangoql/static/djangoql/js/completion.js b/djangoql/static/djangoql/js/completion.js index 3b6c1b8..dd7fe35 100644 --- a/djangoql/static/djangoql/js/completion.js +++ b/djangoql/static/djangoql/js/completion.js @@ -114,6 +114,8 @@ return { currentModel: null, models: {}, + currentHistory: null, + historyEnabled: false, token: token, lexer: lexer, @@ -130,13 +132,18 @@ init: function (options) { var syntaxHelp; + var history; + var history_enabled = false; // Initialization if (!this.isObject(options)) { this.logError('Please pass an object with initialization parameters'); return; } + this.loadIntrospections(options.introspections); + this.loadHistory(options.history) + this.textarea = document.querySelector(options.selector); if (!this.textarea) { this.logError('Element not found by selector: ' + options.selector); @@ -199,6 +206,17 @@ document.querySelector('body').appendChild(this.completion); this.completionUL = document.createElement('ul'); this.completion.appendChild(this.completionUL); + + history = document.createElement('p'); + history.className = 'syntax-help'; + history.innerHTML = 'Search History'; + history.addEventListener('mousedown', function (e) { + this.historyEnabled = true; + this.loadHistory(options.history); + this.renderHistory(); + }.bind(this)); + this.completion.appendChild(history); + if (typeof options.syntaxHelp === 'string') { syntaxHelp = document.createElement('p'); syntaxHelp.className = 'syntax-help'; @@ -223,6 +241,35 @@ this.hideCompletion(); }, + loadHistory: function (history) { + var onLoadError; + var request; + + onLoadError = function (history) { + this.logError('failed to load history from' + history); + }.bind(this); + request = new XMLHttpRequest(); + request.open('GET', history, true); + request.onload = function () { + var data; + if (request.status === 200) { + data = JSON.parse(request.responseText); + this.currentHistory = data.history; + } else { + onLoadError(); + } + }.bind(this); + request.ontimeout = onLoadError; + request.onerror = onLoadError; + /* eslint-disable max-len */ + // Workaround for IE9, see + // https://cypressnorth.com/programming/internet-explorer-aborting-ajax-requests-fixed/ + /* eslint-enable max-len */ + request.onprogress = function () {}; + window.setTimeout(request.send.bind(request)); + + }, + loadIntrospections: function (introspections) { var onLoadError; var request; @@ -318,7 +365,12 @@ }, onCompletionMouseClick: function (e) { - this.selectCompletion(parseInt(e.target.getAttribute('data-index'), 10)); + if (!this.historyEnabled) { + this.selectCompletion(parseInt(e.target.getAttribute('data-index'), 10)); + } else { + this.textarea.value = e.target.textContent; + document.getElementById('changelist-search').submit(); + } }, onCompletionMouseDown: function (e) { @@ -327,16 +379,21 @@ }, onCompletionMouseOut: function () { - this.selected = null; - this.debouncedRenderCompletion(); + if (!this.historyEnabled) { + this.selected = null; + this.debouncedRenderCompletion(); + } }, onCompletionMouseOver: function (e) { - this.selected = parseInt(e.target.getAttribute('data-index'), 10); - this.debouncedRenderCompletion(); + if (!this.historyEnabled) { + this.selected = parseInt(e.target.getAttribute('data-index'), 10); + this.debouncedRenderCompletion(); + } }, onKeydown: function (e) { + switch (e.keyCode) { case 38: // up arrow if (this.suggestions.length) { @@ -419,8 +476,10 @@ }, popupCompletion: function () { - this.generateSuggestions(); - this.renderCompletion(); + if (!this.historyEnabled) { + this.generateSuggestions(); + this.renderCompletion(); + } }, selectCompletion: function (index) { @@ -445,13 +504,17 @@ this.textareaResize(); } this.generateSuggestions(this.textarea); - this.renderCompletion(); + if (!this.historyEnabled) { + this.renderCompletion(); + } }, hideCompletion: function () { - this.selected = null; - if (this.completion) { - this.completion.style.display = 'none'; + if (!this.historyEnabled) { + this.selected = null; + if (this.completion) { + this.completion.style.display = 'none'; + } } }, @@ -472,6 +535,80 @@ '$1'); }, + renderHistory: function (dontForceDisplay) { + var currentLi; + var i; + var completionRect; + var currentLiRect; + var inputRect; + var li; + var liLen; + var historyLen; + + if (!this.completionEnabled) { + this.hideCompletion(); + return; + } + + if (dontForceDisplay && this.completion.style.display === 'none') { + return; + } + if (!this.currentHistory.length) { + this.hideCompletion(); + return; + } + + historyLen = this.currentHistory.length; + li = [].slice.call(this.completionUL.querySelectorAll('li')); + liLen = li.length; + + // Update or create necessary elements + for (i = 0; i < historyLen; i++) { + if (i < liLen) { + currentLi = li[i]; + } else { + currentLi = document.createElement('li'); + currentLi.setAttribute('data-index', i); + this.completionUL.appendChild(currentLi); + currentLi.addEventListener('click', this.onCompletionMouseClick); + currentLi.addEventListener('mousedown', this.onCompletionMouseDown); + currentLi.addEventListener('mouseout', this.onCompletionMouseOut); + currentLi.addEventListener('mouseover', this.onCompletionMouseOver); + } + currentLi.textContent = this.currentHistory[i]; + + if (this.currentHistory[i] == this.selected) { + currentLi.className = 'active'; + currentLiRect = currentLi.getBoundingClientRect(); + completionRect = this.completionUL.getBoundingClientRect(); + if (currentLiRect.bottom > completionRect.bottom) { + this.completionUL.scrollTop = this.completionUL.scrollTop + 2 + + (currentLiRect.bottom - completionRect.bottom); + } else if (currentLiRect.top < completionRect.top) { + this.completionUL.scrollTop = this.completionUL.scrollTop - 2 - + (completionRect.top - currentLiRect.top); + } + } else { + currentLi.className = ''; + } + } + // Remove redundant elements + while (liLen > historyLen) { + liLen--; + li[liLen].removeEventListener('click', this.onCompletionMouseClick); + li[liLen].removeEventListener('mousedown', this.onCompletionMouseDown); + li[liLen].removeEventListener('mouseout', this.onCompletionMouseOut); + li[liLen].removeEventListener('mouseover', this.onCompletionMouseOver); + this.completionUL.removeChild(li[liLen]); + } + + inputRect = this.textarea.getBoundingClientRect(); + this.completion.style.top = window.pageYOffset + inputRect.top + + inputRect.height + 'px'; + this.completion.style.left = inputRect.left + 'px'; + this.completion.style.display = 'block'; + }, + renderCompletion: function (dontForceDisplay) { var currentLi; var i; @@ -482,6 +619,11 @@ var liLen; var suggestionsLen; + if (this.historyEnabled) { + this.historyEnabled = false; + return + } + if (!this.completionEnabled) { this.hideCompletion(); return; @@ -495,6 +637,10 @@ return; } + if (!this.currentHistory) { + return; + } + suggestionsLen = this.suggestions.length; li = [].slice.call(this.completionUL.querySelectorAll('li')); liLen = li.length; @@ -669,6 +815,7 @@ return { prefix: prefix, scope: scope, model: model, field: field }; }, + generateSuggestions: function () { var input = this.textarea; var context; @@ -681,6 +828,12 @@ var textBefore; var textAfter; + + if (this.historyEnabled) { + this.historyEnabled = false; + return + } + if (!this.completionEnabled) { this.prefix = ''; this.suggestions = []; diff --git a/djangoql/static/djangoql/js/completion_admin.js b/djangoql/static/djangoql/js/completion_admin.js index 0136dff..a81eb68 100644 --- a/djangoql/static/djangoql/js/completion_admin.js +++ b/djangoql/static/djangoql/js/completion_admin.js @@ -26,6 +26,31 @@ return result; } + function parseQueryString() { + var qs = window.location.search.substring(1); + var result = {}; + var vars = qs.split('&'); + var i; + var l = vars.length; + var pair; + var key; + for (i = 0; i < l; i++) { + pair = vars[i].split('='); + key = decodeURIComponent(pair[0]); + if (key) { + if (typeof result[key] !== 'undefined') { + if (({}).toString.call(result[key]) !== '[object Array]') { + result[key] = [result[key]]; + } + result[key].push(decodeURIComponent(pair[1])); + } else { + result[key] = decodeURIComponent(pair[1]); + } + } + } + return result; + } + // Replace standard search input with textarea and add completion toggle DjangoQL.DOMReady(function () { // use '-' in the param name to prevent conflicts with any model field name @@ -36,6 +61,7 @@ var QLPlaceholder = 'Advanced search with Query Language'; var originalPlaceholder; var textarea; + var datalist; var input = document.querySelector('input[name=q]'); if (!input) { @@ -86,6 +112,7 @@ textarea.rows = 1; textarea.placeholder = QLEnabled ? QLPlaceholder : originalPlaceholder; textarea.setAttribute('maxlength', 2000); + input.parentNode.insertBefore(textarea, input); input.parentNode.removeChild(input); @@ -95,6 +122,7 @@ completionEnabled: QLEnabled, introspections: 'introspect/', syntaxHelp: 'djangoql-syntax/', + history: 'djangoql-history/', selector: 'textarea[name=q]', autoResize: true }); From a058334067e00405a1ac260a46bd5c88a6288ff7 Mon Sep 17 00:00:00 2001 From: enrico ferreguti Date: Tue, 19 Jun 2018 22:50:28 +0200 Subject: [PATCH 2/2] Revert pr change --- djangoql/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangoql/admin.py b/djangoql/admin.py index e1798cd..5abd165 100644 --- a/djangoql/admin.py +++ b/djangoql/admin.py @@ -114,7 +114,7 @@ def get_urls(self): TemplateView.as_view( template_name=self.djangoql_syntax_help_template, ), - name='history', + name='djangoql_syntax_help', ), ] return custom_urls + super(DjangoQLSearchMixin, self).get_urls()