Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 155 additions & 6 deletions apps/client/src/widgets/collections/table/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,34 @@ interface TableConfig {
export default function TableView({ note, noteIds, notePath, viewConfig, saveConfig }: ViewModeProps<TableConfig>) {
const tabulatorRef = useRef<VanillaTabulator>(null);
const parentComponent = useContext(ParentComponent);
const expandedRowsRef = useRef<Set<string>>(new Set());
const isDataRefreshingRef = useRef<boolean>(false);

// Load persisted expansion state on mount
useEffect(() => {
const storageKey = `trilium-tree-expanded-${note.noteId}`;
try {
const stored = localStorage.getItem(storageKey);
if (stored) {
const expandedIds = JSON.parse(stored);
expandedRowsRef.current = new Set(expandedIds);
console.log('Loaded expansion state from storage:', expandedIds);
}
} catch (e) {
console.warn('Failed to load tree expansion state:', e);
}
}, [note.noteId]);
Comment on lines +33 to +45
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea is good but Trilium does not use the local storage feature at all. All data in Trilium is managed within the SQL database. For storing tree expansion state, ideally you should use the already provided view storage see ViewModeProps<TableConfig>.

Since the user can be expanding/collapsing a lot of times, it's best to delay the saving in a spaced update.


// Save expansion state changes to localStorage
const persistExpandedState = useCallback(() => {
const storageKey = `trilium-tree-expanded-${note.noteId}`;
try {
const expandedIds = Array.from(expandedRowsRef.current);
localStorage.setItem(storageKey, JSON.stringify(expandedIds));
} catch (e) {
console.warn('Failed to save tree expansion state:', e);
}
}, [note.noteId]);

const [ attributeDetailWidgetEl, attributeDetailWidget ] = useLegacyWidget(() => new AttributeDetailWidget().contentSized());
const contextMenuEvents = useContextMenu(note, parentComponent, tabulatorRef);
Expand All @@ -39,10 +67,14 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon
dataTree: true,
dataTreeStartExpanded: true,
dataTreeBranchElement: false,
dataTreeElementColumn: "title",
dataTreeElementColumn: "title",
dataTreeChildIndent: 20,
dataTreeExpandElement: `<button class="tree-expand"><span class="bx bx-chevron-right"></span></button>`,
dataTreeCollapseElement: `<button class="tree-collapse"><span class="bx bx-chevron-down"></span></button>`
dataTreeCollapseElement: `<button class="tree-collapse"><span class="bx bx-chevron-down"></span></button>`,
persistenceMode: "local",
persistence: {
tree: true
}
Comment on lines +74 to +77
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned previously, we don't store data in local storage, we store it in an attachment inside the note. We must not break this functionality.

}
}, [ hasChildren ]);

