This is an automated email from the ASF dual-hosted git repository.

maximebeauchemin pushed a commit to branch default_chart_settings
in repository https://gitbox.apache.org/repos/asf/superset.git

commit f58d1f0d0dd2e2034dabd750eaea2ec993454916
Author: Maxime Beauchemin <maximebeauche...@gmail.com>
AuthorDate: Sun Aug 3 17:30:08 2025 -0700

    feat(dataset): Add chart creation defaults to improve UX
    
    ## Summary
    Introduces dataset-level chart defaults that automatically pre-populate 
common settings when creating new charts, significantly improving the user 
experience by reducing repetitive configuration steps.
    
    ## Features Added
    
    ### Backend
    - Added computed properties to `SqlaTable` model for reading/writing chart 
defaults from the `extra` JSON field
    - Properties: `default_metric`, `default_dimension`, `default_time_grain`, 
`default_time_range`, `default_row_limit`
    - Added `set_default_chart_metadata()` method for updating defaults
    
    ### Frontend UI
    - Added new "Chart Defaults" section in Dataset Editor (Settings tab)
    - Includes dropdowns for:
      - Default Metric (from available metrics)
      - Default Dimension (from groupable columns)
      - Default Time Grain (common intervals)
      - Default Time Range (common presets)
      - Default Row Limit (numeric input)
    
    ### Chart Creation Integration
    - Modified `hydrateExplore` action to apply defaults when creating new 
charts
    - Only applies to new charts (no existing slice_id)
    - Validates that referenced metrics/columns exist before applying
    - Falls back gracefully if defaults can't be applied
    
    ### Metadata Sync Protection
    - Added `cleanupChartDefaults()` to handle column removal during metadata 
refresh
    - Shows warning if default dimension is removed
    - Preserves all other defaults safely
    
    ## Technical Details
    
    ### Storage
    - Uses existing `extra` JSON column - no database migration needed
    - Structure: `{"default_chart_metadata": {"default_metric": "count", ...}}`
    
    ### Best-Effort Resolution
    - String-based references (not foreign keys) for flexibility
    - Graceful degradation if referenced objects are deleted
    - No cascading failures
    
    ### Validation
    - Backend: Type hints and property methods ensure data consistency
    - Frontend: Dropdowns only show valid options
    - Chart creation: Validates existence before applying
    
    ## Benefits
    - **Improved UX**: Users don't need to repeatedly select the same 
metric/dimension
    - **Time Savings**: Especially helpful for datasets with many 
metrics/columns
    - **Consistency**: Encourages use of primary metrics across charts
    - **Flexibility**: Optional and can be overridden per chart
    
    ## Future Opportunities
    - SQL Lab → Explore flow integration
    - Dashboard quick chart creation
    - API exposure for external tools
    - Viz-type specific defaults
    
    🤖 Generated with [Claude Code](https://claude.ai/code)
    
    Co-Authored-By: Claude <nore...@anthropic.com>
---
 .../src/components/Datasource/DatasourceEditor.jsx | 361 +++++++++++++++++----
 .../src/explore/actions/hydrateExplore.ts          |  62 ++++
 superset/connectors/sqla/models.py                 |  44 +++
 3 files changed, 400 insertions(+), 67 deletions(-)

diff --git a/superset-frontend/src/components/Datasource/DatasourceEditor.jsx 
b/superset-frontend/src/components/Datasource/DatasourceEditor.jsx
index b55a511bb3..8eb080f505 100644
--- a/superset-frontend/src/components/Datasource/DatasourceEditor.jsx
+++ b/superset-frontend/src/components/Datasource/DatasourceEditor.jsx
@@ -827,11 +827,18 @@ class DatasourceEditor extends PureComponent {
         newCols,
         this.props.addSuccessToast,
       );
+
+      // Update columns
+      const updatedDatabaseColumns = columnChanges.finalColumns.filter(
+        col => !col.expression, // remove calculated columns
+      );
       this.setColumns({
-        databaseColumns: columnChanges.finalColumns.filter(
-          col => !col.expression, // remove calculated columns
-        ),
+        databaseColumns: updatedDatabaseColumns,
       });
+
+      // Clean up chart defaults that may reference removed columns
+      this.cleanupChartDefaults(updatedDatabaseColumns, columnChanges.removed);
+
       this.props.addSuccessToast(t('Metadata has been synced'));
       this.setState({ metadataLoading: false });
     } catch (error) {
@@ -844,6 +851,46 @@ class DatasourceEditor extends PureComponent {
     }
   }
 
