From 509093b75535c017c24da3035d33d3bb6c2f9e5d Mon Sep 17 00:00:00 2001 From: azivner Date: Fri, 3 Aug 2018 11:11:57 +0200 Subject: [PATCH] added "type" to attribute dialog, name autocomplete servers according to the choice --- .../a2c75661-f9e2-478f-a69f-6a9409e69997.xml | 500 ++++++++++-------- src/public/javascripts/dialogs/attributes.js | 239 +++++++++ src/public/javascripts/services/bootstrap.js | 1 + .../javascripts/services/entrypoints.js | 4 + src/routes/api/attributes.js | 17 +- src/routes/routes.js | 2 +- src/services/attributes.js | 45 +- src/views/index.ejs | 48 ++ 8 files changed, 623 insertions(+), 233 deletions(-) create mode 100644 src/public/javascripts/dialogs/attributes.js diff --git a/.idea/dataSources/a2c75661-f9e2-478f-a69f-6a9409e69997.xml b/.idea/dataSources/a2c75661-f9e2-478f-a69f-6a9409e69997.xml index 87b69781b..2badc45a5 100644 --- a/.idea/dataSources/a2c75661-f9e2-478f-a69f-6a9409e69997.xml +++ b/.idea/dataSources/a2c75661-f9e2-478f-a69f-6a9409e69997.xml @@ -12,292 +12,289 @@ -
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
1
- +
1
- - +
+ 1 TEXT|0s 1 - + 2 TEXT|0s 1 - + 3 TEXT|0s 1 - + 4 INT|0s 1 0 - + 5 TEXT|0s 1 "" - + 1 apiTokenId 1 - + apiTokenId 1 sqlite_autoindex_api_tokens_1 - + 1 TEXT|0s 1 - + 2 TEXT|0s 1 - + 3 TEXT|0s 1 - + + 4 + TEXT|0s + 1 + + + 5 + TEXT|0s + 1 + '' + + + 6 + INT|0s + 1 + 0 + + + 7 + TEXT|0s + 1 + + + 8 + TEXT|0s + 1 + + + 9 + INT|0s + 1 + + + 10 + TEXT|0s + 1 + "" + + + 1 + attributeId + + 1 + + + attributeId + 1 + sqlite_autoindex_attributes_1 + + + 1 + TEXT|0s + 1 + + + 2 + TEXT|0s + 1 + + + 3 + TEXT|0s + 1 + + 4 INTEGER|0s 1 - + 5 TEXT|0s - + 6 BOOLEAN|0s - + 7 INTEGER|0s 1 0 - + 8 TEXT|0s 1 - + 9 TEXT|0s 1 "" - + 10 TEXT|0s 1 '1970-01-01T00:00:00.000Z' - + 1 branchId 1 - + noteId parentNoteId - + noteId - + parentNoteId - + branchId 1 sqlite_autoindex_branches_1 - + 1 TEXT|0s 1 - + 2 TEXT|0s - + 3 TEXT|0s - + 4 TEXT|0s 1 - + 1 eventId 1 - + eventId 1 sqlite_autoindex_event_log_1 - + 1 TEXT|0s 1 - + 2 TEXT|0s 1 - + 3 TEXT|0s 1 - + 4 TEXT|0s 1 - + 5 BLOB|0s - + 6 INT|0s 1 0 - + 7 TEXT|0s 1 - + 8 TEXT|0s 1 - + 9 TEXT|0s 1 "" - + 1 imageId 1 - + imageId 1 sqlite_autoindex_images_1 - - 1 - TEXT|0s - 1 - - - 2 - TEXT|0s - 1 - - - 3 - TEXT|0s - 1 - - - 4 - TEXT|0s - 1 - '' - - - 5 - INT|0s - 1 - 0 - - - 6 - TEXT|0s - 1 - - - 7 - TEXT|0s - 1 - - - 8 - INT|0s - 1 - - - 9 - TEXT|0s - 1 - "" - - - 1 - labelId - - 1 - - - noteId - - - - name -value - - - - labelId - 1 - sqlite_autoindex_labels_1 - - + 1 TEXT|0s 1 @@ -307,437 +304,518 @@ value TEXT|0s 1 - + 3 TEXT|0s 1 - + 4 + TEXT|0s + 1 + '' + + + 5 INT|0s 1 0 - - 5 - TEXT|0s - 1 - 6 TEXT|0s 1 - + + 7 + TEXT|0s + 1 + + + 8 + INT|0s + 1 + + + 9 + TEXT|0s + 1 + "" + + + 1 + labelId + + 1 + + + noteId + + + + noteId + + + + name +value + + + + name +value + + + + value + + + + labelId + 1 + sqlite_autoindex_labels_1 + + + 1 + TEXT|0s + 1 + + + 2 + TEXT|0s + 1 + + + 3 + TEXT|0s + 1 + + + 4 + INT|0s + 1 + 0 + + + 5 + TEXT|0s + 1 + + + 6 + TEXT|0s + 1 + + 7 TEXT|0s 1 "" - + 1 noteImageId 1 - + noteId imageId - + noteId - + imageId - + noteImageId 1 sqlite_autoindex_note_images_1 - + 1 TEXT|0s 1 - + 2 TEXT|0s 1 - + 3 TEXT|0s - + 4 TEXT|0s - + 5 INT|0s 1 0 - + 6 TEXT|0s 1 - + 7 TEXT|0s 1 - + 8 TEXT|0s 1 '' - + 9 TEXT|0s 1 '' - + 10 TEXT|0s 1 "" - + 1 noteRevisionId 1 - + noteId - + dateModifiedFrom - + dateModifiedTo - + noteRevisionId 1 sqlite_autoindex_note_revisions_1 - + 1 TEXT|0s 1 - + 2 TEXT|0s 1 "unnamed" - + 3 TEXT|0s 1 "" - + 4 INT|0s 1 0 - + 5 INT|0s 1 0 - + 6 TEXT|0s 1 - + 7 TEXT|0s 1 - + 8 TEXT|0s 1 'text' - + 9 TEXT|0s 1 'text/html' - + 10 TEXT|0s 1 "" - + 1 noteId 1 - + type - + noteId 1 sqlite_autoindex_notes_1 - + 1 TEXT|0s 1 - + 2 TEXT|0s - + 3 INT|0s - + 4 INTEGER|0s 1 0 - + 5 TEXT|0s 1 "" - + 6 TEXT|0s 1 '1970-01-01T00:00:00.000Z' - + 1 name 1 - + name 1 sqlite_autoindex_options_1 - + 1 TEXT|0s 1 - + 2 TEXT|0s 1 - + 3 TEXT|0s 1 "" - + 4 TEXT|0s 1 - + 5 INT|0s - + 1 branchId 1 - + branchId 1 sqlite_autoindex_recent_notes_1 - + 1 TEXT|0s 1 - + 2 TEXT|0s 1 - + 3 TEXT|0s 1 - + 4 TEXT|0s 1 - + 5 INT|0s 1 0 - + 6 TEXT|0s 1 - + 7 TEXT|0s 1 - + 8 INT|0s 1 - + 9 TEXT|0s 1 "" - + 10 int|0s 0 - + 1 relationId 1 - + sourceNoteId - + targetNoteId - + relationId 1 sqlite_autoindex_relations_1 - + 1 TEXT|0s 1 - + 2 TEXT|0s 1 - + 1 sourceId 1 - + sourceId 1 sqlite_autoindex_source_ids_1 - + 1 text|0s - + 2 text|0s - + 3 text|0s - + 4 integer|0s - + 5 text|0s - + 1 - + 2 - + 1 INTEGER|0s 1 1 - + 2 TEXT|0s 1 - + 3 TEXT|0s 1 - + 4 TEXT|0s 1 - + 5 TEXT|0s 1 - + entityName entityId 1 - + syncDate - + id 1 diff --git a/src/public/javascripts/dialogs/attributes.js b/src/public/javascripts/dialogs/attributes.js new file mode 100644 index 000000000..263887c2a --- /dev/null +++ b/src/public/javascripts/dialogs/attributes.js @@ -0,0 +1,239 @@ +import noteDetailService from '../services/note_detail.js'; +import server from '../services/server.js'; +import infoService from "../services/info.js"; + +const $dialog = $("#attributes-dialog"); +const $saveAttributesButton = $("#save-attributes-button"); +const $attributesBody = $('#attributes-table tbody'); + +const attributesModel = new AttributesModel(); + +function AttributesModel() { + const self = this; + + this.attributes = ko.observableArray(); + + this.availableTypes = [ + { text: "Label", value: "label" }, + { text: "Label definition", value: "definition" }, + { text: "Relation", value: "relation" } + ]; + + this.updateAttributePositions = function() { + let position = 0; + + // we need to update positions by searching in the DOM, because order of the + // attributes in the viewmodel (self.attributes()) stays the same + $attributesBody.find('input[name="position"]').each(function() { + const attribute = self.getTargetAttribute(this); + + attribute().position = position++; + }); + }; + + this.loadAttributes = async function() { + const noteId = noteDetailService.getCurrentNoteId(); + + const attributes = await server.get('notes/' + noteId + '/attributes'); + + self.attributes(attributes.map(ko.observable)); + + addLastEmptyRow(); + + // attribute might not be rendered immediatelly so could not focus + setTimeout(() => $(".attribute-name:last").focus(), 100); + + $attributesBody.sortable({ + handle: '.handle', + containment: $attributesBody, + update: this.updateAttributePositions + }); + }; + + this.deleteAttribute = function(data, event) { + const attribute = self.getTargetAttribute(event.target); + const attributeData = attribute(); + + if (attributeData) { + attributeData.isDeleted = 1; + + attribute(attributeData); + + addLastEmptyRow(); + } + }; + + function isValid() { + for (let attributes = self.attributes(), i = 0; i < attributes.length; i++) { + if (self.isEmptyName(i)) { + return false; + } + } + + return true; + } + + this.save = async function() { + // we need to defocus from input (in case of enter-triggered save) because value is updated + // on blur event (because of conflict with jQuery UI Autocomplete). Without this, input would + // stay in focus, blur wouldn't be triggered and change wouldn't be updated in the viewmodel. + $saveAttributesButton.focus(); + + if (!isValid()) { + alert("Please fix all validation errors and try saving again."); + return; + } + + self.updateAttributePositions(); + + const noteId = noteDetailService.getCurrentNoteId(); + + const attributesToSave = self.attributes() + .map(attribute => attribute()) + .filter(attribute => attribute.attributeId !== "" || attribute.name !== ""); + + const attributes = await server.put('notes/' + noteId + '/attributes', attributesToSave); + + self.attributes(attributes.map(ko.observable)); + + addLastEmptyRow(); + + infoService.showMessage("Attributes have been saved."); + + noteDetailService.loadAttributeList(); + }; + + function addLastEmptyRow() { + const attributes = self.attributes().filter(attr => attr().isDeleted === 0); + const last = attributes.length === 0 ? null : attributes[attributes.length - 1](); + + if (!last || last.name.trim() !== "" || last.value !== "") { + self.attributes.push(ko.observable({ + attributeId: '', + type: 'label', + name: '', + value: '', + isDeleted: 0, + position: 0 + })); + } + } + + this.attributeChanged = function (data, event) { + addLastEmptyRow(); + + const attribute = self.getTargetAttribute(event.target); + + attribute.valueHasMutated(); + }; + + this.isNotUnique = function(index) { + const cur = self.attributes()[index](); + + if (cur.name.trim() === "") { + return false; + } + + for (let attributes = self.attributes(), i = 0; i < attributes.length; i++) { + const attribute = attributes[i](); + + if (index !== i && cur.name === attribute.name) { + return true; + } + } + + return false; + }; + + this.isEmptyName = function(index) { + const cur = self.attributes()[index](); + + return cur.name.trim() === "" && (cur.attributeId !== "" || cur.value !== ""); + }; + + this.getTargetAttribute = function(target) { + const context = ko.contextFor(target); + const index = context.$index(); + + return self.attributes()[index]; + } +} + +async function showDialog() { + glob.activeDialog = $dialog; + + await attributesModel.loadAttributes(); + + $dialog.dialog({ + modal: true, + width: 950, + height: 500 + }); +} + +ko.applyBindings(attributesModel, $dialog[0]); + +$dialog.on('focus', '.attribute-name', function (e) { + if (!$(this).hasClass("ui-autocomplete-input")) { + $(this).autocomplete({ + source: async (request, response) => { + const attribute = attributesModel.getTargetAttribute(this); + const type = attribute().type === 'relation' ? 'relation' : 'label'; + const names = await server.get('attributes/names/?type=' + type + '&query=' + encodeURIComponent(request.term)); + const result = names.map(name => { + return { + label: name, + value: name + } + }); + + if (result.length > 0) { + response(result); + } + else { + response([{ + label: "No results", + value: "No results" + }]); + } + }, + minLength: 0 + }); + } + + $(this).autocomplete("search", $(this).val()); +}); + +$dialog.on('focus', '.attribute-value', async function (e) { + if (!$(this).hasClass("ui-autocomplete-input")) { + const attributeName = $(this).parent().parent().find('.attribute-name').val(); + + if (attributeName.trim() === "") { + return; + } + + const attributeValues = await server.get('attributes/values/' + encodeURIComponent(attributeName)); + + if (attributeValues.length === 0) { + return; + } + + $(this).autocomplete({ + // shouldn't be required and autocomplete should just accept array of strings, but that fails + // because we have overriden filter() function in autocomplete.js + source: attributeValues.map(attribute => { + return { + attribute: attribute, + value: attribute + } + }), + minLength: 0 + }); + } + + $(this).autocomplete("search", $(this).val()); +}); + +export default { + showDialog +}; \ No newline at end of file diff --git a/src/public/javascripts/services/bootstrap.js b/src/public/javascripts/services/bootstrap.js index 84273e479..15236b222 100644 --- a/src/public/javascripts/services/bootstrap.js +++ b/src/public/javascripts/services/bootstrap.js @@ -1,6 +1,7 @@ import addLinkDialog from '../dialogs/add_link.js'; import jumpToNoteDialog from '../dialogs/jump_to_note.js'; import labelsDialog from '../dialogs/labels.js'; +import attributesDialog from '../dialogs/attributes.js'; import noteRevisionsDialog from '../dialogs/note_revisions.js'; import noteSourceDialog from '../dialogs/note_source.js'; import recentChangesDialog from '../dialogs/recent_changes.js'; diff --git a/src/public/javascripts/services/entrypoints.js b/src/public/javascripts/services/entrypoints.js index d25f79785..0d11d92ae 100644 --- a/src/public/javascripts/services/entrypoints.js +++ b/src/public/javascripts/services/entrypoints.js @@ -11,6 +11,7 @@ import noteSourceDialog from "../dialogs/note_source.js"; import recentChangesDialog from "../dialogs/recent_changes.js"; import sqlConsoleDialog from "../dialogs/sql_console.js"; import searchNotesService from "./search_notes.js"; +import attributesDialog from "../dialogs/attributes.js"; import labelsDialog from "../dialogs/labels.js"; import relationsDialog from "../dialogs/relations.js"; import protectedSessionService from "./protected_session.js"; @@ -38,6 +39,9 @@ function registerEntrypoints() { $("#toggle-search-button").click(searchNotesService.toggleSearch); utils.bindShortcut('ctrl+s', searchNotesService.toggleSearch); + $(".show-attributes-button").click(attributesDialog.showDialog); + utils.bindShortcut('alt+a', attributesDialog.showDialog); + $(".show-labels-button").click(labelsDialog.showDialog); utils.bindShortcut('alt+l', labelsDialog.showDialog); diff --git a/src/routes/api/attributes.js b/src/routes/api/attributes.js index 174eb05eb..f582070ec 100644 --- a/src/routes/api/attributes.js +++ b/src/routes/api/attributes.js @@ -42,18 +42,11 @@ async function updateNoteAttributes(req) { return await repository.getEntities("SELECT * FROM attributes WHERE isDeleted = 0 AND noteId = ? ORDER BY position, dateCreated", [noteId]); } -async function getAllAttributeNames() { - const names = await sql.getColumn("SELECT DISTINCT name FROM attributes WHERE isDeleted = 0"); +async function getAttributeNames(req) { + const type = req.query.type; + const query = req.query.query; - for (const attribute of attributeService.BUILTIN_ATTRIBUTES) { - if (!names.includes(attribute)) { - names.push(attribute); - } - } - - names.sort(); - - return names; + return attributeService.getAttributeNames(type, query); } async function getValuesForAttribute(req) { @@ -65,6 +58,6 @@ async function getValuesForAttribute(req) { module.exports = { getNoteAttributes, updateNoteAttributes, - getAllAttributeNames, + getAttributeNames, getValuesForAttribute }; \ No newline at end of file diff --git a/src/routes/routes.js b/src/routes/routes.js index 1cc74a817..f492b12dc 100644 --- a/src/routes/routes.js +++ b/src/routes/routes.js @@ -136,7 +136,7 @@ function register(app) { apiRoute(GET, '/api/notes/:noteId/attributes', attributesRoute.getNoteAttributes); apiRoute(PUT, '/api/notes/:noteId/attributes', attributesRoute.updateNoteAttributes); - apiRoute(GET, '/api/attributes/names', attributesRoute.getAllAttributeNames); + apiRoute(GET, '/api/attributes/names', attributesRoute.getAttributeNames); apiRoute(GET, '/api/attributes/values/:attributeName', attributesRoute.getValuesForAttribute); apiRoute(GET, '/api/notes/:noteId/labels', labelsRoute.getNoteLabels); diff --git a/src/services/attributes.js b/src/services/attributes.js index 069df17ba..0ece703c6 100644 --- a/src/services/attributes.js +++ b/src/services/attributes.js @@ -1,18 +1,25 @@ "use strict"; const repository = require('./repository'); +const sql = require('./sql'); +const utils = require('./utils'); const Attribute = require('../entities/attribute'); const BUILTIN_ATTRIBUTES = [ - 'disableVersioning', - 'calendarRoot', - 'archived', - 'excludeFromExport', - 'run', - 'manualTransactionHandling', - 'disableInclusion', - 'appCss', - 'hideChildrenOverview' + // label names + { type: 'label', name: 'disableVersioning' }, + { type: 'label', name: 'calendarRoot' }, + { type: 'label', name: 'archived' }, + { type: 'label', name: 'excludeFromExport' }, + { type: 'label', name: 'run' }, + { type: 'label', name: 'manualTransactionHandling' }, + { type: 'label', name: 'disableInclusion' }, + { type: 'label', name: 'appCss' }, + { type: 'label', name: 'hideChildrenOverview' }, + + // relation names + { type: 'relation', name: 'runOnNoteView' }, + { type: 'relation', name: 'runOnNoteTitleChange' } ]; async function getNotesWithAttribute(name, value) { @@ -44,9 +51,29 @@ async function createAttribute(noteId, name, value = "") { }).save(); } +async function getAttributeNames(type, nameLike) { + const names = await sql.getColumn( + `SELECT DISTINCT name + FROM attributes + WHERE isDeleted = 0 + AND type = ? + AND name LIKE '%${utils.sanitizeSql(nameLike)}%'`, [ type ]); + + for (const attribute of BUILTIN_ATTRIBUTES) { + if (attribute.type === type && !names.includes(attribute.name)) { + names.push(attribute.name); + } + } + + names.sort(); + + return names; +} + module.exports = { getNotesWithAttribute, getNoteWithAttribute, createAttribute, + getAttributeNames, BUILTIN_ATTRIBUTES }; \ No newline at end of file diff --git a/src/views/index.ejs b/src/views/index.ejs index d7a6f6898..b62f50820 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -169,6 +169,7 @@
+ + + + + + + + + + + + + + + + + + + + +
IDNameValue
+ + + + + + + +
Duplicate attribute.
+
Attribute name can't be empty.
+
+ + + +
+ + + +