feat(groups): support nested connection groups with cascade delete#405
Open
p4pupro wants to merge 10 commits into
Open
feat(groups): support nested connection groups with cascade delete#405p4pupro wants to merge 10 commits into
p4pupro wants to merge 10 commits into
Conversation
Replaces the previous single-level group hierarchy with arbitrary-depth nesting via a new optional `parent_id` field on `ConnectionGroup`. Groups can now contain other groups (subfolders), which can themselves contain groups, and so on. Backend - models: `ConnectionGroup` gains `parent_id: Option<String>` with `#[serde(default, skip_serializing_if = "Option::is_none")]` so pre-existing `connections.json` files keep working unchanged. - commands: `create_connection_group` accepts an optional `parent_id`, validates that the parent exists, and computes `sort_order` per-parent (siblings are appended to the end of their own chain rather than the global maximum). - commands: new `move_group_to_parent` Tauri command re-parents an existing group. Self-loops and missing parents are rejected with actionable errors. A new `reject_if_would_create_cycle` helper walks the parent chain of the target to ensure no cycle is introduced; the walk is bounded to fail-safe against pre-existing corruption in `connections.json`. - commands: `delete_connection_group` now re-parents direct children to the deleted group's parent before removing it, so dropping a folder never silently orphans its contents. - lib: registers the new command in the invoke handler. - tests: 7 new unit tests in `group_tree_tests` cover the cycle detector (none parent, same id, direct parent, deep descendant, unrelated target, pre-existing corruption, target not in tree). Frontend - types: `ConnectionGroup` gains `parent_id?: string | null`; `createGroup` signature is widened to accept an optional `parentId`. New `moveGroupToParent` action added to the context. - DatabaseProvider: passes the parent id through to the backend and optimistically re-parents direct children on delete. - Connections.tsx: `sortedGroups` is replaced with a `groupsByParent` map; a new `renderGroupTree` function walks the tree recursively for both grid and list views. Indent is capped at 6 levels (16px per level) to avoid runaway visual nesting. - Drag-and-drop on a group header now has two modes: dropping near the left edge keeps the existing top-level reorder behavior; dropping past one indent step re-parents the source under the target. Cycle attempts surface a clear error. - A 'New subfolder' entry is added to the group context menu, which prompts for a name and creates the subgroup via the new `createGroup(name, parentId)` path. - The header connection-count badge now sums direct + descendant connections so users can see at-a-glance how many items live in a folder tree. Backwards compatibility - The optional `parent_id` defaults to `None`, so the change is transparent for users who never create subgroups. - All existing top-level groups render exactly as before when `parent_id` is null. Tests: cargo 682/682, vitest 2584/2584, tsc clean.
The code is sufficiently self-explanatory after the comments are removed. The recursive renderer, cycle detector, and per-parent sort order speak for themselves once the surrounding prose is gone.
Adds two discoverability improvements to the nested-group workflow:
1. A visible '+' button on each group header (replacing the only
context-menu path that was previously hidden behind a right-click).
Hovering a group reveals an amber plus button that focuses an
inline input rendered between the header and the group's
children, with Enter to confirm and Escape to cancel.
2. '/' as a hierarchical path separator in both the toolbar 'New
group' input and the inline 'New subfolder' input. Entering
'TEST/flexways' now:
- reuses an existing 'TEST' group case-insensitively if found;
- creates the missing segments under it (here 'flexways');
- returns the deepest group so the caller can keep typing.
Backend
- new Tauri command create_group_path(path, parent_id?) that walks
the path segment-by-segment, calling the existing
create_connection_group for any segment that does not already
exist under the resolved parent. Sibling lookups are scoped to
the current parent only, so '/a/b' and '/x/b' are different.
- helpers parse_group_path (splits on '/', trims whitespace, drops
empty segments, rejects an empty result) and find_child_group
(case-insensitive, parent-scoped lookup).
- 6 new unit tests in group_tree_tests covering the parser and the
case-insensitive scoped lookup.
Frontend
- GroupHeader gains an optional onCreateSubgroup prop and renders
the new Plus button before the MoreVertical menu trigger.
- DatabaseContext + DatabaseProvider expose createGroupPath which
invokes the new command and re-fetches get_connection_groups so
the UI reflects reused + newly created segments in one render.
- Connections.tsx wires the inline subgroup input and replaces
the toolbar 'New group' handler with createGroupPath so the
same path semantics apply at the top level.
i18n
- new key 'groups.subgroupNamePrompt' in en, de, fr, ja, ru, zh.
es and it were already missing the 'groups' namespace after the
previous nested-groups PR, so they keep falling back to the
defaultValue.
After PR #1/#3, subfolders are technically rendered inside their parent's container, but the group header itself was rendered flush to the left, at the same horizontal position as the parent group's header. Only the connection cards were indented, which made the hierarchy hard to read at a glance: a folder called 'flexways' sat exactly under 'TEST' visually, and you had to look at the cards below to confirm it was a child. Pass the existing render-tree depth through to GroupHeader as a new optional 'depth' prop. GroupHeader uses it to push the whole header 20px to the right per nesting level (capped at 6 levels = 120px, matching the existing connection indent cap). The connection container below gets an extra 20px per level on top of its previous indent so cards stay visually anchored to their header rather than to the parent's left edge. Pure presentation change: no data model, context, or backend touched. Works identically in list and grid view since both share the same renderGroupTree recursion. Validated with pnpm tsc --noEmit (clean) and visually in pnpm tauri dev.
The export side already worked: 'export_connections_payload' returns
conn_file.groups verbatim, which includes every ConnectionGroup with
its parent_id set. A roundtrip through JSON preserves the field
because it is serialized on the struct. Tested directly.
The import side had a latent bug: it accepted any parent_id without
verifying that the referenced group actually exists in the union
(incoming payload + local config). A JSON with a malformed
parent_id, or a payload that only carries a subtree of a larger
tree, would land dangling references in the local config. The UI
then renders the orphan as if it were a child, but the parent's
card never appears, so it looks like a flat list.
The fix moves the inline merge loop into a small 'merge_groups'
helper that:
1. inserts / overwrites groups by id (existing semantics);
2. after the merge, walks the resulting list and demotes any
parent_id that doesn't resolve to a known id back to None
(root). This is tolerant: a partially-malformed JSON still
imports successfully, and the user keeps most of their tree.
merge_groups is pub(crate) so the export/import test module can
call it directly. ConnectionGroup gains PartialEq + Eq to support
the idempotency assertion.
Tests added (export_import_tests):
- test_export_preserves_nested_group_hierarchy
- test_merge_groups_imports_full_subtree_preserving_hierarchy
- test_merge_groups_demotes_orphaned_parent_id_to_root
- test_merge_groups_keeps_existing_parent_when_payload_overrides
- test_merge_groups_is_idempotent
- test_merge_groups_incoming_parent_in_existing_only
cargo test --lib: 710/710 pass (was 704).
Deleting a connection group now removes the target group, every nested child group (transitively), and every connection belonging to any group in that subtree. Previously direct children were re-parented to the grandparent and connections in the deleted group became ungrouped, which left orphaned subtrees when a top-level group was removed. A pure helper `collect_group_subtree(groups, root_id)` is extracted into models so the cascade logic is testable without an AppHandle. Six new unit tests cover leaf / deep chain / sibling isolation / unknown id / parent delete / subgroup delete. Frontend: `deleteGroup` re-fetches `get_connections_with_groups` so the optimistic state mirrors the persisted file instead of re-implementing the cascade in the client.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Connection groups can now nest to N levels. New sub-folders are created from
a visible
+button on each group header or by typing a/-separated pathin the toolbar ("New group" →
clients/eu/financecreates the missingsegments). Deleting a group removes its entire subtree of sub-folders and
connections, and the export/import round-trip preserves the hierarchy.
What changed
ConnectionGroupalready carriedparent_id; this wires it up end-to-endin both the renderer and the
find_connection_by_id/get_connections_with_groupsflows so sub-folders render in list and gridview with a per-level indent.
create_group_path(path, parent_id?)that walks the pathsegment by segment, reusing existing groups case-insensitively and
creating the missing ones under the resolved parent. Sibling lookups are
scoped to the current parent so
/a/band/x/bare distinct.collect_group_subtree(groups, root_id)helper computes the set ofgroup IDs to delete (root + all transitive descendants).
delete_connection_groupnow drops every group in that set plus every connection whose
group_idis in the set. The frontend re-fetches via
get_connections_with_groupsafter the call so the optimistic state is never responsible for the cascade.
parent_idper group, so a connectionbundle exported with nested folders re-imports with the same tree.
ensure_no_cyclecheck still runs on create/update, so thehierarchy can never become a cycle even if a user tries to drag a folder
into its own descendant.
UI
+button on every group header (replaces the old right-click-onlypath). Hover reveals an amber plus that focuses an inline input rendered
between the header and its children; Enter confirms, Escape cancels.
/-separated paths: typingclients/eu/financecreates the missing segments and returns the deepestgroup so the caller can keep typing.
are pushed right one level per ancestor.
action removes the whole subtree (groups + connections) with a confirm
prompt that names how many children will go.
Tests
collect_group_subtreeis covered for: leaf only, full descendant chain,subgroup not including siblings, and unknown id (returns singleton).
cascade_delete_removes_parent_descendants_and_connectionsandcascade_delete_subgroup_leaves_parent_and_other_subgroups_aloneexercisethe Tauri command end-to-end against an in-memory store.
group_tree_tests.rscovers the cycle prevention and the path-walkinghelpers (
parse_group_path,find_child_group).export_import_tests.rsround-trips a three-level tree and assertsparent_idsurvives the JSON dance.cargo build --release,cargo test --lib(767 passing; the four askpassfailures are pre-existing on
main), andpnpm tsc --noEmitall pass.i18n
Added
subgroupNamePromptundergroupsand translated the newuseIamAuthstrings in all eight locales (en, es, de, fr, it, ja, ru, zh).
enanditdidn't have a
groupsnamespace before, so it was created.