mirror of
https://github.com/zadam/trilium.git
synced 2025-10-27 16:26:31 +01:00
Compare commits
14 Commits
v0.5.4-bet
...
v0.5.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85d32c66f2 | ||
|
|
214d2e7659 | ||
|
|
f380bb7f65 | ||
|
|
0a9a032daa | ||
|
|
23a2b58b24 | ||
|
|
aee64b2522 | ||
|
|
02e07ec03a | ||
|
|
3d2dc8e699 | ||
|
|
c84e15c9be | ||
|
|
e18d0b9fd4 | ||
|
|
52817504d1 | ||
|
|
a3b31fab54 | ||
|
|
bc4aa3e40a | ||
|
|
873ea67e9c |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "trilium",
|
||||
"description": "Trilium Notes",
|
||||
"version": "0.5.4-beta",
|
||||
"version": "0.5.6",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "electron.js",
|
||||
"repository": {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
const attributesDialog = (function() {
|
||||
const dialogEl = $("#attributes-dialog");
|
||||
const saveAttributesButton = $("#save-attributes-button");
|
||||
const attributesModel = new AttributesModel();
|
||||
let attributeNames = [];
|
||||
|
||||
function AttributesModel() {
|
||||
const self = this;
|
||||
@@ -14,38 +16,112 @@ const attributesDialog = (function() {
|
||||
|
||||
const attributes = await server.get('notes/' + noteId + '/attributes');
|
||||
|
||||
this.attributes(attributes);
|
||||
self.attributes(attributes.map(ko.observable));
|
||||
|
||||
addLastEmptyRow();
|
||||
|
||||
attributeNames = await server.get('attributes/names');
|
||||
|
||||
// attribute might not be rendered immediatelly so could not focus
|
||||
setTimeout(() => $(".attribute-name:last").focus(), 100);
|
||||
};
|
||||
|
||||
this.addNewRow = function() {
|
||||
self.attributes.push({
|
||||
attributeId: '',
|
||||
name: '',
|
||||
value: ''
|
||||
});
|
||||
};
|
||||
function isValid() {
|
||||
for (let attrs = self.attributes(), i = 0; i < attrs.length; i++) {
|
||||
if (self.isEmptyName(i) || self.isNotUnique(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;
|
||||
}
|
||||
|
||||
const noteId = noteEditor.getCurrentNoteId();
|
||||
|
||||
const attributes = await server.put('notes/' + noteId + '/attributes', this.attributes());
|
||||
const attributesToSave = self.attributes()
|
||||
.map(attr => attr())
|
||||
.filter(attr => attr.attributeId !== "" || attr.name !== "");
|
||||
|
||||
self.attributes(attributes);
|
||||
const attributes = await server.put('notes/' + noteId + '/attributes', attributesToSave);
|
||||
|
||||
self.attributes(attributes.map(ko.observable));
|
||||
|
||||
addLastEmptyRow();
|
||||
|
||||
showMessage("Attributes have been saved.");
|
||||
|
||||
noteEditor.loadAttributeList();
|
||||
};
|
||||
|
||||
function addLastEmptyRow() {
|
||||
const attrs = self.attributes();
|
||||
const last = attrs.length === 0 ? null : attrs[attrs.length - 1]();
|
||||
|
||||
if (!last || last.name.trim() !== "" || last.value !== "") {
|
||||
self.attributes.push(ko.observable({
|
||||
attributeId: '',
|
||||
name: '',
|
||||
value: ''
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
this.attributeChanged = function (row) {
|
||||
addLastEmptyRow();
|
||||
|
||||
for (const attr of self.attributes()) {
|
||||
if (row.attributeId === attr().attributeId) {
|
||||
attr.valueHasMutated();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.isNotUnique = function(index) {
|
||||
const cur = self.attributes()[index]();
|
||||
|
||||
if (cur.name.trim() === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let attrs = self.attributes(), i = 0; i < attrs.length; i++) {
|
||||
const attr = attrs[i]();
|
||||
|
||||
if (index !== i && cur.name === attr.name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
this.isEmptyName = function(index) {
|
||||
const cur = self.attributes()[index]();
|
||||
|
||||
return cur.name.trim() === "" && (cur.attributeId !== "" || cur.value !== "");
|
||||
}
|
||||
}
|
||||
|
||||
async function showDialog() {
|
||||
glob.activeDialog = dialogEl;
|
||||
|
||||
await attributesModel.loadAttributes();
|
||||
|
||||
dialogEl.dialog({
|
||||
modal: true,
|
||||
width: 800,
|
||||
height: 500
|
||||
});
|
||||
|
||||
attributesModel.loadAttributes();
|
||||
}
|
||||
|
||||
$(document).bind('keydown', 'alt+a', e => {
|
||||
@@ -56,6 +132,54 @@ const attributesDialog = (function() {
|
||||
|
||||
ko.applyBindings(attributesModel, document.getElementById('attributes-dialog'));
|
||||
|
||||
$(document).on('focus', '.attribute-name', function (e) {
|
||||
if (!$(this).hasClass("ui-autocomplete-input")) {
|
||||
$(this).autocomplete({
|
||||
// shouldn't be required and autocomplete should just accept array of strings, but that fails
|
||||
// because we have overriden filter() function in init.js
|
||||
source: attributeNames.map(attr => {
|
||||
return {
|
||||
label: attr,
|
||||
value: attr
|
||||
}
|
||||
}),
|
||||
minLength: 0
|
||||
});
|
||||
}
|
||||
|
||||
$(this).autocomplete("search", $(this).val());
|
||||
});
|
||||
|
||||
$(document).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 init.js
|
||||
source: attributeValues.map(attr => {
|
||||
return {
|
||||
label: attr,
|
||||
value: attr
|
||||
}
|
||||
}),
|
||||
minLength: 0
|
||||
});
|
||||
}
|
||||
|
||||
$(this).autocomplete("search", $(this).val());
|
||||
});
|
||||
|
||||
return {
|
||||
showDialog
|
||||
};
|
||||
|
||||
@@ -54,24 +54,6 @@ $(document).bind('keydown', 'ctrl+f', () => {
|
||||
}
|
||||
});
|
||||
|
||||
$(document).bind('keydown', "ctrl+shift+left", () => {
|
||||
const node = noteTree.getCurrentNode();
|
||||
node.navigate($.ui.keyCode.LEFT, true);
|
||||
|
||||
$("#note-detail").focus();
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
$(document).bind('keydown', "ctrl+shift+right", () => {
|
||||
const node = noteTree.getCurrentNode();
|
||||
node.navigate($.ui.keyCode.RIGHT, true);
|
||||
|
||||
$("#note-detail").focus();
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
$(document).bind('keydown', "ctrl+shift+up", () => {
|
||||
const node = noteTree.getCurrentNode();
|
||||
node.navigate($.ui.keyCode.UP, true);
|
||||
@@ -123,7 +105,7 @@ $(window).on('beforeunload', () => {
|
||||
// Overrides the default autocomplete filter function to search for matched on atleast 1 word in each of the input term's words
|
||||
$.ui.autocomplete.filter = (array, terms) => {
|
||||
if (!terms) {
|
||||
return [];
|
||||
return array;
|
||||
}
|
||||
|
||||
const startDate = new Date();
|
||||
|
||||
@@ -9,6 +9,8 @@ const noteEditor = (function() {
|
||||
const unprotectButton = $("#unprotect-button");
|
||||
const noteDetailWrapperEl = $("#note-detail-wrapper");
|
||||
const noteIdDisplayEl = $("#note-id-display");
|
||||
const attributeListEl = $("#attribute-list");
|
||||
const attributeListInnerEl = $("#attribute-list-inner");
|
||||
|
||||
let editor = null;
|
||||
let codeEditor = null;
|
||||
@@ -187,6 +189,27 @@ const noteEditor = (function() {
|
||||
|
||||
// after loading new note make sure editor is scrolled to the top
|
||||
noteDetailWrapperEl.scrollTop(0);
|
||||
|
||||
loadAttributeList();
|
||||
}
|
||||
|
||||
async function loadAttributeList() {
|
||||
const noteId = getCurrentNoteId();
|
||||
|
||||
const attributes = await server.get('notes/' + noteId + '/attributes');
|
||||
|
||||
attributeListInnerEl.html('');
|
||||
|
||||
if (attributes.length > 0) {
|
||||
for (const attr of attributes) {
|
||||
attributeListInnerEl.append(formatAttribute(attr) + " ");
|
||||
}
|
||||
|
||||
attributeListEl.show();
|
||||
}
|
||||
else {
|
||||
attributeListEl.hide();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNote(noteId) {
|
||||
@@ -290,6 +313,7 @@ const noteEditor = (function() {
|
||||
newNoteCreated,
|
||||
getEditor,
|
||||
focus,
|
||||
executeCurrentNote
|
||||
executeCurrentNote,
|
||||
loadAttributeList
|
||||
};
|
||||
})();
|
||||
@@ -3,7 +3,7 @@
|
||||
const noteTree = (function() {
|
||||
const treeEl = $("#tree");
|
||||
const parentListEl = $("#parent-list");
|
||||
const parentListListEl = $("#parent-list-list");
|
||||
const parentListListEl = $("#parent-list-inner");
|
||||
|
||||
let startNotePath = null;
|
||||
let notesTreeMap = {};
|
||||
|
||||
@@ -118,4 +118,18 @@ async function stopWatch(what, func) {
|
||||
function executeScript(script) {
|
||||
// last \r\n is necessary if script contains line comment on its last line
|
||||
eval("(async function() {" + script + "\r\n})()");
|
||||
}
|
||||
|
||||
function formatValueWithWhitespace(val) {
|
||||
return /[^\w_-]/.test(val) ? '"' + val + '"' : val;
|
||||
}
|
||||
|
||||
function formatAttribute(attr) {
|
||||
let str = "@" + formatValueWithWhitespace(attr.name);
|
||||
|
||||
if (attr.value !== "") {
|
||||
str += "=" + formatValueWithWhitespace(attr.value);
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
@@ -5,12 +5,18 @@
|
||||
display: grid;
|
||||
grid-template-areas: "header header"
|
||||
"tree-actions title"
|
||||
"search note-content"
|
||||
"tree note-content"
|
||||
"parent-list note-content";
|
||||
"parent-list note-content"
|
||||
"parent-list attribute-list";
|
||||
grid-template-columns: 2fr 5fr;
|
||||
grid-template-rows: auto
|
||||
auto
|
||||
1fr;
|
||||
auto
|
||||
1fr
|
||||
auto
|
||||
auto;
|
||||
|
||||
justify-content: center;
|
||||
grid-gap: 10px;
|
||||
}
|
||||
@@ -134,6 +140,7 @@ div.ui-tooltip {
|
||||
margin-left: 20px;
|
||||
border-top: 2px solid #eee;
|
||||
padding-top: 10px;
|
||||
grid-area: parent-list;
|
||||
}
|
||||
|
||||
#parent-list ul {
|
||||
@@ -238,7 +245,7 @@ div.ui-tooltip {
|
||||
#note-id-display {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 5px;
|
||||
bottom: 8px;
|
||||
z-index: 1000;
|
||||
color: lightgrey;
|
||||
}
|
||||
@@ -249,4 +256,16 @@ div.ui-tooltip {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.cm-matchhighlight {background-color: #eeeeee}
|
||||
.cm-matchhighlight {background-color: #eeeeee}
|
||||
|
||||
#attribute-list {
|
||||
grid-area: attribute-list;
|
||||
color: #777777;
|
||||
border-top: 1px solid #eee;
|
||||
padding: 5px; display: none;
|
||||
}
|
||||
|
||||
#attribute-list button {
|
||||
padding: 2px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
@@ -7,14 +7,15 @@ const auth = require('../../services/auth');
|
||||
const sync_table = require('../../services/sync_table');
|
||||
const utils = require('../../services/utils');
|
||||
const wrap = require('express-promise-wrap').wrap;
|
||||
const attributes = require('../../services/attributes');
|
||||
|
||||
router.get('/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next) => {
|
||||
router.get('/notes/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next) => {
|
||||
const noteId = req.params.noteId;
|
||||
|
||||
res.send(await sql.getRows("SELECT * FROM attributes WHERE noteId = ? ORDER BY dateCreated", [noteId]));
|
||||
}));
|
||||
|
||||
router.put('/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next) => {
|
||||
router.put('/notes/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next) => {
|
||||
const noteId = req.params.noteId;
|
||||
const attributes = req.body;
|
||||
const now = utils.nowDate();
|
||||
@@ -45,4 +46,26 @@ router.put('/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next)
|
||||
res.send(await sql.getRows("SELECT * FROM attributes WHERE noteId = ? ORDER BY dateCreated", [noteId]));
|
||||
}));
|
||||
|
||||
router.get('/attributes/names', auth.checkApiAuth, wrap(async (req, res, next) => {
|
||||
const names = await sql.getColumn("SELECT DISTINCT name FROM attributes");
|
||||
|
||||
for (const attr of attributes.BUILTIN_ATTRIBUTES) {
|
||||
if (!names.includes(attr)) {
|
||||
names.push(attr);
|
||||
}
|
||||
}
|
||||
|
||||
names.sort();
|
||||
|
||||
res.send(names);
|
||||
}));
|
||||
|
||||
router.get('/attributes/values/:attributeName', auth.checkApiAuth, wrap(async (req, res, next) => {
|
||||
const attributeName = req.params.attributeName;
|
||||
|
||||
const values = await sql.getColumn("SELECT DISTINCT value FROM attributes WHERE name = ? AND value != '' ORDER BY value", [attributeName]);
|
||||
|
||||
res.send(values);
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
@@ -58,15 +58,112 @@ router.put('/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => {
|
||||
}));
|
||||
|
||||
router.get('/', auth.checkApiAuth, wrap(async (req, res, next) => {
|
||||
const search = '%' + utils.sanitizeSql(req.query.search) + '%';
|
||||
let {attrFilters, searchText} = parseFilters(req.query.search);
|
||||
|
||||
// searching in protected notes is pointless because of encryption
|
||||
const noteIds = await sql.getColumn(`SELECT noteId FROM notes
|
||||
WHERE isDeleted = 0 AND isProtected = 0 AND (title LIKE ? OR content LIKE ?)`, [search, search]);
|
||||
const {query, params} = getSearchQuery(attrFilters, searchText);
|
||||
|
||||
const noteIds = await sql.getColumn(query, params);
|
||||
|
||||
res.send(noteIds);
|
||||
}));
|
||||
|
||||
function parseFilters(searchText) {
|
||||
const attrFilters = [];
|
||||
|
||||
const attrRegex = /(\b(and|or)\s+)?@(!?)([\w_-]+|"[^"]+")((=|!=|<|<=|>|>=)([\w_-]+|"[^"]+"))?/i;
|
||||
|
||||
let match = attrRegex.exec(searchText);
|
||||
|
||||
function trimQuotes(str) { return str.startsWith('"') ? str.substr(1, str.length - 2) : str; }
|
||||
|
||||
while (match != null) {
|
||||
const relation = match[2] !== undefined ? match[2].toLowerCase() : 'and';
|
||||
const operator = match[3] === '!' ? 'not-exists' : 'exists';
|
||||
|
||||
attrFilters.push({
|
||||
relation: relation,
|
||||
name: trimQuotes(match[4]),
|
||||
operator: match[6] !== undefined ? match[6] : operator,
|
||||
value: match[7] !== undefined ? trimQuotes(match[7]) : null
|
||||
});
|
||||
|
||||
// remove attributes from further fulltext search
|
||||
searchText = searchText.split(match[0]).join('');
|
||||
|
||||
match = attrRegex.exec(searchText);
|
||||
}
|
||||
|
||||
return {attrFilters, searchText};
|
||||
}
|
||||
|
||||
function getSearchQuery(attrFilters, searchText) {
|
||||
const joins = [];
|
||||
const joinParams = [];
|
||||
let where = '1';
|
||||
const whereParams = [];
|
||||
|
||||
let i = 1;
|
||||
|
||||
for (const filter of attrFilters) {
|
||||
joins.push(`LEFT JOIN attributes AS attr${i} ON attr${i}.noteId = notes.noteId AND attr${i}.name = ?`);
|
||||
joinParams.push(filter.name);
|
||||
|
||||
where += " " + filter.relation + " ";
|
||||
|
||||
if (filter.operator === 'exists') {
|
||||
where += `attr${i}.attributeId IS NOT NULL`;
|
||||
}
|
||||
else if (filter.operator === 'not-exists') {
|
||||
where += `attr${i}.attributeId IS NULL`;
|
||||
}
|
||||
else if (filter.operator === '=' || filter.operator === '!=') {
|
||||
where += `attr${i}.value ${filter.operator} ?`;
|
||||
whereParams.push(filter.value);
|
||||
}
|
||||
else if ([">", ">=", "<", "<="].includes(filter.operator)) {
|
||||
const floatParam = parseFloat(filter.value);
|
||||
|
||||
if (isNaN(floatParam)) {
|
||||
where += `attr${i}.value ${filter.operator} ?`;
|
||||
whereParams.push(filter.value);
|
||||
}
|
||||
else {
|
||||
where += `CAST(attr${i}.value AS DECIMAL) ${filter.operator} ?`;
|
||||
whereParams.push(floatParam);
|
||||
}
|
||||
}
|
||||
else {
|
||||
throw new Error("Unknown operator " + filter.operator);
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
let searchCondition = '';
|
||||
const searchParams = [];
|
||||
|
||||
if (searchText.trim() !== '') {
|
||||
// searching in protected notes is pointless because of encryption
|
||||
searchCondition = ' AND (notes.isProtected = 0 AND (notes.title LIKE ? OR notes.content LIKE ?))';
|
||||
|
||||
searchText = '%' + searchText.trim() + '%';
|
||||
|
||||
searchParams.push(searchText);
|
||||
searchParams.push(searchText); // two occurences in searchCondition
|
||||
}
|
||||
|
||||
const query = `SELECT notes.noteId FROM notes
|
||||
${joins.join('\r\n')}
|
||||
WHERE
|
||||
notes.isDeleted = 0
|
||||
AND (${where})
|
||||
${searchCondition}`;
|
||||
|
||||
const params = joinParams.concat(whereParams).concat(searchParams);
|
||||
|
||||
return { query, params };
|
||||
}
|
||||
|
||||
router.put('/:noteId/sort', auth.checkApiAuth, wrap(async (req, res, next) => {
|
||||
const noteId = req.params.noteId;
|
||||
const sourceId = req.headers.source_id;
|
||||
|
||||
@@ -40,7 +40,7 @@ function register(app) {
|
||||
app.use('/api/notes', notesApiRoute);
|
||||
app.use('/api/tree', treeChangesApiRoute);
|
||||
app.use('/api/notes', cloningApiRoute);
|
||||
app.use('/api/notes', attributesRoute);
|
||||
app.use('/api', attributesRoute);
|
||||
app.use('/api/notes-history', noteHistoryApiRoute);
|
||||
app.use('/api/recent-changes', recentChangesApiRoute);
|
||||
app.use('/api/settings', settingsApiRoute);
|
||||
|
||||
@@ -5,6 +5,8 @@ const utils = require('./utils');
|
||||
const sync_table = require('./sync_table');
|
||||
const Repository = require('./repository');
|
||||
|
||||
const BUILTIN_ATTRIBUTES = [ 'run_on_startup', 'disable_versioning' ];
|
||||
|
||||
async function getNoteAttributeMap(noteId) {
|
||||
return await sql.getMap(`SELECT name, value FROM attributes WHERE noteId = ?`, [noteId]);
|
||||
}
|
||||
@@ -64,5 +66,6 @@ module.exports = {
|
||||
getNotesWithAttribute,
|
||||
getNoteWithAttribute,
|
||||
getNoteIdsWithAttribute,
|
||||
createAttribute
|
||||
createAttribute,
|
||||
BUILTIN_ATTRIBUTES
|
||||
};
|
||||
@@ -56,14 +56,13 @@
|
||||
<img src="images/icons/search.png" alt="Search in notes"/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="search-box" style="display: none; padding: 10px; margin-top: 10px;">
|
||||
<p>
|
||||
<label>Search:</label>
|
||||
<input name="search-text" autocomplete="off">
|
||||
<button id="reset-search-button">×</button>
|
||||
<span id="matches"></span>
|
||||
</p>
|
||||
<div id="search-box" class="hide-toggle" style="grid-area: search; display: none; padding: 10px; margin-top: 10px;">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<label>Search:</label>
|
||||
<input name="search-text" style="flex-grow: 100; margin-left: 5px; margin-right: 5px;" autocomplete="off">
|
||||
<button id="reset-search-button" class="btn btn-sm" title="Reset search">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -73,7 +72,7 @@
|
||||
<div id="parent-list" class="hide-toggle">
|
||||
<p><strong>Note locations:</strong></p>
|
||||
|
||||
<ul id="parent-list-list"></ul>
|
||||
<ul id="parent-list-inner"></ul>
|
||||
</div>
|
||||
|
||||
<div class="hide-toggle" style="grid-area: title;">
|
||||
@@ -143,6 +142,12 @@
|
||||
|
||||
<div id="note-detail-render"></div>
|
||||
</div>
|
||||
|
||||
<div id="attribute-list">
|
||||
<button class="btn btn-sm" onclick="attributesDialog.showDialog();">Attributes:</button>
|
||||
|
||||
<span id="attribute-list-inner"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="recent-notes-dialog" title="Recent notes" style="display: none;">
|
||||
@@ -383,10 +388,8 @@
|
||||
|
||||
<div id="attributes-dialog" title="Note attributes" style="display: none; padding: 20px;">
|
||||
<form data-bind="submit: save">
|
||||
<div style="display: flex; justify-content: space-between; padding: 15px; padding-top: 0;">
|
||||
<button class="btn-default" type="button" data-bind="click: addNewRow">Add new attribute</button>
|
||||
|
||||
<button class="btn-primary" type="submit">Save</button>
|
||||
<div style="text-align: center">
|
||||
<button class="btn btn-large" style="width: 200px;" id="save-attributes-button" type="submit">Save <kbd>enter</kbd></button>
|
||||
</div>
|
||||
|
||||
<div style="height: 97%; overflow: auto">
|
||||
@@ -402,10 +405,14 @@
|
||||
<tr>
|
||||
<td data-bind="text: attributeId"></td>
|
||||
<td>
|
||||
<input type="text" data-bind="value: name"/>
|
||||
<!-- Change to valueUpdate: blur is necessary because jQuery UI autocomplete hijacks change event -->
|
||||
<input type="text" class="attribute-name" data-bind="value: name, valueUpdate: 'blur', event: { blur: $parent.attributeChanged }"/>
|
||||
|
||||
<div style="color: red" data-bind="if: $parent.isNotUnique($index())">Attribute name must be unique per note.</div>
|
||||
<div style="color: red" data-bind="if: $parent.isEmptyName($index())">Attribute name can't be empty.</div>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" data-bind="value: value" style="width: 300px"/>
|
||||
<input type="text" class="attribute-value" data-bind="value: value, valueUpdate: 'blur', event: { blur: $parent.attributeChanged }" style="width: 300px"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user