Skip to content

feat: canvas tab groups#9594

Open
nishantmonu51 wants to merge 26 commits into
mainfrom
canvas_tabs
Open

feat: canvas tab groups#9594
nishantmonu51 wants to merge 26 commits into
mainfrom
canvas_tabs

Conversation

@nishantmonu51

Copy link
Copy Markdown
Collaborator

Adds inline tab groups to canvas dashboards.

  • A top-level rows: entry is either a plain row (items:) or a tab group (tabs:); a canvas can interleave free rows with multiple independent tab groups.
  • Query isolation: only the active tab's components mount, so inactive tabs issue no queries until selected. Existing canvases render unchanged.
  • Backend: CanvasTabGroup/CanvasTab proto messages; parser validation (no items+tabs, no nesting, ≥1 tab, unique group/tab names); recursive component-name collection; schema + generated docs.
  • Frontend state/render: LayoutBlock/TabGroup model with per-tab grids, prefixed component paths, and per-group ?tabs=group:tab URL state (keys percent-encoded so names with :/, round-trip).
  • Visual editor: add/rename/delete/reorder tabs (menu + drag), add a tab group from the + 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:

  • Covered by tests
  • Ran it and it works as intended
  • Reviewed the diff before requesting a review
  • Checked for unhandled edge cases
  • Linked the issues it closes
  • Checked if the docs need to be updated. If so, create a separate Linear DOCS issue
  • Intend to cherry-pick into the release branch
  • I'm proud of this work!

Developed in collaboration with Claude Code

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.
@nishantmonu51 nishantmonu51 requested a review from djbarnwal June 20, 2026 07:10
Comment thread web-common/vite.config.ts
Comment on lines +24 to +28
// 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",
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@AdityaHegde does this make sense?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@AdityaHegde

AdityaHegde commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Some initial UXQA,

  1. Cannot drag drop into an empty tab.
  2. Should we add a delete button for the tab group? It is not apparant that we need to delete the last tab for it to go away. Maybe we need to add a confirmation if there are components inside components.
  3. Tab name editing is inline vs everything else that is to the right. Maybe we should move the controls to the right? This way we could add tab specific filters in there. Also scales well with any more config.
  4. How about a clone button for tab? I can see people adding a bunch of components and wanting to duplicate them. This might be needed for sure if and when we add local filters for tab.
  5. For the full canvas we only show the large Add widget to tab when there are no components, we should do the same for tabs.
  6. Empty tabs should show No components added in preview. Showing a collapsed tab feels like a bad experience.
  7. Tab selection doesnt persist in edit mode. The url doesnt seem to be updated.
  8. Focusing a tab and moving tabs left or right doesnt keep the focus on the tab, the focus shoud be retained.

Will take a deeper look at the code before commenting on #9594 (comment)

djbarnwal and others added 7 commits June 24, 2026 21:02
…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).
@nishantmonu51

Copy link
Copy Markdown
Collaborator Author

@AdityaHegde Thanks for the thorough pass — status on all 8:

  1. Drop into empty tab — fixed; the empty-tab box is now a drop target (add menu suppressed mid-drag).
  2. Delete tab group — added a "Delete tab group" action in the new sidebar, with confirmation when any tab has content (deleting the last tab still unwraps as before).
  3. Controls to the right — clicking a tab now opens a tab-group inspector on the right (rename / move / duplicate / delete tab + delete group); this is where per-tab filters can live later. Kept the strip ⋮ menu too.
  4. Clone tab — added "Duplicate" in both the strip menu and the sidebar.
  5. Add-widget only when empty — done (@djbarnwal commit).
  6. "No components added" in preview — done (@djbarnwal commit).
  7. Tab selection persists in edit mode + URL — done (@djbarnwal commit).
  8. Move keeps focus — the active tab now follows its position when moved.

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 AdityaHegde left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We are defining new structure for tabs but not supporting all use cases.

  1. Having multiple tab groups in the same row.
  2. 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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 (

@AdityaHegde AdityaHegde Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since we fixed the no component found error in a different way do we still need this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread runtime/parser/parse_canvas.go Outdated
// 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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

How about moving these functions that operate within a tab group to TabGroup class?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

^ Abobe is claude's assessment, let me know if you feel strongly about a refactor here.

});
}

updateContents();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do we not need performTransaction here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

@nishantmonu51

Copy link
Copy Markdown
Collaborator Author

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 -

  • Having multiple tab groups in the same row.
  • Nested tab groups.

- extract slugify into runtime/pkg/urlutils and consolidate tab-name derivation
- add reusable AlertDialogConfirmation and use it for tab/group delete dialogs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants