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 | InputEvent | JQuery.TriggeredEvent; type OnChangeListener = (e: OnChangeEventData) => Promise; export default function PromotedAttributes() { const { note, componentId } = useNoteContext(); const [ cells, setCells ] = usePromotedAttributeData(note, componentId); const [ cellToFocus, setCellToFocus ] = useState(); return (
{note && cells?.map(cell => )}
); } /** * 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> ] { const [ viewType ] = useNoteLabel(note, "viewType"); const [ cells, setCells ] = useState(); 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 = ; className = `promoted-attribute-label-${definition.labelType}`; break; case "relation": correspondingInput = ; className = "promoted-attribute-relation"; break; default: ws.logError(t(`promoted_attributes.unknown_attribute_type`, { type: valueAttr.type })); break; } return (
{definition.labelType !== "boolean" && } {correspondingInput}
) } const LABEL_MAPPINGS: Record = { 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); } } }, []); 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 = ; if (definition.labelType === "boolean") { return <>
} else { return (
{inputNode} { definition.labelType === "color" && } { definition.labelType === "url" && ( { const inputEl = document.getElementById(inputId) as HTMLInputElement | null; const url = inputEl?.value; if (url) { window.open(url, "_blank"); } }} /> )}
); } } // 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) => Promise, inputId: string; }) { const defaultColor = "#ffffff"; const colorInputRef = useRef(null); return ( <> { // 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); }} /> ) } function RelationInput({ inputId, ...props }: CellProps & { inputId: string }) { return ( { 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" && { 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); }} />{' '} { // 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)); }} /> ) } function PromotedActionButton({ icon, title, onClick }: { icon: string, title: string, onClick: MouseEventHandler }) { return ( ) } function InputButton({ icon, className, title, onClick }: { icon: string; className?: string; title: string; onClick: MouseEventHandler; }) { return ( ) } function setupTextLabelAutocomplete(el: HTMLInputElement, valueAttr: Attribute, onChangeListener: OnChangeListener) { // no need to await for this, can be done asynchronously const $input = $(el); server.get(`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( `notes/${note.noteId}/attribute`, { attributeId: cell.valueAttr.attributeId, type: cell.valueAttr.type, name: cell.valueName, value: value || "" }, componentId ); }