+  cleanupChartDefaults(updatedColumns, removedColumnNames) {
+    const { datasource } = this.state;
+    if (!datasource.extra) return;
+
+    try {
+      const extra = JSON.parse(datasource.extra);
+      const chartDefaults = extra.default_chart_metadata || {};
+      let needsUpdate = false;
+
+      // Check if default dimension was removed
+      if (
+        chartDefaults.default_dimension &&
+        removedColumnNames.includes(chartDefaults.default_dimension)
+      ) {
+        delete chartDefaults.default_dimension;
+        needsUpdate = true;
+        this.props.addDangerToast(
+          t(
+            'Default dimension "%s" was removed during metadata sync',
+            chartDefaults.default_dimension,
+          ),
+        );
+      }
+
+      // Note: We don't check metrics here since they're not part of column 
sync
+      // Metrics are managed separately and won't be affected by column 
metadata sync
+
+      if (needsUpdate) {
+        extra.default_chart_metadata = chartDefaults;
+        this.onDatasourcePropChange('extra', JSON.stringify(extra));
+      }
+    } catch (error) {
+      // Ignore JSON parsing errors
+      console.warn(
+        'Failed to parse dataset extra during chart defaults cleanup:',
+        error,
+      );
+    }
+  }
+
   findDuplicates(arr, accessor) {
     const seen = {};
     const dups = [];
@@ -913,86 +960,266 @@ class DatasourceEditor extends PureComponent {
   renderSettingsFieldset() {
     const { datasource } = this.state;
     return (
-      <Fieldset
-        title={t('Basic')}
-        item={datasource}
-        onChange={this.onDatasourceChange}
-      >
-        <Field
-          fieldKey="description"
-          label={t('Description')}
-          control={
-            <TextAreaControl
-              language="markdown"
-              offerEditInModal={false}
-              resize="vertical"
-            />
-          }
-        />
-        <Field
-          fieldKey="default_endpoint"
-          label={t('Default URL')}
-          description={t(
-            `Default URL to redirect to when accessing from the dataset list 
page.
+      <>
+        <Fieldset
+          title={t('Basic')}
+          item={datasource}
+          onChange={this.onDatasourceChange}
+        >
+          <Field
+            fieldKey="description"
+            label={t('Description')}
+            control={
+              <TextAreaControl
+                language="markdown"
+                offerEditInModal={false}
+                resize="vertical"
+              />
+            }
+          />
+          <Field
+            fieldKey="default_endpoint"
+            label={t('Default URL')}
+            description={t(
+              `Default URL to redirect to when accessing from the dataset list 
page.
             Accepts relative URLs such as <span style=„white-space: 
nowrap;”>/superset/dashboard/{id}/</span>`,
+            )}
+            control={<TextControl controlId="default_endpoint" />}
+          />
+          <Field
+            inline
+            fieldKey="filter_select_enabled"
+            label={t('Autocomplete filters')}
+            description={t('Whether to populate autocomplete filters options')}
+            control={<CheckboxControl />}
+          />
+          {this.state.isSqla && (
+            <Field
+              fieldKey="fetch_values_predicate"
+              label={t('Autocomplete query predicate')}
+              description={t(
+                'When using "Autocomplete filters", this can be used to 
improve performance ' +
+                  'of the query fetching the values. Use this option to apply 
a ' +
+                  'predicate (WHERE clause) to the query selecting the 
distinct ' +
+                  'values from the table. Typically the intent would be to 
limit the scan ' +
+                  'by applying a relative time filter on a partitioned or 
indexed time-related field.',
+              )}
+              control={
+                <TextAreaControl
+                  language="sql"
+                  controlId="fetch_values_predicate"
+                  minLines={5}
+                  resize="vertical"
+                />
+              }
+            />
           )}
-          control={<TextControl controlId="default_endpoint" />}
-        />
-        <Field
-          inline
-          fieldKey="filter_select_enabled"
-          label={t('Autocomplete filters')}
-          description={t('Whether to populate autocomplete filters options')}
-          control={<CheckboxControl />}
-        />
-        {this.state.isSqla && (
+          {this.state.isSqla && (
+            <Field
+              fieldKey="extra"
+              label={t('Extra')}
+              description={t(
+                'Extra data to specify table metadata. Currently supports ' +
+                  'metadata of the format: `{ "certification": { 
"certified_by": ' +
+                  '"Data Platform Team", "details": "This table is the source 
of truth." ' +
+                  '}, "warning_markdown": "This is a warning." }`.',
+              )}
+              control={
+                <TextAreaControl
+                  controlId="extra"
+                  language="json"
+                  offerEditInModal={false}
+                  resize="vertical"
+                />
+              }
+            />
+          )}
+          <OwnersSelector
+            datasource={datasource}
+            onChange={newOwners => {
+              this.onDatasourceChange({ ...datasource, owners: newOwners });
+            }}
+          />
+        </Fieldset>
+        <Fieldset
+          title={t('Chart Defaults')}
+          item={datasource}
+          onChange={this.onDatasourceChange}
+        >
           <Field
-            fieldKey="fetch_values_predicate"
-            label={t('Autocomplete query predicate')}
+            fieldKey="default_metric"
+            label={t('Default Metric')}
             description={t(
-              'When using "Autocomplete filters", this can be used to improve 
performance ' +
-                'of the query fetching the values. Use this option to apply a 
' +
-                'predicate (WHERE clause) to the query selecting the distinct 
' +
-                'values from the table. Typically the intent would be to limit 
the scan ' +
-                'by applying a relative time filter on a partitioned or 
indexed time-related field.',
+              'Pre-populate this metric when creating new charts from this 
dataset',
             )}
             control={
-              <TextAreaControl
-                language="sql"
-                controlId="fetch_values_predicate"
-                minLines={5}
-                resize="vertical"
+              <Select
+                name="default_metric"
+                options={
+                  datasource?.metrics?.map(metric => ({
+                    value: metric.metric_name,
+                    label: metric.verbose_name || metric.metric_name,
+                  })) || []
+                }
+                value={
+                  datasource.extra &&
+                  JSON.parse(datasource.extra || '{}')?.default_chart_metadata
+                    ?.default_metric
+                }
+                onChange={value => {
+                  const extra = JSON.parse(datasource.extra || '{}');
+                  if (!extra.default_chart_metadata) {
+                    extra.default_chart_metadata = {};
+                  }
+                  extra.default_chart_metadata.default_metric = value;
+                  this.onDatasourcePropChange('extra', JSON.stringify(extra));
+                }}
+                placeholder={t('Select a metric')}
+                allowClear
               />
             }
           />
-        )}
-        {this.state.isSqla && (
           <Field
-            fieldKey="extra"
-            label={t('Extra')}
+            fieldKey="default_dimension"
+            label={t('Default Dimension')}
             description={t(
-              'Extra data to specify table metadata. Currently supports ' +
-                'metadata of the format: `{ "certification": { "certified_by": 
' +
-                '"Data Platform Team", "details": "This table is the source of 
truth." ' +
-                '}, "warning_markdown": "This is a warning." }`.',
+              'Pre-populate this dimension/groupby when creating new charts 
from this dataset',
             )}
             control={
-              <TextAreaControl
-                controlId="extra"
-                language="json"
-                offerEditInModal={false}
-                resize="vertical"
+              <Select
+                name="default_dimension"
+                options={
+                  datasource?.columns
+                    ?.filter(col => col.groupby)
+                    ?.map(column => ({
+                      value: column.column_name,
+                      label: column.verbose_name || column.column_name,
+                    })) || []
+                }
+                value={
+                  datasource.extra &&
+                  JSON.parse(datasource.extra || '{}')?.default_chart_metadata
+                    ?.default_dimension
+                }
+                onChange={value => {
+                  const extra = JSON.parse(datasource.extra || '{}');
+                  if (!extra.default_chart_metadata) {
+                    extra.default_chart_metadata = {};
+                  }
+                  extra.default_chart_metadata.default_dimension = value;
+                  this.onDatasourcePropChange('extra', JSON.stringify(extra));
+                }}
+                placeholder={t('Select a dimension')}
+                allowClear
               />
             }
           />
-        )}
-        <OwnersSelector
-          datasource={datasource}
-          onChange={newOwners => {
-            this.onDatasourceChange({ ...datasource, owners: newOwners });
-          }}
-        />
-      </Fieldset>
+          <Field
+            fieldKey="default_time_grain"
+            label={t('Default Time Grain')}
+            description={t(
+              'Pre-populate this time grain when creating new charts from this 
dataset',
+            )}
+            control={
+              <Select
+                name="default_time_grain"
+                options={[
+                  { value: 'PT1S', label: t('Second') },
+                  { value: 'PT1M', label: t('Minute') },
+                  { value: 'PT5M', label: t('5 Minutes') },
+                  { value: 'PT10M', label: t('10 Minutes') },
+                  { value: 'PT15M', label: t('15 Minutes') },
+                  { value: 'PT30M', label: t('30 Minutes') },
+                  { value: 'PT1H', label: t('Hour') },
+                  { value: 'P1D', label: t('Day') },
+                  { value: 'P1W', label: t('Week') },
+                  { value: 'P1M', label: t('Month') },
+                  { value: 'P1Y', label: t('Year') },
+                ]}
+                value={
+                  datasource.extra &&
+                  JSON.parse(datasource.extra || '{}')?.default_chart_metadata
+                    ?.default_time_grain
+                }
+                onChange={value => {
+                  const extra = JSON.parse(datasource.extra || '{}');
+                  if (!extra.default_chart_metadata) {
+                    extra.default_chart_metadata = {};
+                  }
+                  extra.default_chart_metadata.default_time_grain = value;
+                  this.onDatasourcePropChange('extra', JSON.stringify(extra));
+                }}
+                placeholder={t('Select a time grain')}
+                allowClear
+              />
+            }
+          />
+          <Field
+            fieldKey="default_time_range"
+            label={t('Default Time Range')}
+            description={t(
+              'Pre-populate this time range when creating new charts from this 
dataset',
+            )}
+            control={
+              <Select
+                name="default_time_range"
+                options={[
+                  { value: 'Last day', label: t('Last day') },
+                  { value: 'Last 7 days', label: t('Last 7 days') },
+                  { value: 'Last 30 days', label: t('Last 30 days') },
+                  { value: 'Last 90 days', label: t('Last 90 days') },
+                  { value: 'Last year', label: t('Last year') },
+                  { value: 'No filter', label: t('No filter') },
+                ]}
+                value={
+                  datasource.extra &&
+                  JSON.parse(datasource.extra || '{}')?.default_chart_metadata
+                    ?.default_time_range
+                }
+                onChange={value => {
+                  const extra = JSON.parse(datasource.extra || '{}');
+                  if (!extra.default_chart_metadata) {
+                    extra.default_chart_metadata = {};
+                  }
+                  extra.default_chart_metadata.default_time_range = value;
+                  this.onDatasourcePropChange('extra', JSON.stringify(extra));
+                }}
+                placeholder={t('Select a time range')}
+                allowClear
+              />
+            }
+          />
+          <Field
+            fieldKey="default_row_limit"
+            label={t('Default Row Limit')}
+            description={t(
+              'Pre-populate this row limit when creating new charts from this 
dataset',
+            )}
+            control={
+              <TextControl
+                name="default_row_limit"
+                value={
+                  datasource.extra &&
+                  JSON.parse(datasource.extra || '{}')?.default_chart_metadata
+                    ?.default_row_limit
+                }
+                onChange={value => {
+                  const extra = JSON.parse(datasource.extra || '{}');
+                  if (!extra.default_chart_metadata) {
+                    extra.default_chart_metadata = {};
+                  }
+                  extra.default_chart_metadata.default_row_limit = value
+                    ? parseInt(value, 10)
+                    : null;
+                  this.onDatasourcePropChange('extra', JSON.stringify(extra));
+                }}
+                placeholder={t('e.g., 1000')}
+                type="number"
+              />
+            }
+          />
+        </Fieldset>
+      </>
     );
   }
 
