diff --git a/docs/docs/reference/project-files/metrics-views.md b/docs/docs/reference/project-files/metrics-views.md index 04eac9e6a50b..173c58c086ad 100644 --- a/docs/docs/reference/project-files/metrics-views.md +++ b/docs/docs/reference/project-files/metrics-views.md @@ -393,6 +393,34 @@ _[object]_ - Defines an optional inline explore view for the metrics view. If no - **`banner`** - _[string]_ - Custom banner displayed at the header of the explore view. + - **`dimensions`** - _[oneOf]_ - Dimensions to include in the explore view. Defaults to all dimensions in the metrics view. + + - **option 1** - _[string]_ - Wildcard(*) selector that includes all available fields in the selection + + - **option 2** - _[array of string]_ - Explicit list of fields to include in the selection + + - **option 3** - _[object]_ - Advanced matching using regex, DuckDB expression, or exclusion + + - **`regex`** - _[string]_ - Select fields using a regular expression + + - **`expr`** - _[string]_ - DuckDB SQL expression to select fields based on custom logic + + - **`exclude`** - _[object]_ - Select all fields except those listed here + + - **`measures`** - _[oneOf]_ - Measures to include in the explore view. Defaults to all measures in the metrics view. + + - **option 1** - _[string]_ - Wildcard(*) selector that includes all available fields in the selection + + - **option 2** - _[array of string]_ - Explicit list of fields to include in the selection + + - **option 3** - _[object]_ - Advanced matching using regex, DuckDB expression, or exclusion + + - **`regex`** - _[string]_ - Select fields using a regular expression + + - **`expr`** - _[string]_ - DuckDB SQL expression to select fields based on custom logic + + - **`exclude`** - _[object]_ - Select all fields except those listed here + - **`theme`** - _[oneOf]_ - Name of the theme to use or define a theme inline. Either theme name or inline theme can be set. - **option 1** - _[string]_ - Name of an existing theme to apply to the explore view. diff --git a/runtime/ai/instructions/data/resources/explore.md b/runtime/ai/instructions/data/resources/explore.md index 1846373b6eb0..b3998da91d3a 100644 --- a/runtime/ai/instructions/data/resources/explore.md +++ b/runtime/ai/instructions/data/resources/explore.md @@ -27,10 +27,11 @@ Explore dashboards require minimal configuration. In most cases, you only need t ## Inline explores in metrics views -Metrics views create an explore resource by default with the same name as the metrics view. For legacy reasons, this does not happen for metrics views containing `version: 1`. You can customize a metrics view's explore with the `explore:` property inside the metrics view file: +The preferred way to create an explore is inline in the metrics view file: set `version: 1` and add an `explore:` block, which emits an explore resource with the same name as the metrics view (or `name:` if set): ```yaml # metrics/sales.yaml +version: 1 type: metrics_view display_name: Sales Analytics @@ -45,8 +46,11 @@ measures: - name: total_revenue expression: SUM(revenue) -# Inline explore configuration (optional) +# Inline explore configuration explore: + display_name: Sales Dashboard + dimensions: '*' # Optional: dimensions to expose ('*', a list, or {exclude: [...]}); defaults to all + measures: '*' # Optional: measures to expose ('*', a list, or {exclude: [...]}); defaults to all time_ranges: - P7D - P30D @@ -55,7 +59,9 @@ explore: time_range: P30D ``` -Use inline explores for simple cases where you want to keep the metrics view and its dashboard configuration together. Use separate explore files when you need multiple explores for the same metrics view or more complex configurations. +For legacy reasons, metrics views without `version:` auto-emit an explore even without an `explore:` block; metrics views with `version: 1` only emit one when the block is present. + +Use inline explores to keep the metrics view and its dashboard configuration together. Use separate explore files when you need multiple explores for the same metrics view. ## Example with annotations diff --git a/runtime/ai/instructions/data/resources/metrics_view.md b/runtime/ai/instructions/data/resources/metrics_view.md index d1d73af0eb8a..a79788c73606 100644 --- a/runtime/ai/instructions/data/resources/metrics_view.md +++ b/runtime/ai/instructions/data/resources/metrics_view.md @@ -163,13 +163,16 @@ format_d3: ",.0f" # 1,235 (rounded, with thousands separator) - If there is any date/timestamp column in the underlying table, pick the primary or most interesting one and add it under `dimensions:` - It is also _strongly_ recommended that you configure a primary time dimension using `timeseries:` -### Auto-generated explore +### Inline explore -When you create a metrics view, Rill automatically generates an explore dashboard with the same name, exposing all dimensions and measures. To customize the explore (you usually should not need to), add an `explore:` block: +New metrics views should set `version: 1` and include an `explore:` block, which makes Rill emit an explore dashboard for the metrics view (named after the metrics view unless `name:` is set): ```yaml +version: 1 explore: display_name: Sales Dashboard + dimensions: '*' # Optional: dimensions to expose ('*', a list, or {exclude: [...]}); defaults to all + measures: '*' # Optional: measures to expose ('*', a list, or {exclude: [...]}); defaults to all defaults: time_range: P7D measures: @@ -177,7 +180,9 @@ explore: - order_count ``` -**Legacy behavior**: Files with `version: 1` do NOT auto-generate an explore. Omit `version:` in new metrics views to get the auto-generated explore. +An empty block (`explore: {}`) is enough to enable the dashboard with all dimensions and measures. Note that `explore:` with no value (null) does NOT enable it. Set `explore: {skip: true}` to create a metrics view without a dashboard. + +**Legacy behavior**: Files without `version:` (or `version: 0`) auto-generate an explore even without an `explore:` block. Files with `version: 1` only get an explore if an `explore:` block is present. ## Full Example @@ -185,6 +190,7 @@ Here is a complete, annotated metrics view: ```yaml # metrics/orders.yaml +version: 1 type: metrics_view # Display metadata @@ -255,6 +261,10 @@ measures: expression: SUM(item_count) / NULLIF(COUNT(*), 0) format_d3: ",.1f" valid_percent_of_total: false + +# Explore dashboard for this metrics view +explore: + display_name: Orders Dashboard ``` ## Security Policies diff --git a/runtime/parser/parse_metrics_view.go b/runtime/parser/parse_metrics_view.go index d22d7d3b1899..30c7aaabc390 100644 --- a/runtime/parser/parse_metrics_view.go +++ b/runtime/parser/parse_metrics_view.go @@ -110,6 +110,8 @@ type MetricsViewYAML struct { DisplayName string `yaml:"display_name"` Description string `yaml:"description"` Banner string `yaml:"banner"` + Dimensions *FieldSelectorYAML `yaml:"dimensions"` + Measures *FieldSelectorYAML `yaml:"measures"` Theme yaml.Node `yaml:"theme"` // Name (string) or inline theme definition (map) TimeRanges []ExploreTimeRangeYAML `yaml:"time_ranges"` TimeZones []string `yaml:"time_zones"` // Single time zone or list of time zones @@ -1128,6 +1130,18 @@ func (p *Parser) parseAndInsertInlineExplore(tmp *MetricsViewYAML, mvName string allowCustomTimeRange = *tmp.Explore.AllowCustomTimeRange } + // Resolve the dimensions and measures selectors; when omitted, they default to all fields. + var dimensionsSelector *runtimev1.FieldSelector + dimensions, ok := tmp.Explore.Dimensions.TryResolve() + if !ok { + dimensionsSelector = tmp.Explore.Dimensions.Proto() + } + var measuresSelector *runtimev1.FieldSelector + measures, ok := tmp.Explore.Measures.TryResolve() + if !ok { + measuresSelector = tmp.Explore.Measures.Proto() + } + refs := []ResourceName{{Kind: ResourceKindMetricsView, Name: mvName}} // Parse theme if present. // If it returns a themeSpec, it will be inserted as a separate resource later in this function. @@ -1162,8 +1176,10 @@ func (p *Parser) parseAndInsertInlineExplore(tmp *MetricsViewYAML, mvName string r.ExploreSpec.Description = tmp.Explore.Description r.ExploreSpec.MetricsView = mvName r.ExploreSpec.Banner = tmp.Explore.Banner - r.ExploreSpec.DimensionsSelector = &runtimev1.FieldSelector{Selector: &runtimev1.FieldSelector_All{All: true}} - r.ExploreSpec.MeasuresSelector = &runtimev1.FieldSelector{Selector: &runtimev1.FieldSelector_All{All: true}} + r.ExploreSpec.Dimensions = dimensions + r.ExploreSpec.DimensionsSelector = dimensionsSelector + r.ExploreSpec.Measures = measures + r.ExploreSpec.MeasuresSelector = measuresSelector r.ExploreSpec.Theme = themeName r.ExploreSpec.EmbeddedTheme = themeSpec r.ExploreSpec.TimeRanges = timeRanges diff --git a/runtime/parser/parse_metrics_view_test.go b/runtime/parser/parse_metrics_view_test.go index 8868e5451160..bb2611994cc9 100644 --- a/runtime/parser/parse_metrics_view_test.go +++ b/runtime/parser/parse_metrics_view_test.go @@ -1069,3 +1069,84 @@ rollups: require.Len(t, mvSpec.Rollups, 1) require.Equal(t, "-1Y to now", mvSpec.Rollups[0].DataTimeRange) } + +func TestMetricsViewInlineExploreFieldSelectors(t *testing.T) { + mvYAML := func(explore string) string { + return ` +type: metrics_view +version: 1 +model: m1 +dimensions: +- name: foo + expression: id +measures: +- name: count + expression: COUNT(*) +` + explore + } + + files := map[string]string{ + `rill.yaml`: ``, + `models/m1.sql`: `SELECT 1 AS id`, + // No selectors: defaults to all dimensions and measures + `metrics_views/mv1.yaml`: mvYAML(` +explore: {} +`), + // Star and exclude selectors + `metrics_views/mv2.yaml`: mvYAML(` +explore: + dimensions: '*' + measures: + exclude: count +`), + // List and expression selectors + `metrics_views/mv3.yaml`: mvYAML(` +explore: + dimensions: [foo] + measures: + regex: 'count.*' +`), + // A null explore does not enable the inline explore + `metrics_views/mv4.yaml`: mvYAML(` +explore: +`), + // An explicitly skipped explore is not emitted either + `metrics_views/mv5.yaml`: mvYAML(` +explore: + skip: true +`), + } + + ctx := context.Background() + repo := makeRepo(t, files) + p, err := Parse(ctx, repo, "", "", "duckdb", true) + require.NoError(t, err) + require.Empty(t, p.Errors) + + explores := map[string]*runtimev1.ExploreSpec{} + for _, r := range p.Resources { + if r.Name.Kind == ResourceKindExplore { + explores[r.Name.Name] = r.ExploreSpec + } + } + require.Len(t, explores, 3) + + e1 := explores["mv1"] + require.True(t, e1.DefinedInMetricsView) + require.Equal(t, "mv1", e1.MetricsView) + require.Empty(t, e1.Dimensions) + require.Equal(t, &runtimev1.FieldSelector{Selector: &runtimev1.FieldSelector_All{All: true}}, e1.DimensionsSelector) + require.Empty(t, e1.Measures) + require.Equal(t, &runtimev1.FieldSelector{Selector: &runtimev1.FieldSelector_All{All: true}}, e1.MeasuresSelector) + + e2 := explores["mv2"] + require.True(t, e2.DefinedInMetricsView) + require.Equal(t, &runtimev1.FieldSelector{Selector: &runtimev1.FieldSelector_All{All: true}}, e2.DimensionsSelector) + require.Equal(t, &runtimev1.FieldSelector{Invert: true, Selector: &runtimev1.FieldSelector_Fields{Fields: &runtimev1.StringListValue{Values: []string{"count"}}}}, e2.MeasuresSelector) + + e3 := explores["mv3"] + require.True(t, e3.DefinedInMetricsView) + require.Equal(t, []string{"foo"}, e3.Dimensions) + require.Nil(t, e3.DimensionsSelector) + require.Equal(t, &runtimev1.FieldSelector{Selector: &runtimev1.FieldSelector_Regex{Regex: "count.*"}}, e3.MeasuresSelector) +} diff --git a/runtime/parser/schema/project.schema.yaml b/runtime/parser/schema/project.schema.yaml index 02dc12afdf4c..bd8b9af885e6 100644 --- a/runtime/parser/schema/project.schema.yaml +++ b/runtime/parser/schema/project.schema.yaml @@ -4117,6 +4117,12 @@ definitions: banner: type: string description: Custom banner displayed at the header of the explore view. + dimensions: + $ref: '#/definitions/field_selector_properties' + description: Dimensions to include in the explore view. Defaults to all dimensions in the metrics view. + measures: + $ref: '#/definitions/field_selector_properties' + description: Measures to include in the explore view. Defaults to all measures in the metrics view. theme: oneOf: - type: string diff --git a/runtime/server/generate_metrics_view.go b/runtime/server/generate_metrics_view.go index 78f19e3240a4..8f7888e1e00c 100644 --- a/runtime/server/generate_metrics_view.go +++ b/runtime/server/generate_metrics_view.go @@ -274,6 +274,10 @@ func (s *Server) generateMetricsViewYAMLWithAI(ctx context.Context, instanceID, doc.Type = "metrics_view" doc.TimeDimension = generateMetricsViewYAMLSimpleTimeDimension(tbl.Schema) doc.Dimensions = generateMetricsViewYAMLSimpleDimensions(tbl.Schema) + if doc.DisplayName == "" { + doc.DisplayName = identifierToDisplayName(tbl.Name) + } + doc.Explore = &metricsViewExploreYAML{DisplayName: doc.DisplayName + " dashboard"} for _, measure := range doc.Measures { // Apply the default format preset to measures (the AI doesn't set the format preset). measure.FormatPreset = "humanize" @@ -425,6 +429,7 @@ func generateMetricsViewYAMLSimple(connector string, tbl *drivers.OlapTable, isM TimeDimension: generateMetricsViewYAMLSimpleTimeDimension(tbl.Schema), Dimensions: generateMetricsViewYAMLSimpleDimensions(tbl.Schema), Measures: generateMetricsViewYAMLSimpleMeasures(tbl, dialect), + Explore: &metricsViewExploreYAML{DisplayName: identifierToDisplayName(tbl.Name) + " dashboard"}, } doc.Connector = connector @@ -525,6 +530,13 @@ type metricsViewYAML struct { TimeDimension string `yaml:"timeseries,omitempty"` Dimensions []*metricsViewDimensionYAML `yaml:"dimensions,omitempty"` Measures []*metricsViewMeasureYAML `yaml:"measures,omitempty"` + Explore *metricsViewExploreYAML `yaml:"explore,omitempty"` +} + +// metricsViewExploreYAML enables the inline explore dashboard for a generated metrics view. +// It must always be marshaled with at least one key: a null `explore:` value does not enable the explore. +type metricsViewExploreYAML struct { + DisplayName string `yaml:"display_name"` } type metricsViewDimensionYAML struct { @@ -584,7 +596,7 @@ func insertEmptyLinesInYaml(node *yaml.Node) { keyNode := node.Content[i].Content[j] valueNode := node.Content[i].Content[j+1] - if keyNode.Value == "dimensions" || keyNode.Value == "measures" { + if keyNode.Value == "dimensions" || keyNode.Value == "measures" || keyNode.Value == "explore" { keyNode.HeadComment = "\n" } if keyNode.Value == "type" { diff --git a/runtime/server/generate_metrics_view_test.go b/runtime/server/generate_metrics_view_test.go index b4d53780bd3e..2b894559440b 100644 --- a/runtime/server/generate_metrics_view_test.go +++ b/runtime/server/generate_metrics_view_test.go @@ -60,6 +60,8 @@ driver: duckdb "model: ad_bids", "measures:", "format_preset: humanize", + "explore:", + "display_name: Ad Bids dashboard", }, }, { @@ -108,6 +110,12 @@ driver: duckdb } }) } + + // The generated metrics view should emit an inline explore. + testruntime.ReconcileParserAndWait(t, rt, instanceID) + explore := testruntime.GetResource(t, rt, instanceID, runtime.ResourceKindExplore, "generated_metrics_view") + require.Equal(t, "generated_metrics_view", explore.GetExplore().Spec.MetricsView) + require.True(t, explore.GetExplore().Spec.DefinedInMetricsView) } func TestGenerateMetricsViewWithAI(t *testing.T) { @@ -171,8 +179,9 @@ func TestGenerateMetricsViewWithAI(t *testing.T) { require.NoError(t, err) require.True(t, res.AiSucceeded) + // Expecting 4 resources: the project parser, the model, the metrics view, and its inline explore. testruntime.ReconcileParserAndWait(t, rt, instanceID) - testruntime.RequireReconcileState(t, rt, instanceID, 3, 0, 0) + testruntime.RequireReconcileState(t, rt, instanceID, 4, 0, 0) data, err := repo.Get(ctx, "/metrics/generated_metrics_view.yaml") require.NoError(t, err) diff --git a/web-common/src/features/dashboards/selectors.ts b/web-common/src/features/dashboards/selectors.ts index f2e76b6cb5aa..d750920c4aec 100644 --- a/web-common/src/features/dashboards/selectors.ts +++ b/web-common/src/features/dashboards/selectors.ts @@ -258,3 +258,41 @@ export const useGetExploresForMetricsView = ( (res) => res.explore?.spec?.metricsView === metricsViewName, ); }; + +// Canvases don't reference metrics views directly: their rows contain components, +// and each component's renderer properties name the metrics view it queries. +export const useGetCanvasesForMetricsView = ( + client: RuntimeClient, + metricsViewName: string, +) => { + return createRuntimeServiceListResources( + client, + {}, + { + query: { + select: (data) => { + const componentNames = new Set( + data.resources + ?.filter( + (res) => + res.meta?.name?.kind === ResourceKind.Component && + res.component?.state?.validSpec?.rendererProperties + ?.metrics_view === metricsViewName, + ) + .map((res) => res.meta?.name?.name), + ); + return ( + data.resources?.filter( + (res) => + res.meta?.name?.kind === ResourceKind.Canvas && + res.canvas?.state?.validSpec?.rows?.some((row) => + row.items?.some((item) => componentNames.has(item.component)), + ), + ) ?? [] + ); + }, + }, + }, + queryClient, + ); +}; diff --git a/web-common/src/features/entity-management/add/new-files.ts b/web-common/src/features/entity-management/add/new-files.ts index 8dc58edd2a2b..af75371cc6b1 100644 --- a/web-common/src/features/entity-management/add/new-files.ts +++ b/web-common/src/features/entity-management/add/new-files.ts @@ -178,10 +178,13 @@ version: 1 type: metrics_view model: # Choose a model to underpin your metrics view -timeseries: # Choose a timestamp column (if any) from your model +timeseries: # Choose a timestamp column (if any) from your model dimensions: measures: + +explore: + display_name: # Optional: display name for this metrics view's explore dashboard `; case ResourceKind.Explore: if (baseResource) { diff --git a/web-common/src/features/explores/ExploreEditDropdown.svelte b/web-common/src/features/explores/ExploreEditDropdown.svelte index d1bf95a26008..542d7d4b75e2 100644 --- a/web-common/src/features/explores/ExploreEditDropdown.svelte +++ b/web-common/src/features/explores/ExploreEditDropdown.svelte @@ -6,6 +6,7 @@ import MetricsViewIcon from "@rilldata/web-common/components/icons/MetricsViewIcon.svelte"; import { useExplore } from "@rilldata/web-common/features/explores/selectors"; import { getFileHref } from "@rilldata/web-common/layout/navigation/editor-routing"; + import { workspaces } from "@rilldata/web-common/layout/workspace/workspace-stores"; import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; let { exploreName }: { exploreName: string } = $props(); @@ -19,6 +20,12 @@ let metricsViewFilePath = $derived( $exploreQuery.data?.metricsView?.meta?.filePaths?.[0] ?? "", ); + // When the explore is defined inline in the metrics view file, both items point + // at the same file; the workspace view distinguishes what gets edited. + let definedInMetricsView = $derived( + $exploreQuery.data?.explore?.explore?.state?.validSpec + ?.definedInMetricsView ?? false, + ); @@ -31,11 +38,25 @@ {/snippet} - + { + if (definedInMetricsView) { + workspaces.get(exploreFilePath).view.set("explore"); + } + }} + > Explore dashboard - + { + if (definedInMetricsView) { + workspaces.get(metricsViewFilePath).view.set("viz"); + } + }} + > Metrics View diff --git a/web-common/src/features/explores/PreviewButton.svelte b/web-common/src/features/explores/PreviewButton.svelte index 1aee69aefcd2..7e59ab076d2e 100644 --- a/web-common/src/features/explores/PreviewButton.svelte +++ b/web-common/src/features/explores/PreviewButton.svelte @@ -16,6 +16,7 @@ export let disabled: boolean; export let href: string | null; export let reconciling: boolean = false; + export let label: string = "Preview"; const viewDashboard = () => { if (!href) return; @@ -41,7 +42,7 @@ diff --git a/web-common/src/features/metrics-views/GoToDashboardButton.svelte b/web-common/src/features/metrics-views/GoToDashboardButton.svelte index 99f9f54ce918..720755680e4a 100644 --- a/web-common/src/features/metrics-views/GoToDashboardButton.svelte +++ b/web-common/src/features/metrics-views/GoToDashboardButton.svelte @@ -2,33 +2,62 @@ import { Button } from "@rilldata/web-common/components/button"; import * as DropdownMenu from "@rilldata/web-common/components/dropdown-menu"; import Add from "@rilldata/web-common/components/icons/Add.svelte"; + import CanvasIcon from "@rilldata/web-common/components/icons/CanvasIcon.svelte"; import ExploreIcon from "@rilldata/web-common/components/icons/ExploreIcon.svelte"; import { removeLeadingSlash } from "@rilldata/web-common/features/entity-management/entity-mappers"; import { getFileHref } from "@rilldata/web-common/layout/navigation/editor-routing"; import { featureFlags } from "@rilldata/web-common/features/feature-flags"; + import { ResourceKind } from "@rilldata/web-common/features/entity-management/resource-selectors"; + import { workspaces } from "@rilldata/web-common/layout/workspace/workspace-stores"; import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient"; import type { V1Resource } from "@rilldata/web-common/runtime-client"; import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; - import { useGetExploresForMetricsView } from "../dashboards/selectors"; + import { + useGetCanvasesForMetricsView, + useGetExploresForMetricsView, + } from "../dashboards/selectors"; import { allowPrimary } from "../dashboards/workspace/DeployProjectCTA.svelte"; import { createCanvasDashboardFromMetricsView, createCanvasDashboardFromMetricsViewWithAgent, } from "./ai-generation/generateMetricsView"; - import { createAndPreviewExplore } from "./create-and-preview-explore"; + import { enableInlineExploreAndPreview } from "./inline-explore"; import NavigateOrDropdown from "./NavigateOrDropdown.svelte"; export let resource: V1Resource | undefined; + // True when the metrics view file already defines an inline explore. The explore + // is then reachable via the workspace's explore view and Preview button, so this + // CTA only deals in canvases: it lists canvas dashboards instead of explores and + // hides "Generate Explore Dashboard". + export let hasInlineExplore = false; + + // When a dashboard is defined inline in a metrics view file, its file path is the + // metrics view file itself; switch the workspace to the explore view on navigation. + function onNavigateToDashboard(dashboard: V1Resource) { + const filePath = dashboard?.meta?.filePaths?.[0]; + if ( + filePath && + dashboard?.explore?.state?.validSpec?.definedInMetricsView + ) { + workspaces.get(filePath).view.set("explore"); + } + } const runtimeClient = useRuntimeClient(); const { ai, developerChat } = featureFlags; - $: ({ instanceId } = runtimeClient); - $: dashboardsQuery = useGetExploresForMetricsView( - runtimeClient, - resource?.meta?.name?.name ?? "", - ); + $: dashboardsQuery = hasInlineExplore + ? useGetCanvasesForMetricsView( + runtimeClient, + resource?.meta?.name?.name ?? "", + ) + : useGetExploresForMetricsView( + runtimeClient, + resource?.meta?.name?.name ?? "", + ); $: dashboards = $dashboardsQuery.data ?? []; + + $: metricsViewFilePath = resource?.meta?.filePaths?.[0]; {#if dashboards?.length === 0} @@ -55,41 +84,53 @@ > Generate Canvas Dashboard{$ai ? " with AI" : ""} - + {#if !hasInlineExplore} + + {/if} {:else} {#snippet child({ props })} - + {/snippet} {#each dashboards as resource (resource?.meta?.name?.name)} + {@const isCanvas = resource?.meta?.name?.kind === ResourceKind.Canvas} {@const label = - resource?.explore?.state?.validSpec?.displayName || + (isCanvas + ? resource?.canvas?.state?.validSpec?.displayName + : resource?.explore?.state?.validSpec?.displayName) || resource?.meta?.name?.name} {@const filePath = resource?.meta?.filePaths?.[0]} {#if label && filePath} onNavigateToDashboard(resource)} > - + {#if isCanvas} + + {:else} + + {/if} {label} {/if} @@ -116,20 +157,20 @@ Generate Canvas Dashboard{$ai ? " with AI" : ""} - { - if (resource) - await createAndPreviewExplore( - runtimeClient, - queryClient, - instanceId, - resource, - ); - }} - > - - Generate Explore Dashboard{$ai ? " with AI" : ""} - + {#if !hasInlineExplore} + { + if (metricsViewFilePath) + await enableInlineExploreAndPreview( + queryClient, + metricsViewFilePath, + ); + }} + > + + Generate Explore Dashboard{$ai ? " with AI" : ""} + + {/if} diff --git a/web-common/src/features/metrics-views/MetricsViewMenuItems.svelte b/web-common/src/features/metrics-views/MetricsViewMenuItems.svelte index 8636dffdad4c..68ba900b9ed2 100644 --- a/web-common/src/features/metrics-views/MetricsViewMenuItems.svelte +++ b/web-common/src/features/metrics-views/MetricsViewMenuItems.svelte @@ -22,7 +22,7 @@ createCanvasDashboardFromMetricsView, createCanvasDashboardFromMetricsViewWithAgent, } from "./ai-generation/generateMetricsView"; - import { createAndPreviewExplore } from "./create-and-preview-explore"; + import { enableInlineExploreAndPreview } from "./inline-explore"; const runtimeClient = useRuntimeClient(); const { ai, developerChat } = featureFlags; @@ -31,7 +31,6 @@ $: fileArtifact = fileArtifacts.getFileArtifact(filePath); - $: ({ instanceId } = runtimeClient); $: resourceQuery = fileArtifact.getResource(queryClient); $: resource = $resourceQuery.data; @@ -121,13 +120,7 @@ {/if} {#if resource} - createAndPreviewExplore( - runtimeClient, - queryClient, - instanceId, - resource, - )} + onclick={() => enableInlineExploreAndPreview(queryClient, filePath)} >
diff --git a/web-common/src/features/metrics-views/NavigateOrDropdown.svelte b/web-common/src/features/metrics-views/NavigateOrDropdown.svelte index 0f7b8f88f020..c733590e30f2 100644 --- a/web-common/src/features/metrics-views/NavigateOrDropdown.svelte +++ b/web-common/src/features/metrics-views/NavigateOrDropdown.svelte @@ -11,17 +11,23 @@ // svelte-ignore custom_element_props_identifier let { resources, + onNavigate = undefined, ...triggerProps }: { resources: V1Resource[]; + onNavigate?: (resource: V1Resource) => void; [key: string]: unknown; } = $props(); let firstResource = $derived(resources?.[0]); + // Spell out "canvas dashboard" to distinguish the target from the metrics + // view's own explore dashboard (displayResourceKind flattens both to "dashboard"). let firstResourceType = $derived( - displayResourceKind( - firstResource?.meta?.name?.kind as ResourceKind | undefined, - ), + firstResource?.meta?.name?.kind === ResourceKind.Canvas + ? "canvas dashboard" + : displayResourceKind( + firstResource?.meta?.name?.kind as ResourceKind | undefined, + ), ); @@ -33,6 +39,7 @@ href={getFileHref( `/${removeLeadingSlash(firstResource.meta?.filePaths?.[0])}`, )} + onclick={() => onNavigate?.(firstResource)} class="text-inherit font-medium flex items-center border-r px-3 size-full hover:bg-surface-hover border-accent-primary-action hover:text-fg-accent" > Go to {firstResourceType} diff --git a/web-common/src/features/metrics-views/ai-generation/generateMetricsView.ts b/web-common/src/features/metrics-views/ai-generation/generateMetricsView.ts index 097c7e965372..6efefd6663da 100644 --- a/web-common/src/features/metrics-views/ai-generation/generateMetricsView.ts +++ b/web-common/src/features/metrics-views/ai-generation/generateMetricsView.ts @@ -1,7 +1,6 @@ import { goto } from "$app/navigation"; import { navigateToCanvas, - navigateToExplore, navigateToFile, withEditorPrefix, } from "@rilldata/web-common/layout/navigation/editor-routing"; @@ -13,12 +12,10 @@ import { ResourceKind, resourceIsLoading, } from "@rilldata/web-common/features/entity-management/resource-selectors"; -import { createResourceFile } from "@rilldata/web-common/features/entity-management/add/new-files.ts"; import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import { getScreenNameFromPage } from "@rilldata/web-common/features/file-explorer/telemetry"; import { extractErrorMessage } from "@rilldata/web-common/lib/errors"; import { eventBus } from "@rilldata/web-common/lib/event-bus/event-bus"; -import type { QueryClient } from "@tanstack/svelte-query"; import { get } from "svelte/store"; import { overlay } from "../../../layout/overlay-store"; import { queryClient } from "../../../lib/svelte-query/globalQueryClient"; @@ -41,7 +38,7 @@ import { import { createYamlModelFromTable } from "../../connectors/code-utils"; import { getName } from "../../entity-management/name-utils"; import { featureFlags } from "../../feature-flags"; -import { createAndPreviewExplore } from "../create-and-preview-explore"; +import { previewInlineExplore } from "../inline-explore"; import OptionToCancelAIGeneration from "./OptionToCancelAIGeneration.svelte"; /** @@ -162,22 +159,8 @@ export function useCreateMetricsViewFromTableUIAction( return; } - // If we are creating an Explore... - - // Get the Metrics View to use as a base for the Explore - const metricsViewResource = fileArtifacts - .getFileArtifact(newMetricsViewFilePath) - .getResource(queryClient); - - await waitUntil(() => get(metricsViewResource).data !== undefined, 5000); - - const resource = get(metricsViewResource).data; - if (!resource) { - throw new Error("Failed to create a Metrics View resource"); - } - - // Create the Explore file, and navigate to it - await createAndPreviewExplore(client, queryClient, instanceId, resource); + // The generated metrics view defines its explore inline; open it + await previewInlineExplore(queryClient, newMetricsViewFilePath); } catch (err) { eventBus.emit("notification", { message: @@ -462,26 +445,8 @@ export async function createModelAndMetricsAndExplore( return; } - // If we are creating an Explore... - - // Update overlay for explore dashboard creation - overlay.set({ - title: `Creating explore dashboard...`, - detail: { - component: OptionToCancelAIGeneration, - props: { - onCancel: () => { - abortController.abort( - "Explore dashboard creation cancelled by user", - ); - isAICancelled = true; - }, - }, - }, - }); - - // Step 5: Create explore dashboard - await createAndPreviewExplore(client, queryClient, instanceId, resource); + // Step 5: Open the explore dashboard defined inline in the metrics view + await previewInlineExplore(queryClient, metricsViewFilePath); } catch (err) { console.error("Failed to create model and metrics view:", err); throw err; @@ -581,37 +546,6 @@ async function createMetricsViewFromTable( return resource; } -/** - * Creates an Explore dashboard file without navigation. - * Returns the file path of the created explore. - */ -export async function createExploreWithoutNavigation( - client: RuntimeClient, - queryClient: QueryClient, - instanceId: string, - metricsViewResource: V1Resource, -): Promise { - // Create the Explore file - const filePath = await createResourceFile( - client, - ResourceKind.Explore, - metricsViewResource, - ); - - // Wait until the Explore resource is ready - const fileArtifact = fileArtifacts.getFileArtifact(filePath); - const resource = fileArtifact.getResource(queryClient); - - await waitUntil(() => { - return get(resource).data !== undefined; - }, 10000); - - const name = get(resource).data?.meta?.name?.name; - if (!name) throw new Error("Failed to create an Explore resource"); - - return filePath; -} - /** * Wrapper function that creates metrics view and canvas dashboard from a table. * Navigates to canvas dashboard when complete. @@ -760,12 +694,12 @@ export function useCreateMetricsViewWithCanvasAndExploreUIAction( }, }); - let exploreFilePath: string | null = null; let canvasFilePath: string | null = null; let metricsViewName: string | undefined; + let metricsViewFilePath: string | undefined; try { - // Step 1: Create metrics view + // Step 1: Create metrics view (which defines its explore dashboard inline) const resource = await createMetricsViewFromTable( client, connector, @@ -776,7 +710,8 @@ export function useCreateMetricsViewWithCanvasAndExploreUIAction( ); metricsViewName = resource.meta?.name?.name; - if (!metricsViewName) { + metricsViewFilePath = resource.meta?.filePaths?.[0]; + if (!metricsViewName || !metricsViewFilePath) { throw new Error("Failed to get metrics view name"); } @@ -796,27 +731,7 @@ export function useCreateMetricsViewWithCanvasAndExploreUIAction( await new Promise((resolve) => setTimeout(resolve, 2000)); - // Step 3: Create Explore dashboard (without navigation) - overlay.set({ - title: `Creating Explore dashboard...`, - detail: { - component: OptionToCancelAIGeneration, - props: { - onCancel: () => { - abortController.abort("Dashboard creation cancelled by user"); - }, - }, - }, - }); - - exploreFilePath = await createExploreWithoutNavigation( - client, - queryClient, - client.instanceId, - resource, - ); - - // Step 4: Try to create Canvas dashboard + // Step 3: Try to create Canvas dashboard overlay.set({ title: `Creating Canvas dashboard${isAiEnabled ? " with AI" : ""}...`, detail: { @@ -834,7 +749,7 @@ export function useCreateMetricsViewWithCanvasAndExploreUIAction( metricsViewName, ); - // Step 5: Navigate to Canvas if successful, otherwise Explore + // Step 4: Navigate to Canvas if successful, otherwise the inline Explore const isPreview = get(previewModeStore); if (canvasFilePath) { const canvasName = canvasFilePath @@ -850,13 +765,8 @@ export function useCreateMetricsViewWithCanvasAndExploreUIAction( MetricsEventScreenName.Source, MetricsEventScreenName.Canvas, ); - } else if (exploreFilePath) { - const exploreName = exploreFilePath - .replace("/dashboards/", "") - .replace(".yaml", ""); - await (isPreview - ? navigateToExplore(exploreName) - : navigateToFile(exploreFilePath)); + } else { + await previewInlineExplore(queryClient, metricsViewFilePath); void behaviourEvent?.fireNavigationEvent( metricsViewName, behaviourEventMedium, @@ -874,15 +784,9 @@ export function useCreateMetricsViewWithCanvasAndExploreUIAction( detail: extractErrorMessage(err), }); - // If we have an explore path but canvas failed, navigate to explore - if (exploreFilePath && metricsViewName) { - const isPreview = get(previewModeStore); - const exploreName = exploreFilePath - .replace("/dashboards/", "") - .replace(".yaml", ""); - await (isPreview - ? navigateToExplore(exploreName) - : navigateToFile(exploreFilePath)); + // If the metrics view was created but canvas failed, open its inline explore + if (metricsViewFilePath && metricsViewName) { + await previewInlineExplore(queryClient, metricsViewFilePath); void behaviourEvent?.fireNavigationEvent( metricsViewName, behaviourEventMedium, diff --git a/web-common/src/features/metrics-views/create-and-preview-explore.ts b/web-common/src/features/metrics-views/create-and-preview-explore.ts deleted file mode 100644 index 59f5774eed17..000000000000 --- a/web-common/src/features/metrics-views/create-and-preview-explore.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { QueryClient } from "@tanstack/svelte-query"; -import { get } from "svelte/store"; -import { previewModeStore } from "../../layout/preview-mode-store"; -import { waitUntil } from "../../lib/waitUtils"; -import type { V1Resource } from "../../runtime-client"; -import type { RuntimeClient } from "../../runtime-client/v2"; -import { fileArtifacts } from "../entity-management/file-artifacts"; -import { ResourceKind } from "../entity-management/resource-selectors"; -import { createResourceFile } from "../entity-management/add/new-files.ts"; -import { - navigateToExplore, - navigateToFile, -} from "../../layout/navigation/editor-routing"; - -export async function createAndPreviewExplore( - client: RuntimeClient, - queryClient: QueryClient, - instanceId: string, - metricsViewResource: V1Resource, -) { - // Create the Explore file - const filePath = await createResourceFile( - client, - ResourceKind.Explore, - metricsViewResource, - ); - - // Wait until the Explore resource is ready - const fileArtifact = fileArtifacts.getFileArtifact(filePath); - const resource = fileArtifact.getResource(queryClient); - - await waitUntil(() => { - return get(resource).data !== undefined; - }, 10000); - - const name = get(resource).data?.meta?.name?.name; - if (!name) throw new Error("Failed to create an Explore resource"); - - const isPreview = get(previewModeStore); - await (isPreview ? navigateToExplore(name) : navigateToFile(filePath)); -} diff --git a/web-common/src/features/metrics-views/inline-explore.ts b/web-common/src/features/metrics-views/inline-explore.ts new file mode 100644 index 000000000000..073ac3504c6f --- /dev/null +++ b/web-common/src/features/metrics-views/inline-explore.ts @@ -0,0 +1,109 @@ +import type { QueryClient } from "@tanstack/svelte-query"; +import { get } from "svelte/store"; +import { YAMLMap, parseDocument } from "yaml"; +import { + navigateToExplore, + navigateToFile, +} from "../../layout/navigation/editor-routing"; +import { previewModeStore } from "../../layout/preview-mode-store"; +import { workspaces } from "../../layout/workspace/workspace-stores"; +import { waitUntil } from "../../lib/waitUtils"; +import { fileArtifacts } from "../entity-management/file-artifacts"; + +export type InlineExploreState = { + // True when the file opts out of the legacy behavior where an explore is + // auto-emitted for every metrics view (i.e. it sets `version` > 0). + isLatestVersion: boolean; + // True when the file defines an inline explore: a `explore:` mapping that is + // not `skip: true`. Only meaningful for latest-version metrics views. + exploreEnabled: boolean; + // The inline explore's `name` override, or null to default to the metrics view name. + exploreName: string | null; +}; + +// parseInlineExploreState inspects a metrics view file's YAML for the inline +// explore configuration. It never throws: unparseable content yields all-false state. +export function parseInlineExploreState( + content: string | null | undefined, +): InlineExploreState { + const doc = parseDocument(content ?? ""); + + const isLatestVersion = Number(doc.get("version")) > 0; + + const explore = doc.get("explore"); + const exploreEnabled = + isLatestVersion && + explore instanceof YAMLMap && + explore.get("skip") !== true; + + const rawName = explore instanceof YAMLMap ? explore.get("name") : undefined; + const exploreName = + typeof rawName === "string" && rawName !== "" ? rawName : null; + + return { isLatestVersion, exploreEnabled, exploreName }; +} + +// previewInlineExplore opens the explore emitted by a metrics view file's inline +// explore block: the explore page in preview mode, or the metrics view file's +// explore view in the editor. +export async function previewInlineExplore( + queryClient: QueryClient, + filePath: string, +) { + const artifact = fileArtifacts.getFileArtifact(filePath); + const resource = artifact.getResource(queryClient); + await waitUntil(() => get(resource).data !== undefined, 10000); + + const metricsViewName = get(resource).data?.meta?.name?.name; + if (!metricsViewName) { + throw new Error(`Failed to resolve the metrics view for ${filePath}`); + } + + const { exploreName } = parseInlineExploreState( + get(artifact.editorContent) ?? get(artifact.remoteContent), + ); + + if (get(previewModeStore)) { + await navigateToExplore(exploreName ?? metricsViewName); + } else { + workspaces.get(filePath).view.set("explore"); + await navigateToFile(filePath); + } +} + +// enableInlineExploreAndPreview ensures the metrics view file defines an inline +// explore, then opens it. Legacy (unversioned) metrics views already auto-emit an +// explore named after the metrics view, so those only navigate. +export async function enableInlineExploreAndPreview( + queryClient: QueryClient, + filePath: string, +) { + const artifact = fileArtifacts.getFileArtifact(filePath); + const content = + get(artifact.editorContent) ?? get(artifact.remoteContent) ?? ""; + const { isLatestVersion, exploreEnabled } = parseInlineExploreState(content); + + if (!isLatestVersion) { + const resource = artifact.getResource(queryClient); + await waitUntil(() => get(resource).data !== undefined, 10000); + const metricsViewName = get(resource).data?.meta?.name?.name; + if (!metricsViewName) { + throw new Error(`Failed to resolve the metrics view for ${filePath}`); + } + await navigateToExplore(metricsViewName); + return; + } + + if (!exploreEnabled) { + const doc = parseDocument(content); + const explore = doc.get("explore"); + if (explore instanceof YAMLMap) { + explore.delete("skip"); + } else { + doc.set("explore", {}); + } + artifact.updateEditorContent(doc.toString(), false, true); + } + + await previewInlineExplore(queryClient, filePath); +} diff --git a/web-common/src/features/visual-editing/CodeToggle.svelte b/web-common/src/features/visual-editing/CodeToggle.svelte index 3569e6a60140..ab11a537e0f6 100644 --- a/web-common/src/features/visual-editing/CodeToggle.svelte +++ b/web-common/src/features/visual-editing/CodeToggle.svelte @@ -1,3 +1,14 @@ + +
- {#each viewOptions as { view, icon: Icon } (view)} + {#each viewOptions as { view, icon: Icon, label } (view)} - {view === "code" ? "Code view" : "No-code view"} + {label} {/each}
diff --git a/web-common/src/features/workspaces/MetricsWorkspace.svelte b/web-common/src/features/workspaces/MetricsWorkspace.svelte index 99544a45ba73..2b2ddc3def0f 100644 --- a/web-common/src/features/workspaces/MetricsWorkspace.svelte +++ b/web-common/src/features/workspaces/MetricsWorkspace.svelte @@ -1,25 +1,41 @@ - - - {#snippet cta()} -
- {#if !inPreviewMode} - {#if isOldMetricsView} - - {:else} - + + {#snippet cta()} +
+ {#if !inPreviewMode} + {#if !isLatestVersion} + + {:else if exploreEnabled} + + + {:else} + + {/if} {/if} - {/if} -
- {/snippet} -
- - -
-
- - {#if $selectedView === "code"} - - {:else} - {#key fileArtifact} - + {/snippet} + + + +
+
+ + {#if effectiveView === "code"} + { - $selectedView = "code"; - }} + {filePath} + {parseError} + {metricsViewName} /> - {/key} - {/if} - + {:else if effectiveView === "explore"} + {#if parseError || rootCauseReconcileError} + + + + + + {:else if exploreResource && metricsViewName} + + + + {:else} + + {/if} + {:else} + {#key fileArtifact} + { + $selectedView = "code"; + }} + /> + {/key} + {/if} + +
+
- -
- - - - + + + + {#if effectiveView === "explore"} + {#if ready} + { + $selectedView = "code"; + }} + /> + {/if} + {:else} + + {/if} + + +{/snippet} + +{#if exploreEnabled && metricsViewName} + {#key metricsViewName + inlineExploreName} + + {@render workspaceContent(ready)} + + {/key} +{:else} + {@render workspaceContent(false)} +{/if} diff --git a/web-common/src/features/workspaces/VisualExploreEditing.svelte b/web-common/src/features/workspaces/VisualExploreEditing.svelte index cd7d87a925f1..0e3ec815439a 100644 --- a/web-common/src/features/workspaces/VisualExploreEditing.svelte +++ b/web-common/src/features/workspaces/VisualExploreEditing.svelte @@ -56,6 +56,12 @@ export let viewingDashboard: boolean; export let autoSave: boolean; export let switchView: () => void; + // When the explore is defined inline in a metrics view file, keyPath points at the + // nested block holding the explore properties (e.g. ["explore"]). Empty for a + // standalone explore file, where the properties live at the top level. + export let keyPath: string[] = []; + + $: isInlineExplore = keyPath.length > 0; const runtimeClient = useRuntimeClient(); const StateManagers = getStateManagers(); @@ -109,15 +115,15 @@ $: metricsViewSpec = metricsViewResource?.state?.validSpec; - $: rawTitle = parsedDocument.get("title"); - $: rawDisplayName = parsedDocument.get("display_name"); - $: rawMetricsView = parsedDocument.get("metrics_view"); - $: rawDimensions = parsedDocument.get("dimensions"); - $: rawMeasures = parsedDocument.get("measures"); - $: rawTimeZones = parsedDocument.get("time_zones"); - $: rawTheme = parsedDocument.get("theme"); - $: rawTimeRanges = parsedDocument.get("time_ranges"); - $: rawDefaults = parsedDocument.get("defaults"); + $: rawTitle = parsedDocument.getIn([...keyPath, "title"]); + $: rawDisplayName = parsedDocument.getIn([...keyPath, "display_name"]); + $: rawMetricsView = parsedDocument.getIn([...keyPath, "metrics_view"]); + $: rawDimensions = parsedDocument.getIn([...keyPath, "dimensions"]); + $: rawMeasures = parsedDocument.getIn([...keyPath, "measures"]); + $: rawTimeZones = parsedDocument.getIn([...keyPath, "time_zones"]); + $: rawTheme = parsedDocument.getIn([...keyPath, "theme"]); + $: rawTimeRanges = parsedDocument.getIn([...keyPath, "time_ranges"]); + $: rawDefaults = parsedDocument.getIn([...keyPath, "defaults"]); $: resourceTags = readRootYamlTags(parsedDocument); $: tagSuggestions = getResourceTagSuggestions( @@ -260,9 +266,9 @@ ) { Object.entries(newRecord).forEach(([property, value]) => { if (!value) { - parsedDocument.delete(property); + parsedDocument.deleteIn([...keyPath, property]); } else { - parsedDocument.set(property, value); + parsedDocument.setIn([...keyPath, property], value); } }); @@ -270,9 +276,9 @@ removeProperties.forEach((prop) => { try { if (Array.isArray(prop)) { - parsedDocument.deleteIn(prop); + parsedDocument.deleteIn([...keyPath, ...prop]); } else { - parsedDocument.delete(prop); + parsedDocument.deleteIn([...keyPath, prop]); } } catch { // ignore @@ -410,32 +416,34 @@ onChange={updateResourceTags} /> - ({ - label: name, - value: name, - }))} - onChange={() => { - killState(); - - updateProperties( - { - metrics_view: metricsView, - measures: "*", - dimensions: "*", - }, - ["defaults"], - ); - }} - /> + {#if !isInlineExplore} + ({ + label: name, + value: name, + }))} + onChange={() => { + killState(); + + updateProperties( + { + metrics_view: metricsView, + measures: "*", + dimensions: "*", + }, + ["defaults"], + ); + }} + /> + {/if} {#each itemTypes as type (type)} {@const items = type === "measures" ? measures : dimensions} @@ -558,14 +566,30 @@ const altMode = isDarkMode ? "light" : "dark"; // check if theme exists for alt mode - const setAltMode = !parsedDocument.hasIn(["theme", altMode]); - - parsedDocument.setIn(["theme", modeKey, "primary"], primary); - parsedDocument.setIn(["theme", modeKey, "secondary"], secondary); + const setAltMode = !parsedDocument.hasIn([ + ...keyPath, + "theme", + altMode, + ]); + + parsedDocument.setIn( + [...keyPath, "theme", modeKey, "primary"], + primary, + ); + parsedDocument.setIn( + [...keyPath, "theme", modeKey, "secondary"], + secondary, + ); if (setAltMode) { - parsedDocument.setIn(["theme", altMode, "primary"], primary); - parsedDocument.setIn(["theme", altMode, "secondary"], secondary); + parsedDocument.setIn( + [...keyPath, "theme", altMode, "primary"], + primary, + ); + parsedDocument.setIn( + [...keyPath, "theme", altMode, "secondary"], + secondary, + ); } killState(); diff --git a/web-common/src/layout/workspace/WorkspaceHeader.svelte b/web-common/src/layout/workspace/WorkspaceHeader.svelte index bc497d43e925..61bf01c2ad68 100644 --- a/web-common/src/layout/workspace/WorkspaceHeader.svelte +++ b/web-common/src/layout/workspace/WorkspaceHeader.svelte @@ -8,7 +8,9 @@ import TooltipContent from "@rilldata/web-common/components/tooltip/TooltipContent.svelte"; import { getIconComponent } from "@rilldata/web-common/features/entity-management/resource-icon-mapping"; import { ResourceKind } from "@rilldata/web-common/features/entity-management/resource-selectors"; - import CodeToggle from "@rilldata/web-common/features/visual-editing/CodeToggle.svelte"; + import CodeToggle, { + type ViewOption, + } from "@rilldata/web-common/features/visual-editing/CodeToggle.svelte"; import WorkspaceBreadcrumbs from "@rilldata/web-common/features/workspaces/WorkspaceBreadcrumbs.svelte"; import type { V1Resource } from "@rilldata/web-common/runtime-client"; import { navigationOpen } from "../navigation/Navigation.svelte"; @@ -27,6 +29,7 @@ hasUnsavedChanges, filePath, codeToggle = false, + codeToggleViews = undefined, showBreadcrumbs = true, onTitleChange, workspaceControls, @@ -41,6 +44,7 @@ hasUnsavedChanges: boolean; filePath: string; codeToggle?: boolean; + codeToggleViews?: ViewOption[] | undefined; showBreadcrumbs?: boolean; onTitleChange?: (title: string) => void; workspaceControls?: Snippet<[number]>; @@ -79,7 +83,7 @@
{#if codeToggle && resourceKind} - + {:else} diff --git a/web-common/src/layout/workspace/workspace-stores.ts b/web-common/src/layout/workspace/workspace-stores.ts index db47fb5f848f..4db34f017fe7 100644 --- a/web-common/src/layout/workspace/workspace-stores.ts +++ b/web-common/src/layout/workspace/workspace-stores.ts @@ -5,6 +5,8 @@ import { DEFAULT_PREVIEW_TABLE_HEIGHT, } from "../config"; +export type WorkspaceView = "code" | "split" | "viz" | "explore"; + type WorkspaceLayout = { inspector: { width: number; @@ -14,7 +16,7 @@ type WorkspaceLayout = { height: number; visible: boolean; }; - view: "code" | "split" | "viz"; + view: WorkspaceView; }; class WorkspaceLayoutStore { @@ -22,7 +24,7 @@ class WorkspaceLayoutStore { private inspectorWidth = writable(DEFAULT_INSPECTOR_WIDTH); private tableVisible = writable(true); private tableHeight = writable(DEFAULT_PREVIEW_TABLE_HEIGHT); - public view = writable<"code" | "split" | "viz">("viz"); + public view = writable("viz"); constructor(key: string) { const history = localStorage.getItem(key); diff --git a/web-local/tests/breadcrumbs.spec.ts b/web-local/tests/breadcrumbs.spec.ts index 068038777b5e..9eecef7b6005 100644 --- a/web-local/tests/breadcrumbs.spec.ts +++ b/web-local/tests/breadcrumbs.spec.ts @@ -27,39 +27,16 @@ test.describe("Breadcrumbs", () => { exact: true, }); - await expect(link).toBeVisible(); - await expect(link).toHaveClass(/selected/g); - - await page.getByText("Generate Explore Dashboard").click(); - - await page.waitForURL("**/files/dashboards/AdBids_metrics_explore.yaml"); - - link = page.getByRole("link", { - name: "AdBids_metrics_explore", - exact: true, - }); + // The generated metrics view defines its explore dashboard inline, so the + // metrics view crumb and the dashboard crumb both point at this file. + await expect(link.first()).toBeVisible(); + await expect(link.first()).toHaveClass(/selected/g); - await expect(link).toBeVisible(); - await expect(link).toHaveClass(/selected/g); - - await page - .getByRole("link", { - name: "AdBids_metrics", - exact: true, - }) - .click(); - - await page.getByRole("button", { name: "Create resource menu" }).click(); await page - .getByRole("menuitem", { name: "Generate Explore Dashboard" }) + .getByRole("link", { name: "AdBids", exact: true }) + .first() .click(); - await page.waitForURL( - "**/files/dashboards/AdBids_metrics_explore_1.yaml", - ); - - await page.getByRole("link", { name: "AdBids", exact: true }).click(); - await page.waitForURL("**/files/models/AdBids.yaml"); await expect( @@ -70,36 +47,20 @@ test.describe("Breadcrumbs", () => { ).toBeVisible(); await expect( - page.getByRole("link", { - name: "AdBids_metrics", - exact: true, - }), - ).toBeVisible(); - - await expect( - page.getByRole("button", { - name: "2 dashboards", - exact: true, - }), + page + .getByRole("link", { + name: "AdBids_metrics", + exact: true, + }) + .first(), ).toBeVisible(); await page .getByRole("link", { name: "AdBids_metrics", exact: true }) + .first() .click(); await page.waitForURL("**/files/metrics/AdBids_metrics.yaml"); - - await page - .getByRole("button", { name: "2 dashboards", exact: true }) - .click(); - await page - .getByRole("menuitem", { - name: "AdBids_metrics_explore", - exact: true, - }) - .click(); - - await page.waitForURL("**/files/dashboards/AdBids_metrics_explore.yaml"); }); }); }); diff --git a/web-local/tests/explores/explores.spec.ts b/web-local/tests/explores/explores.spec.ts index d3676f4907bb..494db2b45f5a 100644 --- a/web-local/tests/explores/explores.spec.ts +++ b/web-local/tests/explores/explores.spec.ts @@ -3,8 +3,6 @@ import { interactWithTimeRangeMenu } from "@rilldata/web-common/tests/utils/expl import { test } from "../setup/base"; import { updateCodeEditor, wrapRetryAssertion } from "../utils/commonHelpers"; import { - AD_BIDS_EXPLORE_PATH, - AD_BIDS_METRICS_PATH, assertAdBidsDashboard, createAdBidsModel, } from "../utils/dataSpecifcHelpers"; @@ -16,7 +14,6 @@ import { import { assertLeaderboards } from "../utils/metricsViewHelpers"; import { ResourceWatcher } from "../utils/ResourceWatcher"; import { createSourceV2 } from "../utils/sourceHelpers"; -import { gotoNavEntry } from "../utils/waitHelpers"; test.describe("explores", () => { test.use({ project: "Blank" }); @@ -68,37 +65,47 @@ test.describe("explores", () => { await page.getByRole("button", { name: "switch to code editor" }).click(); - // Add `inf` alias to the time range + // Add `inf` alias to the time range of the inline explore const addAllTime = ` -type: explore - -title: "Adbids dashboard" -metrics_view: AdBids_model_metrics - -dimensions: '*' -measures: '*' - -time_ranges: - - PT6H - - PT24H - - P7D - - P14D - - P4W - - P12M - - rill-TD - - rill-WTD - - rill-MTD - - rill-QTD - - rill-YTD - - rill-PDC - - rill-PWC - - rill-PMC - - rill-PQC - - rill-PYC - - inf +version: 1 +type: metrics_view + +model: AdBids_model +timeseries: timestamp + +dimensions: + - column: publisher + - column: domain + +measures: + - name: total_records + display_name: Total records + expression: COUNT(*) + format_preset: humanize + +explore: + display_name: "Adbids dashboard" + time_ranges: + - PT6H + - PT24H + - P7D + - P14D + - P4W + - P12M + - rill-TD + - rill-WTD + - rill-MTD + - rill-QTD + - rill-YTD + - rill-PDC + - rill-PWC + - rill-PMC + - rill-PQC + - rill-PYC + - inf `; - await watcher.updateAndWaitForExplore(addAllTime); + await watcher.updateAndWaitForExplore(addAllTime, "AdBids_model_metrics"); await clickPreviewButton(page); @@ -255,42 +262,56 @@ time_ranges: // Check that the data is updated for last 6 hours // Change time range back to all time - // Edit Explore + // Edit Explore (defined inline in the metrics view file) await page.getByRole("button", { name: "Edit" }).click(); await page.getByRole("menuitem", { name: "Explore" }).click(); + await page.getByRole("button", { name: "switch to code editor" }).click(); // Get the dashboard name field and change it const changeDisplayNameDoc = ` -type: explore - -title: "Adbids dashboard renamed" -metrics_view: AdBids_model_metrics - -dimensions: '*' -measures: '*' - -time_ranges: - - PT6H - - PT24H - - P7D - - P14D - - P4W - - P12M - - rill-TD - - rill-WTD - - rill-MTD - - rill-QTD - - rill-YTD - - rill-PDC - - rill-PWC - - rill-PMC - - rill-PQC - - rill-PYC - - inf +version: 1 +type: metrics_view + +model: AdBids_model +timeseries: timestamp + +dimensions: + - column: publisher + - column: domain + +measures: + - name: total_records + display_name: Total records + expression: COUNT(*) + format_preset: humanize + +explore: + display_name: "Adbids dashboard renamed" + time_ranges: + - PT6H + - PT24H + - P7D + - P14D + - P4W + - P12M + - rill-TD + - rill-WTD + - rill-MTD + - rill-QTD + - rill-YTD + - rill-PDC + - rill-PWC + - rill-PMC + - rill-PQC + - rill-PYC + - inf `; - await watcher.updateAndWaitForExplore(changeDisplayNameDoc); + await watcher.updateAndWaitForExplore( + changeDisplayNameDoc, + "AdBids_model_metrics", + ); // Remove timestamp column // await page.getByLabel("Remove timestamp column").click(); @@ -313,37 +334,33 @@ time_ranges: const addBackTimestampColumnDoc = `# Visit https://docs.rilldata.com/reference/project-files to learn more about Rill project files. - version: 1 - type: metrics_view - title: "AdBids_model_dashboard" - model: "AdBids_model" - default_time_range: "" - smallest_time_grain: "week" - timeseries: "timestamp" - measures: - - label: Total records - expression: count(*) - name: total_records - description: Total number of records present - format_preset: humanize - dimensions: - - name: publisher - label: Publisher - column: publisher - description: "" - - name: domain - label: Domain - column: domain - description: "" - - `; +version: 1 +type: metrics_view +title: "AdBids_model_dashboard" +model: "AdBids_model" +smallest_time_grain: "week" +timeseries: "timestamp" +measures: + - label: Total records + expression: count(*) + name: total_records + description: Total number of records present + format_preset: humanize +dimensions: + - name: publisher + label: Publisher + column: publisher + description: "" + - name: domain + label: Domain + column: domain + description: "" +explore: + display_name: "Adbids dashboard renamed" +`; await page.getByRole("button", { name: "switch to code editor" }).click(); await watcher.updateAndWaitForDashboard(addBackTimestampColumnDoc); - await page.getByRole("button", { name: "Create resource menu" }).click(); - await page - .getByRole("menuitem", { name: "Adbids dashboard renamed" }) - .click(); // Preview await clickPreviewButton(page); @@ -351,40 +368,37 @@ time_ranges: // Assert that time dimension is now week await expect(timeGrainSelector).toHaveText("as of latest week end"); - // Edit Explore + // Edit the metrics view (the explore is defined inline in the same file) await page.getByRole("button", { name: "Edit" }).click(); - await page.getByRole("menuitem", { name: "Explore" }).click(); - - await gotoNavEntry(page, AD_BIDS_METRICS_PATH); + await page.getByRole("menuitem", { name: "Metrics View" }).click(); + await page.getByRole("button", { name: "switch to code editor" }).click(); // Write an incomplete measure const docWithIncompleteMeasure = `# Visit https://docs.rilldata.com/reference/project-files to learn more about Rill project files. - version: 1 - type: metrics_view - title: "AdBids_model_dashboard" - model: "AdBids_model" - default_time_range: "" - smallest_time_grain: "week" - timeseries: "timestamp" - measures: - - label: Avg Bid Price - dimensions: - - name: publisher - label: Publisher - column: publisher - description: "" - - name: domain - label: Domain - column: domain - description: "" - - `; +version: 1 +type: metrics_view +title: "AdBids_model_dashboard" +model: "AdBids_model" +smallest_time_grain: "week" +timeseries: "timestamp" +measures: + - label: Avg Bid Price +dimensions: + - name: publisher + label: Publisher + column: publisher + description: "" + - name: domain + label: Domain + column: domain + description: "" +explore: + display_name: "Adbids dashboard renamed" +`; await updateCodeEditor(page, docWithIncompleteMeasure); - await gotoNavEntry(page, AD_BIDS_EXPLORE_PATH); await expect(page.getByRole("button", { name: "Preview" })).toBeDisabled(); - await gotoNavEntry(page, AD_BIDS_METRICS_PATH); // Complete the measure const docWithCompleteMeasure = `# Visit https://docs.rilldata.com/reference/project-files to learn more about Rill project files. @@ -393,7 +407,6 @@ version: 1 type: metrics_view title: "AdBids_model_dashboard_rename" model: "AdBids_model" -default_time_range: "" smallest_time_grain: "week" timeseries: "timestamp" measures: @@ -415,10 +428,11 @@ dimensions: label: Domain Name column: domain description: "" - `; +explore: + display_name: "Adbids dashboard renamed" +`; await updateCodeEditor(page, docWithCompleteMeasure); - await gotoNavEntry(page, AD_BIDS_EXPLORE_PATH); await expect(page.getByRole("button", { name: "Preview" })).toBeEnabled(); // Preview diff --git a/web-local/tests/explores/inline-explore-editing.spec.ts b/web-local/tests/explores/inline-explore-editing.spec.ts new file mode 100644 index 000000000000..38b1a9886b5f --- /dev/null +++ b/web-local/tests/explores/inline-explore-editing.spec.ts @@ -0,0 +1,149 @@ +import { expect } from "@playwright/test"; +import { test } from "../setup/base"; +import { updateCodeEditor } from "../utils/commonHelpers"; +import { + assertAdBidsDashboard, + createAdBidsModel, +} from "../utils/dataSpecifcHelpers"; +import { createExploreFromModel } from "../utils/exploreHelpers"; +import { gotoNavEntry } from "../utils/waitHelpers"; + +test.describe("inline explore editing", () => { + test.use({ project: "Blank" }); + + test("edit the inline explore from the metrics view workspace", async ({ + page, + }) => { + test.setTimeout(60_000); + + // Generate a metrics view, which defines its explore dashboard inline + await createAdBidsModel(page); + await createExploreFromModel(page); + + // Switch to the explore view via the three-way editor toggle + await page + .getByRole("button", { name: "switch to explore editor" }) + .click(); + + // The visual explore editor renders next to the live dashboard preview + await expect(page.getByText("Edit dashboard")).toBeVisible(); + await assertAdBidsDashboard(page); + + // Restrict measures and dimensions to a subset via the sidebar + await page.getByRole("button", { name: "Subset" }).first().click(); + await page.getByRole("button", { name: "Subset" }).nth(1).click(); + + // The edits land under the `explore:` key of the metrics view file + await page.getByRole("button", { name: "switch to code editor" }).click(); + const editor = page.getByRole("textbox", { name: "codemirror editor" }); + const editorText = () => editor.textContent(); + await expect.poll(editorText, { timeout: 10_000 }).toContain("explore:"); + await expect.poll(editorText).toContain("type: metrics_view"); + + // The explore block sits at the end of the file, which can fall outside + // CodeMirror's virtualized viewport; page down to the end to render it. + await editor.click(); + for (let i = 0; i < 5; i++) { + await page.keyboard.press("PageDown"); + } + await expect.poll(editorText, { timeout: 10_000 }).toContain("- publisher"); + await expect + .poll(editorText, { timeout: 10_000 }) + .toContain("- total_records"); + }); + + test("header CTA offers canvas generation", async ({ page }) => { + test.setTimeout(60_000); + + await createAdBidsModel(page); + await createExploreFromModel(page); + + // The inline explore is reachable via the Preview button and the editor toggle, + // so the CTA only deals in canvases. With none in the project yet, it offers + // canvas generation, but neither "Go to dashboard" nor another explore. + await expect( + page.getByRole("button", { name: "Generate Canvas Dashboard" }), + ).toBeVisible({ timeout: 10_000 }); + await expect( + page.getByRole("button", { name: "Generate Explore Dashboard" }), + ).toHaveCount(0); + await expect(page.getByRole("link", { name: /Go to/ })).toHaveCount(0); + }); +}); + +test.describe("inline explore go-to-dashboard CTA", () => { + test.use({ project: "AdBids" }); + + test("lists the canvas dashboards of the metrics view", async ({ page }) => { + test.setTimeout(60_000); + + // Define the explore inline in the existing metrics view, which the project's + // canvas dashboard is built on + await page.getByLabel("/metrics").click(); + await gotoNavEntry(page, "/metrics/AdBids_metrics.yaml"); + await page.getByRole("button", { name: "switch to code editor" }).click(); + await updateCodeEditor(page, METRICS_VIEW_WITH_INLINE_EXPLORE); + + // The header CTA links to the metrics view's canvas dashboards + await expect( + page.getByRole("link", { name: "Go to canvas dashboard" }), + ).toBeVisible({ timeout: 10_000 }); + await page.getByRole("button", { name: "Create resource menu" }).click(); + await expect( + page.getByRole("menuitem", { name: "Adbids Canvas Dashboard" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Generate Canvas Dashboard" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Generate Explore Dashboard" }), + ).toHaveCount(0); + + // The project's standalone explore is not listed; the CTA only deals in canvases + await expect( + page.getByRole("menuitem", { name: "Adbids dashboard" }), + ).toHaveCount(0); + + await page + .getByRole("menuitem", { name: "Adbids Canvas Dashboard" }) + .click(); + await page.waitForURL("**/files/dashboards/AdBids_metrics_canvas.yaml"); + }); +}); + +const METRICS_VIEW_WITH_INLINE_EXPLORE = `version: 1 +type: metrics_view + +display_name: Adbids +table: AdBids_model +timeseries: timestamp + +dimensions: + - name: publisher + display_name: Publisher + column: publisher + - name: domain + display_name: Domain + column: domain + - name: timestamp + display_name: Timestamp + column: timestamp + type: time + - name: offset_timestamp + display_name: Offset Timestamp + column: offset_timestamp + type: time + +measures: + - name: total_records + display_name: Total records + expression: COUNT(*) + format_preset: humanize + - name: bid_price_sum + display_name: Sum of Bid Price + expression: SUM(bid_price) + format_preset: humanize + +explore: + display_name: Adbids Inline Explore +`; diff --git a/web-local/tests/utils/dataSpecifcHelpers.ts b/web-local/tests/utils/dataSpecifcHelpers.ts index ace6f1c13092..04c07927dcf0 100644 --- a/web-local/tests/utils/dataSpecifcHelpers.ts +++ b/web-local/tests/utils/dataSpecifcHelpers.ts @@ -19,8 +19,6 @@ export interface TimeSeriesResponse { } export const AD_BIDS_METRICS_PATH = "/metrics/AdBids_model_metrics.yaml"; -export const AD_BIDS_EXPLORE_PATH = - "/dashboards/AdBids_model_metrics_explore.yaml"; export async function createAdBidsModel(page: Page) { await Promise.all([ diff --git a/web-local/tests/utils/exploreHelpers.ts b/web-local/tests/utils/exploreHelpers.ts index bf2706a0c9bc..689a6a5dfe21 100644 --- a/web-local/tests/utils/exploreHelpers.ts +++ b/web-local/tests/utils/exploreHelpers.ts @@ -17,6 +17,8 @@ export async function clickPreviewButton(page: Page, timeout = 10_000) { await previewButton.click(); } +// Generated metrics views define their explore dashboard inline, so generating +// metrics is enough to get an explore; these helpers optionally open its preview. export async function createExploreFromSource( page: Page, sourcePath = "/models/AdBids.yaml", @@ -25,7 +27,7 @@ export async function createExploreFromSource( await openFileNavEntryContextMenu(page, sourcePath); await clickMenuButton(page, "Generate metrics"); await waitForFileNavEntry(page, metricsViewPath, true); - await page.getByText("Generate Explore Dashboard").click(); + await clickPreviewButton(page); } export async function createExploreFromModel( @@ -37,7 +39,6 @@ export async function createExploreFromModel( await openFileNavEntryContextMenu(page, modelPath); await clickMenuButton(page, "Generate metrics"); await waitForFileNavEntry(page, metricsViewPath, true); - await page.getByText("Generate Explore Dashboard").click(); if (navigateToPreview) { await clickPreviewButton(page);