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