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_explore.go b/runtime/parser/parse_explore.go index cb22a4227a2c..49c1b0474c6a 100644 --- a/runtime/parser/parse_explore.go +++ b/runtime/parser/parse_explore.go @@ -13,12 +13,21 @@ import ( ) type ExploreYAML struct { - commonYAML `yaml:",inline"` // Not accessed here, only setting it so we can use KnownFields for YAML parsing + commonYAML `yaml:",inline"` // Not accessed here, only setting it so we can use KnownFields for YAML parsing + ExploreDefinitionYAML `yaml:",inline"` + Title string `yaml:"title"` // Deprecated: use display_name + MetricsView string `yaml:"metrics_view"` + Security *SecurityPolicyYAML `yaml:"security"` +} + +// ExploreDefinitionYAML contains the explore definition fields that are shared between +// standalone explore files (ExploreYAML) and the inline `explore:` block in a metrics view YAML. +// Fields added here automatically become available in both places; +// they are parsed and validated by parseExploreDefinition. +type ExploreDefinitionYAML struct { DisplayName string `yaml:"display_name"` - Title string `yaml:"title"` // Deprecated: use display_name Description string `yaml:"description"` Banner string `yaml:"banner"` - MetricsView string `yaml:"metrics_view"` Dimensions *FieldSelectorYAML `yaml:"dimensions"` Measures *FieldSelectorYAML `yaml:"measures"` Theme yaml.Node `yaml:"theme"` // Name (string) or inline theme definition (map) @@ -26,17 +35,19 @@ type ExploreYAML struct { TimeZones []string `yaml:"time_zones"` // Single time zone or list of time zones LockTimeZone bool `yaml:"lock_time_zone"` AllowCustomTimeRange *bool `yaml:"allow_custom_time_range"` - Defaults *struct { - Dimensions *FieldSelectorYAML `yaml:"dimensions"` - Measures *FieldSelectorYAML `yaml:"measures"` - TimeRange string `yaml:"time_range"` - ComparisonMode string `yaml:"comparison_mode"` - ComparisonDimension string `yaml:"comparison_dimension"` - } `yaml:"defaults"` - Embeds struct { + Defaults *ExploreDefaultsYAML `yaml:"defaults"` + Embeds struct { HidePivot bool `yaml:"hide_pivot"` } `yaml:"embeds"` - Security *SecurityPolicyYAML `yaml:"security"` +} + +// ExploreDefaultsYAML represents the `defaults` block of an explore definition. +type ExploreDefaultsYAML struct { + Dimensions *FieldSelectorYAML `yaml:"dimensions"` + Measures *FieldSelectorYAML `yaml:"measures"` + TimeRange string `yaml:"time_range"` + ComparisonMode string `yaml:"comparison_mode"` + ComparisonDimension string `yaml:"comparison_dimension"` } // ExploreTimeRangeYAML represents a time range in an ExploreYAML. @@ -138,78 +149,126 @@ func (p *Parser) parseExplore(node *Node) error { tmp.DisplayName = tmp.Title } - // Set default for AllowCustomTimeRange to true if not provided - allowCustomTimeRange := true - if tmp.AllowCustomTimeRange != nil { - allowCustomTimeRange = *tmp.AllowCustomTimeRange - } - // Validate metrics_view if tmp.MetricsView == "" { return errors.New("metrics_view is required") } node.Refs = append(node.Refs, ResourceName{Kind: ResourceKindMetricsView, Name: tmp.MetricsView}) + // Parse and validate the fields shared with inline explore definitions + def, err := p.parseExploreDefinition(&tmp.ExploreDefinitionYAML) + if err != nil { + return err + } + if def.themeName != "" && def.themeSpec == nil { + node.Refs = append(node.Refs, ResourceName{Kind: ResourceKindTheme, Name: def.themeName}) + } + + // Build security rules + rules, err := tmp.Security.Proto() + if err != nil { + return err + } + for _, rule := range rules { + if rule.GetAccess() == nil { + return fmt.Errorf("the 'explore' resource type only supports 'access' security rules") + } + } + + // Track explore + r, err := p.insertResource(ResourceKindExplore, node.Name, node.Paths, node.Tags, node.Refs...) + if err != nil { + return err + } + // NOTE: After calling insertResource, an error must not be returned. Any validation should be done before calling it. + + def.applyToSpec(r.ExploreSpec, &tmp.ExploreDefinitionYAML) + if r.ExploreSpec.DisplayName == "" { + r.ExploreSpec.DisplayName = ToDisplayName(node.Name) + } + r.ExploreSpec.MetricsView = tmp.MetricsView + r.ExploreSpec.SecurityRules = rules + + return nil +} + +// exploreDefinition holds validated values derived from an ExploreDefinitionYAML by parseExploreDefinition. +// It exists because validation must happen before insertResource while spec assignment happens after; +// applyToSpec performs the assignment. +type exploreDefinition struct { + dimensions []string + dimensionsSelector *runtimev1.FieldSelector + measures []string + measuresSelector *runtimev1.FieldSelector + themeName string + themeSpec *runtimev1.ThemeSpec + timeRanges []*runtimev1.ExploreTimeRange + defaultPreset *runtimev1.ExplorePreset + allowCustomTimeRange bool +} + +// parseExploreDefinition parses and validates the explore definition fields shared between +// standalone explores and inline explores in metrics views. +// It must be called before insertResource since it can return validation errors. +func (p *Parser) parseExploreDefinition(tmp *ExploreDefinitionYAML) (*exploreDefinition, error) { + def := &exploreDefinition{} + // Parse the dimensions and measures selectors - var dimensionsSelector *runtimev1.FieldSelector - dimensions, ok := tmp.Dimensions.TryResolve() + var ok bool + def.dimensions, ok = tmp.Dimensions.TryResolve() if !ok { - dimensionsSelector = tmp.Dimensions.Proto() + def.dimensionsSelector = tmp.Dimensions.Proto() } - var measuresSelector *runtimev1.FieldSelector - measures, ok := tmp.Measures.TryResolve() + def.measures, ok = tmp.Measures.TryResolve() if !ok { - measuresSelector = tmp.Measures.Proto() + def.measuresSelector = tmp.Measures.Proto() } // Parse theme if present. - // If it returns a themeSpec, it will be inserted as a separate resource later in this function. + // If it returns a themeSpec, the caller must insert it as a separate resource. themeName, themeSpec, err := p.parseThemeRef(&tmp.Theme) if err != nil { - return err + return nil, err } // Fallback to top-level theme from rill.yaml if no local theme or default theme is set if themeName == "" && themeSpec == nil && p.RillYAML != nil && p.RillYAML.Theme != "" { themeName = p.RillYAML.Theme } - if themeName != "" && themeSpec == nil { - node.Refs = append(node.Refs, ResourceName{Kind: ResourceKindTheme, Name: themeName}) - } + def.themeName = themeName + def.themeSpec = themeSpec // Build and validate time ranges - var timeRanges []*runtimev1.ExploreTimeRange for _, tr := range tmp.TimeRanges { if _, err := rilltime.Parse(tr.Range, rilltime.ParseOptions{}); err != nil { - return fmt.Errorf("invalid time range %q: %w", tr.Range, err) + return nil, fmt.Errorf("invalid time range %q: %w", tr.Range, err) } res := &runtimev1.ExploreTimeRange{Range: tr.Range} for _, ctr := range tr.ComparisonTimeRanges { - err = rilltime.ParseCompatibility(ctr.Range, ctr.Offset) + err := rilltime.ParseCompatibility(ctr.Range, ctr.Offset) if err != nil { - return err + return nil, err } res.ComparisonTimeRanges = append(res.ComparisonTimeRanges, &runtimev1.ExploreComparisonTimeRange{ Offset: ctr.Offset, Range: ctr.Range, }) } - timeRanges = append(timeRanges, res) + def.timeRanges = append(def.timeRanges, res) } // Validate time zones for _, tz := range tmp.TimeZones { _, err := time.LoadLocation(tz) if err != nil { - return err + return nil, err } } // Build and validate presets - var defaultPreset *runtimev1.ExplorePreset if tmp.Defaults != nil { if tmp.Defaults.TimeRange != "" { if _, err := rilltime.Parse(tmp.Defaults.TimeRange, rilltime.ParseOptions{}); err != nil { - return fmt.Errorf("invalid time range %q: %w", tmp.Defaults.TimeRange, err) + return nil, fmt.Errorf("invalid time range %q: %w", tmp.Defaults.TimeRange, err) } } @@ -218,12 +277,12 @@ func (p *Parser) parseExplore(node *Node) error { var ok bool mode, ok = exploreComparisonModes[tmp.Defaults.ComparisonMode] if !ok { - return fmt.Errorf("invalid comparison mode %q (options: %s)", tmp.Defaults.ComparisonMode, strings.Join(maps.Keys(exploreComparisonModes), ", ")) + return nil, fmt.Errorf("invalid comparison mode %q (options: %s)", tmp.Defaults.ComparisonMode, strings.Join(maps.Keys(exploreComparisonModes), ", ")) } } if tmp.Defaults.ComparisonDimension != "" && mode != runtimev1.ExploreComparisonMode_EXPLORE_COMPARISON_MODE_DIMENSION { - return errors.New("can only set comparison_dimension when comparison_mode is 'dimension'") + return nil, errors.New("can only set comparison_dimension when comparison_mode is 'dimension'") } var presetDimensionsSelector *runtimev1.FieldSelector @@ -246,7 +305,7 @@ func (p *Parser) parseExplore(node *Node) error { if tmp.Defaults.ComparisonDimension != "" { compareDim = &tmp.Defaults.ComparisonDimension } - defaultPreset = &runtimev1.ExplorePreset{ + def.defaultPreset = &runtimev1.ExplorePreset{ Dimensions: presetDimensions, DimensionsSelector: presetDimensionsSelector, Measures: presetMeasures, @@ -257,46 +316,33 @@ func (p *Parser) parseExplore(node *Node) error { } } - // Build security rules - rules, err := tmp.Security.Proto() - if err != nil { - return err - } - for _, rule := range rules { - if rule.GetAccess() == nil { - return fmt.Errorf("the 'explore' resource type only supports 'access' security rules") - } + // Set default for AllowCustomTimeRange to true if not provided + def.allowCustomTimeRange = true + if tmp.AllowCustomTimeRange != nil { + def.allowCustomTimeRange = *tmp.AllowCustomTimeRange } - // Track explore - r, err := p.insertResource(ResourceKindExplore, node.Name, node.Paths, node.Tags, node.Refs...) - if err != nil { - return err - } - // NOTE: After calling insertResource, an error must not be returned. Any validation should be done before calling it. - - r.ExploreSpec.DisplayName = tmp.DisplayName - if r.ExploreSpec.DisplayName == "" { - r.ExploreSpec.DisplayName = ToDisplayName(node.Name) - } - r.ExploreSpec.Description = tmp.Description - r.ExploreSpec.MetricsView = tmp.MetricsView - r.ExploreSpec.Banner = tmp.Banner - 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 - r.ExploreSpec.TimeZones = tmp.TimeZones - r.ExploreSpec.DefaultPreset = defaultPreset - r.ExploreSpec.EmbedsHidePivot = tmp.Embeds.HidePivot - r.ExploreSpec.SecurityRules = rules - r.ExploreSpec.LockTimeZone = tmp.LockTimeZone - r.ExploreSpec.AllowCustomTimeRange = allowCustomTimeRange + return def, nil +} - return nil +// applyToSpec assigns the parsed definition values to an ExploreSpec. +// It must only be called after the explore resource has been inserted. +func (d *exploreDefinition) applyToSpec(spec *runtimev1.ExploreSpec, tmp *ExploreDefinitionYAML) { + spec.DisplayName = tmp.DisplayName + spec.Description = tmp.Description + spec.Banner = tmp.Banner + spec.Dimensions = d.dimensions + spec.DimensionsSelector = d.dimensionsSelector + spec.Measures = d.measures + spec.MeasuresSelector = d.measuresSelector + spec.Theme = d.themeName + spec.EmbeddedTheme = d.themeSpec + spec.TimeRanges = d.timeRanges + spec.TimeZones = tmp.TimeZones + spec.DefaultPreset = d.defaultPreset + spec.EmbedsHidePivot = tmp.Embeds.HidePivot + spec.LockTimeZone = tmp.LockTimeZone + spec.AllowCustomTimeRange = d.allowCustomTimeRange } // parseThemeRef parses a theme from a YAML node. diff --git a/runtime/parser/parse_metrics_view.go b/runtime/parser/parse_metrics_view.go index d22d7d3b1899..25d51fc7e67b 100644 --- a/runtime/parser/parse_metrics_view.go +++ b/runtime/parser/parse_metrics_view.go @@ -10,7 +10,6 @@ import ( runtimev1 "github.com/rilldata/rill/proto/gen/rill/runtime/v1" "github.com/rilldata/rill/runtime/pkg/duration" "github.com/rilldata/rill/runtime/pkg/rilltime" - "golang.org/x/exp/maps" "google.golang.org/protobuf/types/known/structpb" "gopkg.in/yaml.v3" @@ -105,26 +104,9 @@ type MetricsViewYAML struct { TimestampsTTL string `yaml:"timestamps_ttl"` // fallback TTL for timestamp caching when MV-level cache is disabled; defaults to 5m } `yaml:"cache"` Explore *struct { - Skip bool `yaml:"skip"` - Name string `yaml:"name"` // Name of the explore, defaults to the metrics view name - DisplayName string `yaml:"display_name"` - Description string `yaml:"description"` - Banner string `yaml:"banner"` - 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 - LockTimeZone bool `yaml:"lock_time_zone"` - AllowCustomTimeRange *bool `yaml:"allow_custom_time_range"` - Defaults *struct { - Dimensions *FieldSelectorYAML `yaml:"dimensions"` - Measures *FieldSelectorYAML `yaml:"measures"` - TimeRange string `yaml:"time_range"` - ComparisonMode string `yaml:"comparison_mode"` - ComparisonDimension string `yaml:"comparison_dimension"` - } `yaml:"defaults"` - Embeds struct { - HidePivot bool `yaml:"hide_pivot"` - } `yaml:"embeds"` + ExploreDefinitionYAML `yaml:",inline"` + Skip bool `yaml:"skip"` + Name string `yaml:"name"` // Name of the explore, defaults to the metrics view name } `yaml:"explore"` // DEPRECATED FIELDS @@ -1042,101 +1024,15 @@ func (p *Parser) parseAndInsertInlineExplore(tmp *MetricsViewYAML, mvName string return false, nil, fmt.Errorf("setting defaults or available time zones or ranges under metrics view is deprecated, set them under explore key") } - var timeRanges []*runtimev1.ExploreTimeRange - for _, tr := range tmp.Explore.TimeRanges { - if _, err := rilltime.Parse(tr.Range, rilltime.ParseOptions{}); err != nil { - return false, nil, fmt.Errorf("invalid time range %q: %w", tr.Range, err) - } - res := &runtimev1.ExploreTimeRange{Range: tr.Range} - for _, ctr := range tr.ComparisonTimeRanges { - err := rilltime.ParseCompatibility(ctr.Range, ctr.Offset) - if err != nil { - return false, nil, err - } - res.ComparisonTimeRanges = append(res.ComparisonTimeRanges, &runtimev1.ExploreComparisonTimeRange{ - Offset: ctr.Offset, - Range: ctr.Range, - }) - } - timeRanges = append(timeRanges, res) - } - - // Validate time zones - for _, tz := range tmp.Explore.TimeZones { - _, err := time.LoadLocation(tz) - if err != nil { - return false, nil, err - } - } - - // Build and validate presets - var defaultPreset *runtimev1.ExplorePreset - if tmp.Explore.Defaults != nil { - if tmp.Explore.Defaults.TimeRange != "" { - if _, err := rilltime.Parse(tmp.Explore.Defaults.TimeRange, rilltime.ParseOptions{}); err != nil { - return false, nil, fmt.Errorf("invalid time range %q: %w", tmp.Explore.Defaults.TimeRange, err) - } - } - - mode := runtimev1.ExploreComparisonMode_EXPLORE_COMPARISON_MODE_NONE - if tmp.Explore.Defaults.ComparisonMode != "" { - var ok bool - mode, ok = exploreComparisonModes[tmp.Explore.Defaults.ComparisonMode] - if !ok { - return false, nil, fmt.Errorf("invalid comparison mode %q (options: %s)", tmp.Explore.Defaults.ComparisonMode, strings.Join(maps.Keys(exploreComparisonModes), ", ")) - } - } - - if tmp.Explore.Defaults.ComparisonDimension != "" && mode != runtimev1.ExploreComparisonMode_EXPLORE_COMPARISON_MODE_DIMENSION { - return false, nil, errors.New("can only set comparison_dimension when comparison_mode is 'dimension'") - } - - var presetDimensionsSelector *runtimev1.FieldSelector - presetDimensions, ok := tmp.Explore.Defaults.Dimensions.TryResolve() - if !ok { - presetDimensionsSelector = tmp.Explore.Defaults.Dimensions.Proto() - } - - var presetMeasuresSelector *runtimev1.FieldSelector - presetMeasures, ok := tmp.Explore.Defaults.Measures.TryResolve() - if !ok { - presetMeasuresSelector = tmp.Explore.Defaults.Measures.Proto() - } - - var tr *string - if tmp.Explore.Defaults.TimeRange != "" { - tr = &tmp.Explore.Defaults.TimeRange - } - var compareDim *string - if tmp.Explore.Defaults.ComparisonDimension != "" { - compareDim = &tmp.Explore.Defaults.ComparisonDimension - } - defaultPreset = &runtimev1.ExplorePreset{ - Dimensions: presetDimensions, - DimensionsSelector: presetDimensionsSelector, - Measures: presetMeasures, - MeasuresSelector: presetMeasuresSelector, - TimeRange: tr, - ComparisonMode: mode, - ComparisonDimension: compareDim, - } - } - - // Set default for AllowCustomTimeRange to true if not provided - allowCustomTimeRange := true - if tmp.Explore.AllowCustomTimeRange != nil { - allowCustomTimeRange = *tmp.Explore.AllowCustomTimeRange - } - - 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. - themeName, themeSpec, err := p.parseThemeRef(&tmp.Explore.Theme) + // Parse and validate the fields shared with standalone explore files + def, err := p.parseExploreDefinition(&tmp.Explore.ExploreDefinitionYAML) if err != nil { return false, nil, err } - if themeName != "" && themeSpec == nil { - refs = append(refs, ResourceName{Kind: ResourceKindTheme, Name: themeName}) + + refs := []ResourceName{{Kind: ResourceKindMetricsView, Name: mvName}} + if def.themeName != "" && def.themeSpec == nil { + refs = append(refs, ResourceName{Kind: ResourceKindTheme, Name: def.themeName}) } // before inserting inline explore, dry run inserting the parent metrics view resource to ensure that the explore can be inserted @@ -1155,23 +1051,11 @@ func (p *Parser) parseAndInsertInlineExplore(tmp *MetricsViewYAML, mvName string return false, nil, err } // NOTE: After calling insertResource, an error must not be returned. Any validation should be done before calling it. - r.ExploreSpec.DisplayName = tmp.Explore.DisplayName + def.applyToSpec(r.ExploreSpec, &tmp.Explore.ExploreDefinitionYAML) if r.ExploreSpec.DisplayName == "" { r.ExploreSpec.DisplayName = ToDisplayName(name) } - 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.Theme = themeName - r.ExploreSpec.EmbeddedTheme = themeSpec - r.ExploreSpec.TimeRanges = timeRanges - r.ExploreSpec.TimeZones = tmp.Explore.TimeZones - r.ExploreSpec.DefaultPreset = defaultPreset - r.ExploreSpec.EmbedsHidePivot = tmp.Explore.Embeds.HidePivot - r.ExploreSpec.LockTimeZone = tmp.Explore.LockTimeZone - r.ExploreSpec.AllowCustomTimeRange = allowCustomTimeRange r.ExploreSpec.DefinedInMetricsView = true return true, r, nil 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-admin/src/routes/[organization]/[project]/-/edit/(workspace)/files/[...file]/+page.ts b/web-admin/src/routes/[organization]/[project]/-/edit/(workspace)/files/[...file]/+page.ts index d6920382bac7..84fee30c9119 100644 --- a/web-admin/src/routes/[organization]/[project]/-/edit/(workspace)/files/[...file]/+page.ts +++ b/web-admin/src/routes/[organization]/[project]/-/edit/(workspace)/files/[...file]/+page.ts @@ -1,10 +1,17 @@ import { addLeadingSlash } from "@rilldata/web-common/features/entity-management/entity-mappers.js"; import { fileArtifacts } from "@rilldata/web-common/features/entity-management/file-artifacts.js"; -import { error } from "@sveltejs/kit"; +import { consumeViewSearchParam } from "@rilldata/web-common/layout/workspace/workspace-stores"; +import { error, redirect } from "@sveltejs/kit"; -export const load = async ({ params: { file }, parent }) => { +export const load = async ({ params: { file }, parent, url }) => { await parent(); const path = addLeadingSlash(file); + + const urlWithoutViewParam = consumeViewSearchParam(url, path); + if (urlWithoutViewParam) { + throw redirect(307, urlWithoutViewParam); + } + const fileArtifact = fileArtifacts.getFileArtifact(path); if (fileArtifact.fileTypeUnsupported) { diff --git a/web-common/src/features/dashboards/selectors.ts b/web-common/src/features/dashboards/selectors.ts index f2e76b6cb5aa..1efb46af765c 100644 --- a/web-common/src/features/dashboards/selectors.ts +++ b/web-common/src/features/dashboards/selectors.ts @@ -258,3 +258,51 @@ export const useGetExploresForMetricsView = ( (res) => res.explore?.spec?.metricsView === metricsViewName, ); }; + +// Canvases don't reference metrics views directly: each canvas has refs to its +// component resources, whose refs in turn name the metrics view they query. +// Walking the resource graph refs (canvas → component → metrics view) delegates +// the knowledge of how components reference metrics views to the parser. +// Note: components that only reference a metrics view through a `metrics_sql` +// query get no metrics view ref from the parser and are missed here; full parity +// with the backend's runtime.ResolveCanvas would require a reverse-lookup API. +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.meta?.refs?.some( + (ref) => + ref.kind === ResourceKind.MetricsView && + ref.name === metricsViewName, + ), + ) + .map((res) => res.meta?.name?.name), + ); + return ( + data.resources?.filter( + (res) => + res.meta?.name?.kind === ResourceKind.Canvas && + res.meta?.refs?.some( + (ref) => + ref.kind === ResourceKind.Component && + componentNames.has(ref.name), + ), + ) ?? [] + ); + }, + }, + }, + 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..c6d084c0c144 100644 --- a/web-common/src/features/explores/ExploreEditDropdown.svelte +++ b/web-common/src/features/explores/ExploreEditDropdown.svelte @@ -19,6 +19,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 ?view= param selects what gets edited. + let definedInMetricsView = $derived( + $exploreQuery.data?.explore?.explore?.state?.validSpec + ?.definedInMetricsView ?? false, + ); @@ -31,11 +37,21 @@ {/snippet} - + Explore dashboard - + 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..5f5655118ed7 100644 --- a/web-common/src/features/metrics-views/GoToDashboardButton.svelte +++ b/web-common/src/features/metrics-views/GoToDashboardButton.svelte @@ -2,33 +2,59 @@ 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 { 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 "Create 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; the ?view=explore param opens the workspace on the + // explore view. + function dashboardHref(dashboard: V1Resource): string { + const filePath = `/${removeLeadingSlash(dashboard?.meta?.filePaths?.[0] ?? "")}`; + const definedInMetricsView = + dashboard?.explore?.state?.validSpec?.definedInMetricsView; + return getFileHref(filePath, definedInMetricsView ? "explore" : undefined); + } 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 +81,50 @@ > 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} - - + + {#if isCanvas} + + {:else} + + {/if} {label} {/if} @@ -116,20 +151,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, + ); + }} + > + + Create Explore Dashboard + + {/if} diff --git a/web-common/src/features/metrics-views/MetricsViewMenuItems.svelte b/web-common/src/features/metrics-views/MetricsViewMenuItems.svelte index 8636dffdad4c..baf635b7b102 100644 --- a/web-common/src/features/metrics-views/MetricsViewMenuItems.svelte +++ b/web-common/src/features/metrics-views/MetricsViewMenuItems.svelte @@ -22,7 +22,10 @@ createCanvasDashboardFromMetricsView, createCanvasDashboardFromMetricsViewWithAgent, } from "./ai-generation/generateMetricsView"; - import { createAndPreviewExplore } from "./create-and-preview-explore"; + import { + enableInlineExploreAndPreview, + parseInlineExploreState, + } from "./inline-explore"; const runtimeClient = useRuntimeClient(); const { ai, developerChat } = featureFlags; @@ -31,10 +34,16 @@ $: fileArtifact = fileArtifacts.getFileArtifact(filePath); - $: ({ instanceId } = runtimeClient); $: resourceQuery = fileArtifact.getResource(queryClient); $: resource = $resourceQuery.data; + $: ({ editorContent, remoteContent } = fileArtifact); + // Legacy (unversioned) metrics views auto-emit an explore, so there is nothing + // to create; the explore item is only shown for latest-version metrics views. + $: ({ isLatestVersion, exploreEnabled } = parseInlineExploreState( + $editorContent ?? $remoteContent, + )); + /** * Get the name of the dashboard's underlying model (if any). * Note that not all dashboards have an underlying model. Some dashboards are @@ -119,24 +128,12 @@ {/if} - {#if resource} + {#if resource && isLatestVersion} - createAndPreviewExplore( - runtimeClient, - queryClient, - instanceId, - resource, - )} + onclick={() => enableInlineExploreAndPreview(queryClient, filePath)} > -
- Generate Explore Dashboard - {#if $ai} - with AI - - {/if} -
+ {exploreEnabled ? "Edit Explore Dashboard" : "Create Explore Dashboard"}
{/if} {:else} diff --git a/web-common/src/features/metrics-views/NavigateOrDropdown.svelte b/web-common/src/features/metrics-views/NavigateOrDropdown.svelte index 0f7b8f88f020..1364d20efc80 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, + hrefForResource = undefined, ...triggerProps }: { resources: V1Resource[]; + hrefForResource?: (resource: V1Resource) => string; [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, + ), ); @@ -30,9 +36,10 @@ class="border-accent-primary-action flex items-center border h-7 rounded-[2px] bg-transparent text-accent-primary-action" > 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..862af3f472da 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,9 @@ 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); + // Open the generated dashboard; previewInlineExplore inspects the generated + // YAML and falls back to the metrics view if no inline explore was emitted. + await previewInlineExplore(queryClient, newMetricsViewFilePath); } catch (err) { eventBus.emit("notification", { message: @@ -462,26 +446,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 +547,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 +695,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 +711,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 +732,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 +750,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 +766,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 +785,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..caea3d5bac94 --- /dev/null +++ b/web-common/src/features/metrics-views/inline-explore.ts @@ -0,0 +1,123 @@ +import { goto } from "$app/navigation"; +import type { QueryClient } from "@tanstack/svelte-query"; +import { get } from "svelte/store"; +import { YAMLMap, parseDocument } from "yaml"; +import { + getFileHref, + navigateToExplore, + withEditorPrefix, +} from "../../layout/navigation/editor-routing"; +import { previewModeStore } from "../../layout/preview-mode-store"; +import type { WorkspaceView } from "../../layout/workspace/workspace-stores"; +import { waitUntil } from "../../lib/waitUtils"; +import { fileArtifacts } from "../entity-management/file-artifacts"; + +// Views supported by the metrics view workspace: the base views plus the inline +// explore dashboard editor. +export type MetricsWorkspaceView = WorkspaceView | "explore"; + +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. It routes based on the file's actual YAML rather +// than assuming an inline explore exists (e.g. AI generation is not guaranteed to +// emit one): without an inline explore it falls back to the metrics view itself. +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 { isLatestVersion, exploreEnabled, exploreName } = + parseInlineExploreState( + get(artifact.editorContent) ?? get(artifact.remoteContent), + ); + + if (get(previewModeStore)) { + // Legacy metrics views auto-emit an explore named after the metrics view. + if (exploreEnabled || !isLatestVersion) { + await navigateToExplore(exploreName ?? metricsViewName); + } else { + await goto(withEditorPrefix("/dashboards")); + } + return; + } + + await goto(getFileHref(filePath, exploreEnabled ? "explore" : undefined)); +} + +// 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..b2e607661614 100644 --- a/web-common/src/features/visual-editing/CodeToggle.svelte +++ b/web-common/src/features/visual-editing/CodeToggle.svelte @@ -1,3 +1,15 @@ + +
- {#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..7edb6a4bf3ee 100644 --- a/web-common/src/features/workspaces/MetricsWorkspace.svelte +++ b/web-common/src/features/workspaces/MetricsWorkspace.svelte @@ -1,25 +1,44 @@ - - - {#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/navigation/editor-routing.ts b/web-common/src/layout/navigation/editor-routing.ts index 9b4eb534a813..52395aca072d 100644 --- a/web-common/src/layout/navigation/editor-routing.ts +++ b/web-common/src/layout/navigation/editor-routing.ts @@ -22,8 +22,10 @@ export function navigateToFile( return goto(withEditorPrefix(`/files${filePath}`), options); } -export function getFileHref(filePath: string): string { - return withEditorPrefix(`/files${filePath}`); +// The optional view selects the workspace view to open the file on; +// it is consumed by the files route (see consumeViewSearchParam). +export function getFileHref(filePath: string, view?: string): string { + return withEditorPrefix(`/files${filePath}${view ? `?view=${view}` : ""}`); } export function navigateToHome(options?: Parameters[1]) { diff --git a/web-common/src/layout/workspace/WorkspaceHeader.svelte b/web-common/src/layout/workspace/WorkspaceHeader.svelte index bc497d43e925..3e6ee1d2c8ce 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]>; @@ -51,7 +55,8 @@ let editing = $state(false); let value = $derived(titleInput); - let workspaceLayout = $derived(workspaces.get(filePath)); + // Typed as string since some workspaces extend the base view union (e.g. "explore"). + let workspaceLayout = $derived(workspaces.get(filePath)); let inspectorVisible = $derived(workspaceLayout.inspector.visible); let tableVisible = $derived(workspaceLayout.table.visible); let view = $derived(workspaceLayout.view); @@ -79,7 +84,11 @@
{#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..ce28c532cdc2 100644 --- a/web-common/src/layout/workspace/workspace-stores.ts +++ b/web-common/src/layout/workspace/workspace-stores.ts @@ -5,7 +5,12 @@ import { DEFAULT_PREVIEW_TABLE_HEIGHT, } from "../config"; -type WorkspaceLayout = { +// Views available in every workspace. Individual workspaces can support additional +// views by passing an extended union to `workspaces.get` (e.g. the metrics view +// workspace adds an "explore" view). +export type WorkspaceView = "code" | "split" | "viz"; + +type WorkspaceLayout = { inspector: { width: number; visible: boolean; @@ -14,21 +19,21 @@ type WorkspaceLayout = { height: number; visible: boolean; }; - view: "code" | "split" | "viz"; + view: View; }; -class WorkspaceLayoutStore { +class WorkspaceLayoutStore { private inspectorVisible = writable(true); 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" as View); constructor(key: string) { const history = localStorage.getItem(key); if (history) { - const parsed = JSON.parse(history) as WorkspaceLayout; + const parsed = JSON.parse(history) as WorkspaceLayout; this.inspectorVisible.set(parsed?.inspector?.visible ?? true); this.inspectorWidth.set( parsed?.inspector?.width ?? DEFAULT_INSPECTOR_WIDTH, @@ -41,7 +46,8 @@ class WorkspaceLayoutStore { } const debouncer = debounce( - (v: WorkspaceLayout) => localStorage.setItem(key, JSON.stringify(v)), + (v: WorkspaceLayout) => + localStorage.setItem(key, JSON.stringify(v)), 500, ); @@ -63,7 +69,7 @@ class WorkspaceLayoutStore { $tableVisible, $view, ]) => { - const layout: WorkspaceLayout = { + const layout: WorkspaceLayout = { inspector: { visible: $inspectorVisible, width: $inspectorWidth, @@ -100,9 +106,11 @@ class WorkspaceLayoutStore { } class Workspaces { - private workspaces = new Map(); + private workspaces = new Map>(); - get = (context: string) => { + get = ( + context: string, + ): WorkspaceLayoutStore => { let store = this.workspaces.get(context); if (!store) { @@ -110,8 +118,27 @@ class Workspaces { this.workspaces.set(context, store); } - return store; + return store as WorkspaceLayoutStore; }; } export const workspaces = new Workspaces(); + +// consumeViewSearchParam handles the `view` search param on file routes: links can +// append `?view=` to open a file's workspace on a specific view (e.g. +// `?view=explore` for the explore editor of a metrics view file). It stores the view +// for the file and returns the URL to redirect to with the param removed, or null if +// the param is not present. Called from the files `+page.ts` load functions. +export function consumeViewSearchParam( + url: URL, + filePath: string, +): string | null { + const view = url.searchParams.get("view"); + if (!view) return null; + + workspaces.get(filePath).view.set(view); + + const clean = new URL(url); + clean.searchParams.delete("view"); + return clean.pathname + clean.search; +} diff --git a/web-local/src/routes/(application)/(workspace)/files/[...file]/+page.ts b/web-local/src/routes/(application)/(workspace)/files/[...file]/+page.ts index e66e3a78e9a0..9246c4bd2746 100644 --- a/web-local/src/routes/(application)/(workspace)/files/[...file]/+page.ts +++ b/web-local/src/routes/(application)/(workspace)/files/[...file]/+page.ts @@ -1,10 +1,11 @@ import { addLeadingSlash } from "@rilldata/web-common/features/entity-management/entity-mappers.js"; import { fileArtifacts } from "@rilldata/web-common/features/entity-management/file-artifacts.js"; import { featureFlags } from "@rilldata/web-common/features/feature-flags"; +import { consumeViewSearchParam } from "@rilldata/web-common/layout/workspace/workspace-stores"; import { error, redirect } from "@sveltejs/kit"; import { get } from "svelte/store"; -export const load = async ({ params: { file }, parent }) => { +export const load = async ({ params: { file }, parent, url }) => { const parentData = await parent(); if (!parentData.initialized) { @@ -18,6 +19,11 @@ export const load = async ({ params: { file }, parent }) => { throw redirect(303, "/"); } + const urlWithoutViewParam = consumeViewSearchParam(url, path); + if (urlWithoutViewParam) { + throw redirect(307, urlWithoutViewParam); + } + const fileArtifact = fileArtifacts.getFileArtifact(path); if (fileArtifact.fileTypeUnsupported) { 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..6396bc9f06d8 --- /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: "Create 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: "Create 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);