diff --git a/superset-frontend/src/dashboard/actions/hydrate.js b/superset-frontend/src/dashboard/actions/hydrate.js index 699c2041c892b..04ffb374087ac 100644 --- a/superset-frontend/src/dashboard/actions/hydrate.js +++ b/superset-frontend/src/dashboard/actions/hydrate.js @@ -152,6 +152,7 @@ export const hydrateDashboard = owners: slice.owners, modified: slice.modified, changed_on: new Date(slice.changed_on).getTime(), + force_save: slice.force_save, }; sliceIds.add(key); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx b/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx index a99061c7071c6..a6e09f21d3ce4 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx @@ -19,7 +19,7 @@ import cx from 'classnames'; import React from 'react'; import PropTypes from 'prop-types'; -import { styled, t, logging } from '@superset-ui/core'; +import { SupersetClient, styled, t, logging } from '@superset-ui/core'; import { debounce, isEqual } from 'lodash'; import { withRouter } from 'react-router-dom'; @@ -86,6 +86,7 @@ const propTypes = { datasetsStatus: PropTypes.oneOf(['loading', 'error', 'complete']), isInView: PropTypes.bool, emitCrossFilters: PropTypes.bool, + updateSlices: PropTypes.func.isRequired, }; const defaultProps = { @@ -219,6 +220,23 @@ class Chart extends React.Component { const descriptionHeight = this.getDescriptionHeight(); this.setState({ descriptionHeight }); } + + if (this.props.slice.force_save) { + const { slice, updateSlices } = this.props; + SupersetClient.put({ + endpoint: `/api/v1/chart/${slice.slice_id}`, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query_context: slice.query_context, + params: JSON.stringify(slice.form_data), + force_save: false, + }), + }).then(response => + updateSlices({ + [slice.slice_id]: { ...slice, ...response.json.result }, + }), + ); + } } componentWillUnmount() { diff --git a/superset-frontend/src/dashboard/containers/Chart.jsx b/superset-frontend/src/dashboard/containers/Chart.jsx index 1e50602b06299..b212eba389afa 100644 --- a/superset-frontend/src/dashboard/containers/Chart.jsx +++ b/superset-frontend/src/dashboard/containers/Chart.jsx @@ -24,6 +24,7 @@ import { unsetFocusedFilterField, } from 'src/dashboard/actions/dashboardState'; import { updateComponents } from 'src/dashboard/actions/dashboardLayout'; +import { updateSlices } from 'src/dashboard/actions/sliceEntities'; import { changeFilter } from 'src/dashboard/actions/dashboardFilters'; import { addSuccessToast, @@ -116,6 +117,7 @@ function mapDispatchToProps(dispatch) { unsetFocusedFilterField, refreshChart, logEvent, + updateSlices, }, dispatch, ); diff --git a/superset-frontend/src/explore/actions/saveModalActions.js b/superset-frontend/src/explore/actions/saveModalActions.js index 8c864eceacd2d..67b23df53026d 100644 --- a/superset-frontend/src/explore/actions/saveModalActions.js +++ b/superset-frontend/src/explore/actions/saveModalActions.js @@ -114,6 +114,7 @@ export const getSlicePayload = ( ownState: null, }), ), + force_save: false, }; return payload; }; diff --git a/superset-frontend/src/explore/components/ExploreChartPanel/index.jsx b/superset-frontend/src/explore/components/ExploreChartPanel/index.jsx index 984b389a5c9e9..50f12851c1977 100644 --- a/superset-frontend/src/explore/components/ExploreChartPanel/index.jsx +++ b/superset-frontend/src/explore/components/ExploreChartPanel/index.jsx @@ -172,6 +172,28 @@ const ExploreChartPanel = ({ chart.chartStatus !== 'failed' && ensureIsArray(chart.queriesResponse).length > 0; + /* When feature flags are toggled we might need to resave the chart to update all + * required parameters. This can be done by setting `Slice.force_save` to false in a + * migration or manually. */ + const updateChart = useCallback( + async function overwriteChart() { + if (slice?.force_save) { + await actions.updateSlice(slice, slice.slice_name); + // TODO (betodealmeida): better refresh logic + window.location.reload(); + } + }, + [slice], + ); + + useEffect(() => { + updateChart(); + }, [updateChart]); + + /* Orignally charts could be saved without `Slice.query_context`, but the field is + * needed for alerts & reports and can only be computed by the visualization Javascript + * code. Because of this, when we encounter a chart where the field is null we compute + * and store it. */ const updateQueryContext = useCallback( async function fetchChartData() { if (slice && slice.query_context === null) { diff --git a/superset-frontend/src/types/Chart.ts b/superset-frontend/src/types/Chart.ts index f26525cd33580..d9563477fc59f 100644 --- a/superset-frontend/src/types/Chart.ts +++ b/superset-frontend/src/types/Chart.ts @@ -74,6 +74,7 @@ export type Slice = { query_context?: object; is_managed_externally: boolean; owners?: number[]; + force_save?: boolean; }; export default Chart; diff --git a/superset/charts/api.py b/superset/charts/api.py index 768d3302915c2..36473fd1f28b0 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -146,6 +146,7 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]: "viz_type", "query_context", "is_managed_externally", + "force_save", "tags.id", "tags.name", "tags.type", @@ -194,6 +195,7 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]: "thumbnail_url", "url", "viz_type", + "force_save", "tags.id", "tags.name", "tags.type", diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py index 6476c409ec965..bf909345af586 100644 --- a/superset/charts/schemas.py +++ b/superset/charts/schemas.py @@ -162,6 +162,11 @@ class ChartEntityResponseSchema(Schema): description_markeddown = fields.String( metadata={"description": description_markeddown_description} ) + force_save = fields.Boolean( + metadata={ + "description": "Does the chart need to be re-saved to update metadata?" + } + ) form_data = fields.Dict(metadata={"description": form_data_description}) slice_url = fields.String(metadata={"description": slice_url_description}) certified_by = fields.String(metadata={"description": certified_by_description}) @@ -229,6 +234,11 @@ class ChartPostSchema(Schema): ) is_managed_externally = fields.Boolean(allow_none=True, dump_default=False) external_url = fields.String(allow_none=True) + force_save = fields.Boolean( + metadata={ + "description": "Does the chart need to be re-saved to update metadata?" + } + ) class ChartPutSchema(Schema): @@ -285,6 +295,11 @@ class ChartPutSchema(Schema): is_managed_externally = fields.Boolean(allow_none=True, dump_default=False) external_url = fields.String(allow_none=True) tags = fields.Nested(TagSchema, many=True) + force_save = fields.Boolean( + metadata={ + "description": "Does the chart need to be re-saved to update metadata?" + } + ) class ChartGetDatasourceObjectDataResponseSchema(Schema): diff --git a/superset/explore/schemas.py b/superset/explore/schemas.py index 75c3dcac2cf30..e6131a797d578 100644 --- a/superset/explore/schemas.py +++ b/superset/explore/schemas.py @@ -147,6 +147,11 @@ class SliceSchema(Schema): slice_id = fields.Integer(metadata={"description": "The slice ID."}) slice_name = fields.String(metadata={"description": "The slice name."}) slice_url = fields.String(metadata={"description": "The slice URL."}) + force_save = fields.Boolean( + metadata={ + "description": "Does the chart need to be re-saved to update metadata?" + } + ) class ExploreContextSchema(Schema): diff --git a/superset/migrations/versions/2023-09-05_16-44_122965576ebd_add_force_save_column_to_charts.py b/superset/migrations/versions/2023-09-05_16-44_122965576ebd_add_force_save_column_to_charts.py new file mode 100644 index 0000000000000..30a115786805d --- /dev/null +++ b/superset/migrations/versions/2023-09-05_16-44_122965576ebd_add_force_save_column_to_charts.py @@ -0,0 +1,41 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Add force_save column to charts + +Revision ID: 122965576ebd +Revises: ec54aca4c8a2 +Create Date: 2023-09-05 16:44:58.627402 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "122965576ebd" +down_revision = "ec54aca4c8a2" + + +def upgrade(): + op.add_column( + "slices", + sa.Column("force_save", sa.Boolean(), nullable=True, default=False), + ) + + +def downgrade(): + op.drop_column("slices", "force_save") diff --git a/superset/models/slice.py b/superset/models/slice.py index 248f4ee947e7d..a3111c1b901cf 100644 --- a/superset/models/slice.py +++ b/superset/models/slice.py @@ -118,6 +118,10 @@ class Slice( # pylint: disable=too-many-public-methods lazy="subquery", ) + # force the chart to re-save itself; this is useful when the backend has missing + # information that can only be computed by the frontend + force_save = Column(Boolean, nullable=True, default=False) + token = "" export_fields = [ @@ -247,6 +251,7 @@ def data(self) -> dict[str, Any]: "certified_by": self.certified_by, "certification_details": self.certification_details, "is_managed_externally": self.is_managed_externally, + "force_save": self.force_save, } @property diff --git a/tests/integration_tests/charts/api_tests.py b/tests/integration_tests/charts/api_tests.py index ae64eba8071ab..6ee510d65462b 100644 --- a/tests/integration_tests/charts/api_tests.py +++ b/tests/integration_tests/charts/api_tests.py @@ -923,6 +923,7 @@ def test_get_chart(self): "viz_type": None, "query_context": None, "is_managed_externally": False, + "force_save": False, } data = json.loads(rv.data.decode("utf-8")) self.assertIn("changed_on_delta_humanized", data["result"])