mirror of
https://github.com/zadam/trilium.git
synced 2025-11-26 02:24:23 +01:00
Port promoted attributes to React (#7846)
This commit is contained in:
commit
dc1d497ff3
@ -21,7 +21,6 @@ import NoteTreeWidget from "../widgets/note_tree.js";
|
|||||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||||
import options from "../services/options.js";
|
import options from "../services/options.js";
|
||||||
import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js";
|
import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js";
|
||||||
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
|
|
||||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||||
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
|
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
|
||||||
import Ribbon from "../widgets/ribbon/Ribbon.jsx";
|
import Ribbon from "../widgets/ribbon/Ribbon.jsx";
|
||||||
@ -45,6 +44,7 @@ import utils from "../services/utils.js";
|
|||||||
import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js";
|
import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js";
|
||||||
import NoteDetail from "../widgets/NoteDetail.jsx";
|
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||||
import RightPanelWidget from "../widgets/sidebar/RightPanelWidget.jsx";
|
import RightPanelWidget from "../widgets/sidebar/RightPanelWidget.jsx";
|
||||||
|
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
|
||||||
|
|
||||||
export default class DesktopLayout {
|
export default class DesktopLayout {
|
||||||
|
|
||||||
@ -141,7 +141,7 @@ export default class DesktopLayout {
|
|||||||
.child(<ReadOnlyNoteInfoBar />)
|
.child(<ReadOnlyNoteInfoBar />)
|
||||||
.child(<SharedInfo />)
|
.child(<SharedInfo />)
|
||||||
)
|
)
|
||||||
.child(new PromotedAttributesWidget())
|
.child(<PromotedAttributes />)
|
||||||
.child(<SqlTableSchemas />)
|
.child(<SqlTableSchemas />)
|
||||||
.child(<NoteDetail />)
|
.child(<NoteDetail />)
|
||||||
.child(<NoteList media="screen" />)
|
.child(<NoteList media="screen" />)
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import NoteTitleWidget from "../widgets/note_title.js";
|
|||||||
import ContentHeader from "../widgets/containers/content_header.js";
|
import ContentHeader from "../widgets/containers/content_header.js";
|
||||||
import NoteTreeWidget from "../widgets/note_tree.js";
|
import NoteTreeWidget from "../widgets/note_tree.js";
|
||||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||||
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
|
|
||||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||||
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
|
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
|
||||||
import RootContainer from "../widgets/containers/root_container.js";
|
import RootContainer from "../widgets/containers/root_container.js";
|
||||||
@ -29,6 +28,7 @@ import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button
|
|||||||
import type AppContext from "../components/app_context.js";
|
import type AppContext from "../components/app_context.js";
|
||||||
import NoteDetail from "../widgets/NoteDetail.jsx";
|
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||||
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
|
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
|
||||||
|
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
|
||||||
|
|
||||||
const MOBILE_CSS = `
|
const MOBILE_CSS = `
|
||||||
<style>
|
<style>
|
||||||
@ -152,7 +152,7 @@ export default class MobileLayout {
|
|||||||
.child(<MobileDetailMenu />)
|
.child(<MobileDetailMenu />)
|
||||||
)
|
)
|
||||||
.child(<FloatingButtons items={MOBILE_FLOATING_BUTTONS} />)
|
.child(<FloatingButtons items={MOBILE_FLOATING_BUTTONS} />)
|
||||||
.child(new PromotedAttributesWidget())
|
.child(<PromotedAttributes />)
|
||||||
.child(
|
.child(
|
||||||
new ScrollingContainer()
|
new ScrollingContainer()
|
||||||
.filling()
|
.filling()
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
* @param whether to execute at the beginning (`false`)
|
* @param whether to execute at the beginning (`false`)
|
||||||
* @api public
|
* @api public
|
||||||
*/
|
*/
|
||||||
function debounce<T>(func: (...args: unknown[]) => T, waitMs: number, immediate: boolean = false) {
|
function debounce<T>(func: (...args: any[]) => T, waitMs: number, immediate: boolean = false) {
|
||||||
let timeout: any; // TODO: fix once we split client and server.
|
let timeout: any; // TODO: fix once we split client and server.
|
||||||
let args: unknown[] | null;
|
let args: unknown[] | null;
|
||||||
let context: unknown;
|
let context: unknown;
|
||||||
|
|||||||
@ -1428,9 +1428,7 @@ div.promoted-attribute-cell .tn-checkbox {
|
|||||||
height: 1cap;
|
height: 1cap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Relocate the checkbox before the label */
|
|
||||||
div.promoted-attribute-cell.promoted-attribute-label-boolean > div:first-of-type {
|
div.promoted-attribute-cell.promoted-attribute-label-boolean > div:first-of-type {
|
||||||
order: -1;
|
|
||||||
margin-inline-end: 1.5em;
|
margin-inline-end: 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
97
apps/client/src/widgets/PromotedAttributes.css
Normal file
97
apps/client/src/widgets/PromotedAttributes.css
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
body.mobile .promoted-attributes-widget {
|
||||||
|
/* https://github.com/zadam/trilium/issues/4468 */
|
||||||
|
flex-shrink: 0.4;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component.promoted-attributes-widget {
|
||||||
|
contain: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promoted-attributes-container {
|
||||||
|
margin: 0 1.5em;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 400px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promoted-attribute-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-inline-start: 10px;
|
||||||
|
display: flex;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.promoted-attribute-cell strong {
|
||||||
|
word-break:keep-all;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promoted-attribute-cell input[type="checkbox"] {
|
||||||
|
width: 22px !important;
|
||||||
|
flex-grow: 0;
|
||||||
|
width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Restore default apperance */
|
||||||
|
.promoted-attribute-cell input[type="number"],
|
||||||
|
.promoted-attribute-cell input[type="checkbox"] {
|
||||||
|
appearance: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promoted-attribute-cell input[type="color"] {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-top: 2px;
|
||||||
|
appearance: none;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
border-radius: 25% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch {
|
||||||
|
border: none;
|
||||||
|
border-radius: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promoted-attribute-label-number input {
|
||||||
|
text-align: right;
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"] {
|
||||||
|
position: relative;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"]:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
inset-inline-start: 0px;
|
||||||
|
inset-inline-end: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
450
apps/client/src/widgets/PromotedAttributes.tsx
Normal file
450
apps/client/src/widgets/PromotedAttributes.tsx
Normal file
@ -0,0 +1,450 @@
|
|||||||
|
import { Dispatch, StateUpdater, useEffect, useRef, useState } from "preact/hooks";
|
||||||
|
import "./PromotedAttributes.css";
|
||||||
|
import { useNoteContext, useNoteLabel, useTriliumEvent, useUniqueName } from "./react/hooks";
|
||||||
|
import { Attribute } from "../services/attribute_parser";
|
||||||
|
import FAttribute from "../entities/fattribute";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { t } from "../services/i18n";
|
||||||
|
import { DefinitionObject, LabelType } from "../services/promoted_attribute_definition_parser";
|
||||||
|
import server from "../services/server";
|
||||||
|
import FNote from "../entities/fnote";
|
||||||
|
import { ComponentChild, HTMLInputTypeAttribute, InputHTMLAttributes, MouseEventHandler, TargetedEvent, TargetedInputEvent } from "preact";
|
||||||
|
import NoteAutocomplete from "./react/NoteAutocomplete";
|
||||||
|
import ws from "../services/ws";
|
||||||
|
import { UpdateAttributeResponse } from "@triliumnext/commons";
|
||||||
|
import attributes from "../services/attributes";
|
||||||
|
import debounce from "../services/debounce";
|
||||||
|
|
||||||
|
interface Cell {
|
||||||
|
definitionAttr: FAttribute;
|
||||||
|
definition: DefinitionObject;
|
||||||
|
valueAttr: Attribute;
|
||||||
|
valueName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CellProps {
|
||||||
|
note: FNote;
|
||||||
|
componentId: string;
|
||||||
|
cell: Cell,
|
||||||
|
cells: Cell[],
|
||||||
|
shouldFocus: boolean;
|
||||||
|
setCells(cells: Cell[]): void;
|
||||||
|
setCellToFocus(cell: Cell): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OnChangeEventData = TargetedEvent<HTMLInputElement, Event> | InputEvent | JQuery.TriggeredEvent<HTMLInputElement, undefined, HTMLInputElement, HTMLInputElement>;
|
||||||
|
type OnChangeListener = (e: OnChangeEventData) => Promise<void>;
|
||||||
|
|
||||||
|
export default function PromotedAttributes() {
|
||||||
|
const { note, componentId } = useNoteContext();
|
||||||
|
const [ cells, setCells ] = usePromotedAttributeData(note, componentId);
|
||||||
|
const [ cellToFocus, setCellToFocus ] = useState<Cell>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="promoted-attributes-widget">
|
||||||
|
<div className="promoted-attributes-container">
|
||||||
|
{note && cells?.map(cell => <PromotedAttributeCell
|
||||||
|
cell={cell}
|
||||||
|
cells={cells} setCells={setCells}
|
||||||
|
shouldFocus={cell === cellToFocus} setCellToFocus={setCellToFocus}
|
||||||
|
componentId={componentId} note={note}
|
||||||
|
/>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the individual cells (instances for promoted attributes including empty attributes). Promoted attributes with "multiple" multiplicity will have
|
||||||
|
* each value represented as a separate cell.
|
||||||
|
*
|
||||||
|
* The cells are returned as a state since they can also be altered internally if needed, for example to add a new empty cell.
|
||||||
|
*/
|
||||||
|
function usePromotedAttributeData(note: FNote | null | undefined, componentId: string): [ Cell[] | undefined, Dispatch<StateUpdater<Cell[] | undefined>> ] {
|
||||||
|
const [ viewType ] = useNoteLabel(note, "viewType");
|
||||||
|
const [ cells, setCells ] = useState<Cell[]>();
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (!note || viewType === "table") {
|
||||||
|
setCells([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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
|
||||||
|
ownedAttributes.sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
const cells: Cell[] = [];
|
||||||
|
for (const definitionAttr of promotedDefAttrs) {
|
||||||
|
const valueType = definitionAttr.name.startsWith("label:") ? "label" : "relation";
|
||||||
|
const valueName = definitionAttr.name.substr(valueType.length + 1);
|
||||||
|
|
||||||
|
let valueAttrs = ownedAttributes.filter((el) => el.name === valueName && el.type === valueType) as Attribute[];
|
||||||
|
|
||||||
|
if (valueAttrs.length === 0) {
|
||||||
|
valueAttrs.push({
|
||||||
|
attributeId: "",
|
||||||
|
type: valueType,
|
||||||
|
name: valueName,
|
||||||
|
value: ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definitionAttr.getDefinition().multiplicity === "single") {
|
||||||
|
valueAttrs = valueAttrs.slice(0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const valueAttr of valueAttrs) {
|
||||||
|
const definition = definitionAttr.getDefinition();
|
||||||
|
|
||||||
|
// if not owned, we'll force creation of a new attribute instead of updating the inherited one
|
||||||
|
if (valueAttr.noteId !== note.noteId) {
|
||||||
|
valueAttr.attributeId = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
cells.push({ definitionAttr, definition, valueAttr, valueName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCells(cells);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(refresh, [ note, viewType ]);
|
||||||
|
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||||
|
if (loadResults.getAttributeRows(componentId).find((attr) => attributes.isAffecting(attr, note))) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [ cells, setCells ];
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromotedAttributeCell(props: CellProps) {
|
||||||
|
const { valueName, valueAttr, definition } = props.cell;
|
||||||
|
const inputId = useUniqueName(`value-${valueAttr.name}`);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!props.shouldFocus) return;
|
||||||
|
const inputEl = document.getElementById(inputId);
|
||||||
|
if (inputEl) {
|
||||||
|
inputEl.focus();
|
||||||
|
}
|
||||||
|
}, [ props.shouldFocus ]);
|
||||||
|
|
||||||
|
let correspondingInput: ComponentChild;
|
||||||
|
let className: string | undefined;
|
||||||
|
switch (valueAttr.type) {
|
||||||
|
case "label":
|
||||||
|
correspondingInput = <LabelInput inputId={inputId} {...props} />;
|
||||||
|
className = `promoted-attribute-label-${definition.labelType}`;
|
||||||
|
break;
|
||||||
|
case "relation":
|
||||||
|
correspondingInput = <RelationInput inputId={inputId} {...props} />;
|
||||||
|
className = "promoted-attribute-relation";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
ws.logError(t(`promoted_attributes.unknown_attribute_type`, { type: valueAttr.type }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("promoted-attribute-cell", className)}>
|
||||||
|
{definition.labelType !== "boolean" && <label for={inputId}>{definition.promotedAlias ?? valueName}</label>}
|
||||||
|
{correspondingInput}
|
||||||
|
<MultiplicityCell {...props} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABEL_MAPPINGS: Record<LabelType, HTMLInputTypeAttribute> = {
|
||||||
|
text: "text",
|
||||||
|
number: "number",
|
||||||
|
boolean: "checkbox",
|
||||||
|
date: "date",
|
||||||
|
datetime: "datetime-local",
|
||||||
|
time: "time",
|
||||||
|
color: "hidden", // handled separately.
|
||||||
|
url: "url"
|
||||||
|
};
|
||||||
|
|
||||||
|
function LabelInput({ inputId, ...props }: CellProps & { inputId: string }) {
|
||||||
|
const { valueName, valueAttr, definition, definitionAttr } = props.cell;
|
||||||
|
const onChangeListener = buildPromotedAttributeLabelChangedListener({...props});
|
||||||
|
const extraInputProps: InputHTMLAttributes = {};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (definition.labelType === "text") {
|
||||||
|
const el = document.getElementById(inputId);
|
||||||
|
if (el) {
|
||||||
|
setupTextLabelAutocomplete(el as HTMLInputElement, valueAttr, onChangeListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [ inputId, valueAttr, onChangeListener ]);
|
||||||
|
|
||||||
|
switch (definition.labelType) {
|
||||||
|
case "number": {
|
||||||
|
let step = 1;
|
||||||
|
for (let i = 0; i < (definition.numberPrecision || 0) && i < 10; i++) {
|
||||||
|
step /= 10;
|
||||||
|
}
|
||||||
|
extraInputProps.step = step;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "url": {
|
||||||
|
extraInputProps.placeholder = t("promoted_attributes.url_placeholder");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputNode = <input
|
||||||
|
className="form-control promoted-attribute-input"
|
||||||
|
tabIndex={200 + definitionAttr.position}
|
||||||
|
id={inputId}
|
||||||
|
type={LABEL_MAPPINGS[definition.labelType ?? "text"]}
|
||||||
|
value={valueAttr.value}
|
||||||
|
placeholder={t("promoted_attributes.unset-field-placeholder")}
|
||||||
|
data-attribute-id={valueAttr.attributeId}
|
||||||
|
data-attribute-type={valueAttr.type}
|
||||||
|
data-attribute-name={valueAttr.name}
|
||||||
|
onChange={onChangeListener}
|
||||||
|
{...extraInputProps}
|
||||||
|
/>;
|
||||||
|
|
||||||
|
if (definition.labelType === "boolean") {
|
||||||
|
return <>
|
||||||
|
<div>
|
||||||
|
<label className="tn-checkbox">{inputNode}</label>
|
||||||
|
</div>
|
||||||
|
<label for={inputId}>{definition.promotedAlias ?? valueName}</label>
|
||||||
|
</>
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className="input-group">
|
||||||
|
{inputNode}
|
||||||
|
{ definition.labelType === "color" && <ColorPicker {...props} onChange={onChangeListener} inputId={inputId} />}
|
||||||
|
{ definition.labelType === "url" && (
|
||||||
|
<InputButton
|
||||||
|
className="open-external-link-button"
|
||||||
|
icon="bx bx-window-open"
|
||||||
|
title={t("promoted_attributes.open_external_link")}
|
||||||
|
onClick={(e) => {
|
||||||
|
const inputEl = document.getElementById(inputId) as HTMLInputElement | null;
|
||||||
|
const url = inputEl?.value;
|
||||||
|
if (url) {
|
||||||
|
window.open(url, "_blank");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// We insert a separate input since the color input does not support empty value.
|
||||||
|
// This is a workaround to allow clearing the color input.
|
||||||
|
function ColorPicker({ cell, onChange, inputId }: CellProps & {
|
||||||
|
onChange: (e: TargetedEvent<HTMLInputElement, Event>) => Promise<void>,
|
||||||
|
inputId: string;
|
||||||
|
}) {
|
||||||
|
const defaultColor = "#ffffff";
|
||||||
|
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={colorInputRef}
|
||||||
|
className="form-control promoted-attribute-input"
|
||||||
|
type="color"
|
||||||
|
value={cell.valueAttr.value || defaultColor}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
<InputButton
|
||||||
|
icon="bx bxs-tag-x"
|
||||||
|
title={t("promoted_attributes.remove_color")}
|
||||||
|
onClick={(e) => {
|
||||||
|
// Indicate to the user the color was reset.
|
||||||
|
if (colorInputRef.current) {
|
||||||
|
colorInputRef.current.value = defaultColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger the actual attribute change by injecting it into the hidden field.
|
||||||
|
const inputEl = document.getElementById(inputId) as HTMLInputElement | null;
|
||||||
|
if (!inputEl) return;
|
||||||
|
inputEl.value = "";
|
||||||
|
onChange({
|
||||||
|
...e,
|
||||||
|
target: inputEl
|
||||||
|
} as unknown as TargetedInputEvent<HTMLInputElement>);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RelationInput({ inputId, ...props }: CellProps & { inputId: string }) {
|
||||||
|
return (
|
||||||
|
<NoteAutocomplete
|
||||||
|
id={inputId}
|
||||||
|
noteId={props.cell.valueAttr.value}
|
||||||
|
noteIdChanged={async (value) => {
|
||||||
|
const { note, cell, componentId } = props;
|
||||||
|
cell.valueAttr.attributeId = (await updateAttribute(note, cell, componentId, value)).attributeId;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultiplicityCell({ cell, cells, setCells, setCellToFocus, note, componentId }: CellProps) {
|
||||||
|
return (cell.definition.multiplicity === "multi" &&
|
||||||
|
<td className="multiplicity">
|
||||||
|
<PromotedActionButton
|
||||||
|
icon="bx bx-plus"
|
||||||
|
title={t("promoted_attributes.add_new_attribute")}
|
||||||
|
onClick={() => {
|
||||||
|
const index = cells.indexOf(cell);
|
||||||
|
const newCell: Cell = {
|
||||||
|
...cell,
|
||||||
|
valueAttr: {
|
||||||
|
attributeId: "",
|
||||||
|
type: cell.valueAttr.type,
|
||||||
|
name: cell.valueName,
|
||||||
|
value: ""
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setCells([
|
||||||
|
...cells.slice(0, index + 1),
|
||||||
|
newCell,
|
||||||
|
...cells.slice(index + 1)
|
||||||
|
]);
|
||||||
|
setCellToFocus(newCell);
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
<PromotedActionButton
|
||||||
|
icon="bx bx-trash"
|
||||||
|
title={t("promoted_attributes.remove_this_attribute")}
|
||||||
|
onClick={async () => {
|
||||||
|
// Remove the attribute from the server if it exists.
|
||||||
|
const { attributeId, type } = cell.valueAttr;
|
||||||
|
const valueName = cell.valueName;
|
||||||
|
if (attributeId) {
|
||||||
|
await server.remove(`notes/${note.noteId}/attributes/${attributeId}`, componentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = cells.indexOf(cell);
|
||||||
|
const isLastOneOfType = cells.filter(c => c.valueAttr.type === type && c.valueAttr.name === valueName).length < 2;
|
||||||
|
const newOnesToInsert: Cell[] = [];
|
||||||
|
if (isLastOneOfType) {
|
||||||
|
newOnesToInsert.push({
|
||||||
|
...cell,
|
||||||
|
valueAttr: {
|
||||||
|
attributeId: "",
|
||||||
|
type: cell.valueAttr.type,
|
||||||
|
name: cell.valueName,
|
||||||
|
value: ""
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setCells(cells.toSpliced(index, 1, ...newOnesToInsert));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromotedActionButton({ icon, title, onClick }: {
|
||||||
|
icon: string,
|
||||||
|
title: string,
|
||||||
|
onClick: MouseEventHandler<HTMLSpanElement>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={clsx("tn-tool-button pointer", icon)}
|
||||||
|
title={title}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputButton({ icon, className, title, onClick }: {
|
||||||
|
icon: string;
|
||||||
|
className?: string;
|
||||||
|
title: string;
|
||||||
|
onClick: MouseEventHandler<HTMLSpanElement>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={clsx("input-group-text", className, icon)}
|
||||||
|
title={title}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupTextLabelAutocomplete(el: HTMLInputElement, valueAttr: Attribute, onChangeListener: OnChangeListener) {
|
||||||
|
// no need to await for this, can be done asynchronously
|
||||||
|
const $input = $(el);
|
||||||
|
server.get<string[]>(`attribute-values/${encodeURIComponent(valueAttr.name)}`).then((_attributeValues) => {
|
||||||
|
if (_attributeValues.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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", onChangeListener);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPromotedAttributeLabelChangedListener({ note, cell, componentId, ...props }: CellProps): OnChangeListener {
|
||||||
|
async function onChange(e: OnChangeEventData) {
|
||||||
|
const inputEl = e.target as HTMLInputElement;
|
||||||
|
let value: string;
|
||||||
|
|
||||||
|
if (inputEl.type === "checkbox") {
|
||||||
|
value = inputEl.checked ? "true" : "false";
|
||||||
|
} else {
|
||||||
|
value = inputEl.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
cell.valueAttr.attributeId = (await updateAttribute(note, cell, componentId, value)).attributeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return debounce(onChange, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAttribute(note: FNote, cell: Cell, componentId: string, value: string | undefined) {
|
||||||
|
return server.put<UpdateAttributeResponse>(
|
||||||
|
`notes/${note.noteId}/attribute`,
|
||||||
|
{
|
||||||
|
attributeId: cell.valueAttr.attributeId,
|
||||||
|
type: cell.valueAttr.type,
|
||||||
|
name: cell.valueName,
|
||||||
|
value: value || ""
|
||||||
|
},
|
||||||
|
componentId
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import { ComponentChildren } from "preact";
|
|||||||
import NoteList from "../collections/NoteList";
|
import NoteList from "../collections/NoteList";
|
||||||
import StandaloneRibbonAdapter from "../ribbon/components/StandaloneRibbonAdapter";
|
import StandaloneRibbonAdapter from "../ribbon/components/StandaloneRibbonAdapter";
|
||||||
import FormattingToolbar from "../ribbon/FormattingToolbar";
|
import FormattingToolbar from "../ribbon/FormattingToolbar";
|
||||||
|
import PromotedAttributes from "../PromotedAttributes";
|
||||||
|
|
||||||
export default function PopupEditor() {
|
export default function PopupEditor() {
|
||||||
const [ shown, setShown ] = useState(false);
|
const [ shown, setShown ] = useState(false);
|
||||||
@ -47,6 +48,7 @@ export default function PopupEditor() {
|
|||||||
}}
|
}}
|
||||||
onHidden={() => setShown(false)}
|
onHidden={() => setShown(false)}
|
||||||
>
|
>
|
||||||
|
<PromotedAttributes />
|
||||||
<StandaloneRibbonAdapter component={FormattingToolbar} />
|
<StandaloneRibbonAdapter component={FormattingToolbar} />
|
||||||
<NoteDetail />
|
<NoteDetail />
|
||||||
<NoteList media="screen" displayOnlyCollections />
|
<NoteList media="screen" displayOnlyCollections />
|
||||||
|
|||||||
@ -1,460 +0,0 @@
|
|||||||
import { t } from "../services/i18n.js";
|
|
||||||
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";
|
|
||||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
|
|
||||||
import attributeService from "../services/attributes.js";
|
|
||||||
import options from "../services/options.js";
|
|
||||||
import utils from "../services/utils.js";
|
|
||||||
import type FNote from "../entities/fnote.js";
|
|
||||||
import type { Attribute } from "../services/attribute_parser.js";
|
|
||||||
import type FAttribute from "../entities/fattribute.js";
|
|
||||||
import type { EventData } from "../components/app_context.js";
|
|
||||||
|
|
||||||
const TPL = /*html*/`
|
|
||||||
<div class="promoted-attributes-widget">
|
|
||||||
<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;
|
|
||||||
overflow: auto;
|
|
||||||
max-height: 400px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
display: table;
|
|
||||||
}
|
|
||||||
.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-inline-start: 10px;
|
|
||||||
display: flex;
|
|
||||||
min-height: 40px;
|
|
||||||
}
|
|
||||||
.promoted-attribute-cell strong {
|
|
||||||
word-break:keep-all;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.promoted-attribute-cell input[type="checkbox"] {
|
|
||||||
width: 22px !important;
|
|
||||||
flex-grow: 0;
|
|
||||||
width: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Restore default apperance */
|
|
||||||
.promoted-attribute-cell input[type="number"],
|
|
||||||
.promoted-attribute-cell input[type="checkbox"] {
|
|
||||||
appearance: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.promoted-attribute-cell input[type="color"] {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
margin-top: 2px;
|
|
||||||
appearance: none;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
outline: none;
|
|
||||||
border-radius: 25% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch-wrapper {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch {
|
|
||||||
border: none;
|
|
||||||
border-radius: 25%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"] {
|
|
||||||
position: relative;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"]:after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
inset-inline-start: 0px;
|
|
||||||
inset-inline-end: 0;
|
|
||||||
height: 2px;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
transform: rotate(45deg);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="promoted-attributes-container"></div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
// TODO: Deduplicate
|
|
||||||
interface AttributeResult {
|
|
||||||
attributeId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class PromotedAttributesWidget extends NoteContextAwareWidget {
|
|
||||||
|
|
||||||
private $container!: JQuery<HTMLElement>;
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "promotedAttributes";
|
|
||||||
}
|
|
||||||
|
|
||||||
get toggleCommand() {
|
|
||||||
return "toggleRibbonTabPromotedAttributes";
|
|
||||||
}
|
|
||||||
|
|
||||||
doRender() {
|
|
||||||
this.$widget = $(TPL);
|
|
||||||
this.contentSized();
|
|
||||||
this.$container = this.$widget.find(".promoted-attributes-container");
|
|
||||||
}
|
|
||||||
|
|
||||||
getTitle(note: FNote) {
|
|
||||||
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
|
|
||||||
|
|
||||||
if (promotedDefAttrs.length === 0) {
|
|
||||||
return { show: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
show: true,
|
|
||||||
activate: options.is("promotedAttributesOpenInRibbon"),
|
|
||||||
title: t("promoted_attributes.promoted_attributes"),
|
|
||||||
icon: "bx bx-table"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async refreshWithNote(note: FNote) {
|
|
||||||
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
|
|
||||||
ownedAttributes.sort((a, b) => a.position - b.position);
|
|
||||||
|
|
||||||
if (promotedDefAttrs.length === 0 || note.getLabelValue("viewType") === "table") {
|
|
||||||
this.toggleInt(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const $cells: JQuery<HTMLElement>[] = [];
|
|
||||||
|
|
||||||
for (const definitionAttr of promotedDefAttrs) {
|
|
||||||
const valueType = definitionAttr.name.startsWith("label:") ? "label" : "relation";
|
|
||||||
const valueName = definitionAttr.name.substr(valueType.length + 1);
|
|
||||||
|
|
||||||
let valueAttrs = ownedAttributes.filter((el) => el.name === valueName && el.type === valueType) as Attribute[];
|
|
||||||
|
|
||||||
if (valueAttrs.length === 0) {
|
|
||||||
valueAttrs.push({
|
|
||||||
attributeId: "",
|
|
||||||
type: valueType,
|
|
||||||
name: valueName,
|
|
||||||
value: ""
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (definitionAttr.getDefinition().multiplicity === "single") {
|
|
||||||
valueAttrs = valueAttrs.slice(0, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const valueAttr of valueAttrs) {
|
|
||||||
const $cell = await this.createPromotedAttributeCell(definitionAttr, valueAttr, valueName);
|
|
||||||
|
|
||||||
if ($cell) {
|
|
||||||
$cells.push($cell);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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: FAttribute, valueAttr: Attribute, valueName: string) {
|
|
||||||
const definition = definitionAttr.getDefinition();
|
|
||||||
const id = `value-${valueAttr.attributeId}`;
|
|
||||||
|
|
||||||
const $input = $("<input>")
|
|
||||||
.prop("tabindex", 200 + definitionAttr.position)
|
|
||||||
.prop("id", id)
|
|
||||||
.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)
|
|
||||||
.prop("value", valueAttr.value)
|
|
||||||
.prop("placeholder", t("promoted_attributes.unset-field-placeholder"))
|
|
||||||
.addClass("form-control")
|
|
||||||
.addClass("promoted-attribute-input")
|
|
||||||
.on("change", (event) => this.promotedAttributeChanged(event));
|
|
||||||
|
|
||||||
const $actionCell = $("<div>");
|
|
||||||
const $multiplicityCell = $("<td>").addClass("multiplicity").attr("nowrap", "true");
|
|
||||||
|
|
||||||
const $wrapper = $('<div class="promoted-attribute-cell">')
|
|
||||||
.append(
|
|
||||||
$("<label>")
|
|
||||||
.prop("for", id)
|
|
||||||
.text(definition.promotedAlias ?? valueName)
|
|
||||||
)
|
|
||||||
.append($("<div>").addClass("input-group").append($input))
|
|
||||||
.append($actionCell)
|
|
||||||
.append($multiplicityCell);
|
|
||||||
|
|
||||||
if (valueAttr.type === "label") {
|
|
||||||
$wrapper.addClass(`promoted-attribute-label-${definition.labelType}`);
|
|
||||||
if (definition.labelType === "text") {
|
|
||||||
$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
|
|
||||||
server.get<string[]>(`attribute-values/${encodeURIComponent(valueAttr.name)}`).then((_attributeValues) => {
|
|
||||||
if (_attributeValues.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const 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));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (definition.labelType === "number") {
|
|
||||||
$input.prop("type", "number");
|
|
||||||
|
|
||||||
let step = 1;
|
|
||||||
|
|
||||||
for (let i = 0; i < (definition.numberPrecision || 0) && i < 10; i++) {
|
|
||||||
step /= 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
$input.prop("step", step);
|
|
||||||
$input.css("text-align", "right").css("width", "120");
|
|
||||||
} else if (definition.labelType === "boolean") {
|
|
||||||
$input.prop("type", "checkbox");
|
|
||||||
|
|
||||||
$input.wrap($(`<label class="tn-checkbox"></label>`));
|
|
||||||
$wrapper.find(".input-group").removeClass("input-group");
|
|
||||||
|
|
||||||
if (valueAttr.value === "true") {
|
|
||||||
$input.prop("checked", "checked");
|
|
||||||
}
|
|
||||||
} else if (definition.labelType === "date") {
|
|
||||||
$input.prop("type", "date");
|
|
||||||
} 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"));
|
|
||||||
|
|
||||||
const $openButton = $("<span>")
|
|
||||||
.addClass("input-group-text open-external-link-button bx bx-window-open")
|
|
||||||
.prop("title", t("promoted_attributes.open_external_link"))
|
|
||||||
.on("click", () => window.open($input.val() as string, "_blank"));
|
|
||||||
|
|
||||||
$input.after($openButton);
|
|
||||||
} else if (definition.labelType === "color") {
|
|
||||||
const defaultColor = "#ffffff";
|
|
||||||
$input.prop("type", "hidden");
|
|
||||||
$input.val(valueAttr.value ?? "");
|
|
||||||
|
|
||||||
// We insert a separate input since the color input does not support empty value.
|
|
||||||
// This is a workaround to allow clearing the color input.
|
|
||||||
const $colorInput = $("<input>")
|
|
||||||
.prop("type", "color")
|
|
||||||
.prop("value", valueAttr.value || defaultColor)
|
|
||||||
.addClass("form-control promoted-attribute-input")
|
|
||||||
.on("change", e => setValue((e.target as HTMLInputElement).value, e));
|
|
||||||
$input.after($colorInput);
|
|
||||||
|
|
||||||
const $clearButton = $("<span>")
|
|
||||||
.addClass("input-group-text bx bxs-tag-x")
|
|
||||||
.prop("title", t("promoted_attributes.remove_color"))
|
|
||||||
.on("click", e => setValue("", e));
|
|
||||||
|
|
||||||
const setValue = (color: string, event: JQuery.TriggeredEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) => {
|
|
||||||
$input.val(color);
|
|
||||||
if (!color) {
|
|
||||||
$colorInput.val(defaultColor);
|
|
||||||
}
|
|
||||||
event.target = $input[0]; // Set the event target to the main input
|
|
||||||
this.promotedAttributeChanged(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
$colorInput.after($clearButton);
|
|
||||||
} else {
|
|
||||||
ws.logError(t("promoted_attributes.unknown_label_type", { type: definition.labelType }));
|
|
||||||
}
|
|
||||||
} else if (valueAttr.type === "relation") {
|
|
||||||
if (valueAttr.value) {
|
|
||||||
$input.val(await treeService.getNoteTitle(valueAttr.value));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (utils.isDesktop()) {
|
|
||||||
// no need to wait for this
|
|
||||||
noteAutocompleteService.initNoteAutocomplete($input, { allowCreatingNotes: true });
|
|
||||||
|
|
||||||
$input.on("autocomplete:noteselected", (event, suggestion, dataset) => {
|
|
||||||
this.promotedAttributeChanged(event);
|
|
||||||
});
|
|
||||||
|
|
||||||
$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");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ws.logError(t(`promoted_attributes.unknown_attribute_type`, { type: valueAttr.type }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (definition.multiplicity === "multi") {
|
|
||||||
const $addButton = $("<span>")
|
|
||||||
.addClass("bx bx-plus pointer tn-tool-button")
|
|
||||||
.prop("title", t("promoted_attributes.add_new_attribute"))
|
|
||||||
.on("click", async () => {
|
|
||||||
const $new = await this.createPromotedAttributeCell(
|
|
||||||
definitionAttr,
|
|
||||||
{
|
|
||||||
attributeId: "",
|
|
||||||
type: valueAttr.type,
|
|
||||||
name: valueName,
|
|
||||||
value: ""
|
|
||||||
},
|
|
||||||
valueName
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($new) {
|
|
||||||
$wrapper.after($new);
|
|
||||||
|
|
||||||
$new.find("input").trigger("focus");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const $removeButton = $("<span>")
|
|
||||||
.addClass("bx bx-trash pointer tn-tool-button")
|
|
||||||
.prop("title", t("promoted_attributes.remove_this_attribute"))
|
|
||||||
.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) {
|
|
||||||
const $new = await this.createPromotedAttributeCell(
|
|
||||||
definitionAttr,
|
|
||||||
{
|
|
||||||
attributeId: "",
|
|
||||||
type: valueAttr.type,
|
|
||||||
name: valueName,
|
|
||||||
value: ""
|
|
||||||
},
|
|
||||||
valueName
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($new) {
|
|
||||||
$wrapper.after($new);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$wrapper.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
$multiplicityCell.append(" ").append($addButton).append(" ").append($removeButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $wrapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
async promotedAttributeChanged(event: JQuery.TriggeredEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) {
|
|
||||||
const $attr = $(event.target);
|
|
||||||
|
|
||||||
let value;
|
|
||||||
|
|
||||||
if ($attr.prop("type") === "checkbox") {
|
|
||||||
value = $attr.is(":checked") ? "true" : "false";
|
|
||||||
} else if ($attr.attr("data-attribute-type") === "relation") {
|
|
||||||
const selectedPath = $attr.getSelectedNotePath();
|
|
||||||
|
|
||||||
value = selectedPath ? treeService.getNoteIdFromUrl(selectedPath) : "";
|
|
||||||
} else {
|
|
||||||
value = $attr.val();
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await server.put<AttributeResult>(
|
|
||||||
`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
|
|
||||||
);
|
|
||||||
|
|
||||||
$attr.attr("data-attribute-id", result.attributeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
focus() {
|
|
||||||
this.$widget.find(".promoted-attribute-input:first").focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
|
||||||
if (loadResults.getAttributeRows(this.componentId).find((attr) => attributeService.isAffecting(attr, this.note))) {
|
|
||||||
this.refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -37,25 +37,6 @@ export default function NoteAutocomplete({ id, inputRef: externalInputRef, text,
|
|||||||
...opts,
|
...opts,
|
||||||
container: container?.current
|
container: container?.current
|
||||||
});
|
});
|
||||||
if (onChange || noteIdChanged) {
|
|
||||||
const listener = (_e, suggestion) => {
|
|
||||||
onChange?.(suggestion);
|
|
||||||
|
|
||||||
if (noteIdChanged) {
|
|
||||||
const noteId = suggestion?.notePath?.split("/")?.at(-1);
|
|
||||||
noteIdChanged(noteId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
$autoComplete
|
|
||||||
.on("autocomplete:noteselected", listener)
|
|
||||||
.on("autocomplete:externallinkselected", listener)
|
|
||||||
.on("autocomplete:commandselected", listener)
|
|
||||||
.on("change", (e) => {
|
|
||||||
if (!ref.current?.value) {
|
|
||||||
listener(e, null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (onTextChange) {
|
if (onTextChange) {
|
||||||
$autoComplete.on("input", () => onTextChange($autoComplete[0].value));
|
$autoComplete.on("input", () => onTextChange($autoComplete[0].value));
|
||||||
}
|
}
|
||||||
@ -67,6 +48,40 @@ export default function NoteAutocomplete({ id, inputRef: externalInputRef, text,
|
|||||||
}
|
}
|
||||||
}, [opts, container?.current]);
|
}, [opts, container?.current]);
|
||||||
|
|
||||||
|
// On change event handlers.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const $autoComplete = $(ref.current);
|
||||||
|
|
||||||
|
if (onChange || noteIdChanged) {
|
||||||
|
const autoCompleteListener = (_e, suggestion) => {
|
||||||
|
onChange?.(suggestion);
|
||||||
|
|
||||||
|
if (noteIdChanged) {
|
||||||
|
const noteId = suggestion?.notePath?.split("/")?.at(-1);
|
||||||
|
noteIdChanged(noteId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const changeListener = (e) => {
|
||||||
|
if (!ref.current?.value) {
|
||||||
|
autoCompleteListener(e, null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$autoComplete
|
||||||
|
.on("autocomplete:noteselected", autoCompleteListener)
|
||||||
|
.on("autocomplete:externallinkselected", autoCompleteListener)
|
||||||
|
.on("autocomplete:commandselected", autoCompleteListener)
|
||||||
|
.on("change", changeListener);
|
||||||
|
return () => {
|
||||||
|
$autoComplete
|
||||||
|
.off("autocomplete:noteselected", autoCompleteListener)
|
||||||
|
.off("autocomplete:externallinkselected", autoCompleteListener)
|
||||||
|
.off("autocomplete:commandselected", autoCompleteListener)
|
||||||
|
.off("change", changeListener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [opts, container?.current, onChange, noteIdChanged])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
const $autoComplete = $(ref.current);
|
const $autoComplete = $(ref.current);
|
||||||
@ -76,9 +91,11 @@ export default function NoteAutocomplete({ id, inputRef: externalInputRef, text,
|
|||||||
} else if (text) {
|
} else if (text) {
|
||||||
note_autocomplete.setText($autoComplete, text);
|
note_autocomplete.setText($autoComplete, text);
|
||||||
} else {
|
} else {
|
||||||
|
$autoComplete.setSelectedNotePath("");
|
||||||
|
$autoComplete.autocomplete("val", "").trigger("change");
|
||||||
ref.current.value = "";
|
ref.current.value = "";
|
||||||
}
|
}
|
||||||
}, [text, noteId]);
|
}); // no inputs is intentional to enforce the text of the autocomplete, which is modified externally by the autocomplete.
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="input-group" style={containerStyle}>
|
<div className="input-group" style={containerStyle}>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import BAttribute from "../../becca/entities/battribute.js";
|
|||||||
import becca from "../../becca/becca.js";
|
import becca from "../../becca/becca.js";
|
||||||
import ValidationError from "../../errors/validation_error.js";
|
import ValidationError from "../../errors/validation_error.js";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
|
import { UpdateAttributeResponse } from "@triliumnext/commons";
|
||||||
|
|
||||||
function getEffectiveNoteAttributes(req: Request) {
|
function getEffectiveNoteAttributes(req: Request) {
|
||||||
const note = becca.getNote(req.params.noteId);
|
const note = becca.getNote(req.params.noteId);
|
||||||
@ -18,7 +19,7 @@ function updateNoteAttribute(req: Request) {
|
|||||||
const noteId = req.params.noteId;
|
const noteId = req.params.noteId;
|
||||||
const body = req.body;
|
const body = req.body;
|
||||||
|
|
||||||
let attribute;
|
let attribute: BAttribute;
|
||||||
if (body.attributeId) {
|
if (body.attributeId) {
|
||||||
attribute = becca.getAttributeOrThrow(body.attributeId);
|
attribute = becca.getAttributeOrThrow(body.attributeId);
|
||||||
|
|
||||||
@ -64,7 +65,7 @@ function updateNoteAttribute(req: Request) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
attributeId: attribute.attributeId
|
attributeId: attribute.attributeId
|
||||||
};
|
} satisfies UpdateAttributeResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setNoteAttribute(req: Request) {
|
function setNoteAttribute(req: Request) {
|
||||||
|
|||||||
@ -273,3 +273,7 @@ export interface NoteMapPostResponse {
|
|||||||
links: NoteMapLink[];
|
links: NoteMapLink[];
|
||||||
noteIdToDescendantCountMap: Record<string, number>;
|
noteIdToDescendantCountMap: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateAttributeResponse {
|
||||||
|
attributeId: string;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user