From b658f5bd0e226b6a371bb5cb7c412d1be704b5eb Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 26 Nov 2025 10:36:53 +0200 Subject: [PATCH] feat(collections/list): adjustable expansion depth (closes #7669) --- .../collections/legacy/ListOrGridView.tsx | 50 +++++++++++--- .../ribbon/CollectionPropertiesTab.tsx | 41 ++++++++++-- .../ribbon/collection-properties-config.ts | 65 ++++++++++++++++--- apps/client/src/widgets/ribbon/style.css | 2 +- packages/commons/src/lib/attribute_names.ts | 2 +- 5 files changed, 132 insertions(+), 28 deletions(-) diff --git a/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx b/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx index 749036598..850aa3fba 100644 --- a/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "preact/hooks"; import FNote from "../../../entities/fnote"; import Icon from "../../react/Icon"; import { ViewModeProps } from "../interface"; -import { useNoteLabelBoolean, useImperativeSearchHighlighlighting } from "../../react/hooks"; +import { useImperativeSearchHighlighlighting, useNoteLabel } from "../../react/hooks"; import NoteLink from "../../react/NoteLink"; import "./ListOrGridView.css"; import content_renderer from "../../../services/content_renderer"; @@ -14,7 +14,7 @@ import attribute_renderer from "../../../services/attribute_renderer"; import { filterChildNotes, useFilteredNoteIds } from "./utils"; export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens }: ViewModeProps<{}>) { - const [ isExpanded ] = useNoteLabelBoolean(note, "expanded"); + const expandDepth = useExpansionDepth(note); const noteIds = useFilteredNoteIds(note, unfilteredNoteIds); const { pageNotes, ...pagination } = usePagination(note, noteIds); @@ -25,7 +25,7 @@ export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens } @@ -56,12 +56,19 @@ export function GridView({ note, noteIds: unfilteredNoteIds, highlightedTokens } ); } -function ListNoteCard({ note, parentNote, expand, highlightedTokens }: { note: FNote, parentNote: FNote, expand?: boolean, highlightedTokens: string[] | null | undefined }) { - const [ isExpanded, setExpanded ] = useState(expand); +function ListNoteCard({ note, parentNote, highlightedTokens, currentLevel, expandDepth }: { + note: FNote, + parentNote: FNote, + currentLevel: number, + expandDepth: number, + highlightedTokens: string[] | null | undefined +}) { + + const [ isExpanded, setExpanded ] = useState(currentLevel <= expandDepth); const notePath = getNotePath(parentNote, note); // Reset expand state if switching to another note, or if user manually toggled expansion state. - useEffect(() => setExpanded(expand), [ note, expand ]); + useEffect(() => setExpanded(currentLevel <= expandDepth), [ note, currentLevel, expandDepth ]); return (
- + }
) @@ -160,14 +167,25 @@ function NoteContent({ note, trim, noChildrenList, highlightedTokens }: { note: return
; } -function NoteChildren({ note, parentNote, highlightedTokens }: { note: FNote, parentNote: FNote, highlightedTokens: string[] | null | undefined }) { +function NoteChildren({ note, parentNote, highlightedTokens, currentLevel, expandDepth }: { + note: FNote, + parentNote: FNote, + currentLevel: number, + expandDepth: number, + highlightedTokens: string[] | null | undefined +}) { const [ childNotes, setChildNotes ] = useState(); useEffect(() => { filterChildNotes(note).then(setChildNotes); }, [ note ]); - return childNotes?.map(childNote => ) + return childNotes?.map(childNote => ) } function getNotePath(parentNote: FNote, childNote: FNote) { @@ -178,3 +196,17 @@ function getNotePath(parentNote: FNote, childNote: FNote) { return `${parentNote.noteId}/${childNote.noteId}` } } + +function useExpansionDepth(note: FNote) { + const [ expandDepth ] = useNoteLabel(note, "expanded"); + + if (expandDepth === null || expandDepth === undefined) { // not defined + return 0; + } else if (expandDepth === "") { // defined without value + return 1; + } else if (expandDepth === "all") { + return Number.MAX_SAFE_INTEGER; + } else { + return parseInt(expandDepth, 10); + } +} diff --git a/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx b/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx index e961ae1f0..7a5f9b6b1 100644 --- a/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx +++ b/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx @@ -4,14 +4,15 @@ import FormSelect, { FormSelectWithGroups } from "../react/FormSelect"; import { TabContext } from "./ribbon-interface"; import { mapToKeyValueArray } from "../../services/utils"; import { useNoteLabel, useNoteLabelBoolean } from "../react/hooks"; -import { bookPropertiesConfig, BookProperty, ButtonProperty, CheckBoxProperty, ComboBoxProperty, NumberProperty } from "./collection-properties-config"; -import Button from "../react/Button"; +import { bookPropertiesConfig, BookProperty, ButtonProperty, CheckBoxProperty, ComboBoxProperty, NumberProperty, SplitButtonProperty } from "./collection-properties-config"; +import Button, { SplitButton } from "../react/Button"; import { ParentComponent } from "../react/react_utils"; import FNote from "../../entities/fnote"; import FormCheckbox from "../react/FormCheckbox"; import FormTextBox from "../react/FormTextBox"; import { ComponentChildren } from "preact"; import { ViewTypeOptions } from "../collections/interface"; +import { FormDropdownDivider, FormListItem } from "../react/FormList"; const VIEW_TYPE_MAPPINGS: Record = { grid: t("book_properties.grid"), @@ -80,6 +81,8 @@ function mapPropertyView({ note, property }: { note: FNote, property: BookProper switch (property.type) { case "button": return + case "split-button": + return case "checkbox": return case "number": @@ -97,15 +100,39 @@ function ButtonPropertyView({ note, property }: { note: FNote, property: ButtonP title={property.title} icon={property.icon} onClick={() => { - if (!parentComponent) return; - property.onClick({ - note, - triggerCommand: parentComponent.triggerCommand.bind(parentComponent) - }); + if (!parentComponent) return; + property.onClick({ + note, + triggerCommand: parentComponent.triggerCommand.bind(parentComponent) + }); }} /> } +function SplitButtonPropertyView({ note, property }: { note: FNote, property: SplitButtonProperty }) { + const parentComponent = useContext(ParentComponent); + const clickContext = parentComponent && { + note, + triggerCommand: parentComponent.triggerCommand.bind(parentComponent) + }; + + return clickContext && property.onClick(clickContext)} + > + {clickContext && property.items.map(subproperty => { + if ("type" in subproperty && subproperty) { + return + } + + return ( + clickContext && subproperty.onClick(clickContext)}>{subproperty.label} + ); + })} + +} + function CheckboxPropertyView({ note, property }: { note: FNote, property: CheckBoxProperty }) { const [ value, setValue ] = useNoteLabelBoolean(note, property.bindToLabel); diff --git a/apps/client/src/widgets/ribbon/collection-properties-config.ts b/apps/client/src/widgets/ribbon/collection-properties-config.ts index 76a2193c5..5161e1496 100644 --- a/apps/client/src/widgets/ribbon/collection-properties-config.ts +++ b/apps/client/src/widgets/ribbon/collection-properties-config.ts @@ -22,7 +22,17 @@ export interface ButtonProperty { label: string; title?: string; icon?: string; - onClick: (context: BookContext) => void; + onClick(context: BookContext): void; +} + +export interface SplitButtonProperty extends Omit { + type: "split-button"; + items: ({ + label: string; + onClick(context: BookContext): void; + } | { + type: "separator" + })[]; } export interface NumberProperty { @@ -55,7 +65,7 @@ export interface ComboBoxProperty { options: (ComboBoxItem | ComboBoxGroup)[]; } -export type BookProperty = CheckBoxProperty | ButtonProperty | NumberProperty | ComboBoxProperty; +export type BookProperty = CheckBoxProperty | ButtonProperty | NumberProperty | ComboBoxProperty | SplitButtonProperty; interface BookContext { note: FNote; @@ -87,16 +97,37 @@ export const bookPropertiesConfig: Record = { { label: t("book_properties.expand"), title: t("book_properties.expand_all_children"), - type: "button", + type: "split-button", icon: "bx bx-move-vertical", - async onClick({ note, triggerCommand }) { - const { noteId } = note; - if (!note.isLabelTruthy("expanded")) { - await attributes.addLabel(noteId, "expanded"); + onClick: buildExpandListHandler(1), + items: [ + { + label: "Expand 1 level", + onClick: buildExpandListHandler(1) + }, + { type: "separator" }, + { + label: "Expand 2 levels", + onClick: buildExpandListHandler(2), + }, + { + label: "Expand 3 levels", + onClick: buildExpandListHandler(3), + }, + { + label: "Expand 4 levels", + onClick: buildExpandListHandler(4), + }, + { + label: "Expand 5 levels", + onClick: buildExpandListHandler(5), + }, + { type: "separator" }, + { + label: "Expand all children", + onClick: buildExpandListHandler("all"), } - - triggerCommand("refreshNoteList", { noteId }); - }, + ] } ] }, @@ -185,3 +216,17 @@ function buildMapLayer([ id, layer ]: [ string, MapLayer ]): ComboBoxItem { label: layer.name }; } + +function buildExpandListHandler(depth: number | "all") { + return async ({ note, triggerCommand }: BookContext) => { + const { noteId } = note; + + const existingValue = note.getLabelValue("expanded"); + let newValue: string | undefined = typeof depth === "number" ? depth.toString() : depth; + if (depth === 1) newValue = undefined; // maintain existing behaviour + if (newValue === existingValue) return; + + await attributes.setLabel(noteId, "expanded", newValue); + triggerCommand("refreshNoteList", { noteId }); + } +} diff --git a/apps/client/src/widgets/ribbon/style.css b/apps/client/src/widgets/ribbon/style.css index f47c6d662..290d1b30e 100644 --- a/apps/client/src/widgets/ribbon/style.css +++ b/apps/client/src/widgets/ribbon/style.css @@ -356,7 +356,7 @@ body[dir=rtl] .attribute-list-editor { display: flex; flex-wrap: wrap; gap: 15px; - overflow: hidden; + overflow: visible; align-items: center; } diff --git a/packages/commons/src/lib/attribute_names.ts b/packages/commons/src/lib/attribute_names.ts index e186cf88a..767f05872 100644 --- a/packages/commons/src/lib/attribute_names.ts +++ b/packages/commons/src/lib/attribute_names.ts @@ -29,7 +29,7 @@ type Labels = { status: string; pageSize: number; geolocation: string; - expanded: boolean; + expanded: string; "calendar:hideWeekends": boolean; "calendar:weekNumbers": boolean; "calendar:view": string;