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 + + + -
+

{{ error }}

- +
+ + +