diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index 0c155c1e8..24545fdaa 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -28,6 +28,8 @@ import TouchBarComponent from "./touch_bar.js"; import type { CKTextEditor } from "@triliumnext/ckeditor5"; import type CodeMirror from "@triliumnext/codemirror"; import { StartupChecks } from "./startup_checks.js"; +import type { CreateNoteOpts } from "../services/note_create.js"; +import { ColumnComponent } from "tabulator-tables"; interface Layout { getRootWidget: (appContext: AppContext) => RootWidget; @@ -276,6 +278,17 @@ export type CommandMappings = { geoMapCreateChildNote: CommandData; + // Table view + addNewRow: CommandData & { + customOpts: CreateNoteOpts; + parentNotePath?: string; + }; + addNewTableColumn: CommandData & { + columnToEdit?: ColumnComponent; + referenceColumn?: ColumnComponent; + direction?: "before" | "after"; + }; + buildTouchBar: CommandData & { TouchBar: typeof TouchBar; buildIcon(name: string): NativeImage; diff --git a/apps/client/src/entities/fnote.ts b/apps/client/src/entities/fnote.ts index b5d575ed9..ad5f0e556 100644 --- a/apps/client/src/entities/fnote.ts +++ b/apps/client/src/entities/fnote.ts @@ -256,6 +256,15 @@ class FNote { return this.children; } + async getSubtreeNoteIds() { + let noteIds: (string | string[])[] = []; + for (const child of await this.getChildNotes()) { + noteIds.push(child.noteId); + noteIds.push(await child.getSubtreeNoteIds()); + } + return noteIds.flat(); + } + async getChildNotes() { return await this.froca.getNotes(this.children); } diff --git a/apps/client/src/services/note_list_renderer.ts b/apps/client/src/services/note_list_renderer.ts index 122ba9745..08af048f0 100644 --- a/apps/client/src/services/note_list_renderer.ts +++ b/apps/client/src/services/note_list_renderer.ts @@ -6,33 +6,18 @@ import TableView from "../widgets/view_widgets/table_view/index.js"; import type { ViewModeArgs } from "../widgets/view_widgets/view_mode.js"; import type ViewMode from "../widgets/view_widgets/view_mode.js"; +export type ArgsWithoutNoteId = Omit; export type ViewTypeOptions = "list" | "grid" | "calendar" | "table" | "geoMap"; export default class NoteListRenderer { private viewType: ViewTypeOptions; - public viewMode: ViewMode | null; + private args: ArgsWithoutNoteId; + public viewMode?: ViewMode; - constructor(args: ViewModeArgs) { + constructor(args: ArgsWithoutNoteId) { + this.args = args; this.viewType = this.#getViewType(args.parentNote); - - switch (this.viewType) { - case "list": - case "grid": - this.viewMode = new ListOrGridView(this.viewType, args); - break; - case "calendar": - this.viewMode = new CalendarView(args); - break; - case "table": - this.viewMode = new TableView(args); - break; - case "geoMap": - this.viewMode = new GeoView(args); - break; - default: - this.viewMode = null; - } } #getViewType(parentNote: FNote): ViewTypeOptions { @@ -47,15 +32,36 @@ export default class NoteListRenderer { } get isFullHeight() { - return this.viewMode?.isFullHeight; + switch (this.viewType) { + case "list": + case "grid": + return false; + default: + return true; + } } async renderList() { - if (!this.viewMode) { - return null; - } + const args = this.args; + const viewMode = this.#buildViewMode(args); + this.viewMode = viewMode; + await viewMode.beforeRender(); + return await viewMode.renderList(); + } - return await this.viewMode.renderList(); + #buildViewMode(args: ViewModeArgs) { + switch (this.viewType) { + case "calendar": + return new CalendarView(args); + case "table": + return new TableView(args); + case "geoMap": + return new GeoView(args); + case "list": + case "grid": + default: + return new ListOrGridView(this.viewType, args); + } } } diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index b7c8f7732..3fa6e34ca 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1967,7 +1967,11 @@ "hide-column": "Hide column \"{{title}}\"", "show-hide-columns": "Show/hide columns", "row-insert-above": "Insert row above", - "row-insert-below": "Insert row below" + "row-insert-below": "Insert row below", + "row-insert-child": "Insert child note", + "add-column-to-the-left": "Add column to the left", + "add-column-to-the-right": "Add column to the right", + "edit-column": "Edit column" }, "book_properties_config": { "hide-weekends": "Hide weekends", diff --git a/apps/client/src/widgets/attribute_widgets/attribute_detail.ts b/apps/client/src/widgets/attribute_widgets/attribute_detail.ts index 9017673f3..9b66aa6f9 100644 --- a/apps/client/src/widgets/attribute_widgets/attribute_detail.ts +++ b/apps/client/src/widgets/attribute_widgets/attribute_detail.ts @@ -295,6 +295,7 @@ interface AttributeDetailOpts { x: number; y: number; focus?: "name"; + parent?: HTMLElement; } interface SearchRelatedResponse { @@ -560,19 +561,22 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget { this.toggleInt(true); - const offset = this.parent?.$widget.offset() || { top: 0, left: 0 }; + const offset = this.parent?.$widget?.offset() || { top: 0, left: 0 }; const detPosition = this.getDetailPosition(x, offset); const outerHeight = this.$widget.outerHeight(); const height = $(window).height(); - if (detPosition && outerHeight && height) { - this.$widget - .css("left", detPosition.left) - .css("right", detPosition.right) - .css("top", y - offset.top + 70) - .css("max-height", outerHeight + y > height - 50 ? height - y - 50 : 10000); + if (!detPosition || !outerHeight || !height) { + console.warn("Can't position popup, is it attached?"); + return; } + this.$widget + .css("left", detPosition.left) + .css("right", detPosition.right) + .css("top", y - offset.top + 70) + .css("max-height", outerHeight + y > height - 50 ? height - y - 50 : 10000); + if (focus === "name") { this.$inputName.trigger("focus").trigger("select"); } diff --git a/apps/client/src/widgets/attribute_widgets/attribute_editor.ts b/apps/client/src/widgets/attribute_widgets/attribute_editor.ts index 08bfefac3..3e97723a5 100644 --- a/apps/client/src/widgets/attribute_widgets/attribute_editor.ts +++ b/apps/client/src/widgets/attribute_widgets/attribute_editor.ts @@ -426,7 +426,7 @@ export default class AttributeEditorWidget extends NoteContextAwareWidget implem curNode = curNode.previousSibling; if ((curNode as ModelElement).name === "reference") { - clickIndex += (curNode.getAttribute("notePath") as string).length + 1; + clickIndex += (curNode.getAttribute("href") as string).length + 1; } else if ("data" in curNode) { clickIndex += (curNode.data as string).length; } diff --git a/apps/client/src/widgets/note_list.ts b/apps/client/src/widgets/note_list.ts index 68687e63c..74602c921 100644 --- a/apps/client/src/widgets/note_list.ts +++ b/apps/client/src/widgets/note_list.ts @@ -3,8 +3,6 @@ import NoteListRenderer from "../services/note_list_renderer.js"; import type FNote from "../entities/fnote.js"; import type { CommandListener, CommandListenerData, CommandMappings, CommandNames, EventData, EventNames } from "../components/app_context.js"; import type ViewMode from "./view_widgets/view_mode.js"; -import AttributeDetailWidget from "./attribute_widgets/attribute_detail.js"; -import { Attribute } from "../services/attribute_parser.js"; const TPL = /*html*/`
@@ -39,7 +37,6 @@ export default class NoteListWidget extends NoteContextAwareWidget { private noteIdRefreshed?: string; private shownNoteId?: string | null; private viewMode?: ViewMode | null; - private attributeDetailWidget: AttributeDetailWidget; private displayOnlyCollections: boolean; /** @@ -47,9 +44,7 @@ export default class NoteListWidget extends NoteContextAwareWidget { */ constructor(displayOnlyCollections: boolean) { super(); - this.attributeDetailWidget = new AttributeDetailWidget() - .contentSized() - .setParent(this); + this.displayOnlyCollections = displayOnlyCollections; } @@ -72,7 +67,6 @@ export default class NoteListWidget extends NoteContextAwareWidget { this.$widget = $(TPL); this.contentSized(); this.$content = this.$widget.find(".note-list-widget-content"); - this.$widget.append(this.attributeDetailWidget.render()); const observer = new IntersectionObserver( (entries) => { @@ -91,23 +85,6 @@ export default class NoteListWidget extends NoteContextAwareWidget { setTimeout(() => observer.observe(this.$widget[0]), 10); } - addNoteListItemEvent() { - const attr: Attribute = { - type: "label", - name: "label:myLabel", - value: "promoted,single,text" - }; - - this.attributeDetailWidget!.showAttributeDetail({ - attribute: attr, - allAttributes: [ attr ], - isOwned: true, - x: 100, - y: 200, - focus: "name" - }); - } - checkRenderStatus() { // console.log("this.isIntersecting", this.isIntersecting); // console.log(`${this.noteIdRefreshed} === ${this.noteId}`, this.noteIdRefreshed === this.noteId); @@ -123,8 +100,7 @@ export default class NoteListWidget extends NoteContextAwareWidget { const noteListRenderer = new NoteListRenderer({ $parent: this.$content, parentNote: note, - parentNotePath: this.notePath, - noteIds: note.getChildNoteIds() + parentNotePath: this.notePath }); this.$widget.toggleClass("full-height", noteListRenderer.isFullHeight); await noteListRenderer.renderList(); @@ -169,12 +145,6 @@ export default class NoteListWidget extends NoteContextAwareWidget { this.refresh(); this.checkRenderStatus(); } - - // Inform the view mode of changes and refresh if needed. - if (this.viewMode && this.viewMode.onEntitiesReloaded(e)) { - this.refresh(); - this.checkRenderStatus(); - } } buildTouchBarCommand(data: CommandListenerData<"buildTouchBar">) { diff --git a/apps/client/src/widgets/search_result.ts b/apps/client/src/widgets/search_result.ts index a98e306b3..6fa69ae13 100644 --- a/apps/client/src/widgets/search_result.ts +++ b/apps/client/src/widgets/search_result.ts @@ -68,7 +68,6 @@ export default class SearchResultWidget extends NoteContextAwareWidget { const noteListRenderer = new NoteListRenderer({ $parent: this.$content, parentNote: note, - noteIds: note.getChildNoteIds(), showNotePath: true }); await noteListRenderer.renderList(); diff --git a/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts index b2d99cfd6..7c29e58cc 100644 --- a/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts +++ b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts @@ -59,7 +59,7 @@ async function handleContentUpdate(affectedNoteIds: string[]) { const templateNoteIds = new Set(templateCache.keys()); const affectedTemplateNoteIds = templateNoteIds.intersection(updatedNoteIds); - await froca.getNotes(affectedNoteIds); + await froca.getNotes(affectedNoteIds, true); let fullReloadNeeded = false; for (const affectedTemplateNoteId of affectedTemplateNoteIds) { diff --git a/apps/client/src/widgets/type_widgets/editable_text.ts b/apps/client/src/widgets/type_widgets/editable_text.ts index 3bb5754a2..e1163e2b5 100644 --- a/apps/client/src/widgets/type_widgets/editable_text.ts +++ b/apps/client/src/widgets/type_widgets/editable_text.ts @@ -265,7 +265,12 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { } focus() { - this.$editor.trigger("focus"); + const editor = this.watchdog.editor; + if (editor) { + editor.editing.view.focus(); + } else { + this.$editor.trigger("focus"); + } } scrollToEnd() { diff --git a/apps/client/src/widgets/type_widgets/type_widget.ts b/apps/client/src/widgets/type_widgets/type_widget.ts index 3a437e109..9cd8a64ab 100644 --- a/apps/client/src/widgets/type_widgets/type_widget.ts +++ b/apps/client/src/widgets/type_widgets/type_widget.ts @@ -71,6 +71,15 @@ export default abstract class TypeWidget extends NoteContextAwareWidget { } } + activeNoteChangedEvent() { + if (!this.isActiveNoteContext()) { + return; + } + + // Restore focus to the editor when switching tabs. + this.focus(); + } + /** * {@inheritdoc} * diff --git a/apps/client/src/widgets/view_widgets/calendar_view.ts b/apps/client/src/widgets/view_widgets/calendar_view.ts index c8f0119f6..e4cb03f17 100644 --- a/apps/client/src/widgets/view_widgets/calendar_view.ts +++ b/apps/client/src/widgets/view_widgets/calendar_view.ts @@ -113,7 +113,6 @@ export default class CalendarView extends ViewMode<{}> { private $root: JQuery; private $calendarContainer: JQuery; - private noteIds: string[]; private calendar?: Calendar; private isCalendarRoot: boolean; private lastView?: string; @@ -124,15 +123,10 @@ export default class CalendarView extends ViewMode<{}> { this.$root = $(TPL); this.$calendarContainer = this.$root.find(".calendar-container"); - this.noteIds = args.noteIds; this.isCalendarRoot = false; args.$parent.append(this.$root); } - get isFullHeight(): boolean { - return true; - } - async renderList(): Promise | undefined> { this.isCalendarRoot = this.parentNote.hasLabel("calendarRoot") || this.parentNote.hasLabel("workspaceCalendarRoot"); const isEditable = !this.isCalendarRoot; @@ -396,7 +390,7 @@ export default class CalendarView extends ViewMode<{}> { } } - onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) { + async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) { // Refresh note IDs if they got changed. if (loadResults.getBranchRows().some((branch) => branch.parentNoteId === this.parentNote.noteId)) { this.noteIds = this.parentNote.getChildNoteIds(); @@ -438,7 +432,7 @@ export default class CalendarView extends ViewMode<{}> { events.push(await CalendarView.buildEvent(dateNote, { startDate })); if (dateNote.hasChildren()) { - const childNoteIds = dateNote.getChildNoteIds(); + const childNoteIds = await dateNote.getSubtreeNoteIds(); for (const childNoteId of childNoteIds) { childNoteToDateMapping[childNoteId] = startDate; } @@ -464,13 +458,6 @@ export default class CalendarView extends ViewMode<{}> { for (const note of notes) { const startDate = CalendarView.#getCustomisableLabel(note, "startDate", "calendar:startDate"); - if (note.hasChildren()) { - const childrenEventData = await this.buildEvents(note.getChildNoteIds()); - if (childrenEventData.length > 0) { - events.push(childrenEventData); - } - } - if (!startDate) { continue; } diff --git a/apps/client/src/widgets/view_widgets/geo_view/index.ts b/apps/client/src/widgets/view_widgets/geo_view/index.ts index 4d35fd85a..0ea27e61b 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/index.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/index.ts @@ -243,10 +243,6 @@ export default class GeoView extends ViewMode { } } - get isFullHeight(): boolean { - return true; - } - #changeState(newState: State) { this._state = newState; this.$container.toggleClass("placing-note", newState === State.NewNote); @@ -255,7 +251,7 @@ export default class GeoView extends ViewMode { } } - onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">): boolean | void { + async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) { // If any of the children branches are altered. if (loadResults.getBranchRows().find((branch) => branch.parentNoteId === this.parentNote.noteId)) { this.#reloadMarkers(); diff --git a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts index c8236b053..1bfc029ab 100644 --- a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts +++ b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts @@ -161,7 +161,7 @@ const TPL = /*html*/` class ListOrGridView extends ViewMode<{}> { private $noteList: JQuery; - private noteIds: string[]; + private filteredNoteIds!: string[]; private page?: number; private pageSize?: number; private showNotePath?: boolean; @@ -174,13 +174,6 @@ class ListOrGridView extends ViewMode<{}> { super(args, viewType); this.$noteList = $(TPL); - const includedNoteIds = this.getIncludedNoteIds(); - - this.noteIds = args.noteIds.filter((noteId) => !includedNoteIds.has(noteId) && noteId !== "_hidden"); - - if (this.noteIds.length === 0) { - return; - } args.$parent.append(this.$noteList); @@ -204,8 +197,14 @@ class ListOrGridView extends ViewMode<{}> { return new Set(includedLinks.map((rel) => rel.value)); } + async beforeRender() { + super.beforeRender(); + const includedNoteIds = this.getIncludedNoteIds(); + this.filteredNoteIds = this.noteIds.filter((noteId) => !includedNoteIds.has(noteId) && noteId !== "_hidden"); + } + async renderList() { - if (this.noteIds.length === 0 || !this.page || !this.pageSize) { + if (this.filteredNoteIds.length === 0 || !this.page || !this.pageSize) { this.$noteList.hide(); return; } @@ -226,7 +225,7 @@ class ListOrGridView extends ViewMode<{}> { const startIdx = (this.page - 1) * this.pageSize; const endIdx = startIdx + this.pageSize; - const pageNoteIds = this.noteIds.slice(startIdx, Math.min(endIdx, this.noteIds.length)); + const pageNoteIds = this.filteredNoteIds.slice(startIdx, Math.min(endIdx, this.filteredNoteIds.length)); const pageNotes = await froca.getNotes(pageNoteIds); for (const note of pageNotes) { @@ -246,7 +245,7 @@ class ListOrGridView extends ViewMode<{}> { return; } - const pageCount = Math.ceil(this.noteIds.length / this.pageSize); + const pageCount = Math.ceil(this.filteredNoteIds.length / this.pageSize); $pager.toggle(pageCount > 1); @@ -257,7 +256,7 @@ class ListOrGridView extends ViewMode<{}> { lastPrinted = true; const startIndex = (i - 1) * this.pageSize + 1; - const endIndex = Math.min(this.noteIds.length, i * this.pageSize); + const endIndex = Math.min(this.filteredNoteIds.length, i * this.pageSize); $pager.append( i === this.page @@ -279,7 +278,7 @@ class ListOrGridView extends ViewMode<{}> { } // no need to distinguish "note" vs "notes" since in case of one result, there's no paging at all - $pager.append(`(${this.noteIds.length} notes)`); + $pager.append(`(${this.filteredNoteIds.length} notes)`); } async renderNote(note: FNote, expand: boolean = false) { diff --git a/apps/client/src/widgets/view_widgets/table_view/col_editing.ts b/apps/client/src/widgets/view_widgets/table_view/col_editing.ts new file mode 100644 index 000000000..983b89b1d --- /dev/null +++ b/apps/client/src/widgets/view_widgets/table_view/col_editing.ts @@ -0,0 +1,104 @@ +import { Tabulator } from "tabulator-tables"; +import AttributeDetailWidget from "../../attribute_widgets/attribute_detail"; +import { Attribute } from "../../../services/attribute_parser"; +import Component from "../../../components/component"; +import { CommandListenerData, EventData } from "../../../components/app_context"; +import attributes from "../../../services/attributes"; +import FNote from "../../../entities/fnote"; + +export default class TableColumnEditing extends Component { + + private attributeDetailWidget: AttributeDetailWidget; + private newAttributePosition?: number; + private api: Tabulator; + private newAttribute?: Attribute; + private parentNote: FNote; + + constructor($parent: JQuery, parentNote: FNote, api: Tabulator) { + super(); + const parentComponent = glob.getComponentByEl($parent[0]); + this.attributeDetailWidget = new AttributeDetailWidget() + .contentSized() + .setParent(parentComponent); + $parent.append(this.attributeDetailWidget.render()); + this.api = api; + this.parentNote = parentNote; + } + + addNewTableColumnCommand({ referenceColumn, columnToEdit, direction }: EventData<"addNewTableColumn">) { + let attr: Attribute | undefined; + + if (columnToEdit) { + attr = this.getAttributeFromField(columnToEdit.getField()); + console.log("Built ", attr); + } + + if (!attr) { + attr = { + type: "label", + name: "label:myLabel", + value: "promoted,single,text" + }; + } + + if (referenceColumn && this.api) { + this.newAttributePosition = this.api.getColumns().indexOf(referenceColumn); + + if (direction === "after") { + this.newAttributePosition++; + } + } else { + this.newAttributePosition = undefined; + } + + this.attributeDetailWidget!.showAttributeDetail({ + attribute: attr, + allAttributes: [ attr ], + isOwned: true, + x: 0, + y: 150, + focus: "name" + }); + } + + async updateAttributeListCommand({ attributes }: CommandListenerData<"updateAttributeList">) { + this.newAttribute = attributes[0]; + } + + async saveAttributesCommand() { + if (!this.newAttribute) { + return; + } + + const { name, value } = this.newAttribute; + attributes.setLabel(this.parentNote.noteId, name, value); + } + + getNewAttributePosition() { + return this.newAttributePosition; + } + + resetNewAttributePosition() { + this.newAttributePosition = 0; + } + + getFAttributeFromField(field: string) { + const [ type, name ] = field.split(".", 2); + const attrName = `${type.replace("s", "")}:${name}`; + return this.parentNote.getLabel(attrName); + } + + getAttributeFromField(field: string) { + const fAttribute = this.getFAttributeFromField(field); + if (fAttribute) { + return { + name: fAttribute.name, + value: fAttribute.value, + type: fAttribute.type + }; + } + return undefined; + } + +} + diff --git a/apps/client/src/widgets/view_widgets/table_view/columns.spec.ts b/apps/client/src/widgets/view_widgets/table_view/columns.spec.ts new file mode 100644 index 000000000..083d03820 --- /dev/null +++ b/apps/client/src/widgets/view_widgets/table_view/columns.spec.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { restoreExistingData } from "./columns"; +import type { ColumnDefinition } from "tabulator-tables"; + +describe("restoreExistingData", () => { + it("maintains important columns properties", () => { + const newDefs: ColumnDefinition[] = [ + { field: "title", title: "Title", editor: "input" }, + { field: "noteId", title: "Note ID", formatter: "color", visible: false } + ]; + const oldDefs: ColumnDefinition[] = [ + { field: "title", title: "Title", width: 300, visible: true }, + { field: "noteId", title: "Note ID", width: 200, visible: true } + ]; + const restored = restoreExistingData(newDefs, oldDefs); + expect(restored[0].editor).toBe("input"); + expect(restored[1].formatter).toBe("color"); + }); + + it("should restore existing column data", () => { + const newDefs: ColumnDefinition[] = [ + { field: "title", title: "Title", editor: "input" }, + { field: "noteId", title: "Note ID", visible: false } + ]; + const oldDefs: ColumnDefinition[] = [ + { field: "title", title: "Title", width: 300, visible: true }, + { field: "noteId", title: "Note ID", width: 200, visible: true } + ]; + const restored = restoreExistingData(newDefs, oldDefs); + expect(restored[0].width).toBe(300); + expect(restored[1].width).toBe(200); + }); + + it("restores order of columns", () => { + const newDefs: ColumnDefinition[] = [ + { field: "title", title: "Title", editor: "input" }, + { field: "noteId", title: "Note ID", visible: false } + ]; + const oldDefs: ColumnDefinition[] = [ + { field: "noteId", title: "Note ID", width: 200, visible: true }, + { field: "title", title: "Title", width: 300, visible: true } + ]; + const restored = restoreExistingData(newDefs, oldDefs); + expect(restored[0].field).toBe("noteId"); + expect(restored[1].field).toBe("title"); + }); + + it("inserts new columns at given position", () => { + const newDefs: ColumnDefinition[] = [ + { field: "title", title: "Title", editor: "input" }, + { field: "noteId", title: "Note ID", visible: false }, + { field: "newColumn", title: "New Column", editor: "input" } + ]; + const oldDefs: ColumnDefinition[] = [ + { field: "title", title: "Title", width: 300, visible: true }, + { field: "noteId", title: "Note ID", width: 200, visible: true } + ]; + const restored = restoreExistingData(newDefs, oldDefs, 0); + expect(restored.length).toBe(3); + expect(restored[0].field).toBe("newColumn"); + expect(restored[1].field).toBe("title"); + expect(restored[2].field).toBe("noteId"); + }); + + it("inserts new columns at the end if no position is specified", () => { + const newDefs: ColumnDefinition[] = [ + { field: "title", title: "Title", editor: "input" }, + { field: "noteId", title: "Note ID", visible: false }, + { field: "newColumn", title: "New Column", editor: "input" } + ]; + const oldDefs: ColumnDefinition[] = [ + { field: "title", title: "Title", width: 300, visible: true }, + { field: "noteId", title: "Note ID", width: 200, visible: true } + ]; + const restored = restoreExistingData(newDefs, oldDefs); + expect(restored.length).toBe(3); + expect(restored[0].field).toBe("title"); + expect(restored[1].field).toBe("noteId"); + expect(restored[2].field).toBe("newColumn"); + }); +}); diff --git a/apps/client/src/widgets/view_widgets/table_view/columns.ts b/apps/client/src/widgets/view_widgets/table_view/columns.ts index bae64bda3..7a8348e4e 100644 --- a/apps/client/src/widgets/view_widgets/table_view/columns.ts +++ b/apps/client/src/widgets/view_widgets/table_view/columns.ts @@ -1,12 +1,11 @@ import { RelationEditor } from "./relation_editor.js"; -import { NoteFormatter, NoteTitleFormatter, RowNumberFormatter } from "./formatters.js"; -import { applyHeaderMenu } from "./header-menu.js"; +import { MonospaceFormatter, NoteFormatter, NoteTitleFormatter, RowNumberFormatter } from "./formatters.js"; import type { ColumnDefinition } from "tabulator-tables"; import { LabelType } from "../../../services/promoted_attribute_definition_parser.js"; type ColumnType = LabelType | "relation"; -export interface PromotedAttributeInformation { +export interface AttributeDefinitionInformation { name: string; title?: string; type?: ColumnType; @@ -42,20 +41,21 @@ const labelTypeMappings: Record> = { } }; -export function buildColumnDefinitions(info: PromotedAttributeInformation[], existingColumnData?: ColumnDefinition[]) { - const columnDefs: ColumnDefinition[] = [ +export function buildColumnDefinitions(info: AttributeDefinitionInformation[], movableRows: boolean, existingColumnData?: ColumnDefinition[], position?: number) { + let columnDefs: ColumnDefinition[] = [ { title: "#", headerSort: false, hozAlign: "center", resizable: false, frozen: true, - rowHandle: true, - formatter: RowNumberFormatter + rowHandle: movableRows, + formatter: RowNumberFormatter(movableRows) }, { field: "noteId", title: "Note ID", + formatter: MonospaceFormatter, visible: false }, { @@ -86,27 +86,41 @@ export function buildColumnDefinitions(info: PromotedAttributeInformation[], exi seenFields.add(field); } - applyHeaderMenu(columnDefs); if (existingColumnData) { - restoreExistingData(columnDefs, existingColumnData); + columnDefs = restoreExistingData(columnDefs, existingColumnData, position); } return columnDefs; } -function restoreExistingData(newDefs: ColumnDefinition[], oldDefs: ColumnDefinition[]) { - const byField = new Map; - for (const def of oldDefs) { - byField.set(def.field ?? "", def); - } +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( + newDefs.map(def => [def.field!, def]) + ); + const existingColumns = oldDefs + .map(item => { + return { + ...newItemsByField.get(item.field!), + width: item.width, + visible: item.visible, + }; + }) as ColumnDefinition[]; - for (const newDef of newDefs) { - const oldDef = byField.get(newDef.field ?? ""); - if (!oldDef) { - continue; - } + // 2. Determine new columns. + const existingFields = new Set(existingColumns.map(item => item.field)); + const newColumns = newDefs + .filter(item => !existingFields.has(item.field!)); - newDef.width = oldDef.width; - newDef.visible = oldDef.visible; - } + // 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) + ]; } diff --git a/apps/client/src/widgets/view_widgets/table_view/context_menu.ts b/apps/client/src/widgets/view_widgets/table_view/context_menu.ts index 55fb75002..0c7ed4760 100644 --- a/apps/client/src/widgets/view_widgets/table_view/context_menu.ts +++ b/apps/client/src/widgets/view_widgets/table_view/context_menu.ts @@ -5,10 +5,19 @@ import branches from "../../../services/branches.js"; import { t } from "../../../services/i18n.js"; import link_context_menu from "../../../menus/link_context_menu.js"; import type FNote from "../../../entities/fnote.js"; +import froca from "../../../services/froca.js"; +import type Component from "../../../components/component.js"; export function setupContextMenu(tabulator: Tabulator, parentNote: FNote) { - tabulator.on("rowContext", (e, row) => showRowContextMenu(e, row, parentNote)); + tabulator.on("rowContext", (e, row) => showRowContextMenu(e, row, parentNote, tabulator)); tabulator.on("headerContext", (e, col) => showColumnContextMenu(e, col, tabulator)); + + // Pressing the expand button prevents bubbling and the context menu remains menu when it shouldn't. + if (tabulator.options.dataTree) { + const dismissContextMenu = () => contextMenu.hide(); + tabulator.on("dataTreeRowExpanded", dismissContextMenu); + tabulator.on("dataTreeRowCollapsed", dismissContextMenu); + } } function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, tabulator: Tabulator) { @@ -69,6 +78,29 @@ function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, tabulator: uiIcon: "bx bx-empty", items: buildColumnItems() }, + { title: "----" }, + { + title: t("table_view.add-column-to-the-left"), + uiIcon: "bx bx-horizontal-left", + handler: () => getParentComponent(e)?.triggerCommand("addNewTableColumn", { + referenceColumn: column + }) + }, + { + title: t("table_view.edit-column"), + uiIcon: "bx bx-edit", + handler: () => getParentComponent(e)?.triggerCommand("addNewTableColumn", { + columnToEdit: column + }) + }, + { + title: t("table_view.add-column-to-the-right"), + uiIcon: "bx bx-horizontal-right", + handler: () => getParentComponent(e)?.triggerCommand("addNewTableColumn", { + referenceColumn: column, + direction: "after" + }) + } ], selectMenuItemHandler() {}, x: e.pageX, @@ -79,11 +111,11 @@ function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, tabulator: function buildColumnItems() { const items: MenuItem[] = []; for (const column of tabulator.getColumns()) { - const { title, visible, field } = column.getDefinition(); + const { title, field } = column.getDefinition(); items.push({ title, - checked: visible, + checked: column.isVisible(), uiIcon: "bx bx-empty", enabled: !!field, handler: () => column.toggle() @@ -94,9 +126,19 @@ function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, tabulator: } } -export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: FNote) { +export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: FNote, tabulator: Tabulator) { const e = _e as MouseEvent; const rowData = row.getData() as TableData; + + let parentNoteId: string = parentNote.noteId; + + if (tabulator.options.dataTree) { + const parentRow = row.getTreeParent(); + if (parentRow) { + parentNoteId = parentRow.getData().noteId as string; + } + } + contextMenu.show({ items: [ ...link_context_menu.getItems(), @@ -104,16 +146,25 @@ export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: F { title: t("table_view.row-insert-above"), uiIcon: "bx bx-list-plus", - handler: () => { - const target = e.target; - if (!target) { - return; + handler: () => getParentComponent(e)?.triggerCommand("addNewRow", { + parentNotePath: parentNoteId, + customOpts: { + target: "before", + targetBranchId: rowData.branchId, } - const component = $(target).closest(".component").prop("component"); - component.triggerCommand("addNewRow", { + }) + }, + { + title: t("table_view.row-insert-child"), + uiIcon: "bx bx-empty", + handler: async () => { + const branchId = row.getData().branchId; + const note = await froca.getBranch(branchId)?.getNote(); + getParentComponent(e)?.triggerCommand("addNewRow", { + parentNotePath: note?.noteId, customOpts: { - target: "before", - targetBranchId: rowData.branchId, + target: "after", + targetBranchId: branchId, } }); } @@ -121,19 +172,13 @@ export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: F { title: t("table_view.row-insert-below"), uiIcon: "bx bx-empty", - handler: () => { - const target = e.target; - if (!target) { - return; + handler: () => getParentComponent(e)?.triggerCommand("addNewRow", { + parentNotePath: parentNoteId, + customOpts: { + target: "after", + targetBranchId: rowData.branchId, } - const component = $(target).closest(".component").prop("component"); - component.triggerCommand("addNewRow", { - customOpts: { - target: "after", - targetBranchId: rowData.branchId, - } - }); - } + }) }, { title: "----" }, { @@ -148,3 +193,13 @@ export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: F }); e.preventDefault(); } + +function getParentComponent(e: MouseEvent) { + if (!e.target) { + return; + } + + return $(e.target) + .closest(".component") + .prop("component") as Component; +} diff --git a/apps/client/src/widgets/view_widgets/table_view/footer.ts b/apps/client/src/widgets/view_widgets/table_view/footer.ts index fc533d6a7..64a440236 100644 --- a/apps/client/src/widgets/view_widgets/table_view/footer.ts +++ b/apps/client/src/widgets/view_widgets/table_view/footer.ts @@ -15,7 +15,7 @@ export default function buildFooter(parentNote: FNote) { ${t("table_view.new-row")} - `.trimStart(); diff --git a/apps/client/src/widgets/view_widgets/table_view/formatters.ts b/apps/client/src/widgets/view_widgets/table_view/formatters.ts index 1994ab236..07c9d1060 100644 --- a/apps/client/src/widgets/view_widgets/table_view/formatters.ts +++ b/apps/client/src/widgets/view_widgets/table_view/formatters.ts @@ -36,8 +36,19 @@ export function NoteTitleFormatter(cell: CellComponent) { return $noteRef[0].outerHTML; } -export function RowNumberFormatter(cell: CellComponent) { - return ` ` + cell.getRow().getPosition(true); +export function RowNumberFormatter(draggableRows: boolean) { + return (cell: CellComponent) => { + let html = ""; + if (draggableRows) { + html += ` `; + } + html += cell.getRow().getPosition(true); + return html; + }; +} + +export function MonospaceFormatter(cell: CellComponent) { + return `${cell.getValue()}`; } function buildNoteLink(noteId: string) { diff --git a/apps/client/src/widgets/view_widgets/table_view/header-menu.ts b/apps/client/src/widgets/view_widgets/table_view/header-menu.ts deleted file mode 100644 index 7f098fa99..000000000 --- a/apps/client/src/widgets/view_widgets/table_view/header-menu.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ColumnComponent, ColumnDefinition, MenuObject, Tabulator } from "tabulator-tables"; - -export function applyHeaderMenu(columns: ColumnDefinition[]) { - for (let column of columns) { - if (column.headerSort !== false) { - column.headerMenu = headerMenu; - } - } -} - -function headerMenu(this: Tabulator) { - const menu: MenuObject[] = []; - const columns = this.getColumns(); - - for (let column of columns) { - //create checkbox element using font awesome icons - let icon = document.createElement("i"); - icon.classList.add("bx"); - icon.classList.add(column.isVisible() ? "bx-check" : "bx-empty"); - - //build label - let label = document.createElement("span"); - let title = document.createElement("span"); - - title.textContent = " " + column.getDefinition().title; - - label.appendChild(icon); - label.appendChild(title); - - //create menu item - menu.push({ - label: label, - action: function (e) { - //prevent menu closing - e.stopPropagation(); - - //toggle current column visibility - column.toggle(); - - //change menu item icon - if (column.isVisible()) { - icon.classList.remove("bx-empty"); - icon.classList.add("bx-check"); - } else { - icon.classList.remove("bx-check"); - icon.classList.add("bx-empty"); - } - } - }); - } - - return menu; -}; diff --git a/apps/client/src/widgets/view_widgets/table_view/index.ts b/apps/client/src/widgets/view_widgets/table_view/index.ts index b6ec8ae13..2d3a9b928 100644 --- a/apps/client/src/widgets/view_widgets/table_view/index.ts +++ b/apps/client/src/widgets/view_widgets/table_view/index.ts @@ -1,19 +1,17 @@ -import froca from "../../../services/froca.js"; import ViewMode, { type ViewModeArgs } from "../view_mode.js"; -import attributes, { setAttribute, setLabel } from "../../../services/attributes.js"; -import server from "../../../services/server.js"; +import attributes from "../../../services/attributes.js"; import SpacedUpdate from "../../../services/spaced_update.js"; -import type { CommandListenerData, EventData } from "../../../components/app_context.js"; -import type { Attribute } from "../../../services/attribute_parser.js"; -import note_create, { CreateNoteOpts } from "../../../services/note_create.js"; -import {Tabulator, SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MenuModule, MoveRowsModule, ColumnDefinition} from 'tabulator-tables'; +import type { EventData } from "../../../components/app_context.js"; +import {Tabulator, SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, ColumnDefinition, DataTreeModule, Options, RowComponent, ColumnComponent} from 'tabulator-tables'; import "tabulator-tables/dist/css/tabulator.css"; import "../../../../src/stylesheets/table.css"; import { canReorderRows, configureReorderingRows } from "./dragging.js"; import buildFooter from "./footer.js"; -import getPromotedAttributeInformation, { buildRowDefinitions } from "./rows.js"; -import { buildColumnDefinitions } from "./columns.js"; +import getAttributeDefinitionInformation, { buildRowDefinitions } from "./rows.js"; +import { AttributeDefinitionInformation, buildColumnDefinitions } from "./columns.js"; import { setupContextMenu } from "./context_menu.js"; +import TableColumnEditing from "./col_editing.js"; +import TableRowEditing from "./row_editing.js"; const TPL = /*html*/`
@@ -65,6 +63,26 @@ const TPL = /*html*/` justify-content: left; gap: 0.5em; } + + .tabulator button.tree-expand, + .tabulator button.tree-collapse { + display: inline-block; + appearance: none; + border: 0; + background: transparent; + width: 1.5em; + position: relative; + vertical-align: middle; + } + + .tabulator button.tree-expand span, + .tabulator button.tree-collapse span { + position: absolute; + top: 0; + left: 0; + font-size: 1.5em; + transform: translateY(-50%); + }
@@ -81,29 +99,22 @@ export default class TableView extends ViewMode { private $root: JQuery; private $container: JQuery; - private args: ViewModeArgs; private spacedUpdate: SpacedUpdate; private api?: Tabulator; - private newAttribute?: Attribute; private persistentData: StateInfo["tableData"]; - /** If set to a note ID, whenever the rows will be updated, the title of the note will be automatically focused for editing. */ - private noteIdToEdit?: string; + private colEditing?: TableColumnEditing; + private rowEditing?: TableRowEditing; constructor(args: ViewModeArgs) { super(args, "table"); this.$root = $(TPL); this.$container = this.$root.find(".table-view-container"); - this.args = args; this.spacedUpdate = new SpacedUpdate(() => this.onSave(), 5_000); this.persistentData = {}; args.$parent.append(this.$root); } - get isFullHeight(): boolean { - return true; - } - async renderList() { this.$container.empty(); this.renderTable(this.$container[0]); @@ -111,29 +122,27 @@ export default class TableView extends ViewMode { } private async renderTable(el: HTMLElement) { - const modules = [SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, MenuModule]; + const info = getAttributeDefinitionInformation(this.parentNote); + const modules = [ SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, DataTreeModule ]; for (const module of modules) { Tabulator.registerModule(module); } - this.initialize(el); + this.initialize(el, info); } - private async initialize(el: HTMLElement) { - const notes = await froca.getNotes(this.args.noteIds); - const info = getPromotedAttributeInformation(this.parentNote); - + private async initialize(el: HTMLElement, info: AttributeDefinitionInformation[]) { const viewStorage = await this.viewStorage.restore(); this.persistentData = viewStorage?.tableData || {}; - const columnDefs = buildColumnDefinitions(info); - const movableRows = canReorderRows(this.parentNote); - - this.api = new Tabulator(el, { + const { definitions: rowData, hasSubtree: hasChildren } = await buildRowDefinitions(this.parentNote, info); + const movableRows = canReorderRows(this.parentNote) && !hasChildren; + const columnDefs = buildColumnDefinitions(info, movableRows); + let opts: Options = { layout: "fitDataFill", - index: "noteId", + index: "branchId", columns: columnDefs, - data: await buildRowDefinitions(this.parentNote, notes, info), + data: rowData, persistence: true, movableColumns: true, movableRows, @@ -143,10 +152,28 @@ export default class TableView extends ViewMode { this.spacedUpdate.scheduleUpdate(); }, persistenceReaderFunc: (_id, type: string) => this.persistentData?.[type], - }); - configureReorderingRows(this.api); + }; + + if (hasChildren) { + opts = { + ...opts, + dataTree: hasChildren, + dataTreeStartExpanded: true, + dataTreeElementColumn: "title", + dataTreeExpandElement: ``, + dataTreeCollapseElement: `` + } + } + + this.api = new Tabulator(el, opts); + + this.colEditing = new TableColumnEditing(this.args.$parent, this.args.parentNote, this.api); + this.rowEditing = new TableRowEditing(this.api, this.args.parentNotePath!); + + if (movableRows) { + configureReorderingRows(this.api); + } setupContextMenu(this.api, this.parentNote); - this.setupEditing(); } private onSave() { @@ -155,86 +182,29 @@ export default class TableView extends ViewMode { }); } - private setupEditing() { - this.api!.on("cellEdited", async (cell) => { - const noteId = cell.getRow().getData().noteId; - const field = cell.getField(); - let newValue = cell.getValue(); - - if (field === "title") { - server.put(`notes/${noteId}/title`, { title: newValue }); - return; - } - - if (field.includes(".")) { - const [ type, name ] = field.split(".", 2); - if (type === "labels") { - if (typeof newValue === "boolean") { - newValue = newValue ? "true" : "false"; - } - setLabel(noteId, name, newValue); - } else if (type === "relations") { - const note = await froca.getNote(noteId); - if (note) { - setAttribute(note, "relation", name, newValue); - } - } - } - }); - } - - async reloadAttributesCommand() { - console.log("Reload attributes"); - } - - async updateAttributeListCommand({ attributes }: CommandListenerData<"updateAttributeList">) { - this.newAttribute = attributes[0]; - } - - async saveAttributesCommand() { - if (!this.newAttribute) { - return; - } - - const { name, value } = this.newAttribute; - attributes.addLabel(this.parentNote.noteId, name, value, true); - console.log("Save attributes", this.newAttribute); - } - - addNewRowCommand({ customOpts }: { customOpts: CreateNoteOpts }) { - const parentNotePath = this.args.parentNotePath; - if (parentNotePath) { - const opts: CreateNoteOpts = { - activate: false, - ...customOpts - } - console.log("Create with ", opts); - note_create.createNote(parentNotePath, opts).then(({ note }) => { - if (!note) { - return; - } - this.noteIdToEdit = note.noteId; - }) - } - } - - onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">): boolean | void { + async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) { if (!this.api) { return; } + // Force a refresh if sorted is changed since we need to disable reordering. + if (loadResults.getAttributeRows().find(a => a.name === "sorted" && attributes.isAffecting(a, this.parentNote))) { + return true; + } + // Refresh if promoted attributes get changed. if (loadResults.getAttributeRows().find(attr => attr.type === "label" && (attr.name?.startsWith("label:") || attr.name?.startsWith("relation:")) && attributes.isAffecting(attr, this.parentNote))) { + console.log("Col update"); this.#manageColumnUpdate(); } - if (loadResults.getBranchRows().some(branch => branch.parentNoteId === this.parentNote.noteId) - || loadResults.getNoteIds().some(noteId => this.args.noteIds.includes(noteId) - || loadResults.getAttributeRows().some(attr => this.args.noteIds.includes(attr.noteId!)))) { - this.#manageRowsUpdate(); + if (loadResults.getBranchRows().some(branch => branch.parentNoteId === this.parentNote.noteId || this.noteIds.includes(branch.parentNoteId ?? "")) + || loadResults.getNoteIds().some(noteId => this.noteIds.includes(noteId) + || loadResults.getAttributeRows().some(attr => this.noteIds.includes(attr.noteId!)))) { + return await this.#manageRowsUpdate(); } return false; @@ -245,27 +215,44 @@ export default class TableView extends ViewMode { return; } - const info = getPromotedAttributeInformation(this.parentNote); - const columnDefs = buildColumnDefinitions(info, this.persistentData?.columns); + const info = getAttributeDefinitionInformation(this.parentNote); + const columnDefs = buildColumnDefinitions(info, !!this.api.options.movableRows, this.persistentData?.columns, this.colEditing?.getNewAttributePosition()); this.api.setColumns(columnDefs); + this.colEditing?.resetNewAttributePosition(); } + addNewRowCommand(e) { + this.rowEditing?.addNewRowCommand(e); + } + + addNewTableColumnCommand(e) { + this.colEditing?.addNewTableColumnCommand(e); + } + + updateAttributeListCommand(e) { + this.colEditing?.updateAttributeListCommand(e); + } + + saveAttributesCommand() { + this.colEditing?.saveAttributesCommand(); + } + + async #manageRowsUpdate() { if (!this.api) { return; } - const notes = await froca.getNotes(this.args.noteIds); - const info = getPromotedAttributeInformation(this.parentNote); - this.api.replaceData(await buildRowDefinitions(this.parentNote, notes, info)); + const info = getAttributeDefinitionInformation(this.parentNote); + const { definitions, hasSubtree } = await buildRowDefinitions(this.parentNote, info); - if (this.noteIdToEdit) { - const row = this.api?.getRows().find(r => r.getData().noteId === this.noteIdToEdit); - if (row) { - row.getCell("title").edit(); - } - this.noteIdToEdit = undefined; + // Force a refresh if the data tree needs enabling/disabling. + if (this.api.options.dataTree !== hasSubtree) { + return true; } + + await this.api.replaceData(definitions); + return false; } } diff --git a/apps/client/src/widgets/view_widgets/table_view/row_editing.ts b/apps/client/src/widgets/view_widgets/table_view/row_editing.ts new file mode 100644 index 000000000..92b0eeea4 --- /dev/null +++ b/apps/client/src/widgets/view_widgets/table_view/row_editing.ts @@ -0,0 +1,97 @@ +import { RowComponent, Tabulator } from "tabulator-tables"; +import Component from "../../../components/component.js"; +import { setAttribute, setLabel } from "../../../services/attributes.js"; +import server from "../../../services/server.js"; +import froca from "../../../services/froca.js"; +import note_create, { CreateNoteOpts } from "../../../services/note_create.js"; +import { CommandListenerData } from "../../../components/app_context.js"; + +export default class TableRowEditing extends Component { + + private parentNotePath: string; + private api: Tabulator; + + constructor(api: Tabulator, parentNotePath: string) { + super(); + this.api = api; + this.parentNotePath = parentNotePath; + api.on("cellEdited", async (cell) => { + const noteId = cell.getRow().getData().noteId; + const field = cell.getField(); + let newValue = cell.getValue(); + + if (field === "title") { + server.put(`notes/${noteId}/title`, { title: newValue }); + return; + } + + if (field.includes(".")) { + const [ type, name ] = field.split(".", 2); + if (type === "labels") { + if (typeof newValue === "boolean") { + newValue = newValue ? "true" : "false"; + } + setLabel(noteId, name, newValue); + } else if (type === "relations") { + const note = await froca.getNote(noteId); + if (note) { + setAttribute(note, "relation", name, newValue); + } + } + } + }); + } + + addNewRowCommand({ customOpts, parentNotePath: customNotePath }: CommandListenerData<"addNewRow">) { + const parentNotePath = customNotePath ?? this.parentNotePath; + if (parentNotePath) { + const opts: CreateNoteOpts = { + activate: false, + ...customOpts + } + note_create.createNote(parentNotePath, opts).then(({ branch }) => { + if (branch) { + setTimeout(() => { + this.focusOnBranch(branch?.branchId); + }); + } + }) + } + } + + focusOnBranch(branchId: string) { + if (!this.api) { + return; + } + + const row = findRowDataById(this.api.getRows(), branchId); + if (!row) { + return; + } + + // Expand the parent tree if any. + if (this.api.options.dataTree) { + const parent = row.getTreeParent(); + if (parent) { + parent.treeExpand(); + } + } + + row.getCell("title").edit(); + } + +} + +function findRowDataById(rows: RowComponent[], branchId: string): RowComponent | null { + for (let row of rows) { + const item = row.getIndex() as string; + + if (item === branchId) { + return row; + } + + let found = findRowDataById(row.getTreeChildren(), branchId); + if (found) return found; + } + return null; +} diff --git a/apps/client/src/widgets/view_widgets/table_view/rows.ts b/apps/client/src/widgets/view_widgets/table_view/rows.ts index 288274408..615dd7f9b 100644 --- a/apps/client/src/widgets/view_widgets/table_view/rows.ts +++ b/apps/client/src/widgets/view_widgets/table_view/rows.ts @@ -1,6 +1,6 @@ import FNote from "../../../entities/fnote.js"; import type { LabelType } from "../../../services/promoted_attribute_definition_parser.js"; -import type { PromotedAttributeInformation } from "./columns.js"; +import type { AttributeDefinitionInformation } from "./columns.js"; export type TableData = { iconClass: string; @@ -9,10 +9,12 @@ export type TableData = { labels: Record; relations: Record; branchId: string; + _children?: TableData[]; }; -export async function buildRowDefinitions(parentNote: FNote, notes: FNote[], infos: PromotedAttributeInformation[]) { +export async function buildRowDefinitions(parentNote: FNote, infos: AttributeDefinitionInformation[]) { const definitions: TableData[] = []; + let hasSubtree = false; for (const branch of parentNote.getChildBranches()) { const note = await branch.getNote(); if (!note) { @@ -28,30 +30,43 @@ export async function buildRowDefinitions(parentNote: FNote, notes: FNote[], inf labels[name] = note.getLabelValue(name); } } - definitions.push({ + + const def: TableData = { iconClass: note.getIcon(), noteId: note.noteId, title: note.title, labels, relations, - branchId: branch.branchId - }); + branchId: branch.branchId, + } + + if (note.hasChildren()) { + def._children = (await buildRowDefinitions(note, infos)).definitions; + hasSubtree = true; + } + + definitions.push(def); } - return definitions; + return { + definitions, + hasSubtree + }; } -export default function getPromotedAttributeInformation(parentNote: FNote) { - const info: PromotedAttributeInformation[] = []; - for (const promotedAttribute of parentNote.getPromotedDefinitionAttributes()) { - const def = promotedAttribute.getDefinition(); +export default function getAttributeDefinitionInformation(parentNote: FNote) { + const info: AttributeDefinitionInformation[] = []; + const attrDefs = parentNote.getAttributes() + .filter(attr => attr.isDefinition()); + for (const attrDef of attrDefs) { + const def = attrDef.getDefinition(); if (def.multiplicity !== "single") { console.warn("Multiple values are not supported for now"); continue; } - const [ labelType, name ] = promotedAttribute.name.split(":", 2); - if (promotedAttribute.type !== "label") { + const [ labelType, name ] = attrDef.name.split(":", 2); + if (attrDef.type !== "label") { console.warn("Relations are not supported for now"); continue; } diff --git a/apps/client/src/widgets/view_widgets/view_mode.ts b/apps/client/src/widgets/view_widgets/view_mode.ts index f3706da4a..4755294f8 100644 --- a/apps/client/src/widgets/view_widgets/view_mode.ts +++ b/apps/client/src/widgets/view_widgets/view_mode.ts @@ -1,4 +1,5 @@ import type { EventData } from "../../components/app_context.js"; +import appContext from "../../components/app_context.js"; import Component from "../../components/component.js"; import type FNote from "../../entities/fnote.js"; import type { ViewTypeOptions } from "../../services/note_list_renderer.js"; @@ -8,7 +9,6 @@ export interface ViewModeArgs { $parent: JQuery; parentNote: FNote; parentNotePath?: string | null; - noteIds: string[]; showNotePath?: boolean; } @@ -17,6 +17,8 @@ export default abstract class ViewMode extends Component { private _viewStorage: ViewModeStorage | null; protected parentNote: FNote; protected viewType: ViewTypeOptions; + protected noteIds: string[]; + protected args: ViewModeArgs; constructor(args: ViewModeArgs, viewType: ViewTypeOptions) { super(); @@ -25,6 +27,12 @@ export default abstract class ViewMode extends Component { // note list must be added to the DOM immediately, otherwise some functionality scripting (canvas) won't work args.$parent.empty(); this.viewType = viewType; + this.args = args; + this.noteIds = []; + } + + async beforeRender() { + await this.#refreshNoteIds(); } abstract renderList(): Promise | undefined>; @@ -35,13 +43,18 @@ export default abstract class ViewMode extends Component { * @param e the event data. * @return {@code true} if the view should be re-rendered, a falsy value otherwise. */ - onEntitiesReloaded(e: EventData<"entitiesReloaded">): boolean | void { + async onEntitiesReloaded(e: EventData<"entitiesReloaded">): Promise { // Do nothing by default. } - get isFullHeight() { - // Override to change its value. - return false; + async entitiesReloadedEvent(e: EventData<"entitiesReloaded">) { + if (e.loadResults.getBranchRows().some(branch => branch.parentNoteId === this.parentNote.noteId || this.noteIds.includes(branch.parentNoteId ?? ""))) { + this.#refreshNoteIds(); + } + + if (await this.onEntitiesReloaded(e)) { + appContext.triggerEvent("refreshNoteList", { noteId: this.parentNote.noteId }); + } } get isReadOnly() { @@ -57,4 +70,14 @@ export default abstract class ViewMode extends Component { return this._viewStorage; } + async #refreshNoteIds() { + let noteIds: string[]; + if (this.viewType === "list" || this.viewType === "grid") { + noteIds = this.args.parentNote.getChildNoteIds(); + } else { + noteIds = await this.args.parentNote.getSubtreeNoteIds(); + } + this.noteIds = noteIds; + } + } diff --git a/apps/server/src/etapi/spec.ts b/apps/server/src/etapi/spec.ts index 7ef963f8f..04b814c8c 100644 --- a/apps/server/src/etapi/spec.ts +++ b/apps/server/src/etapi/spec.ts @@ -3,12 +3,18 @@ import type { Router } from "express"; import fs from "fs"; import path from "path"; import { RESOURCE_DIR } from "../services/resource_dir"; +import rateLimit from "express-rate-limit"; const specPath = path.join(RESOURCE_DIR, "etapi.openapi.yaml"); let spec: string | null = null; +const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // limit each IP to 100 requests per windowMs +}); + function register(router: Router) { - router.get("/etapi/etapi.openapi.yaml", (_, res) => { + router.get("/etapi/etapi.openapi.yaml", limiter, (_, res) => { if (!spec) { spec = fs.readFileSync(specPath, "utf8"); }