diff --git a/superset-frontend/src/explore/actions/hydrateExplore.ts 
b/superset-frontend/src/explore/actions/hydrateExplore.ts
index b8e0375a64..56eea7f745 100644
--- a/superset-frontend/src/explore/actions/hydrateExplore.ts
+++ b/superset-frontend/src/explore/actions/hydrateExplore.ts
@@ -77,6 +77,68 @@ export const hydrateExplore =
       initialFormData.time_range =
         common?.conf?.DEFAULT_TIME_FILTER || NO_TIME_RANGE;
     }
+
+    // Apply dataset chart defaults if this is a new chart (no existing slice)
+    const isNewChart = !initialSlice?.slice_id;
+    if (isNewChart && dataset?.extra) {
+      try {
+        const datasetExtra = JSON.parse(
+          typeof dataset.extra === 'string' ? dataset.extra : '{}',
+        );
+        const chartDefaults = datasetExtra?.default_chart_metadata || {};
+
+        // Apply default metric
+        if (chartDefaults.default_metric && !initialFormData.metrics?.length) {
+          const defaultMetric = dataset.metrics?.find(
+            metric => metric.metric_name === chartDefaults.default_metric,
+          );
+          if (defaultMetric) {
+            initialFormData.metrics = [chartDefaults.default_metric];
+          }
+        }
+
+        // Apply default dimension/groupby
+        if (
+          chartDefaults.default_dimension &&
+          !initialFormData.groupby?.length
+        ) {
+          const defaultColumn = dataset.columns?.find(
+            col =>
+              col.column_name === chartDefaults.default_dimension &&
+              col.groupby,
+          );
+          if (defaultColumn) {
+            initialFormData.groupby = [chartDefaults.default_dimension];
+          }
+        }
+
+        // Apply default time grain
+        if (
+          chartDefaults.default_time_grain &&
+          !initialFormData.time_grain_sqla
+        ) {
+          initialFormData.time_grain_sqla = chartDefaults.default_time_grain;
+        }
+
+        // Apply default time range (but don't override if already set above)
+        if (
+          chartDefaults.default_time_range &&
+          initialFormData.time_range ===
+            (common?.conf?.DEFAULT_TIME_FILTER || NO_TIME_RANGE)
+        ) {
+          initialFormData.time_range = chartDefaults.default_time_range;
+        }
+
+        // Apply default row limit
+        if (chartDefaults.default_row_limit && !initialFormData.row_limit) {
+          initialFormData.row_limit = chartDefaults.default_row_limit;
+        }
+      } catch (error) {
+        // Silently ignore JSON parsing errors - defaults will not be applied
+        console.warn('Failed to parse dataset chart defaults:', error);
+      }
+    }
+
     if (
       initialFormData.include_time &&
       initialFormData.granularity_sqla &&
diff --git a/superset/connectors/sqla/models.py 
b/superset/connectors/sqla/models.py
index d6fe46ba5f..8c99f54b02 100644
--- a/superset/connectors/sqla/models.py
+++ b/superset/connectors/sqla/models.py
@@ -1861,6 +1861,50 @@ class SqlaTable(
             extra_cache_keys += sqla_query.extra_cache_keys
         return list(set(extra_cache_keys))
 
+    @property
+    def default_chart_metadata(self) -> dict[str, Any]:
+        """Get chart defaults from the extra JSON field."""
+        try:
+            extra_dict = json.loads(self.extra or "{}")
+            return extra_dict.get("default_chart_metadata", {})
+        except (json.JSONDecodeError, TypeError):
+            return {}
+
+    @property
+    def default_metric(self) -> str | None:
+        """Get the default metric name for chart creation."""
+        return self.default_chart_metadata.get("default_metric")
+
+    @property
+    def default_dimension(self) -> str | None:
+        """Get the default dimension/groupby column for chart creation."""
+        return self.default_chart_metadata.get("default_dimension")
+
+    @property
+    def default_time_grain(self) -> str | None:
+        """Get the default time grain for chart creation."""
+        return self.default_chart_metadata.get("default_time_grain")
+
+    @property
+    def default_time_range(self) -> str | None:
+        """Get the default time range for chart creation."""
+        return self.default_chart_metadata.get("default_time_range")
+
+    @property
+    def default_row_limit(self) -> int | None:
+        """Get the default row limit for chart creation."""
+        return self.default_chart_metadata.get("default_row_limit")
+
+    def set_default_chart_metadata(self, metadata: dict[str, Any]) -> None:
+        """Set chart defaults in the extra JSON field."""
+        try:
+            extra_dict = json.loads(self.extra or "{}")
+        except (json.JSONDecodeError, TypeError):
+            extra_dict = {}
+
+        extra_dict["default_chart_metadata"] = metadata
+        self.extra = json.dumps(extra_dict)
+
     @property
     def quote_identifier(self) -> Callable[[str], str]:
         return self.database.quote_identifier

Reply via email to