Notes/src/public/app/widgets/ribbon_widgets/promoted_attributes.js

374 lines
14 KiB
JavaScript
Raw Normal View History

import { t } from "../../services/i18n.js";
2020-11-26 23:00:27 +01:00
import server from "../../services/server.js";
import ws from "../../services/ws.js";
import treeService from "../../services/tree.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
2021-05-22 12:35:41 +02:00
import NoteContextAwareWidget from "../note_context_aware_widget.js";
2021-08-25 22:49:24 +02:00
import attributeService from "../../services/attributes.js";
import options from "../../services/options.js";
import utils from "../../services/utils.js";
2020-01-13 20:25:56 +01:00
const TPL = `
<div class="promoted-attributes-widget">
2020-01-13 20:25:56 +01:00
<style>
body.mobile .promoted-attributes-widget {
/* https://github.com/zadam/trilium/issues/4468 */
flex-shrink: 0.4;
overflow: auto;
}
.promoted-attributes-container {
margin: 0 1.5em;
2020-01-13 20:25:56 +01:00
overflow: auto;
max-height: 400px;
flex-wrap: wrap;
display: table;
2020-01-13 20:25:56 +01:00
}
.promoted-attribute-cell {
display: flex;
align-items: center;
margin: 10px;
display: table-row;
}
.promoted-attribute-cell > label {
user-select: none;
font-weight: bold;
vertical-align: middle;
}
.promoted-attribute-cell > * {
display: table-cell;
padding: 1px 0;
}
.promoted-attribute-cell div.input-group {
margin-left: 10px;
display: flex;
2024-11-27 21:22:50 +02:00
min-height: 40px;
2020-01-13 20:25:56 +01:00
}
2023-04-08 14:56:37 +08:00
.promoted-attribute-cell strong {
word-break:keep-all;
2023-09-22 04:58:06 -04:00
white-space: nowrap;
2023-04-08 14:56:37 +08:00
}
.promoted-attribute-cell input[type="checkbox"] {
width: 22px !important;
flex-grow: 0;
width: unset;
}
2020-01-13 20:25:56 +01:00
</style>
<div class="promoted-attributes-container"></div>
</div>`;
2020-01-13 20:25:56 +01:00
/**
* This widget is quite special because it's used in the desktop ribbon, but in mobile outside of ribbon.
* This works without many issues (apart from autocomplete), but it should be kept in mind when changing things
* and testing.
*/
2021-05-22 12:35:41 +02:00
export default class PromotedAttributesWidget extends NoteContextAwareWidget {
2021-06-27 12:53:05 +02:00
get name() {
return "promotedAttributes";
}
get toggleCommand() {
return "toggleRibbonTabPromotedAttributes";
}
2020-01-13 20:25:56 +01:00
doRender() {
2020-01-18 18:01:16 +01:00
this.$widget = $(TPL);
2021-06-13 22:55:31 +02:00
this.contentSized();
this.$container = this.$widget.find(".promoted-attributes-container");
2020-01-13 20:25:56 +01:00
}
getTitle(note) {
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
if (promotedDefAttrs.length === 0) {
2024-09-03 18:42:03 +02:00
return { show: false };
}
return {
show: true,
2025-01-09 18:07:02 +02:00
activate: options.is("promotedAttributesOpenInRibbon"),
title: t("promoted_attributes.promoted_attributes"),
icon: "bx bx-table"
};
2020-10-30 22:57:26 +01:00
}
async refreshWithNote(note) {
2020-01-13 20:25:56 +01:00
this.$container.empty();
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
const ownedAttributes = note.getOwnedAttributes();
// attrs are not resorted if position changes after the initial load
// promoted attrs are sorted primarily by order of definitions, but with multi-valued promoted attrs
// the order of attributes is important as well
2023-11-03 01:11:47 +01:00
ownedAttributes.sort((a, b) => a.position - b.position);
2020-01-13 20:25:56 +01:00
if (promotedDefAttrs.length === 0) {
this.toggleInt(false);
return;
}
2020-01-13 20:25:56 +01:00
const $cells = [];
for (const definitionAttr of promotedDefAttrs) {
2025-01-09 18:07:02 +02:00
const valueType = definitionAttr.name.startsWith("label:") ? "label" : "relation";
const valueName = definitionAttr.name.substr(valueType.length + 1);
2020-01-13 20:25:56 +01:00
2025-01-09 18:07:02 +02:00
let valueAttrs = ownedAttributes.filter((el) => el.name === valueName && el.type === valueType);
2020-01-13 20:25:56 +01:00
if (valueAttrs.length === 0) {
valueAttrs.push({
attributeId: "",
type: valueType,
name: valueName,
value: ""
});
}
2020-01-13 20:25:56 +01:00
2025-01-09 18:07:02 +02:00
if (definitionAttr.getDefinition().multiplicity === "single") {
valueAttrs = valueAttrs.slice(0, 1);
2020-01-13 20:25:56 +01:00
}
for (const valueAttr of valueAttrs) {
const $cell = await this.createPromotedAttributeCell(definitionAttr, valueAttr, valueName);
$cells.push($cell);
}
2020-01-19 10:29:21 +01:00
}
2023-06-30 11:18:34 +02:00
// we replace the whole content in one step, so there can't be any race conditions
// (previously we saw promoted attributes doubling)
this.$container.empty().append(...$cells);
this.toggleInt(true);
}
async createPromotedAttributeCell(definitionAttr, valueAttr, valueName) {
2020-07-01 00:02:13 +02:00
const definition = definitionAttr.getDefinition();
const id = `value-${valueAttr.attributeId}`;
2020-01-13 20:25:56 +01:00
const $input = $("<input>")
2020-06-18 23:53:57 +02:00
.prop("tabindex", 200 + definitionAttr.position)
.prop("id", id)
2025-01-09 18:07:02 +02:00
.attr("data-attribute-id", valueAttr.noteId === this.noteId ? valueAttr.attributeId : "") // if not owned, we'll force creation of a new attribute instead of updating the inherited one
.attr("data-attribute-type", valueAttr.type)
.attr("data-attribute-name", valueAttr.name)
2020-01-13 20:25:56 +01:00
.prop("value", valueAttr.value)
.prop("placeholder", t("promoted_attributes.unset-field-placeholder"))
2020-01-13 20:25:56 +01:00
.addClass("form-control")
.addClass("promoted-attribute-input")
2025-01-09 18:07:02 +02:00
.on("change", (event) => this.promotedAttributeChanged(event));
2020-01-13 20:25:56 +01:00
const $actionCell = $("<div>");
2025-01-09 18:07:02 +02:00
const $multiplicityCell = $("<td>").addClass("multiplicity").attr("nowrap", true);
2020-01-13 20:25:56 +01:00
const $wrapper = $('<div class="promoted-attribute-cell">')
2025-01-09 18:07:02 +02:00
.append(
$("<label>")
.prop("for", id)
.text(definition.promotedAlias ?? valueName)
)
.append($("<div>").addClass("input-group").append($input))
2020-01-13 20:25:56 +01:00
.append($actionCell)
.append($multiplicityCell);
2025-01-09 18:07:02 +02:00
if (valueAttr.type === "label") {
if (definition.labelType === "text") {
2020-01-13 20:25:56 +01:00
$input.prop("type", "text");
// autocomplete for label values is just nice to have, mobile can keep labels editable without autocomplete
if (utils.isDesktop()) {
// no need to await for this, can be done asynchronously
2025-01-09 18:07:02 +02:00
server.get(`attribute-values/${encodeURIComponent(valueAttr.name)}`).then((attributeValues) => {
if (attributeValues.length === 0) {
return;
2020-01-13 20:25:56 +01:00
}
2025-01-09 18:07:02 +02:00
attributeValues = attributeValues.map((attribute) => ({ value: attribute }));
$input.autocomplete(
{
appendTo: document.querySelector("body"),
hint: false,
autoselect: false,
openOnFocus: true,
minLength: 0,
tabAutocomplete: false
},
[
{
displayKey: "value",
source: function (term, cb) {
term = term.toLowerCase();
const filtered = attributeValues.filter((attr) => attr.value.toLowerCase().includes(term));
cb(filtered);
}
}
]
);
$input.on("autocomplete:selected", (e) => this.promotedAttributeChanged(e));
});
}
2025-01-09 18:07:02 +02:00
} else if (definition.labelType === "number") {
2020-01-13 20:25:56 +01:00
$input.prop("type", "number");
let step = 1;
for (let i = 0; i < (definition.numberPrecision || 0) && i < 10; i++) {
step /= 10;
}
$input.prop("step", step);
2025-01-09 18:07:02 +02:00
$input.css("text-align", "right").css("width", "120");
} else if (definition.labelType === "boolean") {
2020-01-13 20:25:56 +01:00
$input.prop("type", "checkbox");
$input.wrap($(`<label class="tn-checkbox"></label>`));
$wrapper.find(".input-group").removeClass("input-group");
2020-01-13 20:25:56 +01:00
if (valueAttr.value === "true") {
$input.prop("checked", "checked");
}
2025-01-09 18:07:02 +02:00
} else if (definition.labelType === "date") {
2020-01-13 20:25:56 +01:00
$input.prop("type", "date");
2025-01-09 18:07:02 +02:00
} else if (definition.labelType === "datetime") {
$input.prop("type", "datetime-local");
} else if (definition.labelType === "time") {
$input.prop("type", "time");
} else if (definition.labelType === "url") {
$input.prop("placeholder", t("promoted_attributes.url_placeholder"));
2020-01-13 20:25:56 +01:00
const $openButton = $("<span>")
2020-09-03 22:22:21 +02:00
.addClass("input-group-text open-external-link-button bx bx-window-open")
.prop("title", t("promoted_attributes.open_external_link"))
2025-01-09 18:07:02 +02:00
.on("click", () => window.open($input.val(), "_blank"));
2020-01-13 20:25:56 +01:00
2024-09-03 18:42:03 +02:00
$input.after($openButton);
2025-01-09 18:07:02 +02:00
} else {
ws.logError(t("promoted_attributes.unknown_label_type", { type: definitionAttr.labelType }));
2020-01-13 20:25:56 +01:00
}
2025-01-09 18:07:02 +02:00
} else if (valueAttr.type === "relation") {
2020-01-13 20:25:56 +01:00
if (valueAttr.value) {
2020-01-25 09:56:08 +01:00
$input.val(await treeService.getNoteTitle(valueAttr.value));
2020-01-13 20:25:56 +01:00
}
if (utils.isDesktop()) {
// no need to wait for this
2024-09-03 18:42:03 +02:00
noteAutocompleteService.initNoteAutocomplete($input, { allowCreatingNotes: true });
2020-01-13 20:25:56 +01:00
2025-01-09 18:07:02 +02:00
$input.on("autocomplete:noteselected", (event, suggestion, dataset) => {
this.promotedAttributeChanged(event);
});
2020-01-13 20:25:56 +01:00
$input.setSelectedNotePath(valueAttr.value);
} else {
// we can't provide user a way to edit the relation so make it read only
$input.attr("readonly", "readonly");
}
2025-01-09 18:07:02 +02:00
} else {
2024-09-03 18:42:03 +02:00
ws.logError(t(`promoted_attributes.unknown_attribute_type`, { type: valueAttr.type }));
2020-01-13 20:25:56 +01:00
return;
}
if (definition.multiplicity === "multi") {
const $addButton = $("<span>")
.addClass("bx bx-plus pointer tn-tool-button")
.prop("title", t("promoted_attributes.add_new_attribute"))
2025-01-09 18:07:02 +02:00
.on("click", async () => {
const $new = await this.createPromotedAttributeCell(
definitionAttr,
{
attributeId: "",
type: valueAttr.type,
name: valueName,
value: ""
},
valueName
);
2020-01-13 20:25:56 +01:00
$wrapper.after($new);
2020-01-13 20:25:56 +01:00
2025-01-09 18:07:02 +02:00
$new.find("input").trigger("focus");
2020-01-13 20:25:56 +01:00
});
const $removeButton = $("<span>")
.addClass("bx bx-trash pointer tn-tool-button")
.prop("title", t("promoted_attributes.remove_this_attribute"))
2025-01-09 18:07:02 +02:00
.on("click", async () => {
const attributeId = $input.attr("data-attribute-id");
if (attributeId) {
await server.remove(`notes/${this.noteId}/attributes/${attributeId}`, this.componentId);
}
// if it's the last one the create new empty form immediately
const sameAttrSelector = `input[data-attribute-type='${valueAttr.type}'][data-attribute-name='${valueName}']`;
if (this.$widget.find(sameAttrSelector).length <= 1) {
2025-01-09 18:07:02 +02:00
const $new = await this.createPromotedAttributeCell(
definitionAttr,
{
attributeId: "",
type: valueAttr.type,
name: valueName,
value: ""
},
valueName
);
$wrapper.after($new);
2020-01-13 20:25:56 +01:00
}
$wrapper.remove();
2020-01-13 20:25:56 +01:00
});
2025-01-09 18:07:02 +02:00
$multiplicityCell.append(" &nbsp;").append($addButton).append(" &nbsp;").append($removeButton);
2020-01-13 20:25:56 +01:00
}
return $wrapper;
2020-01-13 20:25:56 +01:00
}
async promotedAttributeChanged(event) {
const $attr = $(event.target);
let value;
if ($attr.prop("type") === "checkbox") {
2025-01-09 18:07:02 +02:00
value = $attr.is(":checked") ? "true" : "false";
} else if ($attr.attr("data-attribute-type") === "relation") {
2020-05-16 22:11:09 +02:00
const selectedPath = $attr.getSelectedNotePath();
2020-01-13 20:25:56 +01:00
value = selectedPath ? treeService.getNoteIdFromUrl(selectedPath) : "";
2025-01-09 18:07:02 +02:00
} else {
2020-01-13 20:25:56 +01:00
value = $attr.val();
}
2025-01-09 18:07:02 +02:00
const result = await server.put(
`notes/${this.noteId}/attribute`,
{
attributeId: $attr.attr("data-attribute-id"),
type: $attr.attr("data-attribute-type"),
name: $attr.attr("data-attribute-name"),
value: value
},
this.componentId
);
2020-01-13 20:25:56 +01:00
$attr.attr("data-attribute-id", result.attributeId);
2020-01-13 20:25:56 +01:00
}
focus() {
this.$widget.find(".promoted-attribute-input:first").focus();
}
2024-09-03 18:42:03 +02:00
entitiesReloadedEvent({ loadResults }) {
2025-01-09 18:07:02 +02:00
if (loadResults.getAttributeRows(this.componentId).find((attr) => attributeService.isAffecting(attr, this.note))) {
this.refresh();
}
}
}