implemented mirror relations

This commit is contained in:
azivner 2018-11-12 23:34:22 +01:00
parent e7cea59ba7
commit 21d3b0c9d8
13 changed files with 164 additions and 31 deletions

View file

@ -40,10 +40,20 @@ class Attribute extends Entity {
}
}
/**
* @returns {Promise<Note|null>}
*/
async getNote() {
return await repository.getEntity("SELECT * FROM notes WHERE noteId = ?", [this.noteId]);
if (!this.__note) {
this.__note = await repository.getEntity("SELECT * FROM notes WHERE noteId = ?", [this.noteId]);
}
return this.__note;
}
/**
* @returns {Promise<Note|null>}
*/
async getTargetNote() {
if (this.type !== 'relation') {
throw new Error(`Attribute ${this.attributeId} is not relation`);
@ -53,9 +63,16 @@ class Attribute extends Entity {
return null;
}
return await repository.getEntity("SELECT * FROM notes WHERE noteId = ?", [this.value]);
if (!this.__targetNote) {
this.__targetNote = await repository.getEntity("SELECT * FROM notes WHERE noteId = ?", [this.value]);
}
return this.__targetNote;
}
/**
* @return {boolean}
*/
isDefinition() {
return this.type === 'label-definition' || this.type === 'relation-definition';
}

View file

@ -71,6 +71,7 @@ function AttributesModel() {
attr.relationDefinition = (attr.type === 'relation-definition' && attr.value) ? attr.value : {
multiplicityType: "singlevalue",
mirrorRelation: "",
isPromoted: true
};
@ -189,6 +190,7 @@ function AttributesModel() {
},
relationDefinition: {
multiplicityType: "singlevalue",
mirrorRelation: "",
isPromoted: true
}
}));

View file

