diff --git a/README.rst b/README.rst
index 0220f73..b1044a1 100644
--- a/README.rst
+++ b/README.rst
@@ -42,6 +42,12 @@ Add ``'djangoql'`` to ``INSTALLED_APPS`` in your ``settings.py``:
...
]
+Include the djangoql URLconf in your project ``urls.py``:
+
+.. code:: python
+
+ url(r'^djangoql/', include('djangoql.urls'))
+
Add it to your Django admin
---------------------------
diff --git a/djangoql/admin.py b/djangoql/admin.py
index f54b98a..b8c3a24 100644
--- a/djangoql/admin.py
+++ b/djangoql/admin.py
@@ -1,7 +1,7 @@
import json
from django.conf.urls import url
-from django.contrib import messages
+from django.contrib import messages, admin
from django.contrib.admin.views.main import ChangeList
from django.core.exceptions import FieldError, ValidationError
from django.forms import Media
@@ -12,9 +12,11 @@
from .exceptions import DjangoQLError
from .queryset import apply_search
from .schema import DjangoQLSchema
+from .models import Query
DJANGOQL_SEARCH_MARKER = 'q-l'
+DJANGOQL_QUERY_ID = 'q-id'
class DjangoQLChangeList(ChangeList):
@@ -25,6 +27,8 @@ def get_filters_params(self, *args, **kwargs):
)
if DJANGOQL_SEARCH_MARKER in params:
del params[DJANGOQL_SEARCH_MARKER]
+ if DJANGOQL_QUERY_ID in self.params:
+ del params[DJANGOQL_QUERY_ID]
return params
@@ -33,6 +37,7 @@ class DjangoQLSearchMixin(object):
djangoql_completion = True
djangoql_schema = DjangoQLSchema
djangoql_syntax_help_template = 'djangoql/syntax_help.html'
+ djangoql_saved_queries = False
def search_mode_toggle_enabled(self):
# If search fields were defined on a child ModelAdmin instance,
@@ -75,16 +80,25 @@ def media(self):
if self.djangoql_completion:
js = [
'djangoql/js/lib/lexer.js',
+ 'djangoql/js/helpers.js',
'djangoql/js/completion.js',
]
+ css = [
+ 'djangoql/css/completion.css',
+ 'djangoql/css/completion_admin.css',
+ ]
if self.search_mode_toggle_enabled():
js.append('djangoql/js/completion_admin_toggle.js')
+
js.append('djangoql/js/completion_admin.js')
+
+ if self.djangoql_saved_queries:
+ js.append('djangoql/js/saved_queries.js')
+ js.append('djangoql/js/saved_queries_admin.js')
+ css.append('djangoql/css/saved_queries.css')
+
media += Media(
- css={'': (
- 'djangoql/css/completion.css',
- 'djangoql/css/completion_admin.css',
- )},
+ css={'': css},
js=js,
)
return media
@@ -117,3 +131,8 @@ def introspect(self, request):
content=json.dumps(response, indent=2),
content_type='application/json; charset=utf-8',
)
+
+
+@admin.register(Query)
+class SavedQueryAdmin(admin.ModelAdmin):
+ pass
diff --git a/djangoql/forms.py b/djangoql/forms.py
new file mode 100644
index 0000000..196d5ab
--- /dev/null
+++ b/djangoql/forms.py
@@ -0,0 +1,8 @@
+from django import forms
+from .models import Query
+
+
+class QueryForm(forms.ModelForm):
+ class Meta:
+ model = Query
+ fields = ['name', 'text', 'private']
diff --git a/djangoql/migrations/0001_initial.py b/djangoql/migrations/0001_initial.py
new file mode 100644
index 0000000..5aa3b13
--- /dev/null
+++ b/djangoql/migrations/0001_initial.py
@@ -0,0 +1,29 @@
+# Generated by Django 2.1.5 on 2019-02-11 18:47
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('contenttypes', '0002_remove_content_type_name'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Query',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=100)),
+ ('text', models.TextField()),
+ ('private', models.BooleanField(default=True)),
+ ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
+ ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='favorite_query_list', 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..96d772c
--- /dev/null
+++ b/djangoql/models.py
@@ -0,0 +1,20 @@
+from django.db import models
+from django.conf import settings
+
+
+class Query(models.Model):
+ name = models.CharField(max_length=100)
+ text = models.TextField()
+ private = models.BooleanField(default=True)
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ related_name='favorite_query_list',
+ on_delete=models.CASCADE,
+ blank=True,
+ null=True)
+ content_type = models.ForeignKey(
+ 'contenttypes.ContentType',
+ on_delete=models.CASCADE)
+
+ def __str__(self):
+ return self.text
diff --git a/djangoql/static/djangoql/css/completion_admin.css b/djangoql/static/djangoql/css/completion_admin.css
index d0296d5..2234ac8 100644
--- a/djangoql/static/djangoql/css/completion_admin.css
+++ b/djangoql/static/djangoql/css/completion_admin.css
@@ -1,5 +1,11 @@
#changelist #toolbar form textarea#searchbar {
- width: 80%;
+ flex-grow: 1;
+ margin-right: 5px;
+}
+
+#changelist-search div {
+ display: flex;
+ align-items: baseline;
}
.djangoql-toggle {
diff --git a/djangoql/static/djangoql/css/saved_queries.css b/djangoql/static/djangoql/css/saved_queries.css
new file mode 100644
index 0000000..8d81d3a
--- /dev/null
+++ b/djangoql/static/djangoql/css/saved_queries.css
@@ -0,0 +1,131 @@
+.djangoql-sq-dialog {
+ position: absolute;
+ overflow: hidden;
+ width: 350px;
+ display: none;
+ border: solid 1px #ccc;
+ border-radius: 4px;
+ background: white;
+ min-width: 183px;
+ font-size: 13px;
+}
+
+.djangoql-sq-dialog ul {
+ padding: 2px 0;
+ margin: 0;
+ max-height: 295px;
+ overflow: auto;
+}
+
+.djangoql-sq-dialog li {
+ list-style: none;
+ padding: 4px 10px;
+ cursor: pointer;
+}
+
+.djangoql-sq-button {
+ margin: 0 5px !important;
+}
+
+.djangoql-sq-actions li:last-child {
+ border-bottom: 1px solid #ccc;
+ padding-bottom: 8px;
+}
+
+.djangoql-sq-actions li {
+ color: #447e9b;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.djangoql-sq-actions li:hover {
+ color: #036;
+}
+
+.djangoql-sq-label {
+ font-size: 12px;
+ color: #666;
+ display: none;
+ margin-top: 10px;
+}
+
+
+#changelist-search .djangoql-sq-label {
+ margin-left: 22px;
+}
+
+.djangoql-sq-query-row {
+ display: flex;
+ overflow: hidden;
+}
+
+.djangoql-sq-query-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex-grow: 1;
+ cursor: pointer;
+}
+
+.djangoql-sq-query-name:hover {
+ color: #000;
+}
+
+.djangoql-sq-ops {
+ display: none;
+ margin-left: 5px;
+}
+
+.djangoql-sq-query-row:hover .djangoql-sq-ops {
+ display: block;
+ white-space: nowrap;
+}
+
+.djangoql-sq-label-reset {
+ width: 8px;
+ height: 8px;
+ background: url(../img/remove.svg);
+ background-size: 100%;
+}
+
+.djangoql-sq-ops span, .djangoql-sq-label-reset {
+ display: inline-block;
+ margin: 0 3px;
+ cursor: pointer;
+
+ color: #666;
+}
+
+.djangoql-sq-ops span:hover {
+ color: rgba(255, 255, 255, 0.7) !important;
+}
+
+.djangoql-sq-label:hover .djangoql-sq-label-reset {
+ display: inline-block;
+}
+
+.django-sq-query-list-placeholder {
+ color: #999;
+ background: none !important;
+ display: none;
+}
+
+.django-sq-query-list li:hover {
+ background-color: #79aec8;
+}
+
+.django-sq-query-list li:hover .djangoql-sq-ops span {
+ color: white;
+ background-color: #79aec8;
+}
+
+.django-sq-query-list li:hover .djangoql-sq-ops span,
+.django-sq-query-list li:hover .djangoql-sq-query-name {
+ color: white;
+ background-color: #79aec8;
+}
+
+.djangoql-sq-button-container {
+ display: inline-block;
+}
\ No newline at end of file
diff --git a/djangoql/static/djangoql/img/remove.svg b/djangoql/static/djangoql/img/remove.svg
new file mode 100644
index 0000000..ab3878b
--- /dev/null
+++ b/djangoql/static/djangoql/img/remove.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/djangoql/static/djangoql/js/completion_admin.js b/djangoql/static/djangoql/js/completion_admin.js
index f354360..964606a 100644
--- a/djangoql/static/djangoql/js/completion_admin.js
+++ b/djangoql/static/djangoql/js/completion_admin.js
@@ -1,36 +1,11 @@
-(function (DjangoQL) {
+(function (DjangoQL, helpers) {
'use strict';
- 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
var QLParamName = 'q-l';
- var QLEnabled = parseQueryString()[QLParamName] === 'on';
+ var QLEnabled = helpers.parseQueryString()[QLParamName] === 'on';
var QLInput;
var QLToggle;
var QLPlaceholder = 'Advanced search with Query Language';
@@ -100,4 +75,4 @@
autoResize: true
});
});
-}(window.DjangoQL));
+}(window.DjangoQL, window.DjangoQLHelpers));
diff --git a/djangoql/static/djangoql/js/helpers.js b/djangoql/static/djangoql/js/helpers.js
new file mode 100644
index 0000000..258944f
--- /dev/null
+++ b/djangoql/static/djangoql/js/helpers.js
@@ -0,0 +1,89 @@
+(function (root, factory) {
+ 'use strict';
+
+ /* global define, require */
+
+ if (typeof define === 'function' && define.amd) {
+ // AMD. Register as an anonymous module.
+ define('DjangoQLHelpers', [], factory);
+ } else if (typeof exports === 'object') {
+ // Node. Does not work with strict CommonJS, but
+ // only CommonJS-like environments that support module.exports,
+ // like Node.
+ module.exports = factory(); // eslint-disable-line
+ } else {
+ // Browser globals (root is window)
+ root.DjangoQLHelpers = factory(); // eslint-disable-line
+ }
+}(this, function () {
+ 'use strict';
+
+ return {
+ parseQueryString: function () {
+ 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;
+ },
+
+ updateURLParameter: function (url, param, paramVal) {
+ var newAdditionalURL = '';
+ var tempArray = url.split('?');
+ var baseURL = tempArray[0];
+ var additionalURL = tempArray[1];
+ var temp = '';
+ var i;
+ var rowsTxt = '';
+ if (additionalURL) {
+ tempArray = additionalURL.split('&');
+ for (i = 0; i < tempArray.length; i++) {
+ if (tempArray[i].split('=')[0] !== param) {
+ newAdditionalURL += temp + tempArray[i];
+ temp = '&';
+ }
+ }
+ }
+
+ if (paramVal !== null) {
+ rowsTxt = temp + '' + param + '=' + paramVal;
+ }
+ return baseURL + '?' + newAdditionalURL + rowsTxt;
+ },
+
+ getCookie: function (name) {
+ var cookieValue = null;
+ var i;
+ var cookie;
+ var cookies = document.cookie.split(';');
+ if (document.cookie && document.cookie !== '') {
+ for (i = 0; i < cookies.length; i++) {
+ cookie = cookies[i].trim();
+ // Does this cookie string begin with the name we want?
+ if (cookie.substring(0, name.length + 1) === (name + '=')) {
+ cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
+ break;
+ }
+ }
+ }
+ return cookieValue;
+ }
+ };
+}));
diff --git a/djangoql/static/djangoql/js/saved_queries.js b/djangoql/static/djangoql/js/saved_queries.js
new file mode 100644
index 0000000..67772a8
--- /dev/null
+++ b/djangoql/static/djangoql/js/saved_queries.js
@@ -0,0 +1,968 @@
+/**
+ * Saved queries module for DjangoQL plugin.
+ *
+ * Consists of three main components:
+ * - QueryRepo - interacts with the backend
+ * - View - abstracts DOM, sends interaction events to Controller
+ * - Controller - wires up view and repo
+ *
+ * Module exposes View and QueryRepo as a constructors for external
+ * instantiation (which can be used for substitution in unit tests),
+ * and the "init" function that constructs Controller with provided view and
+ * repo as parameters.
+ */
+(function (root, factory) {
+ 'use strict';
+
+ /* global define, require */
+
+ if (typeof define === 'function' && define.amd) {
+ // AMD. Register as an anonymous module.
+ define('SavedQueries', ['DjangoQLHelpers'], factory);
+ } else if (typeof exports === 'object') {
+ // Node. Does not work with strict CommonJS, but
+ // only CommonJS-like environments that support module.exports,
+ // like Node.
+ module.exports = factory(require('DjangoQLHelpers')); // eslint-disable-line
+ } else {
+ // Browser globals (root is window)
+ root.SavedQueries = factory(root.DjangoQLHelpers); // eslint-disable-line
+ }
+}(this, function (helpers) {
+ var DJANGO_QL_QUERY_ID = 'q-id';
+ var DJANGO_QUERY_PARAM = 'q';
+
+ /**
+ * Query repository. Persists and retrieves queries from a backend
+ *
+ * @constructor
+ * @param {object} params
+ * @param {string} params.endpoint - URL for requests
+ * @param {string} params.model - Model name
+ * @param {string} params.token - Django CSRF-token
+ */
+ function QueryRepo(params) {
+ this.endpoint = params.endpoint;
+ this.model = params.model;
+ this.token = helpers.getCookie('csrftoken');
+ }
+
+ /**
+ * Get query from repository
+ *
+ * @param {number} id
+ * @param {function} [callback]
+ */
+ QueryRepo.prototype.get = function (id, callback) {
+ var req = new XMLHttpRequest();
+ req.open('GET', this.getUrl(id), true);
+ req.onload = function () {
+ if (req.status !== 200) {
+ // eslint-disable-next-line no-console
+ console.error(req.responseText);
+ } else if (callback) {
+ callback(JSON.parse(req.responseText));
+ }
+ };
+ req.send();
+ };
+
+ /**
+ * Save query
+ *
+ * @param {object} obj
+ * @param {function} [callback]
+ */
+ QueryRepo.prototype.save = function (obj, callback) {
+ var req = new XMLHttpRequest();
+ req.open('POST', this.getUrl(), true);
+ req.setRequestHeader('X-CSRFToken', this.token);
+ req.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
+ req.onload = function () {
+ if (req.status !== 200) {
+ // eslint-disable-next-line no-console
+ console.error(req.responseText);
+ } else if (callback) {
+ callback(JSON.parse(req.responseText));
+ }
+ };
+ req.send(JSON.stringify(obj));
+ };
+
+ /**
+ * Get list of saved queries
+ *
+ * @param {function} [callback]
+ */
+ QueryRepo.prototype.list = function (callback) {
+ var req = new XMLHttpRequest();
+ req.open('GET', this.getUrl(), true);
+ req.onload = function () {
+ if (req.status !== 200) {
+ // eslint-disable-next-line no-console
+ console.error(req.responseText);
+ } else if (callback) {
+ callback(JSON.parse(req.responseText));
+ }
+ };
+ req.send();
+ };
+
+ /**
+ * Remove saved query
+ *
+ * @param {number} id
+ * @param {function} [callback]
+ */
+ QueryRepo.prototype.remove = function (id, callback) {
+ var req = new XMLHttpRequest();
+ req.open('DELETE', this.getUrl(id), true);
+ req.setRequestHeader('X-CSRFToken', this.token);
+ req.onload = function () {
+ if (req.status !== 200) {
+ // eslint-disable-next-line no-console
+ console.error(req.responseText);
+ } else if (callback) {
+ callback(JSON.parse(req.responseText));
+ }
+ };
+ req.send();
+ };
+
+ /**
+ * Get the url for requests
+ *
+ * @param {number} [id]
+ * @returns {string}
+ */
+ QueryRepo.prototype.getUrl = function (id) {
+ return this.endpoint + (id || '') + '?model=' + this.model;
+ };
+
+ /**
+ * Controller for saved queries:
+ * - retrieves and persists the model via the query repository
+ * - exposes the model to the view and provides event handlers
+ *
+ * @param {object} params
+ * @param {object} params.view - Instance of the View
+ * @param {object} params.repo - Instance of the QueryRepository
+ * @param {object} params.submitOnSelect - Submit form when query selected
+ */
+ function Controller(params) {
+ this.view = params.view;
+ this.repo = params.repo;
+ this.submitOnSelect = params.submitOnSelect;
+ this.currentQuery = null;
+
+ this.initCurrentQuery();
+ this.refreshQueryList();
+
+ this.view.subscribe('selectQuery', this.selectQuery.bind(this));
+ this.view.subscribe('removeQuery', this.removeQuery.bind(this));
+ this.view.subscribe('changeQueryScope', this.changeQueryScope.bind(this));
+ this.view.subscribe('saveAsNewQuery', this.saveAsNewQuery.bind(this));
+ this.view.subscribe('renameQuery', this.renameQuery.bind(this));
+ this.view.subscribe('resetCurrentQuery', this.resetCurrentQuery.bind(this));
+ this.view.subscribe('saveAsCurrentQuery',
+ this.saveAsCurrentQuery.bind(this));
+ }
+
+ /**
+ * Removes query from repo
+ *
+ * @param {object} query
+ * @param {function} callback
+ */
+ Controller.prototype.removeQuery = function (query, callback) {
+ var self = this;
+ var isCurrent = self.currentQuery && (query.id === self.currentQuery.id);
+ this.repo.remove(query.id, function () {
+ if (callback) {
+ callback();
+ }
+ if (isCurrent) {
+ self.resetCurrentQuery();
+ }
+ });
+ };
+
+ /**
+ * Changes the private/public attribute of the query
+ *
+ * @param {object} query
+ * @param {function} callback
+ */
+ Controller.prototype.changeQueryScope = function (query, callback) {
+ this.repo.save({
+ id: query.id,
+ name: query.name,
+ text: query.text,
+ private: !query.private
+ }, callback);
+ };
+
+ /**
+ * If the current query is set - tell to the view about this
+ */
+ Controller.prototype.initCurrentQuery = function () {
+ var qs = helpers.parseQueryString();
+ var self = this;
+ var currentQueryId = qs[DJANGO_QL_QUERY_ID];
+ if (!currentQueryId) {
+ // init without an object
+ self.view.setCurrentQuery();
+ return;
+ }
+ this.repo.get(currentQueryId, function (obj) {
+ self.view.setCurrentQuery(obj);
+ self.currentQuery = obj;
+ });
+ };
+
+ /**
+ * Select query:
+ * - set appropriate attributes to the url (query text and id)
+ * - reload the page if necessary
+ * - or just tell to view about the selected query
+ *
+ * @param {object} query
+ * @param {boolean} noReload
+ */
+ Controller.prototype.selectQuery = function (query, noReload) {
+ var loc;
+ loc = window.location.href;
+ loc = helpers.updateURLParameter(loc, DJANGO_QUERY_PARAM, query.text);
+ loc = helpers.updateURLParameter(loc, DJANGO_QL_QUERY_ID, query.id);
+ // Use the history API for url changes, since we don't always need to
+ // reload the page when choosing a query
+ history.replaceState(null, null, loc);
+ if (this.submitOnSelect && !noReload) {
+ location.reload();
+ return;
+ }
+ this.currentQuery = query;
+ this.view.setCurrentQuery(query);
+ };
+
+ /**
+ * Reset current query:
+ * - tell to the view about this
+ * - update url params
+ */
+ Controller.prototype.resetCurrentQuery = function () {
+ var location;
+ this.currentQuery = null;
+ this.view.setCurrentQuery();
+ location = window.location.href;
+ location = helpers.updateURLParameter(location, DJANGO_QL_QUERY_ID, null);
+ // Use the history API for url changes, since we just resets current state,
+ // but want to persist this state after the page is reloaded
+ history.replaceState(null, null, location);
+ };
+
+ /**
+ * Saves current search as new query
+ *
+ * @param {string} [name]
+ * @param {function} [callback]
+ */
+ Controller.prototype.saveAsNewQuery = function (name, callback) {
+ // eslint-disable-next-line no-alert
+ var newName = name || prompt('Please enter a name for the new query');
+ var text = this.view.getSearchText();
+ var self = this;
+ var query;
+ if (!(newName && text)) {
+ return;
+ }
+ query = { name: newName, text: text };
+ this.repo.save(query, function (result) {
+ if (callback) {
+ callback(result);
+ }
+ self.selectQuery({ text: text, id: result.id, name: newName }, true);
+ self.refreshQueryList();
+ });
+ };
+
+ /**
+ * Save current search as selected query
+ *
+ * @param {string} [name]
+ * @param {function} callback
+ */
+ Controller.prototype.saveAsCurrentQuery = function (name, callback) {
+ var self = this;
+ this.repo.save({
+ name: name || this.currentQuery.name,
+ text: this.view.getSearchText(),
+ id: this.currentQuery.id
+ }, function () {
+ self.refreshQueryList();
+ if (callback) {
+ callback();
+ }
+ });
+ };
+
+ /**
+ * Renames current selected query.
+ * if the name is not specified, ask the user to enter it
+ *
+ * @param {object} query
+ * @param {number} query.id
+ * @param {string} query.text
+ * @param {boolean} query.private
+ * @param {string} name
+ * @param {function} callback
+ */
+ Controller.prototype.renameQuery = function (query, name, callback) {
+ var self = this;
+ var changed;
+ // eslint-disable-next-line no-alert
+ var newName = name || prompt(
+ 'Please enter a new name for the query', query.name);
+ if (!newName) {
+ return;
+ }
+ changed = {
+ id: query.id,
+ name: newName,
+ text: query.text,
+ private: query.private
+ };
+ this.repo.save(changed, function () {
+ if (callback) {
+ callback();
+ }
+ if (self.currentQuery && (query.id === self.currentQuery.id)) {
+ self.view.setCurrentQuery(changed);
+ }
+ self.refreshQueryList();
+ });
+ };
+
+ /**
+ * Refreshes the query list from the repo
+ */
+ Controller.prototype.refreshQueryList = function () {
+ var self = this;
+ this.repo.list(function (data) {
+ self.view.populateQueryList(data);
+ });
+ };
+
+ /**
+ * Base prototype for view components
+ *
+ * @constructor
+ * @param {object} params
+ * @param {object} params.eventBus
+ */
+ function ViewComponent(params) {
+ this.eventBus = params && params.eventBus;
+ this.element = this.buildElement();
+ // Backref for debugging
+ this.element.view = this;
+ }
+
+ /**
+ * Factory method for building a DOM-element
+ *
+ * @returns {object} DOM-element
+ */
+ ViewComponent.prototype.buildElement = function () {
+ // eslint-disable-next-line no-console
+ console.error('Must override buildElement method in concrete component.');
+ };
+
+ /**
+ * Inserts the element of the current component after the specified
+ *
+ * @param {object} ref DOM-element to insert after
+ */
+ ViewComponent.prototype.insertAfter = function (ref) {
+ ref.parentNode.insertBefore(this.element, ref.nextSibling);
+ };
+
+ /**
+ * Snaps the element of the current component to the specified
+ *
+ * @param {object} ref DOM-element
+ */
+ ViewComponent.prototype.snapTo = function (ref) {
+ var rect1 = this.element.getBoundingClientRect();
+ var rect2 = ref.getBoundingClientRect();
+
+ /* eslint-disable no-param-reassign */
+ this.element.style.top = (rect2.top + rect2.height +
+ window.pageYOffset + 'px');
+ this.element.style.left = ((rect2.left - rect1.width) +
+ rect2.width + window.pageXOffset + 'px');
+ };
+
+ /**
+ * Insert the current element to another
+ *
+ * @param {object} container
+ */
+ ViewComponent.prototype.insertInto = function (container) {
+ container.appendChild(this.element);
+ };
+
+ /**
+ * Toggle visibility of the current element
+ *
+ * @param {boolean} [val] visibility
+ */
+ ViewComponent.prototype.toggle = function (val) {
+ var visibility = (val === undefined ? !this.isVisible() : val);
+ this.element.style.display = visibility ? 'block' : 'none';
+ };
+
+ /**
+ * Determines whether the element is visible or not
+ *
+ * @returns {boolean}
+ */
+ ViewComponent.prototype.isVisible = function () {
+ return this.element.style.display !== 'none';
+ };
+
+ /**
+ * Removes all element contents
+ */
+ ViewComponent.prototype.clean = function () {
+ this.element.innerHTML = '';
+ };
+
+ /**
+ * Set text to element
+ *
+ * @param {string} text
+ */
+ ViewComponent.prototype.setText = function (text) {
+ this.element.innerText = text;
+ };
+
+
+ /**
+ * Label to display current selected query
+ */
+ function QueryLabel() {
+ ViewComponent.apply(this, arguments);
+ }
+
+ QueryLabel.prototype = Object.create(ViewComponent.prototype);
+
+ /**
+ * Builds label contents
+ *
+ * @returns {object}
+ */
+ QueryLabel.prototype.buildElement = function () {
+ var self = this;
+ var el = document.createElement('div');
+ el.className = 'djangoql-sq-label';
+ this.label = document.createElement('span');
+ this.label.className = 'djangoql-sq-label-name';
+ this.reset = document.createElement('span');
+ this.reset.className = 'djangoql-sq-label-reset';
+ el.appendChild(this.label);
+ el.appendChild(this.reset);
+ this.reset.addEventListener('click', function () {
+ self.eventBus.notify('resetCurrentQuery');
+ });
+ this.eventBus.subscribe('setCurrentQuery', function (query) {
+ if (query) {
+ self.setText(query.name);
+ self.toggle(true);
+ } else {
+ self.toggle(false);
+ }
+ });
+ return el;
+ };
+
+ /**
+ * Set text to label
+ *
+ * @param {string} text
+ */
+ QueryLabel.prototype.setText = function (val) {
+ this.label.innerText = val;
+ };
+
+ /**
+ * Button for displaying saved queries dialog
+ *
+ * @param {object} params
+ * @param {string} params.buttonText
+ */
+ function Button(params) {
+ this.buttonText = params.buttonText;
+
+ ViewComponent.apply(this, arguments);
+ }
+
+ Button.prototype = Object.create(ViewComponent.prototype);
+
+ /**
+ * Builds button contents
+ *
+ * @returns {object}
+ */
+ Button.prototype.buildElement = function () {
+ var el = document.createElement('input');
+ el.type = 'submit';
+ el.className = 'djangoql-sq-button';
+ el.value = this.buttonText;
+ return el;
+ };
+
+ /**
+ * Actions in query dialog
+ */
+ function QueryActions() {
+ var self = this;
+ ViewComponent.apply(this, arguments);
+
+ this.populate();
+
+ // Show "save as new" only when search input is not empty
+ this.eventBus.subscribe('searchTextChanged', function (val) {
+ self.toggleAction('new', !!val);
+ });
+ // Show "save as ..." only when current query selected
+ this.eventBus.subscribe('setCurrentQuery', function (query) {
+ self.toggleAction('current', !!query);
+ if (query) {
+ self.getActionById('current').innerText = (
+ 'Save as "' + query.name + '"');
+ }
+ });
+ }
+
+ QueryActions.prototype = Object.create(ViewComponent.prototype);
+
+ /**
+ * Get action by identifier
+ *
+ * @param {string} id
+ * @returns {object}
+ */
+ QueryActions.prototype.getActionById = function (id) {
+ return this.element.querySelector('[data-id="' + id + '"]');
+ };
+
+ /**
+ * Toggles visibility of the action
+ *
+ * @param {number} id
+ * @param {boolean} val
+ */
+ QueryActions.prototype.toggleAction = function (id, val) {
+ this.getActionById(id).style.display = val ? 'block' : 'none';
+ };
+
+ /**
+ * Add default actions to the list
+ */
+ QueryActions.prototype.populate = function () {
+ var self = this;
+
+ this.addAction('Save as new query', 'new', function () {
+ self.eventBus.notify('saveAsNewQuery');
+ });
+
+ this.addAction('', 'current', function () {
+ self.eventBus.notify('saveAsCurrentQuery');
+ });
+ };
+
+ /**
+ * Builds container for query actions
+ */
+ QueryActions.prototype.buildElement = function () {
+ var el = document.createElement('ul');
+ el.className = 'djangoql-sq-actions';
+ return el;
+ };
+
+ /**
+ * Add query actions with provided parameters
+ *
+ * @param {string} name
+ * @param {number} id
+ * @param {function} callback
+ */
+ QueryActions.prototype.addAction = function (name, id, callback) {
+ var action = document.createElement('li');
+ action.innerText = name;
+ action.title = name;
+ action.style.display = 'none';
+ action.dataset.id = id;
+ action.addEventListener('click', callback);
+ this.element.appendChild(action);
+ };
+
+ /**
+ * List of saved queries
+ */
+ function QueryList() {
+ ViewComponent.apply(this, arguments);
+
+ this.placeholder = this.buildPlaceholder();
+ this.element.appendChild(this.placeholder);
+ }
+
+ QueryList.prototype = Object.create(ViewComponent.prototype);
+
+ /**
+ * Builds container for the saved queries list
+ */
+ QueryList.prototype.buildElement = function () {
+ var el = document.createElement('ul');
+ el.className = 'django-sq-query-list';
+ return el;
+ };
+
+ /**
+ * Builds placeholder for an empty list
+ */
+ QueryList.prototype.buildPlaceholder = function () {
+ var placeholder = document.createElement('li');
+ placeholder.className = 'django-sq-query-list-placeholder';
+ placeholder.innerText = 'Query list is empty';
+ return placeholder;
+ };
+
+ /**
+ * Populate query list with provided data
+ *
+ * @param {array} data
+ */
+ QueryList.prototype.populate = function (data) {
+ this.clean();
+ data.forEach(this.addQuery.bind(this));
+ this.handlePlaceholder();
+ };
+
+ /**
+ * Removes all queries from the list (ignoring the placeholder)
+ */
+ QueryList.prototype.clean = function () {
+ var items = this.element.getElementsByClassName(
+ 'djangoql-sq-query-row');
+ var i;
+ for (i = items.length - 1; i >= 0; i--) {
+ items[i].remove();
+ }
+ };
+
+ /**
+ * Build an action for the query row
+ *
+ * @param {object} params
+ * @param {object} params.name - Name to display in the list
+ * @param {object} params.event - Event name
+ * @param {object} params.title - Title to show on a hover
+ * @param {object} params.query
+ * @param {object} params.callback
+ */
+ QueryList.prototype.buildAction = function (params) {
+ var action = document.createElement('span');
+ var self = this;
+ var name = params.name;
+ var event = params.event;
+ var title = params.title;
+ var query = params.query;
+ var callback = params.callback;
+
+ action.innerText = name;
+ action.title = title;
+ action.addEventListener('click', function (e) {
+ self.eventBus.notify(event, query);
+ if (callback) {
+ callback.call(action);
+ }
+ e.stopPropagation();
+ self.handlePlaceholder();
+ });
+ return action;
+ };
+
+ /**
+ * Adds a query to the list
+ *
+ * @param {object} query
+ */
+ QueryList.prototype.addQuery = function (query) {
+ var self = this;
+ var row = document.createElement('li');
+ var name = document.createElement('span');
+ var ops = document.createElement('span');
+
+ this.element.appendChild(row);
+
+ row.className = 'djangoql-sq-query-row';
+ name.className = 'djangoql-sq-query-name';
+ name.innerText = query.name;
+ name.title = query.name;
+ row.appendChild(name);
+ ops.className = 'djangoql-sq-ops';
+ row.appendChild(ops);
+
+ ops.appendChild(
+ this.buildAction({
+ name: 'remove',
+ event: 'removeQuery',
+ title: 'Remove query',
+ query: query,
+ callback: row.parentNode.removeChild.bind(row.parentNode, row)
+ }));
+
+ ops.appendChild(
+ this.buildAction({
+ name: 'rename',
+ event: 'renameQuery',
+ title: 'Rename query',
+ query: query
+ }));
+
+ ops.appendChild(
+ this.buildAction({
+ name: query.private ? 'private' : 'public',
+ event: 'changeQueryScope',
+ title: 'Change query scope',
+ query: query,
+ callback: function () {
+ query.private = !query.private;
+ this.innerHTML = query.private ? 'private' : 'public';
+ }
+ }));
+
+ row.addEventListener('click', function () {
+ self.eventBus.notify('selectQuery', query);
+ });
+ };
+
+ /**
+ * Shows a placeholder when there is no data
+ */
+ QueryList.prototype.handlePlaceholder = function () {
+ this.placeholder.style.display = this.getQueryCount() ? 'none' : 'block';
+ };
+
+ /**
+ * Returns the total queries count
+ *
+ * @returns {number}
+ */
+ QueryList.prototype.getQueryCount = function () {
+ return this.element.getElementsByClassName(
+ 'djangoql-sq-query-row').length;
+ };
+
+ /**
+ * Dialog with query actions and the query list
+ */
+ function Dialog() {
+ ViewComponent.apply(this, arguments);
+
+ this.queryList = new QueryList({ eventBus: this.eventBus });
+ this.queryActions = new QueryActions({ eventBus: this.eventBus });
+ this.element.appendChild(this.queryActions.element);
+ this.element.appendChild(this.queryList.element);
+ this.eventBus.subscribe('saveAsCurrentQuery',
+ this.toggle.bind(this, false));
+ this.eventBus.subscribe('saveAsNewQuery', this.toggle.bind(this, false));
+ }
+
+ Dialog.prototype = Object.create(ViewComponent.prototype);
+
+ /**
+ * Builds dialog contents
+ *
+ * @returns {object}
+ */
+ Dialog.prototype.buildElement = function () {
+ var el = document.createElement('div');
+ el.className = 'djangoql-sq-dialog';
+ el.style.display = 'none';
+ el.style.outline = 'none';
+ el.tabIndex = 0;
+ return el;
+ };
+
+ /**
+ * Fill the query list with the provided data
+ *
+ * @param {array} data
+ */
+ Dialog.prototype.populateQueryList = function (data) {
+ this.queryList.populate(data);
+ };
+
+ /**
+ * Simple pub/sub implementation
+ */
+ function EventBus() {
+ this.handlers = {};
+ }
+
+ /**
+ * Subscribe a handler to provided event
+ *
+ * @param {string} event
+ * @param {function} fn
+ */
+ EventBus.prototype.subscribe = function (event, fn) {
+ this.handlers[event] = this.handlers[event] || [];
+ this.handlers[event].push(fn);
+ };
+
+ /**
+ * Unsubscribe a handler from the provided event
+ *
+ * @param {string} event
+ * @param {function} fn
+ */
+ EventBus.prototype.unsubscribe = function (event, fn) {
+ var handlers = this.handlers[event] || [];
+ var position = handlers.indexOf(fn);
+ if (position !== -1) {
+ handlers.splice(position, 1);
+ }
+ };
+
+ /**
+ * Notify event subscribers
+ *
+ * @param {string} event
+ * @param {object} payload
+ */
+ EventBus.prototype.notify = function (event, payload) {
+ if (!this.handlers[event]) {
+ return;
+ }
+ this.handlers[event].forEach(function (fn) {
+ fn(payload);
+ });
+ };
+
+ /**
+ * Abstracts DOM elements for saved queries functionality
+ *
+ * @param {object} params
+ * @param {object} params.formSelector
+ * @param {object} params.submitSelector
+ * @param {object} params.inputSelector
+ * @param {object} params.queryIdInputSelector
+ * @param {object} params.buttonText
+ */
+ function View(params) {
+ var self = this;
+
+ this.form = document.querySelector(params.formSelector);
+ this.submit = this.form.querySelector(params.submitSelector);
+ this.input = this.form.querySelector(params.inputSelector);
+ if (params.queryIdInputSelector) {
+ this.queryIdInput = this.form.querySelector(params.queryIdInputSelector);
+ }
+
+ // Event bus used to communicate with the controller
+ this.eventBus = new EventBus();
+
+ // Build components structure
+ this.button = new Button({ eventBus: this.eventBus,
+ buttonText: params.buttonText });
+ this.button.insertAfter(this.submit);
+ this.dialog = new Dialog({ eventBus: this.eventBus });
+ this.dialog.insertInto(document.body);
+ this.label = new QueryLabel({ eventBus: this.eventBus });
+ this.label.insertInto(this.form);
+
+ // Open/hide dialog on button click
+ this.button.element.addEventListener('click', function (e) {
+ e.preventDefault();
+ self.dialog.toggle();
+ self.dialog.snapTo(self.button.element);
+ if (self.dialog.isVisible()) {
+ self.dialog.element.focus();
+ }
+ });
+ this.dialog.element.addEventListener('blur', function () {
+ self.dialog.toggle(false);
+ });
+
+ this.eventBus.notify('searchTextChanged', this.input.value);
+ this.eventBus.subscribe('selectQuery', function () {
+ self.dialog.toggle(false);
+ });
+ this.input.addEventListener('change', function () {
+ self.eventBus.notify('searchTextChanged', this.value);
+ });
+ }
+
+ /**
+ * Fills the query list with the provided data
+ *
+ * @param {array} data
+ */
+ View.prototype.populateQueryList = function (data) {
+ this.dialog.populateQueryList(data);
+ };
+
+ /**
+ * Subscribe to the view events
+ *
+ * @param {string} event
+ * @param {function} fn
+ */
+ View.prototype.subscribe = function (event, fn) {
+ return this.eventBus.subscribe(event, fn);
+ };
+
+ /**
+ * Sets current query
+ *
+ * @param {object} query
+ */
+ View.prototype.setCurrentQuery = function (query) {
+ this.currentQuery = query;
+ this.eventBus.notify('setCurrentQuery', query);
+ if (query) {
+ this.input.value = query.text;
+ } else if (this.queryIdInput) {
+ this.queryIdInput.value = null;
+ }
+ };
+
+ /**
+ * Get search input contents
+ *
+ * @returns {string}
+ */
+ View.prototype.getSearchText = function () {
+ return this.input.value;
+ };
+
+ /**
+ * Set contents for search input
+ *
+ * @param {string} val
+ */
+ View.prototype.setSearchText = function (val) {
+ this.input.value = val;
+ };
+
+ return {
+ View: View,
+ QueryRepo: QueryRepo,
+ init: function (params) {
+ return new Controller(params);
+ }
+ };
+}));
diff --git a/djangoql/static/djangoql/js/saved_queries_admin.js b/djangoql/static/djangoql/js/saved_queries_admin.js
new file mode 100644
index 0000000..2bb4efb
--- /dev/null
+++ b/djangoql/static/djangoql/js/saved_queries_admin.js
@@ -0,0 +1,25 @@
+(function (SavedQueries, DjangoQL) {
+ 'use strict';
+
+ function getModelFromUrl() {
+ var parts = location.href.split('/');
+ return parts[parts.length - 2];
+ }
+
+ DjangoQL.DOMReady(function () {
+ SavedQueries.init({
+ view: new SavedQueries.View({
+ inputSelector: 'textarea[name=q]',
+ formSelector: '#changelist-search',
+ submitSelector: '[type="submit"]',
+ queryIdInputSelector: 'input[name=q-id]',
+ buttonText: 'Saved queries'
+ }),
+ repo: new SavedQueries.QueryRepo({
+ endpoint: '/djangoql/query/',
+ model: getModelFromUrl()
+ }),
+ submitOnSelect: true
+ });
+ });
+}(window.SavedQueries, window.DjangoQL));
diff --git a/djangoql/urls.py b/djangoql/urls.py
new file mode 100644
index 0000000..c038da1
--- /dev/null
+++ b/djangoql/urls.py
@@ -0,0 +1,17 @@
+from djangoql.views import QueryView
+from django.conf.urls import url, include
+
+
+app_name = 'djangoql'
+urlpatterns = [
+ url(
+ r'^query/(?P\d+)$',
+ QueryView.as_view(),
+ name='concrete_query'
+ ),
+ url(
+ r'^query/',
+ QueryView.as_view(),
+ name='query'
+ )
+]
diff --git a/djangoql/views.py b/djangoql/views.py
new file mode 100644
index 0000000..756e380
--- /dev/null
+++ b/djangoql/views.py
@@ -0,0 +1,81 @@
+import json
+import logging
+from functools import wraps
+
+from django.views.generic import View
+from django.http import JsonResponse
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.auth.decorators import login_required
+from django.utils.decorators import method_decorator
+from django.db.models import Q
+from django.forms.models import model_to_dict
+
+from .models import Query
+from .forms import QueryForm
+
+
+logger = logging.getLogger(__name__)
+
+
+def check_model(fn):
+ @wraps(fn)
+ def wrapper(self, request, *args, **kwargs):
+ model = request.GET.get('model')
+ if not model:
+ return JsonResponse({'error': 'No model provided'}, status=400)
+ model = ContentType.objects.get(model=model)
+ return fn(self, request, model, *args, **kwargs)
+ return wrapper
+
+
+class QueryView(View):
+ EXPOSED_MODEL_FIELDS = ('id', 'name', 'text', 'private')
+
+ @check_model
+ def get(self, request, model, *args, **kwargs):
+ if kwargs.get('pk'):
+ return self._get_one(request, model, *args, **kwargs)
+ return self._get_list(request, model, *args, **kwargs)
+
+ @check_model
+ @method_decorator(login_required)
+ def post(self, request, model, *args, **kwargs):
+ obj = json.loads(request.body.decode())
+ pk = obj.get('id')
+ # If pk is provided, then we're modifying an existing query
+ query = Query.objects.get(pk=pk) if pk else None
+ form = QueryForm({
+ 'name': obj.get('name'),
+ 'text': obj.get('text'),
+ 'private': obj.get('private')
+ }, instance=query)
+
+ if form.is_valid():
+ query = form.save(commit=False)
+ query.content_type = model
+ query.user = request.user
+ query.save()
+ return JsonResponse({'id': query.id})
+ else:
+ return JsonResponse({'errors': form.errors}, status=400)
+
+ @check_model
+ @method_decorator(login_required)
+ def delete(self, request, model, *args, **kwargs):
+ query = Query.objects.filter(
+ Q(user=request.user) | Q(private=False), id=self.kwargs['pk'])
+
+ query.delete()
+ return JsonResponse({'ok': True})
+
+ def _get_one(self, request, model, *args, **kwargs):
+ obj = Query.objects.get(pk=kwargs.get('pk'))
+ return JsonResponse(model_to_dict(obj, fields=self.EXPOSED_MODEL_FIELDS))
+
+ def _get_list(self, request, model, *args, **kwargs):
+ query = Query.objects.filter(content_type=model)
+ query = query.filter(Q(user=request.user) | Q(private=False))
+
+ return JsonResponse(
+ list(query.values(*self.EXPOSED_MODEL_FIELDS)), safe=False
+ )
diff --git a/js_tests/index.html b/js_tests/index.html
index 52d5e3f..5f9f917 100644
--- a/js_tests/index.html
+++ b/js_tests/index.html
@@ -3,19 +3,37 @@
+
+
+
@@ -24,7 +42,8 @@
-
+
+
diff --git a/js_tests/tests.js b/js_tests/test.completion.js
similarity index 100%
rename from js_tests/tests.js
rename to js_tests/test.completion.js
diff --git a/js_tests/test.saved_queries.js b/js_tests/test.saved_queries.js
new file mode 100644
index 0000000..fd877b3
--- /dev/null
+++ b/js_tests/test.saved_queries.js
@@ -0,0 +1,265 @@
+'use strict';
+
+/* global SavedQueries, DjangoQL, expect, describe, before, beforeEach, it,
+ afterEach */
+
+/**
+ * Simple storage implementation with the interface similiar
+ * to original QueryRepo
+ */
+var QueryRepo = {
+ get: function (id, callback) {
+ var result = this.objects[id];
+ if (callback) {
+ callback(result);
+ return null;
+ }
+ return result;
+ },
+ save: function (obj, callback) {
+ // clone object to avoid side effects
+ var query = Object.assign({}, obj);
+ if (!query.id) {
+ // query doesn't exists, generate id and insert object
+ query.id = this.generateId();
+ }
+ this.objects[query.id] = query;
+ if (callback) {
+ callback(query.id);
+ return null;
+ }
+ return query.id;
+ },
+ remove: function (id, callback) {
+ delete this.objects[id];
+ if (callback) {
+ callback();
+ }
+ },
+ list: function (callback) {
+ var result = Object.values(this.objects);
+ if (callback) {
+ callback(result);
+ }
+ return result;
+ },
+ generateId: function () {
+ var keys = Object.keys(this.objects);
+ if (keys.length) {
+ return Math.max.apply(Math, keys) + 1;
+ }
+ return 1;
+ },
+ objects: {}
+};
+
+/**
+ * Generates random string
+ */
+function randomString() {
+ return Math.random().toString(36).substring(7);
+}
+
+/**
+ * Builds query with random or provided data
+ *
+ * @param {object} [data]
+ * @param {string} [data.name]
+ * @param {string} [data.text]
+ * @param {boolean} [data.private]
+ */
+function buildQuery(data) {
+ var id;
+ id = QueryRepo.save({
+ name: (data && data.name) || randomString(),
+ text: (data && data.text) || randomString(),
+ private: (data && data.private !== undefined && data.private) || false
+ });
+ return QueryRepo.get(id);
+}
+
+/**
+ * Builds a list of queries with random parameters
+ *
+ * @param {number} cnt
+ */
+function buildQueries(cnt) {
+ var i;
+ var queries = [];
+ for (i = 0; i < cnt; i++) {
+ queries.push(buildQuery());
+ }
+}
+
+
+describe('DjangoQL saved queries', function () {
+ afterEach(function () {
+ QueryRepo.objects = {};
+ });
+
+ describe('Fixtures', function () {
+ describe('QueryRepository mock', function () {
+ it('should generate ids', function () {
+ expect(QueryRepo.generateId()).to.eql(1);
+ });
+ it('should create objects', function () {
+ var query = buildQuery();
+ QueryRepo.save(query, function (id) {
+ var obj = QueryRepo.objects[id];
+ expect(obj.name).to.eql(query.name);
+ expect(obj.text).to.eql(query.text);
+ expect(obj.id).to.be.a('number');
+ });
+ });
+
+ it('should save objects', function () {
+ var query = buildQuery();
+ var changed = {
+ id: query.id,
+ name: 'changed',
+ text: 'new query'
+ };
+ QueryRepo.save(changed, function (id) {
+ var obj = QueryRepo.objects[id];
+ expect(obj.id).to.eql(changed.id);
+ expect(obj.name).to.eql(changed.name);
+ expect(obj.text).to.eql(changed.text);
+ });
+ });
+
+ it('should return list of objects', function () {
+ buildQueries(10);
+ QueryRepo.list(function (arr) {
+ expect(arr).to.be.a('array');
+ expect(arr.length).to.eql(10);
+ });
+ });
+
+ it('should remove objects', function () {
+ var query = buildQuery();
+ QueryRepo.remove(query.id, function () {
+ expect(Object.keys(QueryRepo.objects).length).to.eql(0);
+ });
+ });
+ });
+
+ describe('buildQuery', function () {
+ it('should create query with random name and text', function () {
+ var query = buildQuery();
+ expect(query).to.be.a('object');
+ });
+ });
+ });
+
+ describe('Controller', function () {
+ var controller;
+
+ before(function () {
+ controller = SavedQueries.init({
+ view: new SavedQueries.View({
+ formSelector: 'form',
+ inputSelector: 'textarea',
+ submitSelector: '[type="submit"]',
+ buttonText: 'Saved queries'
+ }),
+ repo: QueryRepo,
+ submitOnSelect: false
+ });
+ QueryRepo.objects = {};
+ });
+
+ describe('Initalization', function () {
+ describe('constructor', function () {
+ it('should create button', function () {
+ var button = document.querySelector('.djangoql-sq-button');
+ expect(button).to.be.an('object');
+ });
+ it('should create dialog', function () {
+ var dialog = document.querySelector('.djangoql-sq-dialog');
+ expect(dialog).to.be.an('object');
+ });
+ it('should create label', function () {
+ var label = document.querySelector('.djangoql-sq-label');
+ expect(label).to.be.an('object');
+ });
+ });
+
+ describe('.saveAsNewQuery()', function () {
+ it('should create new query', function () {
+ document.querySelector('textarea').value = 'example';
+ controller.saveAsNewQuery('test', false, function (id) {
+ var query = QueryRepo.objects[id];
+ document.querySelector('textarea').value = '';
+
+ expect(query).to.be.an('object');
+ expect(query.name).to.eql('test');
+ });
+ });
+ });
+ });
+
+ describe('Working with existing query', function () {
+ var query;
+
+ beforeEach(function () {
+ query = buildQuery();
+ });
+
+ describe('.saveAsCurrentQuery()', function () {
+ it('should update current query text', function () {
+ controller.currentQuery = query;
+ document.querySelector('textarea').value = 'new';
+
+ controller.saveAsCurrentQuery(null, function () {
+ var obj = QueryRepo.get(query.id);
+ document.querySelector('textarea').value = '';
+
+ expect(obj).to.be.an('object');
+ expect(obj.text).to.eql('new');
+ });
+ });
+ });
+
+ describe('.selectQuery()', function () {
+ it('should insert text in search input', function () {
+ controller.selectQuery(query);
+ expect(document.querySelector('textarea').value).to.eql(query.text);
+ });
+ });
+
+ describe('.changeQueryScope()', function () {
+ it('should change private attribute of the query', function () {
+ controller.changeQueryScope(query, function () {
+ var result = QueryRepo.get(query.id);
+ expect(result.private).to.eql(!query.private);
+ });
+ });
+ });
+
+ describe('.removeQuery()', function () {
+ it('should remove query from collection', function () {
+ controller.removeQuery(query, function () {
+ expect(QueryRepo.get(query.id)).to.eql(undefined);
+ });
+ });
+ });
+
+ describe('.renameQuery()', function () {
+ it('should change name attribute of the query', function () {
+ controller.renameQuery(query, 'test', function () {
+ expect(QueryRepo.get(query.id).name).to.eql('test');
+ });
+ });
+ });
+
+ describe('.resetCurrentQuery()', function () {
+ it('should reset current query', function () {
+ controller.selectQuery(query);
+ expect(controller.currentQuery).to.be.an('object');
+ controller.resetCurrentQuery();
+ expect(controller.currentQuery).to.eql(null);
+ });
+ });
+ });
+ });
+});
diff --git a/test_project/core/admin.py b/test_project/core/admin.py
index f0fffa2..2fe2127 100644
--- a/test_project/core/admin.py
+++ b/test_project/core/admin.py
@@ -22,6 +22,7 @@ class BookQLSchema(DjangoQLSchema):
@admin.register(Book)
class BookAdmin(DjangoQLSearchMixin, admin.ModelAdmin):
djangoql_schema = BookQLSchema
+ djangoql_saved_queries = True
list_display = ('name', 'author', 'genre', 'written', 'is_published')
list_filter = ('is_published',)
filter_horizontal = ('similar_books',)
diff --git a/test_project/core/migrations/0005_add_demo_user.py b/test_project/core/migrations/0005_add_demo_user.py
new file mode 100644
index 0000000..e806649
--- /dev/null
+++ b/test_project/core/migrations/0005_add_demo_user.py
@@ -0,0 +1,21 @@
+from django.db import migrations
+from django.conf import settings
+from django.contrib.auth.hashers import make_password
+
+def create_demo_user(apps, schema_editor):
+ User = apps.get_model(settings.AUTH_USER_MODEL)
+ User.objects.create(
+ username='demo',
+ password=make_password('demo')
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0004_book_similar_books_related_name'),
+ ]
+
+ operations = [
+ migrations.RunPython(create_demo_user)
+ ]
diff --git a/test_project/core/templates/completion_demo.html b/test_project/core/templates/completion_demo.html
index 1cfb27e..d350514 100644
--- a/test_project/core/templates/completion_demo.html
+++ b/test_project/core/templates/completion_demo.html
@@ -5,14 +5,20 @@
DjangoQL completion demo
+
+
+
-
@@ -42,6 +48,32 @@
// helpful for really long queries.
autoResize: true
});
+
+ SavedQueries.init({
+ view: new SavedQueries.View({
+ // css selector for form containing the query input.
+ // Used to insert a label with the query name into
+ formSelector: 'form',
+
+ // css selector for submit button, used to insert saved queries
+ // button next to it
+ submitSelector: '[type="submit"]',
+
+ // css selector for search input selector
+ inputSelector: 'textarea[name=q]',
+
+ // saved queries button text
+ buttonText: 'Favorites'
+ }),
+ repo: new SavedQueries.QueryRepo({
+ // backend url used to store queries
+ endpoint: '/djangoql/query/',
+
+ // django model
+ model: 'user'
+ }),
+ submitOnSelect: true
+ });
});
diff --git a/test_project/core/tests/test_schema.py b/test_project/core/tests/test_schema.py
index 2bb587f..e9cef93 100644
--- a/test_project/core/tests/test_schema.py
+++ b/test_project/core/tests/test_schema.py
@@ -71,7 +71,8 @@ def test_exclude(self):
'auth.group',
'auth.permission',
'contenttypes.contenttype',
- 'core.book'
+ 'core.book',
+ 'djangoql.query'
])
def test_include(self):
diff --git a/test_project/core/tests/test_views.py b/test_project/core/tests/test_views.py
new file mode 100644
index 0000000..26dd88c
--- /dev/null
+++ b/test_project/core/tests/test_views.py
@@ -0,0 +1,96 @@
+import json
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+from djangoql.models import Query
+from djangoql.views import QueryView
+from django.contrib.contenttypes.models import ContentType
+try:
+ from django.core.urlresolvers import reverse
+except ImportError: # Django 2.0
+ from django.urls import reverse
+
+
+USER_MODEL = get_user_model()
+
+
+class DjangoQLViewsTest(TestCase):
+ def setUp(self):
+ self.user = USER_MODEL.objects.create_user(username='admin',
+ password='admin')
+ self.client.login(username='admin', password='admin')
+
+ def test_saved_queries_list(self):
+ model = ContentType.objects.get(model='book')
+
+ Query.objects.bulk_create([
+ Query(
+ name='Only published books',
+ text='is_published = True',
+ content_type=model,
+ user=self.user
+ ),
+ Query(
+ name='Drama books',
+ text='genre = "Drama"',
+ content_type=model,
+ private=True,
+ user=self.user
+ )
+ ])
+
+ expected = list(
+ Query.objects.all().values(*QueryView.EXPOSED_MODEL_FIELDS))
+ response = self.client.get(reverse('djangoql:query'),
+ {'model': 'book'})
+ self.assertListEqual(expected, json.loads(response.content.decode()))
+
+ def test_saved_queries_create(self):
+ response = self.client.post(
+ path='{}?model=book'.format(reverse('djangoql:query')),
+ data=json.dumps({'text': 'hello', 'name': 'test'}),
+ content_type='application/json'
+ )
+ result_id = json.loads(response.content.decode())['id']
+ self.assertEqual(type(result_id), int)
+
+ def test_saved_queries_remove(self):
+ obj = Query.objects.create(
+ name='test',
+ text='test',
+ content_type=ContentType.objects.get(model='book'),
+ user=self.user
+ )
+ self.client.delete(
+ path='{}?model=book'.format(
+ reverse('djangoql:concrete_query', args=[obj.id]))
+ )
+ self.assertEqual(Query.objects.count(), 0)
+
+ def test_other_user_gets_only_shared(self):
+ model = ContentType.objects.get(model='book')
+ Query.objects.bulk_create([
+ Query(
+ name='Only published books',
+ text='is_published = True',
+ content_type=model,
+ user=self.user,
+ private=False
+ ),
+ Query(
+ name='Drama books',
+ text='genre = "Drama"',
+ content_type=model,
+ private=True,
+ user=self.user
+ )
+ ])
+
+ self.client.logout()
+ self.user = USER_MODEL.objects.create_user(username='djangoql_demo',
+ password='demo')
+ self.client.login(username='djangoql_demo', password='demo')
+
+ response = self.client.get(reverse('djangoql:query'),
+ {'model': 'book'})
+ self.assertEqual(len(json.loads(response.content.decode())), 1)
diff --git a/test_project/core/views.py b/test_project/core/views.py
index e480131..0fc240f 100644
--- a/test_project/core/views.py
+++ b/test_project/core/views.py
@@ -1,6 +1,7 @@
import json
from django.contrib.auth.models import Group, User
+from django.contrib.auth import authenticate, login
from django.shortcuts import render_to_response
from django.views.decorators.http import require_GET
@@ -24,6 +25,9 @@ def completion_demo(request):
except DjangoQLError as e:
query = query.none()
error = str(e)
+ # Authenticate user to be able to work with the saved queries
+ user = authenticate(username='demo', password='demo')
+ login(request, user)
return render_to_response('completion_demo.html', {
'q': q,
'error': error,
diff --git a/test_project/test_project/urls.py b/test_project/test_project/urls.py
index e189557..253638f 100644
--- a/test_project/test_project/urls.py
+++ b/test_project/test_project/urls.py
@@ -23,6 +23,7 @@
urlpatterns = [
url(r'^admin/', admin.site.urls),
url(r'^$', completion_demo),
+ url(r'^djangoql/', include('djangoql.urls', namespace='djangoql'))
]
if settings.DEBUG and settings.DJDT: