feat: canvas tab groups#9594
Conversation
Add inline tab groups to canvas dashboards. A top-level `rows:` entry is
either a plain row or a `tabs:` group; a canvas can interleave free rows with
multiple independent tab groups. Only the active tab's components mount, so
inactive tabs issue no queries. Existing canvases render unchanged.
- Proto/parser/reconciler: `CanvasTabGroup`/`CanvasTab` messages, parser
validation (no items+tabs, no nesting, ≥1 tab, unique group/tab names),
recursive component-name collection, schema + generated docs.
- Frontend: `LayoutBlock`/`TabGroup` state, per-tab grids, prefixed component
paths, query isolation via `{#if}`, per-group `?tabs=` URL state.
- Visual editor: add/rename/delete/reorder tabs (menu + drag), add tab group
from the add menu, convert rows to a tab group, cross-container component
drag, delete-with-unwrap, in/outside add affordances, group boundary.
| // it so web-common unit tests that pull in canvas-entity can resolve the import graph. | ||
| alias.push({ | ||
| find: "@rilldata/web-admin/client", | ||
| replacement: "/../web-common/tests/web-admin-client.mock.ts", | ||
| }); |
There was a problem hiding this comment.
Ya we need this for the new tests added. It is due the hacky way we get bookmark info in canvas. In explore it is passed from parent vs in canvas where only in admin it is pulled in canvas-entity.
|
Some initial UXQA,
Will take a deeper look at the code before commenting on #9594 (comment) |
…elete group - Drop a dragged widget directly into an empty tab (the empty-tab box is now a drop target; the add menu is suppressed mid-drag). - Tab-group inspector sidebar (opens on tab click): rename, move, duplicate, delete tab, and delete the whole tab group with confirmation when it has content. New `selectedTabGroup` entity state, mutually exclusive with the selected component. - Clone a tab from the strip menu and the sidebar (`duplicateTab`). - Moving a tab keeps focus on the moved tab (active tab follows position).
|
@AdityaHegde Thanks for the thorough pass — status on all 8:
Please review again. |
- Tabs gain an optional `name` (URL key) alongside `label` (display); parser defaults the name from the label and uniquifies within the group. - Tab-group inspector shows a reorderable list of tabs with Name + Display name inputs (shared Input component), duplicate/delete per row, and a group-name field; display-name edits update the strip live as you type. - URL references the active tab as `tabgroup_name.tab_name` (encoded), e.g. `?tabs=deep_dive.detail`. - Fix: queue the active-tab follow before the edit so moving a tab keeps focus.
# Conflicts: # proto/gen/rill/runtime/v1/resources.pb.go # runtime/parser/parse_canvas.go
…ction Inspector inputs commit on blur, but selecting a component, switching tab groups, or clearing the selection didn't blur the focused input first, so a tab name/display name being typed was lost when the inspector unmounted. Add a single commit point (blur the active element) in both setSelectedComponent and setSelectedTabGroup, mirroring the earlier chart property-edit fix (#9555).
…orrect - Display name not saved: commitLabel compared the typed value against the optimistically-updated store (which already reflects live typing), so it saw "no change" and skipped the write. Compare against the persisted YAML. - Moved tab loses active / shows wrong content: match Tab instances by stable name in updateFromSpec so a reordered tab keeps its own grid (was repurposed by index, leaving a tab showing its neighbour's widgets), and follow the moved tab by name (activateByNameWhenReady) so it stays active at its new index — including when moving the first tab.
… tab - updateFromSpec now captures the active tab's name before the rebuild and re-points the index at that same tab afterwards, so reordering (including the first tab) keeps the moved/active tab active instead of snapping to whatever landed at the old index. Renames keep the position; deletes clamp. - Remove the index/name pending-activation dance for moves (no longer needed). - applyTabsFromURL no longer resets a group's active tab in edit mode on every URL change, which was fighting direct tab clicks; view mode still resets absent groups for back/forward.
…tab strip Clicking empty strip area or between tabs now selects the tab group (opens the inspector), not just the tab labels. The inline rename input is excluded so clicking into it keeps focus.
AdityaHegde
left a comment
There was a problem hiding this comment.
We are defining new structure for tabs but not supporting all use cases.
- Having multiple tab groups in the same row.
- Nested tab groups.
Maybe we should add the tab group in item directly? That way all of the cases are handled.
Another idea would be to support matrixes similar to github actions config. This will make it easy to have duplicate charts in tabs with small changes.
I feel like this will need quite a bit of iterations. How about adding this behind a feature flag so that users dont have to rewrite canvases if we decide to change the structure.
|
|
||
| {#if pendingTabDelete !== null} | ||
| {@const index = pendingTabDelete} | ||
| <AlertDialog.Root |
There was a problem hiding this comment.
nit: we should really have a component like AlertDialogGuardedConfirmation. Duplicating all the markup for alert dialog feels wrong. We could move these 2 dialogs and migrate all the other ones slowly.
There was a problem hiding this comment.
Done. Added a reusable AlertDialogConfirmation component (components/alert-dialog/alert-dialog-confirmation.svelte) and replaced both delete dialogs with it. I kept it separate from the existing alert-dialog-guarded-confirmation.svelte since that one is the heavier type-to-confirm variant, whereas these only need a plain confirm/cancel. Other dialogs can migrate to it incrementally.
| const id = makeCanvasId(canvasName, instanceId); | ||
| const store = canvasRegistry.get(id); | ||
|
|
||
| if ( |
There was a problem hiding this comment.
Since we fixed the no component found error in a different way do we still need this?
There was a problem hiding this comment.
This is still load-bearing. CanvasInitialization reads existingStore = getCanvasStoreUnguarded(name, id, allowUnvalidatedSpec) and only fetches/recreates when it's undefined. Without the teardown on an allowUnvalidatedSpec mismatch here, the stale store (wrong mode) is returned, the fetch is skipped, getResolvedStore returns that same stale store, and setCanvasStore is never reached — so its teardown can't help, and the navigate-to-preview "No component found" error returns. The entity's allowUnvalidatedSpec is constructor-only with no setter, so a store must be recreated to switch modes. The two teardowns are complementary: this one forces the refetch; setCanvasStore's is a defensive guard at creation time.
| // label. Either way uniquify it against earlier tabs in this group. | ||
| var tabName string | ||
| if tab.Name != "" { | ||
| tabName = uniqueName(tab.Name, fmt.Sprintf("tab-%d", t), seenNames) |
There was a problem hiding this comment.
A better way to do this would be to get the target name and do unique calclulation here.
name := tab.Name || slugify(tab.Label) || fmt.Sprintf("tab-%d", t) (update for go)
Alternatively we could write a util similar to name-utils.ts::getName
Also slugify should be in a package. Maybe urlutils?
There was a problem hiding this comment.
Done. Moved slugify into a new runtime/pkg/urlutils package (as Slugify, with tests) and consolidated the derivation into a single target/fallback expression:
target := tab.Name
if target == "" {
target = urlutils.Slugify(tab.Label)
}
tabName := uniqueName(target, fmt.Sprintf("tab-%d", t), seenNames)| * Append a new empty tab to the tab group at the given top-level index. | ||
| * Returns the index of the new tab, or -1 if the entry is not a tab group. | ||
| */ | ||
| export function addTab(doc: Document, blockIndex: number): number { |
There was a problem hiding this comment.
How about moving these functions that operate within a tab group to TabGroup class?
There was a problem hiding this comment.
These are pure YAML-Document transforms used only during authoring: they take (doc, blockIndex, ...) and mutate the document in place. The TabGroup/Tab classes in stores/tab-group.ts are the opposite layer: spec-backed render models built from V1CanvasRow/V1CanvasTab, holding Svelte stores and Grids. Folding the authoring transforms onto that class would couple the YAML-authoring model to the reactive render model and mix the two inputs (a yaml.Document vs a spec). Keeping them as standalone functions keeps them stateless and unit-testable in isolation, so I'd prefer to leave them here.
There was a problem hiding this comment.
^ Abobe is claude's assessment, let me know if you feel strongly about a refactor here.
| }); | ||
| } | ||
|
|
||
| updateContents(); |
There was a problem hiding this comment.
Do we not need performTransaction here?
There was a problem hiding this comment.
performTransaction resolves a single container (resolveTarget(target) → one rowsPath + namePrefix) and generateNewAssets operates entirely within it; MoveItemTransaction's source/destination are both Positions inside that one container. moveComponentToTarget moves across two containers (free canvas ↔ tab, or tab ↔ tab) with two different rows-paths and name prefixes, which the transaction model can't express — hence the explicit moveItemAcrossContainers YAML edit plus the optimistic-spec mirror. Inline component names are position-keyed, so the parser re-derives them on the next reconcile.
|
Its intentional to not handle below use-cases, I considered that approach as well, but then could not find much practical utilities for being super flexible there -
|
- extract slugify into runtime/pkg/urlutils and consolidate tab-name derivation - add reusable AlertDialogConfirmation and use it for tab/group delete dialogs
Adds inline tab groups to canvas dashboards.
rows:entry is either a plain row (items:) or a tab group (tabs:); a canvas can interleave free rows with multiple independent tab groups.CanvasTabGroup/CanvasTabproto messages; parser validation (noitems+tabs, no nesting, ≥1 tab, unique group/tab names); recursive component-name collection; schema + generated docs.LayoutBlock/TabGroupmodel with per-tab grids, prefixed component paths, and per-group?tabs=group:tabURL state (keys percent-encoded so names with:/,round-trip).+menu, convert rows to a tab group, cross-container component drag, delete-with-unwrap, in-tab/below-tab add affordances, and a group boundary treatment.Checklist:
Developed in collaboration with Claude Code