mirror of
https://github.com/zadam/trilium.git
synced 2025-06-06 18:08:33 +02:00
added "type" to attribute dialog, name autocomplete servers according to the choice
This commit is contained in:
parent
097114c0f2
commit
509093b755
File diff suppressed because it is too large
Load Diff
239
src/public/javascripts/dialogs/attributes.js
Normal file
239
src/public/javascripts/dialogs/attributes.js
Normal file
@ -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
|
||||||
|
};
|
1
src/public/javascripts/services/bootstrap.js
vendored
1
src/public/javascripts/services/bootstrap.js
vendored
@ -1,6 +1,7 @@
|
|||||||
import addLinkDialog from '../dialogs/add_link.js';
|
import addLinkDialog from '../dialogs/add_link.js';
|
||||||
import jumpToNoteDialog from '../dialogs/jump_to_note.js';
|
import jumpToNoteDialog from '../dialogs/jump_to_note.js';
|
||||||
import labelsDialog from '../dialogs/labels.js';
|
import labelsDialog from '../dialogs/labels.js';
|
||||||
|
import attributesDialog from '../dialogs/attributes.js';
|
||||||
import noteRevisionsDialog from '../dialogs/note_revisions.js';
|
import noteRevisionsDialog from '../dialogs/note_revisions.js';
|
||||||
import noteSourceDialog from '../dialogs/note_source.js';
|
import noteSourceDialog from '../dialogs/note_source.js';
|
||||||
import recentChangesDialog from '../dialogs/recent_changes.js';
|
import recentChangesDialog from '../dialogs/recent_changes.js';
|
||||||
|
@ -11,6 +11,7 @@ import noteSourceDialog from "../dialogs/note_source.js";
|
|||||||
import recentChangesDialog from "../dialogs/recent_changes.js";
|
import recentChangesDialog from "../dialogs/recent_changes.js";
|
||||||
import sqlConsoleDialog from "../dialogs/sql_console.js";
|
import sqlConsoleDialog from "../dialogs/sql_console.js";
|
||||||
import searchNotesService from "./search_notes.js";
|
import searchNotesService from "./search_notes.js";
|
||||||
|
import attributesDialog from "../dialogs/attributes.js";
|
||||||
import labelsDialog from "../dialogs/labels.js";
|
import labelsDialog from "../dialogs/labels.js";
|
||||||
import relationsDialog from "../dialogs/relations.js";
|
import relationsDialog from "../dialogs/relations.js";
|
||||||
import protectedSessionService from "./protected_session.js";
|
import protectedSessionService from "./protected_session.js";
|
||||||
@ -38,6 +39,9 @@ function registerEntrypoints() {
|
|||||||
$("#toggle-search-button").click(searchNotesService.toggleSearch);
|
$("#toggle-search-button").click(searchNotesService.toggleSearch);
|
||||||
utils.bindShortcut('ctrl+s', 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);
|
$(".show-labels-button").click(labelsDialog.showDialog);
|
||||||
utils.bindShortcut('alt+l', labelsDialog.showDialog);
|
utils.bindShortcut('alt+l', labelsDialog.showDialog);
|
||||||
|
|
||||||
|
@ -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]);
|
return await repository.getEntities("SELECT * FROM attributes WHERE isDeleted = 0 AND noteId = ? ORDER BY position, dateCreated", [noteId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAllAttributeNames() {
|
async function getAttributeNames(req) {
|
||||||
const names = await sql.getColumn("SELECT DISTINCT name FROM attributes WHERE isDeleted = 0");
|
const type = req.query.type;
|
||||||
|
const query = req.query.query;
|
||||||
|
|
||||||
for (const attribute of attributeService.BUILTIN_ATTRIBUTES) {
|
return attributeService.getAttributeNames(type, query);
|
||||||
if (!names.includes(attribute)) {
|
|
||||||
names.push(attribute);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
names.sort();
|
|
||||||
|
|
||||||
return names;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getValuesForAttribute(req) {
|
async function getValuesForAttribute(req) {
|
||||||
@ -65,6 +58,6 @@ async function getValuesForAttribute(req) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
getNoteAttributes,
|
getNoteAttributes,
|
||||||
updateNoteAttributes,
|
updateNoteAttributes,
|
||||||
getAllAttributeNames,
|
getAttributeNames,
|
||||||
getValuesForAttribute
|
getValuesForAttribute
|
||||||
};
|
};
|
@ -136,7 +136,7 @@ function register(app) {
|
|||||||
|
|
||||||
apiRoute(GET, '/api/notes/:noteId/attributes', attributesRoute.getNoteAttributes);
|
apiRoute(GET, '/api/notes/:noteId/attributes', attributesRoute.getNoteAttributes);
|
||||||
apiRoute(PUT, '/api/notes/:noteId/attributes', attributesRoute.updateNoteAttributes);
|
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/attributes/values/:attributeName', attributesRoute.getValuesForAttribute);
|
||||||
|
|
||||||
apiRoute(GET, '/api/notes/:noteId/labels', labelsRoute.getNoteLabels);
|
apiRoute(GET, '/api/notes/:noteId/labels', labelsRoute.getNoteLabels);
|
||||||
|
@ -1,18 +1,25 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const repository = require('./repository');
|
const repository = require('./repository');
|
||||||
|
const sql = require('./sql');
|
||||||
|
const utils = require('./utils');
|
||||||
const Attribute = require('../entities/attribute');
|
const Attribute = require('../entities/attribute');
|
||||||
|
|
||||||
const BUILTIN_ATTRIBUTES = [
|
const BUILTIN_ATTRIBUTES = [
|
||||||
'disableVersioning',
|
// label names
|
||||||
'calendarRoot',
|
{ type: 'label', name: 'disableVersioning' },
|
||||||
'archived',
|
{ type: 'label', name: 'calendarRoot' },
|
||||||
'excludeFromExport',
|
{ type: 'label', name: 'archived' },
|
||||||
'run',
|
{ type: 'label', name: 'excludeFromExport' },
|
||||||
'manualTransactionHandling',
|
{ type: 'label', name: 'run' },
|
||||||
'disableInclusion',
|
{ type: 'label', name: 'manualTransactionHandling' },
|
||||||
'appCss',
|
{ type: 'label', name: 'disableInclusion' },
|
||||||
'hideChildrenOverview'
|
{ type: 'label', name: 'appCss' },
|
||||||
|
{ type: 'label', name: 'hideChildrenOverview' },
|
||||||
|
|
||||||
|
// relation names
|
||||||
|
{ type: 'relation', name: 'runOnNoteView' },
|
||||||
|
{ type: 'relation', name: 'runOnNoteTitleChange' }
|
||||||
];
|
];
|
||||||
|
|
||||||
async function getNotesWithAttribute(name, value) {
|
async function getNotesWithAttribute(name, value) {
|
||||||
@ -44,9 +51,29 @@ async function createAttribute(noteId, name, value = "") {
|
|||||||
}).save();
|
}).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 = {
|
module.exports = {
|
||||||
getNotesWithAttribute,
|
getNotesWithAttribute,
|
||||||
getNoteWithAttribute,
|
getNoteWithAttribute,
|
||||||
createAttribute,
|
createAttribute,
|
||||||
|
getAttributeNames,
|
||||||
BUILTIN_ATTRIBUTES
|
BUILTIN_ATTRIBUTES
|
||||||
};
|
};
|
@ -169,6 +169,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-right">
|
<ul class="dropdown-menu dropdown-menu-right">
|
||||||
<li><a id="show-note-revisions-button">Note revisions</a></li>
|
<li><a id="show-note-revisions-button">Note revisions</a></li>
|
||||||
|
<li><a class="show-attributes-button"><kbd>Alt+A</kbd> Attributes</a></li>
|
||||||
<li><a class="show-labels-button"><kbd>Alt+L</kbd> Labels</a></li>
|
<li><a class="show-labels-button"><kbd>Alt+L</kbd> Labels</a></li>
|
||||||
<li><a class="show-relations-button"><kbd>Alt+R</kbd> Relations</a></li>
|
<li><a class="show-relations-button"><kbd>Alt+R</kbd> Relations</a></li>
|
||||||
<li><a id="show-source-button">HTML source</a></li>
|
<li><a id="show-source-button">HTML source</a></li>
|
||||||
@ -554,6 +555,53 @@
|
|||||||
<textarea id="note-source" readonly="readonly"></textarea>
|
<textarea id="note-source" readonly="readonly"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="attributes-dialog" title="Note attributes" style="display: none; padding: 20px;">
|
||||||
|
<form data-bind="submit: save">
|
||||||
|
<div style="text-align: center">
|
||||||
|
<button class="btn btn-large" style="width: 200px;" id="save-attributes-button" type="submit">Save changes <kbd>enter</kbd></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="height: 97%; overflow: auto">
|
||||||
|
<table id="attributes-table" class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Value</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody data-bind="foreach: attributes">
|
||||||
|
<tr data-bind="if: isDeleted == 0">
|
||||||
|
<td class="handle">
|
||||||
|
<span class="glyphicon glyphicon-resize-vertical"></span>
|
||||||
|
<input type="hidden" name="position" data-bind="value: position"/>
|
||||||
|
</td>
|
||||||
|
<!-- ID column has specific width because if it's empty its size can be deformed when dragging -->
|
||||||
|
<td data-bind="text: attributeId" style="min-width: 10em; font-size: smaller;"></td>
|
||||||
|
<td>
|
||||||
|
<select data-bind="options: $root.availableTypes, optionsText: 'text', optionsValue: 'value', value: type"></select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<!-- Change to valueUpdate: blur is necessary because jQuery UI autocomplete hijacks change event -->
|
||||||
|
<input type="text" class="attribute-name form-control" data-bind="value: name, valueUpdate: 'blur', event: { blur: $parent.attributeChanged }"/>
|
||||||
|
<div style="color: yellowgreen" data-bind="if: $parent.isNotUnique($index())"><span class="glyphicon glyphicon-info-sign"></span> Duplicate attribute.</div>
|
||||||
|
<div style="color: red" data-bind="if: $parent.isEmptyName($index())">Attribute name can't be empty.</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="attribute-value form-control" data-bind="value: value, valueUpdate: 'blur', event: { blur: $parent.attributeChanged }" style="width: 300px"/>
|
||||||
|
</td>
|
||||||
|
<td title="Delete" style="padding: 13px; cursor: pointer;">
|
||||||
|
<span class="glyphicon glyphicon-trash" data-bind="click: $parent.deleteAttribute"></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="labels-dialog" title="Note labels" style="display: none; padding: 20px;">
|
<div id="labels-dialog" title="Note labels" style="display: none; padding: 20px;">
|
||||||
<form data-bind="submit: save">
|
<form data-bind="submit: save">
|
||||||
<div style="text-align: center">
|
<div style="text-align: center">
|
||||||
|
Loading…
x
Reference in New Issue
Block a user