Expand All @@ -64,15 +96,94 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon
footerElement={<TableFooter note={note} />}
events={{
...contextMenuEvents,
...rowEditingEvents
...rowEditingEvents,
tableBuilt: () => {
console.log('Table built - setting up tree event tracking');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are many debug logs inside the PR, please get rid of them.

},
// Try all possible expand event names
rowTreeExpanded: (row) => {
const data = row.getData() as TableData;
console.log('Row expanded (rowTreeExpanded):', data.branchId, data.title, 'refreshing:', isDataRefreshingRef.current);
if (data.branchId && !isDataRefreshingRef.current) {
expandedRowsRef.current.add(data.branchId);
console.log('Updated expanded set:', Array.from(expandedRowsRef.current));
persistExpandedState();
}
},
dataTreeRowExpanded: (row) => {
const data = row.getData() as TableData;
console.log('Row expanded (dataTreeRowExpanded):', data.branchId, data.title, 'refreshing:', isDataRefreshingRef.current);
if (data.branchId && !isDataRefreshingRef.current) {
expandedRowsRef.current.add(data.branchId);
console.log('Updated expanded set:', Array.from(expandedRowsRef.current));
persistExpandedState();
}
// Call the original context menu handler if it exists
if (contextMenuEvents.dataTreeRowExpanded) {
contextMenuEvents.dataTreeRowExpanded(row);
}
},
treeExpanded: (row) => {
const data = row.getData() as TableData;
console.log('Row expanded (treeExpanded):', data.branchId, data.title, 'refreshing:', isDataRefreshingRef.current);
if (data.branchId && !isDataRefreshingRef.current) {
expandedRowsRef.current.add(data.branchId);
console.log('Updated expanded set:', Array.from(expandedRowsRef.current));
persistExpandedState();
}
},
// Try all possible collapse event names
rowTreeCollapsed: (row) => {
const data = row.getData() as TableData;
console.log('Row collapsed (rowTreeCollapsed):', data.branchId, data.title, 'refreshing:', isDataRefreshingRef.current);
if (data.branchId && !isDataRefreshingRef.current) {
expandedRowsRef.current.delete(data.branchId);
console.log('Updated expanded set:', Array.from(expandedRowsRef.current));
persistExpandedState();
}
},
dataTreeRowCollapsed: (row) => {
const data = row.getData() as TableData;
console.log('Row collapsed (dataTreeRowCollapsed):', data.branchId, data.title, 'refreshing:', isDataRefreshingRef.current);
if (data.branchId && !isDataRefreshingRef.current) {
expandedRowsRef.current.delete(data.branchId);
console.log('Updated expanded set:', Array.from(expandedRowsRef.current));
persistExpandedState();
}
// Call the original context menu handler if it exists
if (contextMenuEvents.dataTreeRowCollapsed) {
contextMenuEvents.dataTreeRowCollapsed(row);
}
},
treeCollapsed: (row) => {
const data = row.getData() as TableData;
console.log('Row collapsed (treeCollapsed):', data.branchId, data.title, 'refreshing:', isDataRefreshingRef.current);
if (data.branchId && !isDataRefreshingRef.current) {
expandedRowsRef.current.delete(data.branchId);
console.log('Updated expanded set:', Array.from(expandedRowsRef.current));
persistExpandedState();
}
}
}}
persistence {...persistenceProps}
layout="fitDataFill"
index="branchId"
movableColumns
movableRows={movableRows}
rowFormatter={rowFormatter}
preserveTreeState={hasChildren}
expandedRowsRef={expandedRowsRef}
isDataRefreshingRef={isDataRefreshingRef}
{...dataTreeProps}
dataTreeStartExpanded={(row, level) => {
if (expandedRowsRef.current && expandedRowsRef.current.size > 0) {
const rowData = row.getData() as TableData;
const isExpanded = expandedRowsRef.current.has(rowData.branchId);
console.log(`dataTreeStartExpanded called for ${rowData.branchId}: ${isExpanded}`);
return isExpanded;
}
return false; // Default collapsed state
}}
/>
<TableFooter note={note} />
</>
Expand Down Expand Up @@ -124,6 +235,9 @@ function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undef
const [ movableRows, setMovableRows ] = useState(false);

function refresh() {
console.log('🔄 TABLE REFRESH TRIGGERED');
console.trace('Refresh call stack'); // This will show us what triggered it

const info = getAttributeDefinitionInformation(note);
buildRowDefinitions(note, info, includeArchived, maxDepth).then(({ definitions: rowData, hasSubtree: hasChildren, rowNumber }) => {
const columnDefs = buildColumnDefinitions({
Expand All @@ -140,15 +254,43 @@ function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undef
});
}

useEffect(refresh, [ note, noteIds, maxDepth, movableRows ]);
useEffect(() => {
console.log('⚡ useEffect refresh triggered by:', { note: note.noteId, noteIds: noteIds.length, maxDepth, movableRows });
// Debounce rapid changes to movableRows
const timeoutId = setTimeout(() => {
refresh();
}, 50);
return () => clearTimeout(timeoutId);
}, [ note, noteIds.length, maxDepth ]); // Remove movableRows from dependencies

const refreshTimeoutRef = useRef<number>();

// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
};
}, []);