@ -60,7 +60,7 @@ async function showAttributes() {
const $inputCell = $("<td>").append($("<div>").addClass("input-group").append($input));
const $actionCell = $("<td>");
const $multiplicityCell = $("<td>");
const $multiplicityCell = $("<td>").addClass("multiplicity");
$tr
.append($labelCell)
@ -148,9 +148,14 @@ async function showAttributes() {
// ideally we'd use link instead of button which would allow tooltip preview, but
// we can't guarantee updating the link in the a element
const $openButton = $("<button>").addClass("btn btn-sm").text("Open").click(() => {
const notePath = $input.prop("data-selected-path");
const notePath = $input.getSelectedPath();
treeService.activateNote(notePath);
if (notePath) {
treeService.activateNote(notePath);
}
else {
console.log("Empty note path, nothing to open.");
}
});
$actionCell.append($openButton);
@ -162,7 +167,7 @@ async function showAttributes() {
if (definition.multiplicityType === "multivalue") {
const addButton = $("<span>")
.addClass("glyphicon glyphicon-plus pointer")
.addClass("jam jam-plus pointer")
.prop("title", "Add new attribute")
.click(async () => {
const $new = await createRow(definitionAttr, {
@ -178,7 +183,7 @@ async function showAttributes() {
});
const removeButton = $("<span>")
.addClass("glyphicon glyphicon-trash pointer")
.addClass("jam jam-trash pointer")
.prop("title", "Remove this attribute")
.click(async () => {
if (valueAttr.attributeId) {
@ -269,11 +274,9 @@ async function promotedAttributeChanged(event) {
value = $attr.is(':checked') ? "true" : "false";
}
else if ($attr.prop("attribute-type") === "relation") {
const selectedPath = $attr.prop("data-selected-path");
const selectedPath = $attr.getSelectedPath();
if (selectedPath) {
value = treeUtils.getNoteIdFromNotePath(selectedPath);
}
value = selectedPath ? treeUtils.getNoteIdFromNotePath(selectedPath) : "";
}
else {
value = $attr.val();

View file

@ -54,24 +54,34 @@ function initNoteAutocomplete($el) {
$el.prop("data-selected-path", suggestion.path);
});
$el.getSelectedPath = () => $el.prop("data-selected-path");
$el.on('autocomplete:closed', () => {
$el.prop("data-selected-path", "");
});
}
return $el;
}
$.fn.getSelectedPath = function() {
if (!$(this).val().trim()) {
return "";
}
else {
return $(this).prop("data-selected-path");
}
};
ko.bindingHandlers.noteAutocomplete = {
init: function(element, valueAccessor, allBindings, viewModel, bindingContext) {
initNoteAutocomplete($(element));
$(element).on('autocomplete:selected', function(event, suggestion, dataset) {
bindingContext.$data.selectedPath = suggestion.path;
bindingContext.$data.selectedPath = $(element).val().trim() ? suggestion.path : '';
});
}
};
export default {
initNoteAutocomplete,
autocompleteSource,
showRecentNotes
}

View file

@ -152,6 +152,11 @@ async function getRunPath(notePath) {
if (childNoteId !== null) {
const child = await treeCache.getNote(childNoteId);
if (!child) {
console.log("Can't find " + childNoteId);
}
const parents = await child.getParentNotes();
if (!parents) {
@ -609,7 +614,7 @@ $(window).bind('hashchange', function() {
const notePath = getNotePathFromAddress();
if (getCurrentNotePath() !== notePath) {
console.log("Switching to " + notePath + " because of hash change");
console.debug("Switching to " + notePath + " because of hash change");
activateNote(notePath);
}

View file

@ -57,7 +57,7 @@ class TreeCache {
return noteIds.map(noteId => {
if (!this.notes[noteId] && !silentNotFoundError) {
messagingService.logError(`Can't find note ${noteId}`);
messagingService.logError(`Can't find note "${noteId}"`);
return null;
}

View file

@ -521,6 +521,11 @@ table.promoted-attributes-in-tooltip td, table.promoted-attributes-in-tooltip th
margin: 0;
}
.algolia-autocomplete .aa-dropdown-menu .aa-suggestion p {
padding: 0;
margin: 0;
}
.algolia-autocomplete .aa-dropdown-menu .aa-suggestion.aa-cursor {
background-color: #B2D7FF;
}
@ -544,4 +549,8 @@ table.promoted-attributes-in-tooltip td, table.promoted-attributes-in-tooltip th
.fancytree-custom-icon {
font-size: 1.3em;
}
.multiplicity {
font-size: larger;
}

View file

@ -20,6 +20,10 @@ async function updateNoteAttribute(req) {
attribute = await repository.getAttribute(body.attributeId);
}
else {
if (body.type === 'relation' && !body.value.trim()) {
return {};
}
attribute = new Attribute();
attribute.noteId = noteId;
attribute.name = body.name;
@ -30,7 +34,13 @@ async function updateNoteAttribute(req) {
return [400, `Attribute ${body.attributeId} is not owned by ${noteId}`];
}
attribute.value = body.value;
if (body.value.trim()) {
attribute.value = body.value;
}
else {
// relations should never have empty target
attribute.isDeleted = true;
}
await attribute.save();
@ -81,11 +91,18 @@ async function updateNoteAttributes(req) {
attributeEntity.type = attribute.type;
attributeEntity.name = attribute.name;
attributeEntity.value = attribute.value;
attributeEntity.position = attribute.position;
attributeEntity.isInheritable = attribute.isInheritable;
attributeEntity.isDeleted = attribute.isDeleted;
if (attributeEntity.type === 'relation' && !attributeEntity.value.trim()) {
// relation should never have empty target
attributeEntity.isDeleted = true;
}
else {
attributeEntity.value = attribute.value;
}
await attributeEntity.save();
}

View file

@ -5,6 +5,7 @@ const sqlInit = require('./sql_init');
const log = require('./log');
const messagingService = require('./messaging');
const syncMutexService = require('./sync_mutex');
const repository = require('./repository.js');
const cls = require('./cls');
async function runCheck(query, errorText, errorList) {
@ -89,6 +90,17 @@ async function runSyncRowChecks(table, key, errorList) {
`Missing ${table} records for existing sync rows`, errorList);
}
async function fixEmptyRelationTargets(errorList) {
const emptyRelations = await repository.getEntities("SELECT * FROM attributes WHERE isDeleted = 0 AND type = 'relation' AND value = ''");
for (const relation of emptyRelations) {
relation.isDeleted = true;
await relation.save();
errorList.push(`Relation ${relation.attributeId} of name "${relation.name} has empty target. Autofixed.`);
}
}
async function runAllChecks() {
const errorList = [];
@ -221,6 +233,8 @@ async function runAllChecks() {
await checkTreeCycles(errorList);
}
await fixEmptyRelationTargets(errorList);
return errorList;
}

View file

@ -4,6 +4,7 @@ const NOTE_TITLE_CHANGED = "NOTE_TITLE_CHANGED";
const ENTER_PROTECTED_SESSION = "ENTER_PROTECTED_SESSION";
const ENTITY_CREATED = "ENTITY_CREATED";
const ENTITY_CHANGED = "ENTITY_CHANGED";
const ENTITY_DELETED = "ENTITY_DELETED";
const CHILD_NOTE_CREATED = "CHILD_NOTE_CREATED";
const eventListeners = {};
@ -37,5 +38,6 @@ module.exports = {
ENTER_PROTECTED_SESSION,
ENTITY_CREATED,
ENTITY_CHANGED,
ENTITY_DELETED,
CHILD_NOTE_CREATED
};

View file

@ -3,9 +3,10 @@ const scriptService = require('./script');
const treeService = require('./tree');
const messagingService = require('./messaging');
const log = require('./log');
const Attribute = require('../entities/attribute');
async function runAttachedRelations(note, relationName, originEntity) {
const runRelations = (await note.getRelations()).filter(relation => relation.name === relationName);
const runRelations = await note.getRelations(relationName);
for (const relation of runRelations) {
const scriptNote = await relation.getTargetNote();
@ -56,4 +57,54 @@ eventService.subscribe(eventService.ENTITY_CREATED, async ({ entityName, entity
eventService.subscribe(eventService.CHILD_NOTE_CREATED, async ({ parentNote, childNote }) => {
await runAttachedRelations(parentNote, 'runOnChildNoteCreation', childNote);
});
async function processMirrorRelations(entityName, entity, handler) {
if (entityName === 'attributes' && entity.type === 'relation') {
const note = await entity.getNote();
const attributes = (await note.getAttributes(entity.name)).filter(relation => relation.type === 'relation-definition');
for (const attribute of attributes) {
const definition = attribute.value;
if (definition.mirrorRelation && definition.mirrorRelation.trim()) {
const targetNote = await entity.getTargetNote();
await handler(definition, note, targetNote);
}
}
}
}
eventService.subscribe(eventService.ENTITY_CHANGED, async ({ entityName, entity }) => {
await processMirrorRelations(entityName, entity, async (definition, note, targetNote) => {
// we need to make sure that also target's mirror attribute exists and if note, then create it
if (!await targetNote.hasRelation(definition.mirrorRelation)) {
await new Attribute({
noteId: targetNote.noteId,
type: 'relation',
name: definition.mirrorRelation,
value: note.noteId,
isInheritable: entity.isInheritable
}).save();
targetNote.invalidateAttributeCache();
}
});
});
eventService.subscribe(eventService.ENTITY_DELETED, async ({ entityName, entity }) => {
await processMirrorRelations(entityName, entity, async (definition, note, targetNote) => {
// if one mirror attribute is deleted then the other should be deleted as well
const relations = await targetNote.getRelations(definition.mirrorRelation);
for (const relation of relations) {
relation.isDeleted = true;
await relation.save();
}
if (relations.length > 0) {
targetNote.invalidateAttributeCache();
}
});
});

View file

@ -96,20 +96,17 @@ async function updateEntity(entity) {
if (entity.isChanged && (entityName !== 'options' || entity.isSynced)) {
await syncTableService.addEntitySync(entityName, primaryKey);
if (isNewEntity) {
await eventService.emit(eventService.ENTITY_CREATED, {
entityName,
entity
});
const eventPayload = {
entityName,
entity
};
if (isNewEntity && !entity.isDeleted) {
await eventService.emit(eventService.ENTITY_CREATED, eventPayload);
}
// it seems to be better to handle deletion with a separate event
if (!entity.isDeleted) {
await eventService.emit(eventService.ENTITY_CHANGED, {
entityName,
entity
});
}
// it seems to be better to handle deletion and update separately
await eventService.emit(entity.isDeleted ? eventService.ENTITY_DELETED : eventService.ENTITY_CHANGED, eventPayload);
}
});
}

View file

@ -68,6 +68,12 @@
data-bind="checked: relationDefinition.isPromoted"/>
Promoted
</label>
<br/>
<label>
Mirror relation:
<input type="text" value="true" class="attribute-name" data-bind="value: relationDefinition.mirrorRelation"/>
</label>
</div>
</td>
<td title="Inheritable relations are automatically inherited to the child notes">