2025-11-28 22:53:24 +01:00

237 lines
7.5 KiB
TypeScript

import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams, ValueBooleanCallback, ValueVoidCallback } from "tabulator-tables";
import { LabelType } from "../../../services/promoted_attribute_definition_parser.js";
import { JSX } from "preact";
import { renderReactWidget } from "../../react/react_utils.jsx";
import Icon from "../../react/Icon.jsx";
import { useEffect, useRef, useState } from "preact/hooks";
import froca from "../../../services/froca.js";
import NoteAutocomplete from "../../react/NoteAutocomplete.jsx";
import { SuggestionMode } from "../../../services/note_autocomplete.js";
type ColumnType = LabelType | "relation";
export interface AttributeDefinitionInformation {
name: string;
title?: string;
type?: ColumnType;
}
const labelTypeMappings: Record<ColumnType, Partial<ColumnDefinition>> = {
text: {
editor: "input"
},
boolean: {
formatter: "tickCross",
editor: "tickCross"
},
date: {
editor: "date",
},
datetime: {
editor: "datetime"
},
number: {
editor: "number",
sorter: "number"
},
time: {
editor: "input"
},
url: {
formatter: "link",
editor: "input"
},
color: {
editor: "input",
formatter: "color",
editorParams: {
elementAttributes: {
type: "color"
}
}
},
relation: {
editor: wrapEditor(RelationEditor),
formatter: wrapFormatter(NoteFormatter)
}
};
interface BuildColumnArgs {
info: AttributeDefinitionInformation[];
movableRows: boolean;
existingColumnData: ColumnDefinition[] | undefined;
rowNumberHint: number;
position?: number;
}
interface RowNumberFormatterParams {
movableRows?: boolean;
}
export function buildColumnDefinitions({ info, movableRows, existingColumnData, rowNumberHint, position }: BuildColumnArgs) {
let columnDefs: ColumnDefinition[] = [
{
title: "#",
headerSort: false,
hozAlign: "center",
resizable: false,
frozen: true,
rowHandle: movableRows,
width: calculateIndexColumnWidth(rowNumberHint, movableRows),
formatter: wrapFormatter(({ cell, formatterParams }) => <div>
{(formatterParams as RowNumberFormatterParams).movableRows && <><span class="bx bx-dots-vertical-rounded"></span>{" "}</>}
{cell.getRow().getPosition(true)}
</div>),
formatterParams: { movableRows } satisfies RowNumberFormatterParams
},
{
field: "noteId",
title: "Note ID",
formatter: wrapFormatter(({ cell }) => <code>{cell.getValue()}</code>),
visible: false
},
{
field: "title",
title: "Title",
editor: "input",
formatter: wrapFormatter(({ cell }) => {
const { noteId, iconClass, colorClass } = cell.getRow().getData();
return <span className={`reference-link ${colorClass}`} data-href={`#root/${noteId}`}>
<Icon icon={iconClass} />{" "}{cell.getValue()}
</span>;
}),
width: 400
}
];
const seenFields = new Set<string>();
for (const { name, title, type } of info) {
const prefix = (type === "relation" ? "relations" : "labels");
const field = `${prefix}.${name}`;
if (seenFields.has(field)) {
continue;
}
columnDefs.push({
field,
title: title ?? name,
editor: "input",
rowHandle: false,
...labelTypeMappings[type ?? "text"],
});
seenFields.add(field);
}
if (existingColumnData) {
columnDefs = restoreExistingData(columnDefs, existingColumnData, position);
}
return columnDefs;
}
export function restoreExistingData(newDefs: ColumnDefinition[], oldDefs: ColumnDefinition[], position?: number) {
// 1. Keep existing columns, but restore their properties like width, visibility and order.
const newItemsByField = new Map<string, ColumnDefinition>(
newDefs.map(def => [def.field!, def])
);
const existingColumns = oldDefs
.filter(item => (item.field && newItemsByField.has(item.field!)) || item.title === "#")
.map(oldItem => {
const data = newItemsByField.get(oldItem.field!)!;
if (oldItem.resizable !== false && oldItem.width !== undefined) {
data.width = oldItem.width;
}
if (oldItem.visible !== undefined) {
data.visible = oldItem.visible;
}
return data;
}) as ColumnDefinition[];
// 2. Determine new columns.
const existingFields = new Set(existingColumns.map(item => item.field));
const newColumns = newDefs
.filter(item => !existingFields.has(item.field!));
// Clamp position to a valid range
const insertPos = position !== undefined
? Math.min(Math.max(position, 0), existingColumns.length)
: existingColumns.length;
// 3. Insert new columns at the specified position
return [
...existingColumns.slice(0, insertPos),
...newColumns,
...existingColumns.slice(insertPos)
];
}
function calculateIndexColumnWidth(rowNumberHint: number, movableRows: boolean): number {
let columnWidth = 16 * (rowNumberHint.toString().length || 1);
if (movableRows) {
columnWidth += 32;
}
return columnWidth;
}
interface FormatterOpts {
cell: CellComponent
formatterParams: FormatterParams;
}
interface EditorOpts {
cell: CellComponent,
success: ValueBooleanCallback,
cancel: ValueVoidCallback,
editorParams: {}
}
function wrapFormatter(Component: (opts: FormatterOpts) => JSX.Element): ((cell: CellComponent, formatterParams: {}, onRendered: EmptyCallback) => string | HTMLElement) {
return (cell, formatterParams, onRendered) => {
const elWithParams = <Component cell={cell} formatterParams={formatterParams} />;
return renderReactWidget(null, elWithParams)[0];
};
}
function wrapEditor(Component: (opts: EditorOpts) => JSX.Element): ((
cell: CellComponent,
onRendered: EmptyCallback,
success: ValueBooleanCallback,
cancel: ValueVoidCallback,
editorParams: {},
) => HTMLElement | false) {
return (cell, _, success, cancel, editorParams) => {
const elWithParams = <Component cell={cell} success={success} cancel={cancel} editorParams={editorParams} />
return renderReactWidget(null, elWithParams)[0];
};
}
function NoteFormatter({ cell }: FormatterOpts) {
const noteId = cell.getValue();
const [ note, setNote ] = useState(noteId ? froca.getNoteFromCache(noteId) : null)
useEffect(() => {
if (!noteId || note?.noteId === noteId) return;
froca.getNote(noteId).then(setNote);
}, [ noteId ]);
return <span className={`reference-link ${note?.getColorClass()}`} data-href={`#root/${noteId}`}>
{note && <><Icon icon={note?.getIcon()} />{" "}{note.title}</>}
</span>;
}
function RelationEditor({ cell, success }: EditorOpts) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => inputRef.current?.focus());
return <NoteAutocomplete
inputRef={inputRef}
noteId={cell.getValue()}
opts={{
suggestionMode: SuggestionMode.SuggestCreateAndLink,
hideAllButtons: true
}}
noteIdChanged={success}
/>
}