useTriliumEvent("entitiesReloaded", ({ loadResults}) => {
console.log('🔄 entitiesReloaded event triggered');
console.log('Attributes changed:', loadResults.getAttributeRows().length);
console.log('Branches changed:', loadResults.getBranchRows().length);
console.log('Notes changed:', loadResults.getNoteIds().length);

// React to column changes.
if (loadResults.getAttributeRows().find(attr =>
attr.type === "label" &&
(attr.name?.startsWith("label:") || attr.name?.startsWith("relation:")) &&
attributes.isAffecting(attr, note))) {
refresh();
console.log('✅ Refreshing due to column changes');
// Clear any pending refresh
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
refreshTimeoutRef.current = setTimeout(() => refresh(), 100);
return;
}

Expand All @@ -157,9 +299,16 @@ function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undef
|| loadResults.getNoteIds().some(noteId => noteIds.includes(noteId))
|| loadResults.getAttributeRows().some(attr => noteIds.includes(attr.noteId!))
|| loadResults.getAttributeRows().some(attr => attr.name === "archived" && attr.noteId && noteIds.includes(attr.noteId))) {
refresh();
console.log('✅ Refreshing due to row updates');
// Clear any pending refresh and debounce
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
refreshTimeoutRef.current = setTimeout(() => refresh(), 100);
return;
}

console.log('❌ No refresh needed for this entitiesReloaded event');
});

// Identify if movable rows.
Expand Down
40 changes: 37 additions & 3 deletions apps/client/src/widgets/collections/table/tabulator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@ interface TableProps<T> extends Omit<Options, "data" | "footerElement" | "index"
events?: Partial<EventCallBackMethods>;
index: keyof T;
footerElement?: string | HTMLElement | JSX.Element;
preserveTreeState?: boolean;
expandedRowsRef?: RefObject<Set<string>>;
isDataRefreshingRef?: RefObject<boolean>;
}

export default function Tabulator<T>({ className, columns, data, modules, tabulatorRef: externalTabulatorRef, footerElement, events, index, ...restProps }: TableProps<T>) {
export default function Tabulator<T>({ className, columns, data, modules, tabulatorRef: externalTabulatorRef, footerElement, events, index, preserveTreeState, expandedRowsRef, isDataRefreshingRef, ...restProps }: TableProps<T>) {
const parentComponent = useContext(ParentComponent);
const containerRef = useRef<HTMLDivElement>(null);
const tabulatorRef = useRef<VanillaTabulator>(null);
Expand Down Expand Up @@ -62,8 +65,39 @@ export default function Tabulator<T>({ className, columns, data, modules, tabula
}
}, Object.values(events ?? {}));

// Change in data.
useEffect(() => { tabulatorRef.current?.setData(data) }, [ data ]);
const treeStateTimeoutRef = useRef<number>();

// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (treeStateTimeoutRef.current) {
clearTimeout(treeStateTimeoutRef.current);
}
};
}, []);

// Change in data - with tree state preservation
useEffect(() => {
const tabulator = tabulatorRef.current;
if (!tabulator || !data) return;

console.log('Data update triggered, preserveTreeState:', preserveTreeState, 'dataTree option:', tabulator.options?.dataTree);

if (preserveTreeState && tabulator.options && "dataTree" in tabulator.options && tabulator.options.dataTree && expandedRowsRef) {
console.log('Tree state preservation using dataTreeStartExpanded approach');

// Clear any existing timeout to prevent overlapping updates
if (treeStateTimeoutRef.current) {
clearTimeout(treeStateTimeoutRef.current);
}

// Simply update data - expansion state will be handled by dataTreeStartExpanded function
tabulator.setData(data);
} else {
tabulator.setData(data);
}
}, [ data, preserveTreeState, index ]);

useEffect(() => { columns && tabulatorRef.current?.setColumns(columns)}, [ data]);

return (
Expand Down