chore(explore): Get Explore data from endpoint instead of bootstrap_data (#20519)

* feat(explore): Use v1/explore endpoint data instead of bootstrapData

* Add tests

* Fix ci

* Remove redundant dependency

* Use form_data_key in cypress tests

* Add auth headers to for data request

* Address comments

* Remove displaying danger toast

* Conditionally add auth headers

* Address comments

* Fix typing bug

* fix

* Fix opening dataset

* Fix sqllab chart create

* Run queries in parallel

* Fix dashboard id autofill

* Fix lint

* Fix test
This commit is contained in:
Kamil Gabryjelski 2022-06-30 21:06:51 +02:00 committed by GitHub
parent f2af81b1c7
commit b30f6a5db1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 845 additions and 327 deletions

View File

@ -41,7 +41,7 @@ describe('No Results', () => {
],
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.wait('@getJson').its('response.statusCode').should('eq', 200);
cy.get('div.chart-container').contains(
'No results were returned for this query',

View File

@ -148,7 +148,7 @@ describe('Time range filter', () => {
metrics: [NUM_METRIC],
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[data-test=time-range-trigger]')
@ -172,7 +172,7 @@ describe('Time range filter', () => {
time_range: 'Last year',
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[data-test=time-range-trigger]')
@ -192,7 +192,7 @@ describe('Time range filter', () => {
time_range: 'previous calendar month',
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[data-test=time-range-trigger]')
@ -212,7 +212,7 @@ describe('Time range filter', () => {
time_range: 'DATEADD(DATETIME("today"), -7, day) : today',
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[data-test=time-range-trigger]')
@ -235,7 +235,7 @@ describe('Time range filter', () => {
time_range: 'No filter',
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[data-test=time-range-trigger]')

View File

@ -31,7 +31,7 @@ describe('explore view', () => {
it('should load Explore', () => {
const LINE_CHART_DEFAULTS = { ...FORM_DATA_DEFAULTS, viz_type: 'line' };
const formData = { ...LINE_CHART_DEFAULTS, metrics: [NUM_METRIC] };
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
cy.eyesOpen({
testName: 'Explore page',

View File

@ -22,7 +22,7 @@ describe('Edit FilterBox Chart', () => {
const VIZ_DEFAULTS = { ...FORM_DATA_DEFAULTS, viz_type: 'filter_box' };
function verify(formData) {
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson' });
}

View File

@ -74,7 +74,7 @@ describe('Test explore links', () => {
};
const newChartName = `Test chart [${shortid.generate()}]`;
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@tableChartData' });
cy.url().then(() => {
cy.get('[data-test="query-save-button"]').click();

View File

@ -51,7 +51,7 @@ describe('Visualization > Area', () => {
};
function verify(formData) {
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
}
@ -75,23 +75,21 @@ describe('Visualization > Area', () => {
});
it('should work with groupby and filter', () => {
cy.visitChartByParams(
JSON.stringify({
...AREA_FORM_DATA,
groupby: ['region'],
adhoc_filters: [
{
expressionType: 'SIMPLE',
subject: 'region',
operator: 'IN',
comparator: ['South Asia', 'North America'],
clause: 'WHERE',
sqlExpression: null,
filterOptionName: 'filter_txje2ikiv6_wxmn0qwd1xo',
},
],
}),
);
cy.visitChartByParams({
...AREA_FORM_DATA,
groupby: ['region'],
adhoc_filters: [
{
expressionType: 'SIMPLE',
subject: 'region',
operator: 'IN',
comparator: ['South Asia', 'North America'],
clause: 'WHERE',
sqlExpression: null,
filterOptionName: 'filter_txje2ikiv6_wxmn0qwd1xo',
},
],
});
cy.wait('@getJson').then(async ({ response }) => {
const responseBody = response?.body;

View File

@ -25,11 +25,11 @@ describe('Visualization > Big Number with Trendline', () => {
slice_id: 42,
granularity_sqla: 'year',
time_grain_sqla: 'P1D',
time_range: '2000+:+2014-01-02',
time_range: '2000 : 2014-01-02',
metric: 'sum__SP_POP_TOTL',
adhoc_filters: [],
compare_lag: '10',
compare_suffix: 'over+10Y',
compare_suffix: 'over 10Y',
y_axis_format: '.3s',
show_trend_line: true,
start_y_axis_at_zero: true,
@ -42,7 +42,7 @@ describe('Visualization > Big Number with Trendline', () => {
};
function verify(formData) {
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({
waitAlias: '@chartData',
chartSelector: '.superset-legacy-chart-big-number',

View File

@ -33,7 +33,7 @@ describe('Visualization > Box Plot', () => {
};
function verify(formData) {
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson' });
}

View File

@ -23,7 +23,7 @@ describe('Visualization > Bubble', () => {
slice_id: 46,
granularity_sqla: 'year',
time_grain_sqla: 'P1D',
time_range: '2011-01-01+:+2011-01-02',
time_range: '2011-01-01 : 2011-01-02',
series: 'region',
entity: 'country_name',
x: 'sum__SP_RUR_TOTL_ZS',
@ -47,7 +47,7 @@ describe('Visualization > Bubble', () => {
};
function verify(formData) {
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
}
@ -60,7 +60,7 @@ describe('Visualization > Bubble', () => {
// Since main functionality is already covered in filter test below,
// skip this test until we find a solution.
it.skip('should work', () => {
cy.visitChartByParams(JSON.stringify(BUBBLE_FORM_DATA)).then(() => {
cy.visitChartByParams(BUBBLE_FORM_DATA).then(() => {
cy.wait('@getJson').then(xhr => {
let expectedBubblesNumber = 0;
xhr.responseBody.data.forEach(element => {
@ -86,7 +86,7 @@ describe('Visualization > Bubble', () => {
expressionType: 'SIMPLE',
subject: 'region',
operator: '==',
comparator: 'South+Asia',
comparator: 'South Asia',
clause: 'WHERE',
sqlExpression: null,
filterOptionName: 'filter_b2tfg1rs8y_8kmrcyxvsqd',

View File

@ -47,7 +47,7 @@ describe('Visualization > Compare', () => {
};
function verify(formData) {
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
}

View File

@ -33,7 +33,7 @@ describe('Visualization > Distribution bar chart', () => {
groupby: ['state'],
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({
waitAlias: '@getJson',
querySubstring: NUM_METRIC.label,
@ -49,7 +49,7 @@ describe('Visualization > Distribution bar chart', () => {
columns: ['gender'],
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
});
@ -61,7 +61,7 @@ describe('Visualization > Distribution bar chart', () => {
row_limit: 10,
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
});
@ -74,7 +74,7 @@ describe('Visualization > Distribution bar chart', () => {
contribution: true,
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
});
});

View File

@ -33,7 +33,7 @@ describe('Download Chart > Distribution bar chart', () => {
groupby: ['state'],
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.get('.header-with-actions .ant-dropdown-trigger').click();
cy.get(':nth-child(1) > .ant-dropdown-menu-submenu-title').click();
cy.get(

View File

@ -23,7 +23,7 @@ describe('Visualization > Dual Line', () => {
slice_id: 58,
granularity_sqla: 'ds',
time_grain_sqla: 'P1D',
time_range: '100+years+ago+:+now',
time_range: '100 years ago : now',
color_scheme: 'bnbColors',
x_axis_format: 'smart_date',
metric: 'sum__num',
@ -35,7 +35,7 @@ describe('Visualization > Dual Line', () => {
};
function verify(formData) {
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
}

View File

@ -27,7 +27,7 @@ describe('Visualization > Gauge', () => {
};
function verify(formData) {
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson' });
}

View File

@ -46,7 +46,7 @@ describe('Visualization > Graph', () => {
function verify(formData: {
[name: string]: string | boolean | number | Array<adhocFilter>;
}): void {
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson' });
}

View File

@ -39,7 +39,7 @@ describe('Visualization > Histogram', () => {
};
function verify(formData: QueryFormData) {
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
}

View File

@ -28,7 +28,7 @@ describe('Visualization > Line', () => {
it('should show validator error when no metric', () => {
const formData = { ...LINE_CHART_DEFAULTS, metrics: [] };
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.get('.panel-body').contains(
`Add required control values to preview chart`,
);
@ -36,7 +36,7 @@ describe('Visualization > Line', () => {
it('should not show validator error when metric added', () => {
const formData = { ...LINE_CHART_DEFAULTS, metrics: [] };
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.get('.panel-body').contains(
`Add required control values to preview chart`,
);
@ -61,7 +61,7 @@ describe('Visualization > Line', () => {
it('should allow negative values in Y bounds', () => {
const formData = { ...LINE_CHART_DEFAULTS, metrics: [NUM_METRIC] };
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.get('#controlSections-tab-display').click();
cy.get('span').contains('Y Axis Bounds').scrollIntoView();
cy.get('input[placeholder="Min"]').type('-0.1', { delay: 100 });
@ -81,7 +81,7 @@ describe('Visualization > Line', () => {
it('should work with adhoc metric', () => {
const formData = { ...LINE_CHART_DEFAULTS, metrics: [NUM_METRIC] };
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
});
@ -89,7 +89,7 @@ describe('Visualization > Line', () => {
const metrics = ['count'];
const groupby = ['gender'];
const formData = { ...LINE_CHART_DEFAULTS, metrics, groupby };
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
});
@ -101,7 +101,7 @@ describe('Visualization > Line', () => {
metrics,
adhoc_filters: filters,
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
});
@ -113,7 +113,7 @@ describe('Visualization > Line', () => {
groupby: ['name'],
timeseries_limit_metric: NUM_METRIC,
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
});
@ -126,7 +126,7 @@ describe('Visualization > Line', () => {
timeseries_limit_metric: NUM_METRIC,
order_desc: true,
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
});
@ -138,7 +138,7 @@ describe('Visualization > Line', () => {
rolling_type: 'mean',
rolling_periods: 10,
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
});
@ -147,12 +147,12 @@ describe('Visualization > Line', () => {
const formData = {
...LINE_CHART_DEFAULTS,
metrics,
time_compare: ['1+year'],
time_compare: ['1 year'],
comparison_type: 'values',
groupby: ['gender'],
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
// Offset color should match original line color
@ -190,10 +190,10 @@ describe('Visualization > Line', () => {
const formData = {
...LINE_CHART_DEFAULTS,
metrics,
time_compare: ['1+year'],
time_compare: ['1 year'],
comparison_type: 'ratio',
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
});
@ -202,10 +202,10 @@ describe('Visualization > Line', () => {
const formData = {
...LINE_CHART_DEFAULTS,
metrics,
time_compare: ['1+year'],
time_compare: ['1 year'],
comparison_type: 'percentage',
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
});
@ -214,7 +214,7 @@ describe('Visualization > Line', () => {
...LINE_CHART_DEFAULTS,
metrics: ['count'],
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
cy.get('text.nv-legend-text').contains('COUNT(*)');
});
@ -225,7 +225,7 @@ describe('Visualization > Line', () => {
metrics: ['count'],
annotation_layers: [
{
name: 'Goal+line',
name: 'Goal line',
annotationType: 'FORMULA',
sourceType: '',
value: 'y=140000',
@ -245,7 +245,7 @@ describe('Visualization > Line', () => {
},
],
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
cy.get('.slice_container').within(() => {
// Goal line annotation doesn't show up in legend
@ -281,7 +281,7 @@ describe('Visualization > Line', () => {
},
],
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
},
);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });

View File

@ -37,7 +37,7 @@ describe('Visualization > Pie', () => {
};
function verify(formData) {
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson' });
}

View File

@ -23,7 +23,7 @@ describe('Visualization > Pivot Table', () => {
slice_id: 61,
granularity_sqla: 'ds',
time_grain_sqla: 'P1D',
time_range: '100+years+ago+:+now',
time_range: '100 years ago : now',
metrics: ['sum__num'],
adhoc_filters: [],
groupby: ['name'],
@ -54,7 +54,7 @@ describe('Visualization > Pivot Table', () => {
};
function verify(formData) {
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'table' });
}

View File

@ -24,7 +24,7 @@ describe('Visualization > Sankey', () => {
url_params: {},
granularity_sqla: null,
time_grain_sqla: 'P1D',
time_range: 'Last+week',
time_range: 'Last week',
groupby: ['source', 'target'],
metric: 'sum__value',
adhoc_filters: [],
@ -33,7 +33,7 @@ describe('Visualization > Sankey', () => {
};
function verify(formData) {
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
}
@ -53,7 +53,7 @@ describe('Visualization > Sankey', () => {
adhoc_filters: [
{
expressionType: 'SQL',
sqlExpression: 'SUM(value)+>+0',
sqlExpression: 'SUM(value) > 0',
clause: 'HAVING',
subject: null,
operator: null,

View File

@ -24,7 +24,7 @@ export const FORM_DATA_DEFAULTS = {
datasource: '3__table',
granularity_sqla: 'ds',
time_grain_sqla: null,
time_range: '100+years+ago+:+now',
time_range: '100 years ago : now',
adhoc_filters: [],
groupby: [],
limit: null,
@ -37,7 +37,7 @@ export const HEALTH_POP_FORM_DATA_DEFAULTS = {
datasource: '2__table',
granularity_sqla: 'ds',
time_grain_sqla: 'P1D',
time_range: '1960-01-01+:+2014-01-02',
time_range: '1960-01-01 : 2014-01-02',
};
export const NUM_METRIC = {

View File

@ -32,7 +32,7 @@ describe('Visualization > Sunburst', () => {
};
function verify(formData) {
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
}

View File

@ -174,7 +174,7 @@ describe('Visualization > Table', () => {
groupby: ['name'],
row_limit: limit,
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.wait('@chartData').then(({ response }) => {
cy.verifySliceContainer('table');
expect(response?.body.result[0].data.length).to.eq(limit);
@ -219,7 +219,7 @@ describe('Visualization > Table', () => {
order_by_cols: ['["num", false]'],
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.wait('@chartData').then(({ response }) => {
cy.verifySliceContainer('table');
const records = response?.body.result[0].data;
@ -233,7 +233,7 @@ describe('Visualization > Table', () => {
const formData = { ...VIZ_DEFAULTS, metrics, adhoc_filters: filters };
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
});
@ -244,7 +244,7 @@ describe('Visualization > Table', () => {
groupby: ['state'],
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({
waitAlias: '@chartData',
querySubstring: /group by.*state/i,

View File

@ -33,7 +33,7 @@ describe('Visualization > Time TableViz', () => {
column_collection: [
{
key: '9g4K-B-YL',
label: 'Last+Year',
label: 'Last Year',
colType: 'time',
timeLag: '1',
comparisonType: 'value',
@ -42,7 +42,7 @@ describe('Visualization > Time TableViz', () => {
url: '',
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({
waitAlias: '@getJson',
querySubstring: NUM_METRIC.label,
@ -61,7 +61,7 @@ describe('Visualization > Time TableViz', () => {
column_collection: [
{
key: '9g4K-B-YL',
label: 'Last+Year',
label: 'Last Year',
colType: 'time',
timeLag: '1',
comparisonType: 'value',
@ -70,7 +70,7 @@ describe('Visualization > Time TableViz', () => {
url: '',
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({
waitAlias: '@getJson',
querySubstring: NUM_METRIC.label,
@ -107,7 +107,7 @@ describe('Visualization > Time TableViz', () => {
url: '',
};
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({
waitAlias: '@getJson',
querySubstring: NUM_METRIC.label,

View File

@ -38,7 +38,7 @@ describe('Visualization > Treemap', () => {
const level2 = '.chart-container rect[style="fill: rgb(0, 122, 135);"]';
function verify(formData) {
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
}

View File

@ -35,7 +35,7 @@ describe('Visualization > World Map', () => {
};
function verify(formData) {
cy.visitChartByParams(JSON.stringify(formData));
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
}

View File

@ -55,12 +55,46 @@ Cypress.Commands.add('visitChartById', chartId =>
cy.visit(`${BASE_EXPLORE_URL}{"slice_id": ${chartId}}`),
);
Cypress.Commands.add('visitChartByParams', params => {
const queryString =
typeof params === 'string' ? params : JSON.stringify(params);
const url = `${BASE_EXPLORE_URL}${queryString}`;
return cy.visit(url);
});
Cypress.Commands.add(
'visitChartByParams',
(formData: {
datasource?: string;
datasource_id?: number;
datasource_type?: string;
[key: string]: unknown;
}) => {
let datasource_id;
let datasource_type;
if (formData.datasource_id && formData.datasource_type) {
({ datasource_id, datasource_type } = formData);
} else {
[datasource_id, datasource_type] = formData.datasource?.split('__') || [];
}
const accessToken = window.localStorage.getItem('access_token');
cy.request({
method: 'POST',
url: 'api/v1/explore/form_data',
body: {
datasource_id,
datasource_type,
form_data: JSON.stringify(formData),
},
headers: {
...(accessToken && {
Cookie: `csrf_access_token=${accessToken}`,
'X-CSRFToken': accessToken,
}),
...(TokenName && { Authorization: `Bearer ${TokenName}` }),
'Content-Type': 'application/json',
Referer: `${Cypress.config().baseUrl}/`,
},
}).then(response => {
const formDataKey = response.body.key;
const url = `/superset/explore/?form_data_key=${formDataKey}`;
cy.visit(url);
});
},
);
Cypress.Commands.add('verifySliceContainer', chartSelector => {
// After a wait response check for valid slice container

View File

@ -50,6 +50,14 @@ export type SharedControlComponents = typeof sharedControlComponents;
/** ----------------------------------------------
* Input data/props while rendering
* ---------------------------------------------*/
export interface Owner {
first_name: string;
id: number;
last_name: string;
username: string;
email?: string;
}
export type ColumnMeta = Omit<Column, 'id'> & {
id?: number;
} & AnyDict;
@ -67,8 +75,10 @@ export interface Dataset {
time_grain_sqla?: string;
granularity_sqla?: string;
datasource_name: string | null;
name?: string;
description: string | null;
uid?: string;
owners?: Owner[];
}
export interface ControlPanelState {

View File

@ -46,8 +46,11 @@ import {
SqlLabExploreRootState,
getInitialState,
ExploreDatasource,
SqlLabRootState,
} from 'src/SqlLab/types';
import { exploreChart } from 'src/explore/exploreUtils';
import { mountExploreUrl } from 'src/explore/exploreUtils';
import { postFormData } from 'src/explore/exploreUtils/formData';
import { URL_PARAMS } from 'src/constants';
interface SaveDatasetModalProps {
visible: boolean;
@ -115,6 +118,9 @@ export const SaveDatasetModal: FunctionComponent<SaveDatasetModalProps> = ({
modalDescription,
datasource,
}) => {
const defaultVizType = useSelector<SqlLabRootState, string>(
state => state.common?.conf?.DEFAULT_VIZ_TYPE || 'table',
);
const query = datasource as QueryResponse;
const getDefaultDatasetName = () =>
`${query.tab} ${moment().format('MM/DD/YYYY HH:mm:ss')}`;
@ -137,30 +143,40 @@ export const SaveDatasetModal: FunctionComponent<SaveDatasetModalProps> = ({
const dispatch = useDispatch<(dispatch: any) => Promise<JsonObject>>();
const handleOverwriteDataset = async () => {
await updateDataset(
query.dbId,
datasetToOverwrite.datasetId,
query.sql,
query.results.selected_columns.map(
(d: { name: string; type: string; is_dttm: boolean }) => ({
column_name: d.name,
type: d.type,
is_dttm: d.is_dttm,
}),
const [, key] = await Promise.all([
updateDataset(
query.dbId,
datasetToOverwrite.datasetId,
query.sql,
query.results.selected_columns.map(
(d: { name: string; type: string; is_dttm: boolean }) => ({
column_name: d.name,
type: d.type,
is_dttm: d.is_dttm,
}),
),
datasetToOverwrite.owners.map((o: DatasetOwner) => o.id),
true,
),
datasetToOverwrite.owners.map((o: DatasetOwner) => o.id),
true,
);
postFormData(datasetToOverwrite.datasetId, 'table', {
...EXPLORE_CHART_DEFAULT,
datasource: `${datasetToOverwrite.datasetId}__table`,
...(defaultVizType === 'table' && {
all_columns: query.results.selected_columns.map(
column => column.name,
),
}),
}),
]);
const url = mountExploreUrl(null, {
[URL_PARAMS.formDataKey.name]: key,
});
window.open(url, '_blank', 'noreferrer');
setShouldOverwriteDataset(false);
setDatasetToOverwrite({});
setDatasetName(getDefaultDatasetName());
exploreChart({
...EXPLORE_CHART_DEFAULT,
datasource: `${datasetToOverwrite.datasetId}__table`,
selected_columns: query.results.selected_columns,
});
};
const getUserDatasets = async (searchText = '') => {
@ -235,15 +251,20 @@ export const SaveDatasetModal: FunctionComponent<SaveDatasetModalProps> = ({
columns: selectedColumns,
}),
)
.then((data: { table_id: number }) => {
exploreChart({
.then((data: { table_id: number }) =>
postFormData(data.table_id, 'table', {
...EXPLORE_CHART_DEFAULT,
datasource: `${data.table_id}__table`,
metrics: [],
groupby: [],
time_range: 'No filter',
selectedColumns,
row_limit: 1000,
...(defaultVizType === 'table' && {
all_columns: selectedColumns.map(column => column.name),
}),
}),
)
.then((key: string) => {
const url = mountExploreUrl(null, {
[URL_PARAMS.formDataKey.name]: key,
});
window.open(url, '_blank', 'noreferrer');
})
.catch(() => {
addDangerToast(t('An error occurred saving dataset'));

View File

@ -16,11 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Dataset } from '@superset-ui/chart-controls';
import { JsonObject, Query, QueryResponse } from '@superset-ui/core';
import { SupersetError } from 'src/components/ErrorMessage/types';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import { ToastType } from 'src/components/MessageToasts/types';
import { Dataset } from '@superset-ui/chart-controls';
import { Query, QueryResponse } from '@superset-ui/core';
import { ExploreRootState } from 'src/explore/types';
export type ExploreDatasource = Dataset | QueryResponse;
@ -68,7 +68,10 @@ export type SqlLabRootState = {
};
localStorageUsageInKilobytes: number;
messageToasts: toastState[];
common: {};
common: {
flash_messages: string[];
conf: JsonObject;
};
};
export type SqlLabExploreRootState = SqlLabRootState | ExploreRootState;
@ -96,6 +99,7 @@ export const EXPLORE_CHART_DEFAULT = {
metrics: [],
groupby: [],
time_range: 'No filter',
row_limit: 1000,
};
export interface DatasetOwner {

View File

@ -104,7 +104,7 @@ test('renders an enabled button if datasource and viz type are selected', async
const wrapper = await getWrapper();
wrapper.setState({
datasource,
visType: 'table',
vizType: 'table',
});
expect(
wrapper.find(Button).find({ disabled: true }).hostNodes(),
@ -125,7 +125,7 @@ test('double-click viz type submits if datasource is selected', async () => {
wrapper.update();
wrapper.setState({
datasource,
visType: 'table',
vizType: 'table',
});
wrapper.instance().onVizTypeDoubleClick();
@ -136,9 +136,8 @@ test('formats Explore url', async () => {
const wrapper = await getWrapper();
wrapper.setState({
datasource,
visType: 'table',
vizType: 'table',
});
const formattedUrl =
'/superset/explore/?form_data=%7B%22viz_type%22%3A%22table%22%2C%22datasource%22%3A%221%22%7D';
const formattedUrl = '/superset/explore/?viz_type=table&datasource=1';
expect(wrapper.instance().exploreUrl()).toBe(formattedUrl);
});

View File

@ -45,7 +45,7 @@ export type AddSliceContainerProps = {
export type AddSliceContainerState = {
datasource?: { label: string; value: string };
visType: string | null;
vizType: string | null;
canCreateDataset: boolean;
};
@ -208,7 +208,7 @@ export default class AddSliceContainer extends React.PureComponent<
constructor(props: AddSliceContainerProps) {
super(props);
this.state = {
visType: null,
vizType: null,
canCreateDataset: findPermission(
'can_write',
'Dataset',
@ -217,7 +217,7 @@ export default class AddSliceContainer extends React.PureComponent<
};
this.changeDatasource = this.changeDatasource.bind(this);
this.changeVisType = this.changeVisType.bind(this);
this.changeVizType = this.changeVizType.bind(this);
this.gotoSlice = this.gotoSlice.bind(this);
this.newLabel = this.newLabel.bind(this);
this.loadDatasources = this.loadDatasources.bind(this);
@ -226,14 +226,11 @@ export default class AddSliceContainer extends React.PureComponent<
exploreUrl() {
const dashboardId = getUrlParam(URL_PARAMS.dashboardId);
const formData = encodeURIComponent(
JSON.stringify({
viz_type: this.state.visType,
datasource: this.state.datasource?.value,
...(!isNullish(dashboardId) && { dashboardId }),
}),
);
return `/superset/explore/?form_data=${formData}`;
let url = `/superset/explore/?viz_type=${this.state.vizType}&datasource=${this.state.datasource?.value}`;
if (!isNullish(dashboardId)) {
url += `&dashboard_id=${dashboardId}`;
}
return url;
}
gotoSlice() {
@ -244,12 +241,12 @@ export default class AddSliceContainer extends React.PureComponent<
this.setState({ datasource });
}
changeVisType(visType: string | null) {
this.setState({ visType });
changeVizType(vizType: string | null) {
this.setState({ vizType });
}
isBtnDisabled() {
return !(this.state.datasource?.value && this.state.visType);
return !(this.state.datasource?.value && this.state.vizType);
}
onVizTypeDoubleClick() {
@ -369,14 +366,14 @@ export default class AddSliceContainer extends React.PureComponent<
/>
<Steps.Step
title={<StyledStepTitle>{t('Choose chart type')}</StyledStepTitle>}
status={this.state.visType ? 'finish' : 'process'}
status={this.state.vizType ? 'finish' : 'process'}
description={
<StyledStepDescription>
<VizTypeGallery
className="viz-gallery"
onChange={this.changeVisType}
onChange={this.changeVizType}
onDoubleClick={this.onVizTypeDoubleClick}
selectedViz={this.state.visType}
selectedViz={this.state.vizType}
/>
</StyledStepDescription>
}

View File

@ -22,6 +22,7 @@ import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate';
import { DatasourcesAction } from 'src/dashboard/actions/datasources';
import { ChartState } from 'src/explore/types';
import { getFormDataFromControls } from 'src/explore/controlUtils';
import { HYDRATE_EXPLORE } from 'src/explore/actions/hydrateExplore';
import { now } from 'src/utils/dates';
import * as actions from './chartAction';
@ -194,7 +195,7 @@ export default function chartReducer(
delete charts[key];
return charts;
}
if (action.type === HYDRATE_DASHBOARD) {
if (action.type === HYDRATE_DASHBOARD || action.type === HYDRATE_EXPLORE) {
return { ...action.data.charts };
}
if (action.type === DatasourcesAction.SET_DATASOURCES) {

View File

@ -91,6 +91,10 @@ export const URL_PARAMS = {
name: 'permalink_key',
type: 'string',
},
vizType: {
name: 'viz_type',
type: 'string',
},
} as const;
export const RESERVED_CHART_URL_PARAMS: string[] = [

View File

@ -27,10 +27,10 @@ import { DynamicPluginProvider } from 'src/components/DynamicPlugins';
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
import setupApp from 'src/setup/setupApp';
import setupPlugins from 'src/setup/setupPlugins';
import { theme } from 'src/preamble';
import { ExplorePage } from './ExplorePage';
import './main.less';
import '../assets/stylesheets/reactable-pagination.less';
import { theme } from 'src/preamble';
import ExploreViewContainer from './components/ExploreViewContainer';
setupApp();
setupPlugins();
@ -41,7 +41,7 @@ const App = ({ store }) => (
<ThemeProvider theme={theme}>
<GlobalStyles />
<DynamicPluginProvider>
<ExploreViewContainer />
<ExplorePage />
<ToastContainer />
</DynamicPluginProvider>
</ThemeProvider>

View File

@ -0,0 +1,68 @@
/**
* 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.
*/
import React, { useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { makeApi, t } from '@superset-ui/core';
import Loading from 'src/components/Loading';
import { getParsedExploreURLParams } from './exploreUtils/getParsedExploreURLParams';
import { hydrateExplore } from './actions/hydrateExplore';
import ExploreViewContainer from './components/ExploreViewContainer';
import { ExploreResponsePayload } from './types';
import { fallbackExploreInitialData } from './fixtures';
import { addDangerToast } from '../components/MessageToasts/actions';
import { isNullish } from '../utils/common';
const loadErrorMessage = t('Failed to load chart data.');
const fetchExploreData = () => {
const exploreUrlParams = getParsedExploreURLParams();
return makeApi<{}, ExploreResponsePayload>({
method: 'GET',
endpoint: 'api/v1/explore/',
})(exploreUrlParams);
};
export const ExplorePage = () => {
const [isLoaded, setIsLoaded] = useState(false);
const dispatch = useDispatch();
useEffect(() => {
fetchExploreData()
.then(({ result }) => {
if (isNullish(result.dataset?.id) && isNullish(result.dataset?.uid)) {
dispatch(hydrateExplore(fallbackExploreInitialData));
dispatch(addDangerToast(loadErrorMessage));
} else {
dispatch(hydrateExplore(result));
}
})
.catch(() => {
dispatch(hydrateExplore(fallbackExploreInitialData));
dispatch(addDangerToast(loadErrorMessage));
})
.finally(() => {
setIsLoaded(true);
});
}, [dispatch]);
if (!isLoaded) {
return <Loading />;
}
return <ExploreViewContainer />;
};

View File

@ -20,7 +20,7 @@
import { Dispatch } from 'redux';
import { Dataset } from '@superset-ui/chart-controls';
import { updateFormDataByDatasource } from './exploreActions';
import { ExplorePageState } from '../reducers/getInitialState';
import { ExplorePageState } from '../types';
export const SET_DATASOURCE = 'SET_DATASOURCE';
export interface SetDatasource {

View File

@ -0,0 +1,92 @@
/**
* 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.
*/
import { hydrateExplore, HYDRATE_EXPLORE } from './hydrateExplore';
import { exploreInitialData } from '../fixtures';
test('creates hydrate action from initial data', () => {
const dispatch = jest.fn();
const getState = jest.fn(() => ({
user: {},
charts: {},
datasources: {},
common: {},
explore: {},
}));
// ignore type check - we dont need exact explore state for this test
// @ts-ignore
hydrateExplore(exploreInitialData)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: HYDRATE_EXPLORE,
data: {
charts: {
371: {
id: 371,
chartAlert: null,
chartStatus: null,
chartStackTrace: null,
chartUpdateEndTime: null,
chartUpdateStartTime: 0,
latestQueryFormData: {
cache_timeout: undefined,
datasource: '8__table',
slice_id: 371,
url_params: undefined,
viz_type: 'table',
},
sliceFormData: {
cache_timeout: undefined,
datasource: '8__table',
slice_id: 371,
url_params: undefined,
viz_type: 'table',
},
queryController: null,
queriesResponse: null,
triggerQuery: false,
lastRendered: 0,
},
},
datasources: {
'8__table': exploreInitialData.dataset,
},
saveModal: {
dashboards: [],
saveModalAlert: null,
},
explore: {
can_add: false,
can_download: false,
can_overwrite: false,
isDatasourceMetaLoading: false,
isStarred: false,
triggerRender: false,
datasource: exploreInitialData.dataset,
controls: expect.any(Object),
form_data: exploreInitialData.form_data,
slice: exploreInitialData.slice,
controlsTransferred: [],
standalone: null,
force: null,
},
},
}),
);
});

View File

@ -0,0 +1,146 @@
/**
* 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.
*/
import { ControlStateMapping } from '@superset-ui/chart-controls';
import {
ChartState,
ExplorePageInitialData,
ExplorePageState,
} from 'src/explore/types';
import { getChartKey } from 'src/explore/exploreUtils';
import { getControlsState } from 'src/explore/store';
import { Dispatch } from 'redux';
import { ensureIsArray } from '@superset-ui/core';
import {
getFormDataFromControls,
applyMapStateToPropsToControl,
} from 'src/explore/controlUtils';
import { getDatasourceUid } from 'src/utils/getDatasourceUid';
import { getUrlParam } from 'src/utils/urlUtils';
import { URL_PARAMS } from 'src/constants';
import { findPermission } from 'src/utils/findPermission';
export const HYDRATE_EXPLORE = 'HYDRATE_EXPLORE';
export const hydrateExplore =
({ form_data, slice, dataset }: ExplorePageInitialData) =>
(dispatch: Dispatch, getState: () => ExplorePageState) => {
const { user, datasources, charts, sliceEntities, common } = getState();
const sliceId = getUrlParam(URL_PARAMS.sliceId);
const dashboardId = getUrlParam(URL_PARAMS.dashboardId);
const fallbackSlice = sliceId ? sliceEntities?.slices?.[sliceId] : null;
const initialSlice = slice ?? fallbackSlice;
const initialFormData = form_data ?? initialSlice?.form_data;
if (!initialFormData.viz_type) {
const defaultVizType = common?.conf.DEFAULT_VIZ_TYPE || 'table';
initialFormData.viz_type =
getUrlParam(URL_PARAMS.vizType) || defaultVizType;
}
if (dashboardId) {
initialFormData.dashboardId = dashboardId;
}
const initialDatasource =
datasources?.[initialFormData.datasource] ?? dataset;
const initialExploreState = {
form_data: initialFormData,
slice: initialSlice,
datasource: initialDatasource,
};
const initialControls = getControlsState(
initialExploreState,
initialFormData,
) as ControlStateMapping;
const exploreState = {
// note this will add `form_data` to state,
// which will be manipulable by future reducers.
can_add: findPermission('can_write', 'Chart', user?.roles),
can_download: findPermission('can_csv', 'Superset', user?.roles),
can_overwrite: ensureIsArray(slice?.owners).includes(
user?.userId as number,
),
isDatasourceMetaLoading: false,
isStarred: false,
triggerRender: false,
// duplicate datasource in exploreState - it's needed by getControlsState
datasource: initialDatasource,
// Initial control state will skip `control.mapStateToProps`
// because `bootstrapData.controls` is undefined.
controls: initialControls,
form_data: initialFormData,
slice: initialSlice,
controlsTransferred: [],
standalone: getUrlParam(URL_PARAMS.standalone),
force: getUrlParam(URL_PARAMS.force),
};
// apply initial mapStateToProps for all controls, must execute AFTER
// bootstrapState has initialized `controls`. Order of execution is not
// guaranteed, so controls shouldn't rely on each other's mapped state.
Object.entries(exploreState.controls).forEach(([key, controlState]) => {
exploreState.controls[key] = applyMapStateToPropsToControl(
controlState,
exploreState,
);
});
const sliceFormData = initialSlice
? getFormDataFromControls(initialControls)
: null;
const chartKey: number = getChartKey(initialExploreState);
const chart: ChartState = {
id: chartKey,
chartAlert: null,
chartStatus: null,
chartStackTrace: null,
chartUpdateEndTime: null,
chartUpdateStartTime: 0,
latestQueryFormData: getFormDataFromControls(exploreState.controls),
sliceFormData,
queryController: null,
queriesResponse: null,
triggerQuery: false,
lastRendered: 0,
};
return dispatch({
type: HYDRATE_EXPLORE,
data: {
charts: {
...charts,
[chartKey]: chart,
},
datasources: {
...datasources,
[getDatasourceUid(initialDatasource)]: initialDatasource,
},
saveModal: {
dashboards: [],
saveModalAlert: null,
},
explore: exploreState,
},
});
};
export type HydrateExplore = {
type: typeof HYDRATE_EXPLORE;
data: ExplorePageState;
};

View File

@ -54,8 +54,7 @@ import Loading from 'src/components/Loading';
import { usePrevious } from 'src/hooks/usePrevious';
import { getSectionsToRender } from 'src/explore/controlUtils';
import { ExploreActions } from 'src/explore/actions/exploreActions';
import { ExplorePageState } from 'src/explore/reducers/getInitialState';
import { ChartState } from 'src/explore/types';
import { ChartState, ExplorePageState } from 'src/explore/types';
import { Tooltip } from 'src/components/Tooltip';
import { rgba } from 'emotion-rgba';

View File

@ -47,7 +47,9 @@ const datasource = {
main_dttm_col: 'None',
datasource_name: 'table1',
description: 'desc',
owners: [{ username: 'admin', userId: 1 }],
owners: [
{ first_name: 'admin', last_name: 'admin', username: 'admin', id: 1 },
],
};
const mockUser = {

View File

@ -694,17 +694,9 @@ function ExploreViewContainer(props) {
ExploreViewContainer.propTypes = propTypes;
function mapStateToProps(state) {
const {
explore,
charts,
common,
impressionId,
dataMask,
reports,
datasources,
user,
} = state;
const { controls, slice } = explore;
const { explore, charts, common, impressionId, dataMask, reports, user } =
state;
const { controls, slice, datasource } = explore;
const form_data = getFormDataFromControls(controls);
const slice_id = form_data.slice_id ?? slice?.slice_id ?? 0; // 0 - unsaved chart
form_data.extra_form_data = mergeExtraFormData(
@ -720,8 +712,6 @@ function mapStateToProps(state) {
dashboardId = undefined;
}
const datasource = datasources[form_data.datasource];
return {
isDatasourceMetaLoading: explore.isDatasourceMetaLoading,
datasource,

View File

@ -237,7 +237,7 @@ class DatasourceControl extends React.PureComponent {
const isSqlSupported = datasource.type === 'table';
const { user } = this.props;
const allowEdit = datasource.owners
.map(o => o.id || o.value)
?.map(o => o.id || o.value)
.includes(user.userId);
isUserAdmin(user);

View File

@ -29,8 +29,8 @@ import { css, SupersetTheme, t, useTheme } from '@superset-ui/core';
import { usePluginContext } from 'src/components/DynamicPlugins';
import { Tooltip } from 'src/components/Tooltip';
import Icons from 'src/components/Icons';
import { ExplorePageState } from 'src/explore/reducers/getInitialState';
import { getChartKey } from 'src/explore/exploreUtils';
import { ExplorePageState } from 'src/explore/types';
export interface VizMeta {
icon: ReactElement;

View File

@ -0,0 +1,62 @@
/**
* 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.
*/
import { getParsedExploreURLParams } from './getParsedExploreURLParams';
const EXPLORE_BASE_URL = 'http://localhost:9000/superset/explore/';
const setupLocation = (newUrl: string) => {
delete (window as any).location;
// @ts-ignore
window.location = new URL(newUrl);
};
test('get form_data_key and slice_id from search params - url when moving from dashboard to explore', () => {
setupLocation(
`${EXPLORE_BASE_URL}?form_data_key=yrLXmyE9fmhQ11lM1KgaD1PoPSBpuLZIJfqdyIdw9GoBwhPFRZHeIgeFiNZljbpd&slice_id=56`,
);
expect(getParsedExploreURLParams().toString()).toEqual(
'slice_id=56&form_data_key=yrLXmyE9fmhQ11lM1KgaD1PoPSBpuLZIJfqdyIdw9GoBwhPFRZHeIgeFiNZljbpd',
);
});
test('get slice_id from form_data search param - url on Chart List', () => {
setupLocation(`${EXPLORE_BASE_URL}?form_data=%7B%22slice_id%22%3A%2056%7D`);
expect(getParsedExploreURLParams().toString()).toEqual('slice_id=56');
});
test('get datasource and viz type from form_data search param - url when creating new chart', () => {
setupLocation(
`${EXPLORE_BASE_URL}?form_data=%7B%22viz_type%22%3A%22big_number%22%2C%22datasource%22%3A%222__table%22%7D`,
);
expect(getParsedExploreURLParams().toString()).toEqual(
'viz_type=big_number&dataset_id=2&dataset_type=table',
);
});
test('get permalink key from path params', () => {
setupLocation(`${EXPLORE_BASE_URL}p/kpOqweaMY9R/`);
expect(getParsedExploreURLParams().toString()).toEqual(
'permalink_key=kpOqweaMY9R',
);
});
test('get dataset id from path params', () => {
setupLocation(`${EXPLORE_BASE_URL}table/42/`);
expect(getParsedExploreURLParams().toString()).toEqual('dataset_id=42');
});

View File

@ -0,0 +1,117 @@
/**
* 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.
*/
// mapping { url_param: v1_explore_request_param }
const EXPLORE_URL_SEARCH_PARAMS = {
form_data: {
name: 'form_data',
parser: (formData: string) => {
const formDataObject = JSON.parse(formData);
if (formDataObject.datasource) {
const [dataset_id, dataset_type] =
formDataObject.datasource.split('__');
formDataObject.dataset_id = dataset_id;
formDataObject.dataset_type = dataset_type;
delete formDataObject.datasource;
}
return formDataObject;
},
},
slice_id: {
name: 'slice_id',
},
dataset_id: {
name: 'dataset_id',
},
dataset_type: {
name: 'dataset_type',
},
datasource: {
name: 'datasource',
parser: (datasource: string) => {
const [dataset_id, dataset_type] = datasource.split('__');
return { dataset_id, dataset_type };
},
},
form_data_key: {
name: 'form_data_key',
},
permalink_key: {
name: 'permalink_key',
},
viz_type: {
name: 'viz_type',
},
dashboard_id: {
name: 'dashboard_id',
},
};
const EXPLORE_URL_PATH_PARAMS = {
p: 'permalink_key', // permalink
table: 'dataset_id',
};
// search params can be placed in form_data object
// we need to "flatten" the search params to use them with /v1/explore endpoint
const getParsedExploreURLSearchParams = () => {
const urlSearchParams = new URLSearchParams(window.location.search);
return Object.keys(EXPLORE_URL_SEARCH_PARAMS).reduce((acc, currentParam) => {
const paramValue = urlSearchParams.get(currentParam);
if (paramValue === null) {
return acc;
}
let parsedParamValue;
try {
parsedParamValue =
EXPLORE_URL_SEARCH_PARAMS[currentParam].parser?.(paramValue) ??
paramValue;
} catch {
parsedParamValue = paramValue;
}
if (typeof parsedParamValue === 'object') {
return { ...acc, ...parsedParamValue };
}
return {
...acc,
[EXPLORE_URL_SEARCH_PARAMS[currentParam].name]: parsedParamValue,
};
}, {});
};
// path params need to be transformed to search params to use them with /v1/explore endpoint
const getParsedExploreURLPathParams = () =>
Object.keys(EXPLORE_URL_PATH_PARAMS).reduce((acc, currentParam) => {
const re = new RegExp(`/(${currentParam})/(\\w+)`);
const pathGroups = window.location.pathname.match(re);
if (pathGroups && pathGroups[2]) {
return { ...acc, [EXPLORE_URL_PATH_PARAMS[currentParam]]: pathGroups[2] };
}
return acc;
}, {});
export const getParsedExploreURLParams = () =>
new URLSearchParams(
Object.entries({
...getParsedExploreURLSearchParams(),
...getParsedExploreURLPathParams(),
})
.map(entry => entry.join('='))
.join('&'),
);

View File

@ -267,11 +267,12 @@ export const exportChart = ({
SupersetClient.postForm(url, { form_data: safeStringify(payload) });
};
export const exploreChart = formData => {
export const exploreChart = (formData, requestParams) => {
const url = getExploreUrl({
formData,
endpointType: 'base',
allowDomainSharding: false,
requestParams,
});
SupersetClient.postForm(url, { form_data: safeStringify(formData) });
};

View File

@ -18,13 +18,14 @@
*/
import React from 'react';
import { t } from '@superset-ui/core';
import { DatasourceType, t } from '@superset-ui/core';
import {
ColumnMeta,
ColumnOption,
ControlConfig,
ControlPanelSectionConfig,
} from '@superset-ui/chart-controls';
import { ExplorePageInitialData } from './types';
export const controlPanelSectionsChartOptions: (ControlPanelSectionConfig | null)[] =
[
@ -108,3 +109,59 @@ export const controlPanelSectionsChartOptionsTable: ControlPanelSectionConfig[]
],
},
];
export const exploreInitialData: ExplorePageInitialData = {
form_data: {
datasource: '8__table',
metric: 'count',
slice_id: 371,
time_range: 'No filter',
viz_type: 'table',
},
slice: {
cache_timeout: null,
description: null,
slice_id: 371,
slice_name: 'Age distribution of respondents',
is_managed_externally: false,
form_data: {
datasource: '8__table',
metric: 'count',
slice_id: 371,
time_range: 'No filter',
viz_type: 'table',
},
},
dataset: {
id: 8,
type: DatasourceType.Table,
columns: [{ column_name: 'a' }],
metrics: [{ metric_name: 'first' }, { metric_name: 'second' }],
column_format: {},
verbose_map: {},
main_dttm_col: '',
datasource_name: '8__table',
description: null,
},
};
export const fallbackExploreInitialData: ExplorePageInitialData = {
form_data: {
datasource: '0__table',
viz_type: 'table',
},
dataset: {
id: 0,
type: DatasourceType.Table,
columns: [],
metrics: [],
column_format: {},
verbose_map: {},
main_dttm_col: '',
owners: [],
datasource_name: 'missing_datasource',
name: 'missing_datasource',
description: null,
},
slice: null,
};

View File

@ -20,10 +20,11 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import logger from '../middleware/loggerMiddleware';
import { initFeatureFlags } from '../featureFlags';
import { initEnhancer } from '../reduxUtils';
import getInitialState from './reducers/getInitialState';
import shortid from 'shortid';
import getToastsFromPyFlashMessages from 'src/components/MessageToasts/getToastsFromPyFlashMessages';
import logger from 'src/middleware/loggerMiddleware';
import { initFeatureFlags } from 'src/featureFlags';
import { initEnhancer } from 'src/reduxUtils';
import rootReducer from './reducers/index';
import App from './App';
@ -31,11 +32,18 @@ const exploreViewContainer = document.getElementById('app');
const bootstrapData = JSON.parse(
exploreViewContainer.getAttribute('data-bootstrap'),
);
initFeatureFlags(bootstrapData.common.feature_flags);
const initState = getInitialState(bootstrapData);
const user = { ...bootstrapData.user };
const common = { ...bootstrapData.common };
initFeatureFlags(common.feature_flags);
const store = createStore(
rootReducer,
initState,
{
user,
common,
impressionId: shortid.generate(),
messageToasts: getToastsFromPyFlashMessages(common?.flash_messages || []),
},
compose(applyMiddleware(thunk, logger), initEnhancer(false)),
);

View File

@ -22,11 +22,12 @@ import {
AnyDatasourcesAction,
SET_DATASOURCE,
} from '../actions/datasourcesActions';
import { HYDRATE_EXPLORE, HydrateExplore } from '../actions/hydrateExplore';
export default function datasourcesReducer(
// TODO: change type to include other datasource types
datasources: { [key: string]: Dataset },
action: AnyDatasourcesAction,
action: AnyDatasourcesAction | HydrateExplore,
) {
if (action.type === SET_DATASOURCE) {
return {
@ -34,5 +35,8 @@ export default function datasourcesReducer(
[getDatasourceUid(action.datasource)]: action.datasource,
};
}
if (action.type === HYDRATE_EXPLORE) {
return { ...(action as HydrateExplore).data.datasources };
}
return datasources || {};
}

View File

@ -28,6 +28,7 @@ import {
StandardizedFormData,
} from 'src/explore/controlUtils';
import * as actions from 'src/explore/actions/exploreActions';
import { HYDRATE_EXPLORE } from '../actions/hydrateExplore';
export default function exploreReducer(state = {}, action) {
const actionHandlers = {
@ -247,8 +248,12 @@ export default function exploreReducer(state = {}, action) {
force: action.force,
};
},
[HYDRATE_EXPLORE]() {
return {
...action.data.explore,
};
},
};
if (action.type in actionHandlers) {
return actionHandlers[action.type]();
}

View File

@ -1,146 +0,0 @@
/**
* 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.
*/
import shortid from 'shortid';
import {
DatasourceType,
ensureIsArray,
JsonObject,
QueryFormData,
} from '@superset-ui/core';
import { ControlStateMapping, Dataset } from '@superset-ui/chart-controls';
import {
CommonBootstrapData,
UserWithPermissionsAndRoles,
} from 'src/types/bootstrapTypes';
import getToastsFromPyFlashMessages from 'src/components/MessageToasts/getToastsFromPyFlashMessages';
import { ChartState, Slice } from 'src/explore/types';
import { getChartKey } from 'src/explore/exploreUtils';
import { getControlsState } from 'src/explore/store';
import {
getFormDataFromControls,
applyMapStateToPropsToControl,
} from 'src/explore/controlUtils';
import { findPermission } from 'src/utils/findPermission';
import { getDatasourceUid } from 'src/utils/getDatasourceUid';
import { getUrlParam } from 'src/utils/urlUtils';
import { URL_PARAMS } from 'src/constants';
export interface ExplorePageBootstrapData extends JsonObject {
can_add: boolean;
can_download: boolean;
can_overwrite: boolean;
common: CommonBootstrapData;
datasource: Dataset;
datasource_id: number;
datasource_type: DatasourceType;
forced_height: string | null;
form_data: QueryFormData;
slice: Slice | null;
standalone: boolean;
force: boolean;
user: UserWithPermissionsAndRoles;
}
export default function getInitialState(
bootstrapData: ExplorePageBootstrapData,
) {
const {
form_data: initialFormData,
common,
user,
datasource,
slice,
} = bootstrapData;
const exploreState = {
// note this will add `form_data` to state,
// which will be manipulatable by future reducers.
can_add: findPermission('can_write', 'Chart', user?.roles),
can_download: findPermission('can_csv', 'Superset', user?.roles),
can_overwrite: ensureIsArray(slice?.owners).includes(
user?.userId as number,
),
isDatasourceMetaLoading: false,
isStarred: false,
triggerRender: false,
// duplicate datasource in exploreState - it's needed by getControlsState
datasource,
// Initial control state will skip `control.mapStateToProps`
// because `bootstrapData.controls` is undefined.
controls: getControlsState(
bootstrapData,
initialFormData,
) as ControlStateMapping,
form_data: initialFormData,
slice,
controlsTransferred: [],
standalone: getUrlParam(URL_PARAMS.standalone),
force: getUrlParam(URL_PARAMS.force),
};
// apply initial mapStateToProps for all controls, must execute AFTER
// bootstrapState has initialized `controls`. Order of execution is not
// guaranteed, so controls shouldn't rely on each other's mapped state.
Object.entries(exploreState.controls).forEach(([key, controlState]) => {
exploreState.controls[key] = applyMapStateToPropsToControl(
controlState,
exploreState,
);
});
const sliceFormData = slice
? getFormDataFromControls(getControlsState(bootstrapData, slice.form_data))
: null;
const chartKey: number = getChartKey(bootstrapData);
const chart: ChartState = {
id: chartKey,
chartAlert: null,
chartStatus: null,
chartStackTrace: null,
chartUpdateEndTime: null,
chartUpdateStartTime: 0,
latestQueryFormData: getFormDataFromControls(exploreState.controls),
sliceFormData,
queryController: null,
queriesResponse: null,
triggerQuery: false,
lastRendered: 0,
};
return {
common: common || {},
user: user || {},
charts: {
[chartKey]: chart,
},
datasources: { [getDatasourceUid(datasource)]: datasource },
saveModal: {
dashboards: [],
saveModalAlert: null,
},
explore: exploreState,
impressionId: shortid.generate(),
messageToasts: getToastsFromPyFlashMessages(
(bootstrapData.common || {}).flash_messages || [],
),
};
}
export type ExplorePageState = ReturnType<typeof getInitialState>;

View File

@ -18,6 +18,7 @@
*/
/* eslint camelcase: 0 */
import * as actions from '../actions/saveModalActions';
import { HYDRATE_EXPLORE } from '../actions/hydrateExplore';
export default function saveModalReducer(state = {}, action) {
const actionHandlers = {
@ -39,6 +40,9 @@ export default function saveModalReducer(state = {}, action) {
[actions.REMOVE_SAVE_MODAL_ALERT]() {
return { ...state, saveModalAlert: null };
},
[HYDRATE_EXPLORE]() {
return { ...action.data.saveModal };
},
};
if (action.type in actionHandlers) {

View File

@ -42,7 +42,7 @@ export function getControlsState(state, inputFormData) {
// Getting a list of active control names for the current viz
const formData = { ...inputFormData };
const vizType =
formData.viz_type || state.common.conf.DEFAULT_VIZ_TYPE || 'table';
formData.viz_type || state.common?.conf.DEFAULT_VIZ_TYPE || 'table';
handleDeprecatedControls(formData);

View File

@ -21,13 +21,17 @@ import {
QueryFormData,
AnnotationData,
AdhocMetric,
JsonObject,
} from '@superset-ui/core';
import { ColumnMeta, Dataset } from '@superset-ui/chart-controls';
import {
ColumnMeta,
ControlStateMapping,
Dataset,
} from '@superset-ui/chart-controls';
import { DatabaseObject } from 'src/views/CRUD/types';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import { toastState } from 'src/SqlLab/types';
export { Slice, Chart } from 'src/types/Chart';
import { Slice } from 'src/types/Chart';
export type ChartStatus =
| 'loading'
@ -90,3 +94,40 @@ export type ExploreRootState = {
messageToasts: toastState[];
common: {};
};
export interface ExplorePageInitialData {
dataset: Dataset;
form_data: QueryFormData;
slice: Slice | null;
}
export interface ExploreResponsePayload {
result: ExplorePageInitialData & { message: string };
}
export interface ExplorePageState {
user: UserWithPermissionsAndRoles;
common: {
flash_messages: string[];
conf: JsonObject;
};
charts: { [key: number]: ChartState };
datasources: { [key: string]: Dataset };
explore: {
can_add: boolean;
can_download: boolean;
can_overwrite: boolean;
isDatasourceMetaLoading: boolean;
isStarred: boolean;
triggerRender: boolean;
// duplicate datasource in exploreState - it's needed by getControlsState
datasource: Dataset;
controls: ControlStateMapping;
form_data: QueryFormData;
slice: Slice;
controlsTransferred: string[];
standalone: boolean;
force: boolean;
};
sliceEntities?: JsonObject; // propagated from Dashboard view
}