mirror of
https://github.com/apache/superset.git
synced 2026-05-03 06:54:19 +00:00
Compare commits
78 Commits
supersetbo
...
v2021.31.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbbc1be805 | ||
|
|
8abf9c24d0 | ||
|
|
c178bbddcb | ||
|
|
906124a69a | ||
|
|
8d92d731fd | ||
|
|
0145f21def | ||
|
|
86dd052e0e | ||
|
|
6550d56560 | ||
|
|
89df102300 | ||
|
|
68756c8ff5 | ||
|
|
61ae7166f7 | ||
|
|
9916c3c0a2 | ||
|
|
08960aa5c9 | ||
|
|
0dc73c56d0 | ||
|
|
0ce0ad42e7 | ||
|
|
a9a5d6e544 | ||
|
|
966b417035 | ||
|
|
525b88f42e | ||
|
|
a1f9f02524 | ||
|
|
f8d3037c25 | ||
|
|
bbc8dadc83 | ||
|
|
a1b297c133 | ||
|
|
f0fb37fda9 | ||
|
|
af1a63b672 | ||
|
|
c8b7a99ca7 | ||
|
|
dc4ab8da94 | ||
|
|
f2ab391f99 | ||
|
|
728c457d4b | ||
|
|
624d521f8e | ||
|
|
7e0dee618d | ||
|
|
c06383088b | ||
|
|
4161c128c9 | ||
|
|
9ab34c9fb0 | ||
|
|
32f1dae38d | ||
|
|
03a67f7ff1 | ||
|
|
de615eb79e | ||
|
|
9fb638da6f | ||
|
|
60ceb9213f | ||
|
|
7458292ca9 | ||
|
|
adb3ebbba3 | ||
|
|
c5f07bc6c1 | ||
|
|
81d2d32dbf | ||
|
|
cce369ee00 | ||
|
|
d95721cb14 | ||
|
|
0a91bc8c3f | ||
|
|
13f01ac2ab | ||
|
|
705bad9792 | ||
|
|
86d079b31c | ||
|
|
b4d4d1cc88 | ||
|
|
7c5546586c | ||
|
|
5895af50f5 | ||
|
|
baeac9dc97 | ||
|
|
9e21009db3 | ||
|
|
79e4253230 | ||
|
|
520284a4a4 | ||
|
|
bb78d492e9 | ||
|
|
1e9f1de563 | ||
|
|
d00a2c2899 | ||
|
|
0b07566346 | ||
|
|
c0572c5302 | ||
|
|
4b4f6b9c1d | ||
|
|
ffa4226f10 | ||
|
|
64d54d6fdd | ||
|
|
fa7a5249ea | ||
|
|
f8f3b7abbb | ||
|
|
83661aee99 | ||
|
|
9976250954 | ||
|
|
08e64048fa | ||
|
|
18f551580e | ||
|
|
d85875d962 | ||
|
|
31f994b090 | ||
|
|
a16605cf01 | ||
|
|
e70bcc7283 | ||
|
|
980c38d1b7 | ||
|
|
87fad13f89 | ||
|
|
a1a71ee6e1 | ||
|
|
feab690b8d | ||
|
|
b7d9be449d |
@@ -118,6 +118,9 @@ services:
|
||||
depends_on: *superset-depends-on
|
||||
user: *superset-user
|
||||
volumes: *superset-volumes
|
||||
# Bump memory limit if processing selenium / thumbails on superset-worker
|
||||
# mem_limit: 2038m
|
||||
# mem_reservation: 128M
|
||||
|
||||
superset-worker-beat:
|
||||
image: *superset-image
|
||||
|
||||
@@ -66,7 +66,7 @@ Navigate to **Data ‣ Datasets** and select the **+ Dataset** button in the top
|
||||
|
||||
A modal window should pop up in front of you. Select your **Database**,
|
||||
**Schema**, and **Table** using the drop downs that appear. In the following example,
|
||||
we register the **cleaned_sales_data** table from the **examples** database.
|
||||
we register the **Vehicle Sales** table from the **examples** database.
|
||||
|
||||
<img src="/images/tutorial_09_add_new_table.png" />
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ combine_as_imports = true
|
||||
include_trailing_comma = true
|
||||
line_length = 88
|
||||
known_first_party = superset
|
||||
known_third_party =alembic,apispec,backoff,bleach,cachelib,celery,click,colorama,cron_descriptor,croniter,cryptography,dateutil,deprecation,flask,flask_appbuilder,flask_babel,flask_caching,flask_compress,flask_jwt_extended,flask_login,flask_migrate,flask_sqlalchemy,flask_talisman,flask_testing,flask_wtf,freezegun,geohash,geopy,graphlib,holidays,humanize,isodate,jinja2,jwt,markdown,markupsafe,marshmallow,marshmallow_enum,msgpack,numpy,pandas,parameterized,parsedatetime,pgsanity,pkg_resources,polyline,prison,progress,pyarrow,pyhive,pyparsing,pytest,pytest_mock,pytz,redis,requests,selenium,setuptools,simplejson,slack,sqlalchemy,sqlalchemy_utils,sqlparse,tabulate,typing_extensions,werkzeug,wtforms,wtforms_json,yaml
|
||||
known_third_party =alembic,apispec,backoff,bleach,cachelib,celery,click,colorama,cron_descriptor,croniter,cryptography,dateutil,deprecation,flask,flask_appbuilder,flask_babel,flask_caching,flask_compress,flask_jwt_extended,flask_login,flask_migrate,flask_sqlalchemy,flask_talisman,flask_testing,flask_wtf,freezegun,geohash,geopy,graphlib,holidays,humanize,isodate,jinja2,jwt,markdown,markupsafe,marshmallow,marshmallow_enum,msgpack,numpy,pandas,parameterized,parsedatetime,pgsanity,pkg_resources,polyline,prison,progress,pyarrow,pyhive,pyparsing,pytest,pytest_mock,pytz,redis,requests,selenium,setuptools,simplejson,slack,sqlalchemy,sqlalchemy_utils,sqlparse,typing_extensions,urllib3,werkzeug,wtforms,wtforms_json,yaml
|
||||
multi_line_output = 3
|
||||
order_by_type = false
|
||||
|
||||
|
||||
2582
superset-frontend/package-lock.json
generated
2582
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -67,35 +67,35 @@
|
||||
"@emotion/babel-preset-css-prop": "^11.2.0",
|
||||
"@emotion/cache": "^11.1.3",
|
||||
"@emotion/react": "^11.1.5",
|
||||
"@superset-ui/chart-controls": "^0.17.77",
|
||||
"@superset-ui/core": "^0.17.75",
|
||||
"@superset-ui/legacy-plugin-chart-calendar": "^0.17.77",
|
||||
"@superset-ui/legacy-plugin-chart-chord": "^0.17.77",
|
||||
"@superset-ui/legacy-plugin-chart-country-map": "^0.17.77",
|
||||
"@superset-ui/legacy-plugin-chart-event-flow": "^0.17.77",
|
||||
"@superset-ui/legacy-plugin-chart-force-directed": "^0.17.77",
|
||||
"@superset-ui/legacy-plugin-chart-heatmap": "^0.17.77",
|
||||
"@superset-ui/legacy-plugin-chart-histogram": "^0.17.77",
|
||||
"@superset-ui/legacy-plugin-chart-horizon": "^0.17.77",
|
||||
"@superset-ui/legacy-plugin-chart-map-box": "^0.17.77",
|
||||
"@superset-ui/legacy-plugin-chart-paired-t-test": "^0.17.77",
|
||||
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "^0.17.77",
|
||||
"@superset-ui/legacy-plugin-chart-partition": "^0.17.77",
|
||||
"@superset-ui/legacy-plugin-chart-pivot-table": "^0.17.77",
|
||||
"@superset-ui/legacy-plugin-chart-rose": "^0.17.77",
|
||||
"@superset-ui/legacy-plugin-chart-sankey": "^0.17.77",
|
||||
"@superset-ui/legacy-plugin-chart-sankey-loop": "^0.17.77",
|
||||
"@superset-ui/legacy-plugin-chart-sunburst": "^0.17.77",
|
||||
"@superset-ui/legacy-plugin-chart-treemap": "^0.17.77",
|
||||
"@superset-ui/legacy-plugin-chart-world-map": "^0.17.77",
|
||||
"@superset-ui/legacy-preset-chart-big-number": "^0.17.77",
|
||||
"@superset-ui/legacy-preset-chart-deckgl": "^0.4.7",
|
||||
"@superset-ui/legacy-preset-chart-nvd3": "^0.17.77",
|
||||
"@superset-ui/plugin-chart-echarts": "^0.17.77",
|
||||
"@superset-ui/plugin-chart-pivot-table": "^0.17.77",
|
||||
"@superset-ui/plugin-chart-table": "^0.17.77",
|
||||
"@superset-ui/plugin-chart-word-cloud": "^0.17.77",
|
||||
"@superset-ui/preset-chart-xy": "^0.17.77",
|
||||
"@superset-ui/chart-controls": "^0.17.85",
|
||||
"@superset-ui/core": "^0.17.81",
|
||||
"@superset-ui/legacy-plugin-chart-calendar": "^0.17.85",
|
||||
"@superset-ui/legacy-plugin-chart-chord": "^0.17.85",
|
||||
"@superset-ui/legacy-plugin-chart-country-map": "^0.17.85",
|
||||
"@superset-ui/legacy-plugin-chart-event-flow": "^0.17.85",
|
||||
"@superset-ui/legacy-plugin-chart-force-directed": "^0.17.85",
|
||||
"@superset-ui/legacy-plugin-chart-heatmap": "^0.17.85",
|
||||
"@superset-ui/legacy-plugin-chart-histogram": "^0.17.85",
|
||||
"@superset-ui/legacy-plugin-chart-horizon": "^0.17.85",
|
||||
"@superset-ui/legacy-plugin-chart-map-box": "^0.17.85",
|
||||
"@superset-ui/legacy-plugin-chart-paired-t-test": "^0.17.85",
|
||||
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "^0.17.85",
|
||||
"@superset-ui/legacy-plugin-chart-partition": "^0.17.85",
|
||||
"@superset-ui/legacy-plugin-chart-pivot-table": "^0.17.85",
|
||||
"@superset-ui/legacy-plugin-chart-rose": "^0.17.85",
|
||||
"@superset-ui/legacy-plugin-chart-sankey": "^0.17.85",
|
||||
"@superset-ui/legacy-plugin-chart-sankey-loop": "^0.17.85",
|
||||
"@superset-ui/legacy-plugin-chart-sunburst": "^0.17.85",
|
||||
"@superset-ui/legacy-plugin-chart-treemap": "^0.17.85",
|
||||
"@superset-ui/legacy-plugin-chart-world-map": "^0.17.85",
|
||||
"@superset-ui/legacy-preset-chart-big-number": "^0.17.85",
|
||||
"@superset-ui/legacy-preset-chart-deckgl": "^0.4.10",
|
||||
"@superset-ui/legacy-preset-chart-nvd3": "^0.17.85",
|
||||
"@superset-ui/plugin-chart-echarts": "^0.17.85",
|
||||
"@superset-ui/plugin-chart-pivot-table": "^0.17.85",
|
||||
"@superset-ui/plugin-chart-table": "^0.17.85",
|
||||
"@superset-ui/plugin-chart-word-cloud": "^0.17.85",
|
||||
"@superset-ui/preset-chart-xy": "^0.17.85",
|
||||
"@vx/responsive": "^0.0.195",
|
||||
"abortcontroller-polyfill": "^1.1.9",
|
||||
"antd": "^4.9.4",
|
||||
|
||||
@@ -67,60 +67,14 @@ const sumValueAdhocMetric = new AdhocMetric({
|
||||
label: 'SUM(value)',
|
||||
});
|
||||
|
||||
describe('MetricsControl', () => {
|
||||
// TODO: rewrite the tests to RTL
|
||||
describe.skip('MetricsControl', () => {
|
||||
it('renders Select', () => {
|
||||
const { component } = setup();
|
||||
expect(component.find(LabelsContainer)).toExist();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('unifies options for the dropdown select with aggregates', () => {
|
||||
const { component } = setup();
|
||||
expect(component.state('options')).toEqual([
|
||||
{
|
||||
optionName: '_col_source',
|
||||
type: 'VARCHAR(255)',
|
||||
column_name: 'source',
|
||||
},
|
||||
{
|
||||
optionName: '_col_target',
|
||||
type: 'VARCHAR(255)',
|
||||
column_name: 'target',
|
||||
},
|
||||
{ optionName: '_col_value', type: 'DOUBLE', column_name: 'value' },
|
||||
...Object.keys(AGGREGATES).map(aggregate => ({
|
||||
aggregate_name: aggregate,
|
||||
optionName: `_aggregate_${aggregate}`,
|
||||
})),
|
||||
{
|
||||
optionName: 'sum__value',
|
||||
metric_name: 'sum__value',
|
||||
expression: 'SUM(energy_usage.value)',
|
||||
},
|
||||
{
|
||||
optionName: 'avg__value',
|
||||
metric_name: 'avg__value',
|
||||
expression: 'AVG(energy_usage.value)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not show aggregates in options if no columns', () => {
|
||||
const { component } = setup({ columns: [] });
|
||||
expect(component.state('options')).toEqual([
|
||||
{
|
||||
optionName: 'sum__value',
|
||||
metric_name: 'sum__value',
|
||||
expression: 'SUM(energy_usage.value)',
|
||||
},
|
||||
{
|
||||
optionName: 'avg__value',
|
||||
metric_name: 'avg__value',
|
||||
expression: 'AVG(energy_usage.value)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('coerces Adhoc Metrics from form data into instances of the AdhocMetric class and leaves saved metrics', () => {
|
||||
const { component } = setup({
|
||||
value: [
|
||||
@@ -178,194 +132,7 @@ describe('MetricsControl', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkIfAggregateInInput', () => {
|
||||
it('handles an aggregate in the input', () => {
|
||||
const { component } = setup();
|
||||
|
||||
expect(component.state('aggregateInInput')).toBeNull();
|
||||
component.instance().checkIfAggregateInInput('AVG(');
|
||||
expect(component.state('aggregateInInput')).toBe(AGGREGATES.AVG);
|
||||
});
|
||||
|
||||
it('handles no aggregate in the input', () => {
|
||||
const { component } = setup();
|
||||
|
||||
expect(component.state('aggregateInInput')).toBeNull();
|
||||
component.instance().checkIfAggregateInInput('colu');
|
||||
expect(component.state('aggregateInInput')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('option filter', () => {
|
||||
it('includes user defined metrics', () => {
|
||||
const { component } = setup({ datasourceType: 'druid' });
|
||||
|
||||
expect(
|
||||
!!component.instance().selectFilterOption(
|
||||
{
|
||||
data: {
|
||||
metric_name: 'a_metric',
|
||||
optionName: 'a_metric',
|
||||
expression: 'SUM(FANCY(metric))',
|
||||
},
|
||||
},
|
||||
'a',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('includes auto generated avg metrics for druid', () => {
|
||||
const { component } = setup({ datasourceType: 'druid' });
|
||||
|
||||
expect(
|
||||
!!component.instance().selectFilterOption(
|
||||
{
|
||||
data: {
|
||||
metric_name: 'avg__metric',
|
||||
optionName: 'avg__metric',
|
||||
expression: 'AVG(metric)',
|
||||
},
|
||||
},
|
||||
'a',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('includes columns and aggregates', () => {
|
||||
const { component } = setup();
|
||||
|
||||
expect(
|
||||
!!component.instance().selectFilterOption(
|
||||
{
|
||||
data: {
|
||||
type: 'VARCHAR(255)',
|
||||
column_name: 'source',
|
||||
optionName: '_col_source',
|
||||
},
|
||||
},
|
||||
'sou',
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
!!component
|
||||
.instance()
|
||||
.selectFilterOption(
|
||||
{ data: { aggregate_name: 'AVG', optionName: '_aggregate_AVG' } },
|
||||
'av',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('includes columns based on verbose_name', () => {
|
||||
const { component } = setup();
|
||||
|
||||
expect(
|
||||
!!component.instance().selectFilterOption(
|
||||
{
|
||||
data: {
|
||||
metric_name: 'sum__num',
|
||||
verbose_name: 'babies',
|
||||
optionName: '_col_sum_num',
|
||||
},
|
||||
},
|
||||
'bab',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes auto generated avg metrics for sqla', () => {
|
||||
const { component } = setup();
|
||||
|
||||
expect(
|
||||
!!component.instance().selectFilterOption(
|
||||
{
|
||||
data: {
|
||||
metric_name: 'avg__metric',
|
||||
optionName: 'avg__metric',
|
||||
expression: 'AVG(metric)',
|
||||
},
|
||||
},
|
||||
'a',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('includes custom made simple saved metrics', () => {
|
||||
const { component } = setup();
|
||||
|
||||
expect(
|
||||
!!component.instance().selectFilterOption(
|
||||
{
|
||||
data: {
|
||||
metric_name: 'my_fancy_sum_metric',
|
||||
optionName: 'my_fancy_sum_metric',
|
||||
expression: 'SUM(value)',
|
||||
},
|
||||
},
|
||||
'sum',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes auto generated metrics', () => {
|
||||
const { component } = setup();
|
||||
|
||||
expect(
|
||||
!!component.instance().selectFilterOption(
|
||||
{
|
||||
data: {
|
||||
metric_name: 'sum__value',
|
||||
optionName: 'sum__value',
|
||||
expression: 'SUM(value)',
|
||||
},
|
||||
},
|
||||
'sum',
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
!!component.instance().selectFilterOption(
|
||||
{
|
||||
data: {
|
||||
metric_name: 'sum__value',
|
||||
optionName: 'sum__value',
|
||||
expression: 'SUM("table"."value")',
|
||||
},
|
||||
},
|
||||
'sum',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('filters out metrics if the input begins with an aggregate', () => {
|
||||
const { component } = setup();
|
||||
component.setState({ aggregateInInput: true });
|
||||
|
||||
expect(
|
||||
!!component.instance().selectFilterOption(
|
||||
{
|
||||
data: { metric_name: 'metric', expression: 'SUM(FANCY(metric))' },
|
||||
},
|
||||
'SUM(',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('includes columns if the input begins with an aggregate', () => {
|
||||
const { component } = setup();
|
||||
component.setState({ aggregateInInput: true });
|
||||
|
||||
expect(
|
||||
!!component
|
||||
.instance()
|
||||
.selectFilterOption(
|
||||
{ data: { type: 'DOUBLE', column_name: 'value' } },
|
||||
'SUM(',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('Removes metrics if savedMetrics changes', () => {
|
||||
const { props, component, onChange } = setup({
|
||||
value: [
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('VizTypeControl', () => {
|
||||
new ChartMetadata({
|
||||
name: 'vis1',
|
||||
thumbnail: '',
|
||||
tags: ['Highly-used'],
|
||||
tags: ['Popular'],
|
||||
}),
|
||||
)
|
||||
.registerValue(
|
||||
|
||||
@@ -133,7 +133,8 @@ const TableElement = ({ table, actions, ...props }: TableElementProps) => {
|
||||
));
|
||||
}
|
||||
|
||||
if (!partitions && !metadata) {
|
||||
if (!partitions && (!metadata || !metadata.length)) {
|
||||
// hide partition and metadata card view
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,3 +32,5 @@ const StyledForm = styled(AntDForm)`
|
||||
export default function Form(props: FormProps) {
|
||||
return <StyledForm {...props} />;
|
||||
}
|
||||
|
||||
export { FormProps };
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import Form from './Form';
|
||||
import Form, { FormProps } from './Form';
|
||||
import FormItem from './FormItem';
|
||||
import FormLabel from './FormLabel';
|
||||
import LabeledErrorBoundInput from './LabeledErrorBoundInput';
|
||||
|
||||
export { Form, FormItem, FormLabel, LabeledErrorBoundInput };
|
||||
export { Form, FormItem, FormLabel, LabeledErrorBoundInput, FormProps };
|
||||
|
||||
@@ -27,12 +27,21 @@ interface CardCollectionProps {
|
||||
prepareRow: TableInstance['prepareRow'];
|
||||
renderCard?: (row: any) => React.ReactNode;
|
||||
rows: TableInstance['rows'];
|
||||
showThumbnails?: boolean;
|
||||
}
|
||||
|
||||
const CardContainer = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(459px, 1fr));
|
||||
grid-gap: ${({ theme }) => theme.gridUnit * 8}px;
|
||||
const CardContainer = styled.div<{ showThumbnails?: boolean }>`
|
||||
${({ theme, showThumbnails }) => `
|
||||
display: grid;
|
||||
grid-gap: ${theme.gridUnit * 12}px ${theme.gridUnit * 4}px;
|
||||
grid-template-columns: repeat(auto-fit, 300px);
|
||||
margin-top: ${theme.gridUnit * -6}px;
|
||||
padding: ${
|
||||
showThumbnails
|
||||
? `${theme.gridUnit * 8 + 3}px ${theme.gridUnit * 9}px`
|
||||
: `${theme.gridUnit * 8 + 1}px ${theme.gridUnit * 9}px`
|
||||
};
|
||||
`}
|
||||
`;
|
||||
|
||||
const CardWrapper = styled.div`
|
||||
@@ -51,6 +60,7 @@ export default function CardCollection({
|
||||
prepareRow,
|
||||
renderCard,
|
||||
rows,
|
||||
showThumbnails,
|
||||
}: CardCollectionProps) {
|
||||
function handleClick(
|
||||
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
@@ -65,7 +75,7 @@ export default function CardCollection({
|
||||
|
||||
if (!renderCard) return null;
|
||||
return (
|
||||
<CardContainer>
|
||||
<CardContainer showThumbnails={showThumbnails}>
|
||||
{loading &&
|
||||
rows.length === 0 &&
|
||||
[...new Array(25)].map((e, i) => (
|
||||
|
||||
@@ -221,6 +221,7 @@ export interface ListViewProps<T extends object = any> {
|
||||
cardSortSelectOptions?: Array<CardSortSelectOption>;
|
||||
defaultViewMode?: ViewModeType;
|
||||
highlightRowId?: number;
|
||||
showThumbnails?: boolean;
|
||||
emptyState?: {
|
||||
message?: string;
|
||||
slot?: React.ReactNode;
|
||||
@@ -242,6 +243,7 @@ function ListView<T extends object = any>({
|
||||
disableBulkSelect = () => {},
|
||||
renderBulkSelectCopy = selected => t('%s Selected', selected.length),
|
||||
renderCard,
|
||||
showThumbnails,
|
||||
cardSortSelectOptions,
|
||||
defaultViewMode = 'card',
|
||||
highlightRowId,
|
||||
@@ -376,6 +378,7 @@ function ListView<T extends object = any>({
|
||||
renderCard={renderCard}
|
||||
rows={rows}
|
||||
loading={loading}
|
||||
showThumbnails={showThumbnails}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'table' && (
|
||||
|
||||
@@ -69,15 +69,6 @@ const StyledAnchor = styled.a`
|
||||
padding-left: ${({ theme }) => theme.gridUnit}px;
|
||||
`;
|
||||
|
||||
const WaterMark = styled.span`
|
||||
font-size: 13px;
|
||||
color: #b0b4c3;
|
||||
margin: 0 ${({ theme }) => theme.gridUnit * 4}px;
|
||||
@media (max-width: 1070px) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const { SubMenu } = Menu;
|
||||
|
||||
interface RightMenuProps {
|
||||
@@ -95,9 +86,6 @@ const RightMenu = ({
|
||||
}: RightMenuProps) => (
|
||||
<StyledDiv align={align}>
|
||||
<Menu mode="horizontal">
|
||||
{navbarRight.show_watermark && (
|
||||
<WaterMark>{t('Powered by Apache Superset')}</WaterMark>
|
||||
)}
|
||||
{!navbarRight.user_is_anonymous && (
|
||||
<SubMenu
|
||||
data-test="new-dropdown"
|
||||
@@ -148,9 +136,11 @@ const RightMenu = ({
|
||||
<a href={navbarRight.user_profile_url}>{t('Profile')}</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item key="info">
|
||||
<a href={navbarRight.user_info_url}>{t('Info')}</a>
|
||||
</Menu.Item>
|
||||
{navbarRight.user_info_url && (
|
||||
<Menu.Item key="info">
|
||||
<a href={navbarRight.user_info_url}>{t('Info')}</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item key="logout">
|
||||
<a href={navbarRight.user_logout_url}>{t('Logout')}</a>
|
||||
</Menu.Item>
|
||||
@@ -160,6 +150,11 @@ const RightMenu = ({
|
||||
<Menu.Divider key="version-info-divider" />,
|
||||
<Menu.ItemGroup key="about-section" title={t('About')}>
|
||||
<div className="about-section">
|
||||
{navbarRight.show_watermark && (
|
||||
<div css={versionInfoStyles}>
|
||||
{t('Powered by Apache Superset')}
|
||||
</div>
|
||||
)}
|
||||
{navbarRight.version_string && (
|
||||
<div css={versionInfoStyles}>
|
||||
Version: {navbarRight.version_string}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { t, SupersetTheme, css } from '@superset-ui/core';
|
||||
import { t, SupersetTheme, css, useTheme } from '@superset-ui/core';
|
||||
import Icons from 'src/components/Icons';
|
||||
import { Switch } from 'src/components/Switch';
|
||||
import { AlertObject } from 'src/views/CRUD/alert/types';
|
||||
@@ -47,6 +47,7 @@ export default function HeaderReportActionsDropDown({
|
||||
currentReportDeleting,
|
||||
setCurrentReportDeleting,
|
||||
] = useState<AlertObject | null>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
const toggleActiveKey = async (data: AlertObject, checked: boolean) => {
|
||||
if (data?.id) {
|
||||
@@ -60,7 +61,7 @@ export default function HeaderReportActionsDropDown({
|
||||
};
|
||||
|
||||
const menu = () => (
|
||||
<Menu selectable={false}>
|
||||
<Menu selectable={false} css={{ width: '200px' }}>
|
||||
<Menu.Item>
|
||||
{t('Email reports active')}
|
||||
<Switch
|
||||
@@ -68,6 +69,7 @@ export default function HeaderReportActionsDropDown({
|
||||
checked={report?.active}
|
||||
onClick={(checked: boolean) => toggleActiveKey(report, checked)}
|
||||
size="small"
|
||||
css={{ marginLeft: theme.gridUnit * 2 }}
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={showReportModal}>{t('Edit email report')}</Menu.Item>
|
||||
|
||||
@@ -38,6 +38,13 @@ const defaultProps = {
|
||||
userEmail: 'test@test.com',
|
||||
dashboardId: 1,
|
||||
creationMethod: 'charts_dashboards',
|
||||
props: {
|
||||
chart: {
|
||||
sliceFormData: {
|
||||
viz_type: 'table',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('Email Report Modal', () => {
|
||||
|
||||
@@ -29,22 +29,28 @@ import { bindActionCreators } from 'redux';
|
||||
import { connect, useDispatch, useSelector } from 'react-redux';
|
||||
import { addReport, editReport } from 'src/reports/actions/reports';
|
||||
import { AlertObject } from 'src/views/CRUD/alert/types';
|
||||
import LabeledErrorBoundInput from 'src/components/Form/LabeledErrorBoundInput';
|
||||
|
||||
import TimezoneSelector from 'src/components/TimezoneSelector';
|
||||
import LabeledErrorBoundInput from 'src/components/Form/LabeledErrorBoundInput';
|
||||
import Icons from 'src/components/Icons';
|
||||
import withToasts from 'src/messageToasts/enhancers/withToasts';
|
||||
import { CronPicker, CronError } from 'src/components/CronPicker';
|
||||
import { CronError } from 'src/components/CronPicker';
|
||||
import { RadioChangeEvent } from 'src/common/components';
|
||||
import {
|
||||
StyledModal,
|
||||
StyledTopSection,
|
||||
StyledBottomSection,
|
||||
StyledIconWrapper,
|
||||
StyledScheduleTitle,
|
||||
StyledCronPicker,
|
||||
StyledCronError,
|
||||
noBottomMargin,
|
||||
StyledFooterButton,
|
||||
TimezoneHeaderStyle,
|
||||
SectionHeaderStyle,
|
||||
StyledMessageContentTitle,
|
||||
StyledRadio,
|
||||
StyledRadioGroup,
|
||||
} from './styles';
|
||||
|
||||
interface ReportObject {
|
||||
@@ -67,6 +73,19 @@ interface ReportObject {
|
||||
creation_method: string;
|
||||
}
|
||||
|
||||
interface ChartObject {
|
||||
id: number;
|
||||
chartAlert: string;
|
||||
chartStatus: string;
|
||||
chartUpdateEndTime: number;
|
||||
chartUpdateStartTime: number;
|
||||
latestQueryFormData: object;
|
||||
queryController: { abort: () => {} };
|
||||
queriesResponse: object;
|
||||
triggerQuery: boolean;
|
||||
lastRendered: number;
|
||||
}
|
||||
|
||||
interface ReportProps {
|
||||
addDangerToast: (msg: string) => void;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
@@ -77,26 +96,25 @@ interface ReportProps {
|
||||
userId: number;
|
||||
userEmail: string;
|
||||
dashboardId?: number;
|
||||
chartId?: number;
|
||||
chart?: ChartObject;
|
||||
creationMethod: string;
|
||||
props: any;
|
||||
}
|
||||
|
||||
enum ActionType {
|
||||
textChange,
|
||||
inputChange,
|
||||
fetched,
|
||||
reset,
|
||||
}
|
||||
|
||||
interface ReportPayloadType {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
enum ActionType {
|
||||
inputChange,
|
||||
fetched,
|
||||
reset,
|
||||
}
|
||||
|
||||
type ReportActionType =
|
||||
| {
|
||||
type: ActionType.textChange | ActionType.inputChange;
|
||||
type: ActionType.inputChange;
|
||||
payload: ReportPayloadType;
|
||||
}
|
||||
| {
|
||||
@@ -107,27 +125,41 @@ type ReportActionType =
|
||||
type: ActionType.reset;
|
||||
};
|
||||
|
||||
const TEXT_BASED_VISUALIZATION_TYPES = [
|
||||
'pivot_table',
|
||||
'pivot_table_v2',
|
||||
'table',
|
||||
'paired_ttest',
|
||||
];
|
||||
|
||||
const NOTIFICATION_FORMATS = {
|
||||
TEXT: 'TEXT',
|
||||
PNG: 'PNG',
|
||||
CSV: 'CSV',
|
||||
};
|
||||
|
||||
const reportReducer = (
|
||||
state: Partial<ReportObject> | null,
|
||||
action: ReportActionType,
|
||||
): Partial<ReportObject> | null => {
|
||||
const initialState = {
|
||||
name: state?.name || 'Weekly Report',
|
||||
...(state || {}),
|
||||
name: 'Weekly Report',
|
||||
};
|
||||
|
||||
switch (action.type) {
|
||||
case ActionType.textChange:
|
||||
case ActionType.inputChange:
|
||||
return {
|
||||
...initialState,
|
||||
...state,
|
||||
[action.payload.name]: action.payload.value,
|
||||
};
|
||||
case ActionType.fetched:
|
||||
return {
|
||||
...initialState,
|
||||
...action.payload,
|
||||
};
|
||||
case ActionType.reset:
|
||||
return null;
|
||||
return { ...initialState };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -139,6 +171,12 @@ const ReportModal: FunctionComponent<ReportProps> = ({
|
||||
show = false,
|
||||
...props
|
||||
}) => {
|
||||
const vizType = props.props.chart?.sliceFormData?.viz_type;
|
||||
const isChart = !!props.props.chart;
|
||||
const defaultNotificationFormat =
|
||||
isChart && TEXT_BASED_VISUALIZATION_TYPES.includes(vizType)
|
||||
? NOTIFICATION_FORMATS.TEXT
|
||||
: NOTIFICATION_FORMATS.PNG;
|
||||
const [currentReport, setCurrentReport] = useReducer<
|
||||
Reducer<Partial<ReportObject> | null, ReportActionType>
|
||||
>(reportReducer, null);
|
||||
@@ -151,6 +189,7 @@ const ReportModal: FunctionComponent<ReportProps> = ({
|
||||
// Report fetch logic
|
||||
const reports = useSelector<any, AlertObject>(state => state.reports);
|
||||
const isEditMode = reports && Object.keys(reports).length;
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode) {
|
||||
const reportsIds = Object.keys(reports);
|
||||
@@ -166,7 +205,6 @@ const ReportModal: FunctionComponent<ReportProps> = ({
|
||||
}
|
||||
}, [reports]);
|
||||
const onClose = () => {
|
||||
// setLoading(false);
|
||||
onHide();
|
||||
};
|
||||
const onSave = async () => {
|
||||
@@ -174,7 +212,7 @@ const ReportModal: FunctionComponent<ReportProps> = ({
|
||||
const newReportValues: Partial<ReportObject> = {
|
||||
crontab: currentReport?.crontab,
|
||||
dashboard: props.props.dashboardId,
|
||||
chart: props.props.chartId,
|
||||
chart: props.props.chart?.id,
|
||||
description: currentReport?.description,
|
||||
name: currentReport?.name,
|
||||
owners: [props.props.userId],
|
||||
@@ -187,9 +225,10 @@ const ReportModal: FunctionComponent<ReportProps> = ({
|
||||
type: 'Report',
|
||||
creation_method: props.props.creationMethod,
|
||||
active: true,
|
||||
report_format: currentReport?.report_format || defaultNotificationFormat,
|
||||
timezone: currentReport?.timezone,
|
||||
};
|
||||
|
||||
// setLoading(true);
|
||||
if (isEditMode) {
|
||||
await dispatch(
|
||||
editReport(currentReport?.id, newReportValues as ReportObject),
|
||||
@@ -217,7 +256,7 @@ const ReportModal: FunctionComponent<ReportProps> = ({
|
||||
const renderModalFooter = (
|
||||
<>
|
||||
<StyledFooterButton key="back" onClick={onClose}>
|
||||
Cancel
|
||||
{t('Cancel')}
|
||||
</StyledFooterButton>
|
||||
<StyledFooterButton
|
||||
key="submit"
|
||||
@@ -225,11 +264,42 @@ const ReportModal: FunctionComponent<ReportProps> = ({
|
||||
onClick={onSave}
|
||||
disabled={!currentReport?.name}
|
||||
>
|
||||
Add
|
||||
{isEditMode ? t('Save') : t('Add')}
|
||||
</StyledFooterButton>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderMessageContentSection = (
|
||||
<>
|
||||
<StyledMessageContentTitle>
|
||||
<h4>{t('Message Content')}</h4>
|
||||
</StyledMessageContentTitle>
|
||||
<div className="inline-container">
|
||||
<StyledRadioGroup
|
||||
onChange={(event: RadioChangeEvent) => {
|
||||
onChange(ActionType.inputChange, {
|
||||
name: 'report_format',
|
||||
value: event.target.value,
|
||||
});
|
||||
}}
|
||||
value={currentReport?.report_format || defaultNotificationFormat}
|
||||
>
|
||||
{TEXT_BASED_VISUALIZATION_TYPES.includes(vizType) && (
|
||||
<StyledRadio value={NOTIFICATION_FORMATS.TEXT}>
|
||||
{t('Text embedded in email')}
|
||||
</StyledRadio>
|
||||
)}
|
||||
<StyledRadio value={NOTIFICATION_FORMATS.PNG}>
|
||||
{t('Image (PNG) embedded in email')}
|
||||
</StyledRadio>
|
||||
<StyledRadio value={NOTIFICATION_FORMATS.CSV}>
|
||||
{t('Formatted CSV attached in email')}
|
||||
</StyledRadio>
|
||||
</StyledRadioGroup>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
show={show}
|
||||
@@ -248,7 +318,7 @@ const ReportModal: FunctionComponent<ReportProps> = ({
|
||||
required
|
||||
validationMethods={{
|
||||
onChange: ({ target }: { target: HTMLInputElement }) =>
|
||||
onChange(ActionType.textChange, {
|
||||
onChange(ActionType.inputChange, {
|
||||
name: target.name,
|
||||
value: target.value,
|
||||
}),
|
||||
@@ -266,7 +336,7 @@ const ReportModal: FunctionComponent<ReportProps> = ({
|
||||
value={currentReport?.description || ''}
|
||||
validationMethods={{
|
||||
onChange: ({ target }: { target: HTMLInputElement }) =>
|
||||
onChange(ActionType.textChange, {
|
||||
onChange(ActionType.inputChange, {
|
||||
name: target.name,
|
||||
value: target.value,
|
||||
}),
|
||||
@@ -284,16 +354,16 @@ const ReportModal: FunctionComponent<ReportProps> = ({
|
||||
<StyledBottomSection>
|
||||
<StyledScheduleTitle>
|
||||
<h4 css={(theme: SupersetTheme) => SectionHeaderStyle(theme)}>
|
||||
Schedule
|
||||
{t('Schedule')}
|
||||
</h4>
|
||||
<p>Scheduled reports will be sent to your email as a PNG</p>
|
||||
<p>{t('Scheduled reports will be sent to your email as a PNG')}</p>
|
||||
</StyledScheduleTitle>
|
||||
|
||||
<CronPicker
|
||||
<StyledCronPicker
|
||||
clearButton={false}
|
||||
value={currentReport?.crontab || '0 12 * * 1'}
|
||||
setValue={(newValue: string) => {
|
||||
onChange(ActionType.textChange, {
|
||||
onChange(ActionType.inputChange, {
|
||||
name: 'crontab',
|
||||
value: newValue,
|
||||
});
|
||||
@@ -310,12 +380,13 @@ const ReportModal: FunctionComponent<ReportProps> = ({
|
||||
<TimezoneSelector
|
||||
onTimezoneChange={value => {
|
||||
setCurrentReport({
|
||||
type: ActionType.textChange,
|
||||
type: ActionType.inputChange,
|
||||
payload: { name: 'timezone', value },
|
||||
});
|
||||
}}
|
||||
timezone={currentReport?.timezone}
|
||||
/>
|
||||
{isChart && renderMessageContentSection}
|
||||
</StyledBottomSection>
|
||||
</StyledModal>
|
||||
);
|
||||
|
||||
@@ -20,11 +20,17 @@
|
||||
import { styled, css, SupersetTheme } from '@superset-ui/core';
|
||||
import Modal from 'src/components/Modal';
|
||||
import Button from 'src/components/Button';
|
||||
import { Radio } from 'src/components/Radio';
|
||||
import { CronPicker } from 'src/components/CronPicker';
|
||||
|
||||
export const StyledModal = styled(Modal)`
|
||||
.ant-modal-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-weight: 600;
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledTopSection = styled.div`
|
||||
@@ -61,6 +67,14 @@ export const StyledIconWrapper = styled.span`
|
||||
|
||||
export const StyledScheduleTitle = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.gridUnit * 7}px;
|
||||
|
||||
h4 {
|
||||
margin-bottom: ${({ theme }) => theme.gridUnit * 3}px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledCronPicker = styled(CronPicker)`
|
||||
margin-bottom: ${({ theme }) => theme.gridUnit * 3}px;
|
||||
`;
|
||||
|
||||
export const StyledCronError = styled.p`
|
||||
@@ -83,3 +97,17 @@ export const SectionHeaderStyle = (theme: SupersetTheme) => css`
|
||||
margin: ${theme.gridUnit * 3}px 0;
|
||||
font-weight: ${theme.typography.weights.bold};
|
||||
`;
|
||||
|
||||
export const StyledMessageContentTitle = styled.div`
|
||||
margin: ${({ theme }) => theme.gridUnit * 8}px 0
|
||||
${({ theme }) => theme.gridUnit * 4}px;
|
||||
`;
|
||||
|
||||
export const StyledRadio = styled(Radio)`
|
||||
display: block;
|
||||
line-height: ${({ theme }) => theme.gridUnit * 8}px;
|
||||
`;
|
||||
|
||||
export const StyledRadioGroup = styled(Radio.Group)`
|
||||
margin-left: ${({ theme }) => theme.gridUnit * 0.5}px;
|
||||
`;
|
||||
|
||||
@@ -310,10 +310,13 @@ const Select = ({
|
||||
|
||||
const handleOnDeselect = (value: string | number | AntdLabeledValue) => {
|
||||
if (Array.isArray(selectValue)) {
|
||||
const selectedValues = [
|
||||
...(selectValue as []).filter(opt => opt !== value),
|
||||
];
|
||||
setSelectValue(selectedValues);
|
||||
if (typeof value === 'number' || typeof value === 'string') {
|
||||
const array = selectValue as (string | number)[];
|
||||
setSelectValue(array.filter(element => element !== value));
|
||||
} else {
|
||||
const array = selectValue as AntdLabeledValue[];
|
||||
setSelectValue(array.filter(element => element.value !== value.value));
|
||||
}
|
||||
}
|
||||
setSearchedValue('');
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ import moment from 'moment-timezone';
|
||||
import { NativeGraySelect as Select } from 'src/components/Select';
|
||||
|
||||
const DEFAULT_TIMEZONE = 'GMT Standard Time';
|
||||
const MIN_SELECT_WIDTH = '375px';
|
||||
const MIN_SELECT_WIDTH = '400px';
|
||||
|
||||
const offsetsToName = {
|
||||
'-300-240': ['Eastern Standard Time', 'Eastern Daylight Time'],
|
||||
|
||||
@@ -187,7 +187,7 @@ export const Table = styled.table`
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
max-width: 300px;
|
||||
max-width: 320px;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
&:first-of-type {
|
||||
|
||||
@@ -335,7 +335,7 @@ export const hydrateDashboard = (dashboardData, chartData) => (
|
||||
dashboardInfo: {
|
||||
...dashboardData,
|
||||
metadata,
|
||||
userId: String(user.userId), // legacy, please use state.user instead
|
||||
userId: user.userId ? String(user.userId) : null, // legacy, please use state.user instead
|
||||
dash_edit_perm: canEdit,
|
||||
dash_save_perm: findPermission('can_save_dash', 'Superset', roles),
|
||||
dash_share_perm: findPermission(
|
||||
|
||||
@@ -96,6 +96,7 @@ test('Should render "appliedCrossFilterIndicators"', () => {
|
||||
<DetailsPanel {...props}>
|
||||
<div data-test="details-panel-content">Content</div>
|
||||
</DetailsPanel>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId('details-panel-content'));
|
||||
@@ -129,6 +130,7 @@ test('Should render "appliedIndicators"', () => {
|
||||
<DetailsPanel {...props}>
|
||||
<div data-test="details-panel-content">Content</div>
|
||||
</DetailsPanel>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId('details-panel-content'));
|
||||
@@ -160,6 +162,7 @@ test('Should render "incompatibleIndicators"', () => {
|
||||
<DetailsPanel {...props}>
|
||||
<div data-test="details-panel-content">Content</div>
|
||||
</DetailsPanel>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId('details-panel-content'));
|
||||
@@ -193,6 +196,7 @@ test('Should render "unsetIndicators"', () => {
|
||||
<DetailsPanel {...props}>
|
||||
<div data-test="details-panel-content">Content</div>
|
||||
</DetailsPanel>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId('details-panel-content'));
|
||||
@@ -227,6 +231,7 @@ test('Should render empty', () => {
|
||||
<DetailsPanel {...props}>
|
||||
<div data-test="details-panel-content">Content</div>
|
||||
</DetailsPanel>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('details-panel-content')).toBeInTheDocument();
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Global, css } from '@emotion/react';
|
||||
import { t, useTheme } from '@superset-ui/core';
|
||||
import {
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
} from 'src/dashboard/components/FiltersBadge/Styles';
|
||||
import { Indicator } from 'src/dashboard/components/FiltersBadge/selectors';
|
||||
import FilterIndicator from 'src/dashboard/components/FiltersBadge/FilterIndicator';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
|
||||
export interface DetailsPanelProps {
|
||||
appliedCrossFilterIndicators: Indicator[];
|
||||
@@ -55,6 +57,9 @@ const DetailsPanelPopover = ({
|
||||
}: DetailsPanelProps) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const theme = useTheme();
|
||||
const activeTabs = useSelector<RootState>(
|
||||
state => state.dashboardState?.activeTabs,
|
||||
);
|
||||
|
||||
// we don't need to clean up useEffect, setting { once: true } removes the event listener after handle function is called
|
||||
useEffect(() => {
|
||||
@@ -65,6 +70,11 @@ const DetailsPanelPopover = ({
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// if tabs change, popover doesn't close automatically
|
||||
useEffect(() => {
|
||||
setVisible(false);
|
||||
}, [activeTabs]);
|
||||
|
||||
const getDefaultActivePanel = () => {
|
||||
const result = [];
|
||||
if (appliedCrossFilterIndicators.length) {
|
||||
|
||||
@@ -20,7 +20,12 @@ import { NO_TIME_RANGE, TIME_FILTER_MAP } from 'src/explore/constants';
|
||||
import { getChartIdsInFilterScope } from 'src/dashboard/util/activeDashboardFilters';
|
||||
import { ChartConfiguration, Filters } from 'src/dashboard/reducers/types';
|
||||
import { DataMaskStateWithId, DataMaskType } from 'src/dataMask/types';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import {
|
||||
ensureIsArray,
|
||||
FeatureFlag,
|
||||
FilterState,
|
||||
isFeatureEnabled,
|
||||
} from '@superset-ui/core';
|
||||
import { Layout } from '../../types';
|
||||
import { getTreeCheckedItems } from '../nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/utils';
|
||||
|
||||
@@ -50,6 +55,16 @@ type Filter = {
|
||||
datasourceId: string;
|
||||
};
|
||||
|
||||
const extractLabel = (filter?: FilterState): string | null => {
|
||||
if (filter?.label) {
|
||||
return filter.label;
|
||||
}
|
||||
if (filter?.value) {
|
||||
return ensureIsArray(filter?.value).join(', ');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const selectIndicatorValue = (
|
||||
columnKey: string,
|
||||
filter: Filter,
|
||||
@@ -182,16 +197,16 @@ export const selectNativeIndicatorsForChart = (
|
||||
const rejectedColumns = getRejectedColumns(chart);
|
||||
|
||||
const getStatus = ({
|
||||
value,
|
||||
label,
|
||||
column,
|
||||
type = DataMaskType.NativeFilters,
|
||||
}: {
|
||||
value: any;
|
||||
label: string | null;
|
||||
column?: string;
|
||||
type?: DataMaskType;
|
||||
}): IndicatorStatus => {
|
||||
// a filter is only considered unset if it's value is null
|
||||
const hasValue = value !== null;
|
||||
const hasValue = label !== null;
|
||||
if (type === DataMaskType.CrossFilters && hasValue) {
|
||||
return IndicatorStatus.CrossFilterApplied;
|
||||
}
|
||||
@@ -220,19 +235,14 @@ export const selectNativeIndicatorsForChart = (
|
||||
)
|
||||
.map(nativeFilter => {
|
||||
const column = nativeFilter.targets[0]?.column?.name;
|
||||
let value =
|
||||
dataMask[nativeFilter.id]?.filterState?.label ??
|
||||
dataMask[nativeFilter.id]?.filterState?.value ??
|
||||
null;
|
||||
if (!Array.isArray(value) && value !== null) {
|
||||
value = [value];
|
||||
}
|
||||
const filterState = dataMask[nativeFilter.id]?.filterState;
|
||||
const label = extractLabel(filterState);
|
||||
return {
|
||||
column,
|
||||
name: nativeFilter.name,
|
||||
path: [nativeFilter.id],
|
||||
status: getStatus({ value, column }),
|
||||
value,
|
||||
status: getStatus({ label, column }),
|
||||
value: label,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -249,23 +259,26 @@ export const selectNativeIndicatorsForChart = (
|
||||
),
|
||||
)
|
||||
.map(chartConfig => {
|
||||
let value =
|
||||
dataMask[chartConfig.id]?.filterState?.label ??
|
||||
dataMask[chartConfig.id]?.filterState?.value ??
|
||||
null;
|
||||
if (!Array.isArray(value) && value !== null) {
|
||||
value = [value];
|
||||
}
|
||||
const filterState = dataMask[chartConfig.id]?.filterState;
|
||||
const label = extractLabel(filterState);
|
||||
const filtersState = filterState?.filters;
|
||||
const column = filtersState && Object.keys(filtersState)[0];
|
||||
|
||||
const dashboardLayoutItem = Object.values(dashboardLayout).find(
|
||||
layoutItem => layoutItem?.meta?.chartId === chartConfig.id,
|
||||
);
|
||||
return {
|
||||
name: Object.values(dashboardLayout).find(
|
||||
layoutItem => layoutItem?.meta?.chartId === chartConfig.id,
|
||||
)?.meta?.sliceName as string,
|
||||
path: [`${chartConfig.id}`],
|
||||
column,
|
||||
name: dashboardLayoutItem?.meta?.sliceName as string,
|
||||
path: [
|
||||
...(dashboardLayoutItem?.parents ?? []),
|
||||
dashboardLayoutItem?.id,
|
||||
],
|
||||
status: getStatus({
|
||||
value,
|
||||
label,
|
||||
type: DataMaskType.CrossFilters,
|
||||
}),
|
||||
value,
|
||||
value: label,
|
||||
};
|
||||
})
|
||||
.filter(filter => filter.status === IndicatorStatus.CrossFilterApplied);
|
||||
|
||||
@@ -48,7 +48,9 @@ import {
|
||||
SAVE_TYPE_OVERWRITE,
|
||||
DASHBOARD_POSITION_DATA_LIMIT,
|
||||
} from 'src/dashboard/util/constants';
|
||||
import setPeriodicRunner from 'src/dashboard/util/setPeriodicRunner';
|
||||
import setPeriodicRunner, {
|
||||
stopPeriodicRender,
|
||||
} from 'src/dashboard/util/setPeriodicRunner';
|
||||
import { options as PeriodicRefreshOptions } from 'src/dashboard/components/RefreshIntervalModal';
|
||||
|
||||
const propTypes = {
|
||||
@@ -168,18 +170,20 @@ class Header extends React.PureComponent {
|
||||
componentDidMount() {
|
||||
const { refreshFrequency, user, dashboardInfo } = this.props;
|
||||
this.startPeriodicRender(refreshFrequency * 1000);
|
||||
if (user && isFeatureEnabled(FeatureFlag.ALERT_REPORTS)) {
|
||||
if (this.canAddReports()) {
|
||||
// this is in case there is an anonymous user.
|
||||
this.props.fetchUISpecificReport(
|
||||
user.userId,
|
||||
'dashboard_id',
|
||||
'dashboards',
|
||||
dashboardInfo.id,
|
||||
user.email,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps) {
|
||||
const { user } = this.props;
|
||||
if (
|
||||
UNDO_LIMIT - nextProps.undoLength <= 0 &&
|
||||
!this.state.didNotifyMaxUndoHistoryToast
|
||||
@@ -193,9 +197,24 @@ class Header extends React.PureComponent {
|
||||
) {
|
||||
this.props.setMaxUndoHistoryExceeded();
|
||||
}
|
||||
if (
|
||||
this.canAddReports() &&
|
||||
nextProps.dashboardInfo.id !== this.props.dashboardInfo.id
|
||||
) {
|
||||
// this is in case there is an anonymous user.
|
||||
this.props.fetchUISpecificReport(
|
||||
user.userId,
|
||||
'dashboard_id',
|
||||
'dashboards',
|
||||
nextProps.dashboardInfo.id,
|
||||
user.email,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
stopPeriodicRender(this.refreshTimer);
|
||||
this.props.setRefreshFrequency(0);
|
||||
clearTimeout(this.ctrlYTimeout);
|
||||
clearTimeout(this.ctrlZTimeout);
|
||||
}
|
||||
@@ -383,32 +402,31 @@ class Header extends React.PureComponent {
|
||||
|
||||
renderReportModal() {
|
||||
const attachedReportExists = !!Object.keys(this.props.reports).length;
|
||||
const canAddReports = isFeatureEnabled(FeatureFlag.ALERT_REPORTS);
|
||||
return (
|
||||
(canAddReports || null) &&
|
||||
(attachedReportExists ? (
|
||||
<HeaderReportActionsDropdown
|
||||
showReportModal={this.showReportModal}
|
||||
toggleActive={this.props.toggleActive}
|
||||
deleteActiveReport={this.props.deleteActiveReport}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
role="button"
|
||||
title={t('Schedule email report')}
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
onClick={this.showReportModal}
|
||||
>
|
||||
<Icons.Calendar />
|
||||
</span>
|
||||
</>
|
||||
))
|
||||
return attachedReportExists ? (
|
||||
<HeaderReportActionsDropdown
|
||||
showReportModal={this.showReportModal}
|
||||
toggleActive={this.props.toggleActive}
|
||||
deleteActiveReport={this.props.deleteActiveReport}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
role="button"
|
||||
title={t('Schedule email report')}
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
onClick={this.showReportModal}
|
||||
>
|
||||
<Icons.Calendar />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
canAddReports() {
|
||||
if (!isFeatureEnabled(FeatureFlag.ALERT_REPORTS)) {
|
||||
return false;
|
||||
}
|
||||
const { user } = this.props;
|
||||
if (!user) {
|
||||
// this is in the case that there is an anonymous user.
|
||||
@@ -417,7 +435,7 @@ class Header extends React.PureComponent {
|
||||
const roles = Object.keys(user.roles || []);
|
||||
const permissions = roles.map(key =>
|
||||
user.roles[key].filter(
|
||||
perms => perms[0] === 'can_add' && perms[1] === 'AlertModelView',
|
||||
perms => perms[0] === 'menu_access' && perms[1] === 'Manage',
|
||||
),
|
||||
);
|
||||
return permissions[0].length > 0;
|
||||
|
||||
@@ -30,6 +30,7 @@ export const FILTER_SUPPORTED_TYPES = {
|
||||
filter_timegrain: [GenericDataType.TEMPORAL],
|
||||
filter_timecolumn: [GenericDataType.TEMPORAL],
|
||||
filter_select: [
|
||||
GenericDataType.BOOLEAN,
|
||||
GenericDataType.STRING,
|
||||
GenericDataType.NUMERIC,
|
||||
GenericDataType.TEMPORAL,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
const stopPeriodicRender = (refreshTimer?: number) => {
|
||||
export const stopPeriodicRender = (refreshTimer?: number) => {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ const ColumnButtonWrapper = styled.div`
|
||||
const checkboxGenerator = (d, onChange) => (
|
||||
<CheckboxControl value={d} onChange={onChange} />
|
||||
);
|
||||
const DATA_TYPES = ['STRING', 'NUMERIC', 'DATETIME'];
|
||||
const DATA_TYPES = ['STRING', 'NUMERIC', 'DATETIME', 'BOOLEAN'];
|
||||
|
||||
const DATASOURCE_TYPES_ARR = [
|
||||
{ key: 'physical', label: t('Physical (table or view)') },
|
||||
@@ -390,7 +390,7 @@ class DatasourceEditor extends React.PureComponent {
|
||||
this.setState(prevState => ({ isEditMode: !prevState.isEditMode }));
|
||||
}
|
||||
|
||||
onDatasourceChange(datasource, callback) {
|
||||
onDatasourceChange(datasource, callback = this.validateAndChange) {
|
||||
this.setState({ datasource }, callback);
|
||||
}
|
||||
|
||||
@@ -616,7 +616,13 @@ class DatasourceEditor extends React.PureComponent {
|
||||
'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={<TextControl controlId="fetch_values_predicate" />}
|
||||
control={
|
||||
<TextAreaControl
|
||||
language="sql"
|
||||
controlId="fetch_values_predicate"
|
||||
minLines={5}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{this.state.isSqla && (
|
||||
|
||||
@@ -48,7 +48,7 @@ const createProps = () => ({
|
||||
cache_timeout: null,
|
||||
changed_on: '2021-03-19T16:30:56.750230',
|
||||
changed_on_humanized: '3 days ago',
|
||||
datasource: 'FCC 2018 Survey',
|
||||
datasource: 'FCC Survey Results',
|
||||
description: null,
|
||||
description_markeddown: '',
|
||||
edit_url: '/chart/edit/318',
|
||||
|
||||
@@ -91,6 +91,7 @@ const StyledHeader = styled.div`
|
||||
}
|
||||
|
||||
.action-button {
|
||||
color: ${({ theme }) => theme.colors.grayscale.base};
|
||||
margin: 0 ${({ theme }) => theme.gridUnit * 1.5}px 0
|
||||
${({ theme }) => theme.gridUnit}px;
|
||||
}
|
||||
@@ -116,8 +117,8 @@ export class ExploreChartHeader extends React.PureComponent {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { user, chart } = this.props;
|
||||
if (user && isFeatureEnabled(FeatureFlag.ALERT_REPORTS)) {
|
||||
if (this.canAddReports()) {
|
||||
const { user, chart } = this.props;
|
||||
// this is in the case that there is an anonymous user.
|
||||
this.props.fetchUISpecificReport(
|
||||
user.userId,
|
||||
@@ -164,33 +165,32 @@ export class ExploreChartHeader extends React.PureComponent {
|
||||
|
||||
renderReportModal() {
|
||||
const attachedReportExists = !!Object.keys(this.props.reports).length;
|
||||
const canAddReports = isFeatureEnabled(FeatureFlag.ALERT_REPORTS);
|
||||
return (
|
||||
(canAddReports || null) &&
|
||||
(attachedReportExists ? (
|
||||
<HeaderReportActionsDropdown
|
||||
showReportModal={this.showReportModal}
|
||||
hideReportModal={this.hideReportModal}
|
||||
toggleActive={this.props.toggleActive}
|
||||
deleteActiveReport={this.props.deleteActiveReport}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
role="button"
|
||||
title={t('Schedule email report')}
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
onClick={this.showReportModal}
|
||||
>
|
||||
<Icons.Calendar />
|
||||
</span>
|
||||
</>
|
||||
))
|
||||
return attachedReportExists ? (
|
||||
<HeaderReportActionsDropdown
|
||||
showReportModal={this.showReportModal}
|
||||
hideReportModal={this.hideReportModal}
|
||||
toggleActive={this.props.toggleActive}
|
||||
deleteActiveReport={this.props.deleteActiveReport}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
role="button"
|
||||
title={t('Schedule email report')}
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
onClick={this.showReportModal}
|
||||
>
|
||||
<Icons.Calendar />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
canAddReports() {
|
||||
if (!isFeatureEnabled(FeatureFlag.ALERT_REPORTS)) {
|
||||
return false;
|
||||
}
|
||||
const { user } = this.props;
|
||||
if (!user) {
|
||||
// this is in the case that there is an anonymous user.
|
||||
@@ -199,7 +199,7 @@ export class ExploreChartHeader extends React.PureComponent {
|
||||
const roles = Object.keys(user.roles || []);
|
||||
const permissions = roles.map(key =>
|
||||
user.roles[key].filter(
|
||||
perms => perms[0] === 'can_add' && perms[1] === 'AlertModelView',
|
||||
perms => perms[0] === 'menu_access' && perms[1] === 'Manage',
|
||||
),
|
||||
);
|
||||
return permissions[0].length > 0;
|
||||
@@ -294,7 +294,7 @@ export class ExploreChartHeader extends React.PureComponent {
|
||||
props={{
|
||||
userId: this.props.user.userId,
|
||||
userEmail: this.props.user.email,
|
||||
chartId: this.props.chart.id,
|
||||
chart: this.props.chart,
|
||||
creationMethod: 'charts',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -147,6 +147,7 @@ const ExploreChartPanel = props => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query_context: JSON.stringify(queryContext),
|
||||
query_context_generation: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ const Styles = styled.div`
|
||||
text-align: left;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
@@ -448,6 +448,7 @@ function ExploreViewContainer(props) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
body {
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -458,7 +459,7 @@ function ExploreViewContainer(props) {
|
||||
#app {
|
||||
flex-basis: 100%;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
}
|
||||
#app-menu {
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -29,7 +29,7 @@ const createProps = () => ({
|
||||
cache_timeout: null,
|
||||
changed_on: '2021-03-19T16:30:56.750230',
|
||||
changed_on_humanized: '7 days ago',
|
||||
datasource: 'FCC 2018 Survey',
|
||||
datasource: 'FCC Survey Results',
|
||||
description: null,
|
||||
description_markeddown: '',
|
||||
edit_url: '/chart/edit/318',
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function PropertiesModal({
|
||||
buttonStyle="primary"
|
||||
// @ts-ignore
|
||||
onClick={onSubmit}
|
||||
disabled={!owners || submitting || !name}
|
||||
disabled={submitting || !name}
|
||||
cta
|
||||
>
|
||||
{t('Save')}
|
||||
|
||||
@@ -125,6 +125,8 @@ const ConditionalFormattingControl = ({
|
||||
}: ConditionalFormattingConfig) => {
|
||||
const columnName = (column && verboseMap?.[column]) ?? column;
|
||||
switch (operator) {
|
||||
case COMPARATOR.NONE:
|
||||
return `${columnName}`;
|
||||
case COMPARATOR.BETWEEN:
|
||||
return `${targetValueLeft} ${COMPARATOR.LESS_THAN} ${columnName} ${COMPARATOR.LESS_THAN} ${targetValueRight}`;
|
||||
case COMPARATOR.BETWEEN_OR_EQUAL:
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { styled, t } from '@superset-ui/core';
|
||||
import { Form, FormItem } from 'src/components/Form';
|
||||
import { Form, FormItem, FormProps } from 'src/components/Form';
|
||||
import { Select } from 'src/components';
|
||||
import { Col, InputNumber, Row } from 'src/common/components';
|
||||
import Button from 'src/components/Button';
|
||||
@@ -44,6 +44,7 @@ const colorSchemeOptions = [
|
||||
];
|
||||
|
||||
const operatorOptions = [
|
||||
{ value: COMPARATOR.NONE, label: 'None' },
|
||||
{ value: COMPARATOR.GREATER_THAN, label: '>' },
|
||||
{ value: COMPARATOR.LESS_THAN, label: '<' },
|
||||
{ value: COMPARATOR.GREATER_OR_EQUAL, label: '≥' },
|
||||
@@ -56,6 +57,127 @@ const operatorOptions = [
|
||||
{ value: COMPARATOR.BETWEEN_OR_RIGHT_EQUAL, label: '< x ≤' },
|
||||
];
|
||||
|
||||
const targetValueValidator = (
|
||||
compare: (targetValue: number, compareValue: number) => boolean,
|
||||
rejectMessage: string,
|
||||
) => (targetValue: number | string) => (
|
||||
_: any,
|
||||
compareValue: number | string,
|
||||
) => {
|
||||
if (
|
||||
!targetValue ||
|
||||
!compareValue ||
|
||||
compare(Number(targetValue), Number(compareValue))
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error(rejectMessage));
|
||||
};
|
||||
|
||||
const targetValueLeftValidator = targetValueValidator(
|
||||
(target: number, val: number) => target > val,
|
||||
t('This value should be smaller than the right target value'),
|
||||
);
|
||||
|
||||
const targetValueRightValidator = targetValueValidator(
|
||||
(target: number, val: number) => target < val,
|
||||
t('This value should be greater than the left target value'),
|
||||
);
|
||||
|
||||
const isOperatorMultiValue = (operator?: COMPARATOR) =>
|
||||
operator && MULTIPLE_VALUE_COMPARATORS.includes(operator);
|
||||
|
||||
const isOperatorNone = (operator?: COMPARATOR) =>
|
||||
!operator || operator === COMPARATOR.NONE;
|
||||
|
||||
const rulesRequired = [{ required: true, message: t('Required') }];
|
||||
|
||||
type GetFieldValue = Pick<Required<FormProps>['form'], 'getFieldValue'>;
|
||||
const rulesTargetValueLeft = [
|
||||
{ required: true, message: t('Required') },
|
||||
({ getFieldValue }: GetFieldValue) => ({
|
||||
validator: targetValueLeftValidator(getFieldValue('targetValueRight')),
|
||||
}),
|
||||
];
|
||||
|
||||
const rulesTargetValueRight = [
|
||||
{ required: true, message: t('Required') },
|
||||
({ getFieldValue }: GetFieldValue) => ({
|
||||
validator: targetValueRightValidator(getFieldValue('targetValueLeft')),
|
||||
}),
|
||||
];
|
||||
|
||||
const targetValueLeftDeps = ['targetValueRight'];
|
||||
const targetValueRightDeps = ['targetValueLeft'];
|
||||
|
||||
const shouldFormItemUpdate = (
|
||||
prevValues: ConditionalFormattingConfig,
|
||||
currentValues: ConditionalFormattingConfig,
|
||||
) =>
|
||||
isOperatorNone(prevValues.operator) !==
|
||||
isOperatorNone(currentValues.operator) ||
|
||||
isOperatorMultiValue(prevValues.operator) !==
|
||||
isOperatorMultiValue(currentValues.operator);
|
||||
|
||||
const operatorField = (
|
||||
<FormItem
|
||||
name="operator"
|
||||
label={t('Operator')}
|
||||
rules={rulesRequired}
|
||||
initialValue={operatorOptions[0].value}
|
||||
>
|
||||
<Select ariaLabel={t('Operator')} options={operatorOptions} />
|
||||
</FormItem>
|
||||
);
|
||||
|
||||
const renderOperatorFields = ({ getFieldValue }: GetFieldValue) =>
|
||||
isOperatorNone(getFieldValue('operator')) ? (
|
||||
<Row gutter={12}>
|
||||
<Col span={6}>{operatorField}</Col>
|
||||
</Row>
|
||||
) : isOperatorMultiValue(getFieldValue('operator')) ? (
|
||||
<Row gutter={12}>
|
||||
<Col span={9}>
|
||||
<FormItem
|
||||
name="targetValueLeft"
|
||||
label={t('Left value')}
|
||||
rules={rulesTargetValueLeft}
|
||||
dependencies={targetValueLeftDeps}
|
||||
validateTrigger="onBlur"
|
||||
trigger="onBlur"
|
||||
>
|
||||
<FullWidthInputNumber />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span={6}>{operatorField}</Col>
|
||||
<Col span={9}>
|
||||
<FormItem
|
||||
name="targetValueRight"
|
||||
label={t('Right value')}
|
||||
rules={rulesTargetValueRight}
|
||||
dependencies={targetValueRightDeps}
|
||||
validateTrigger="onBlur"
|
||||
trigger="onBlur"
|
||||
>
|
||||
<FullWidthInputNumber />
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
) : (
|
||||
<Row gutter={12}>
|
||||
<Col span={6}>{operatorField}</Col>
|
||||
<Col span={18}>
|
||||
<FormItem
|
||||
name="targetValue"
|
||||
label={t('Target value')}
|
||||
rules={rulesRequired}
|
||||
>
|
||||
<FullWidthInputNumber />
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
|
||||
export const FormattingPopoverContent = ({
|
||||
config,
|
||||
onChange,
|
||||
@@ -64,158 +186,44 @@ export const FormattingPopoverContent = ({
|
||||
config?: ConditionalFormattingConfig;
|
||||
onChange: (config: ConditionalFormattingConfig) => void;
|
||||
columns: { label: string; value: string }[];
|
||||
}) => {
|
||||
const isOperatorMultiValue = (operator?: COMPARATOR) =>
|
||||
operator && MULTIPLE_VALUE_COMPARATORS.includes(operator);
|
||||
|
||||
const operatorField = useMemo(
|
||||
() => (
|
||||
<FormItem
|
||||
name="operator"
|
||||
label={t('Operator')}
|
||||
rules={[{ required: true, message: t('Required') }]}
|
||||
initialValue={operatorOptions[0].value}
|
||||
>
|
||||
<Select ariaLabel={t('Operator')} options={operatorOptions} />
|
||||
</FormItem>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const targetValueLeftValidator = useCallback(
|
||||
(rightValue?: number) => (_: any, value?: number) => {
|
||||
if (!value || !rightValue || rightValue > value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
t('This value should be smaller than the right target value'),
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const targetValueRightValidator = useCallback(
|
||||
(leftValue?: number) => (_: any, value?: number) => {
|
||||
if (!value || !leftValue || leftValue < value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(
|
||||
new Error(t('This value should be greater than the left target value')),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Form
|
||||
onFinish={onChange}
|
||||
initialValues={config}
|
||||
requiredMark="optional"
|
||||
layout="vertical"
|
||||
>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<FormItem
|
||||
name="column"
|
||||
label={t('Column')}
|
||||
rules={[{ required: true, message: t('Required') }]}
|
||||
initialValue={columns[0]?.value}
|
||||
>
|
||||
<Select ariaLabel={t('Select column')} options={columns} />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<FormItem
|
||||
name="colorScheme"
|
||||
label={t('Color scheme')}
|
||||
rules={[{ required: true, message: t('Required') }]}
|
||||
initialValue={colorSchemeOptions[0].value}
|
||||
>
|
||||
<Select
|
||||
ariaLabel={t('Color scheme')}
|
||||
options={colorSchemeOptions}
|
||||
/>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
<FormItem
|
||||
noStyle
|
||||
shouldUpdate={(
|
||||
prevValues: ConditionalFormattingConfig,
|
||||
currentValues: ConditionalFormattingConfig,
|
||||
) =>
|
||||
isOperatorMultiValue(prevValues.operator) !==
|
||||
isOperatorMultiValue(currentValues.operator)
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) =>
|
||||
isOperatorMultiValue(getFieldValue('operator')) ? (
|
||||
<Row gutter={12}>
|
||||
<Col span={9}>
|
||||
<FormItem
|
||||
name="targetValueLeft"
|
||||
label={t('Left value')}
|
||||
rules={[
|
||||
{ required: true, message: t('Required') },
|
||||
({ getFieldValue }) => ({
|
||||
validator: targetValueLeftValidator(
|
||||
getFieldValue('targetValueRight'),
|
||||
),
|
||||
}),
|
||||
]}
|
||||
dependencies={['targetValueRight']}
|
||||
validateTrigger="onBlur"
|
||||
trigger="onBlur"
|
||||
>
|
||||
<FullWidthInputNumber />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span={6}>{operatorField}</Col>
|
||||
<Col span={9}>
|
||||
<FormItem
|
||||
name="targetValueRight"
|
||||
label={t('Right value')}
|
||||
rules={[
|
||||
{ required: true, message: t('Required') },
|
||||
({ getFieldValue }) => ({
|
||||
validator: targetValueRightValidator(
|
||||
getFieldValue('targetValueLeft'),
|
||||
),
|
||||
}),
|
||||
]}
|
||||
dependencies={['targetValueLeft']}
|
||||
validateTrigger="onBlur"
|
||||
trigger="onBlur"
|
||||
>
|
||||
<FullWidthInputNumber />
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
) : (
|
||||
<Row gutter={12}>
|
||||
<Col span={6}>{operatorField}</Col>
|
||||
<Col span={18}>
|
||||
<FormItem
|
||||
name="targetValue"
|
||||
label={t('Target value')}
|
||||
rules={[{ required: true, message: t('Required') }]}
|
||||
>
|
||||
<FullWidthInputNumber />
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<JustifyEnd>
|
||||
<Button htmlType="submit" buttonStyle="primary">
|
||||
{t('Apply')}
|
||||
</Button>
|
||||
</JustifyEnd>
|
||||
</FormItem>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
}) => (
|
||||
<Form
|
||||
onFinish={onChange}
|
||||
initialValues={config}
|
||||
requiredMark="optional"
|
||||
layout="vertical"
|
||||
>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<FormItem
|
||||
name="column"
|
||||
label={t('Column')}
|
||||
rules={rulesRequired}
|
||||
initialValue={columns[0]?.value}
|
||||
>
|
||||
<Select ariaLabel={t('Select column')} options={columns} />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<FormItem
|
||||
name="colorScheme"
|
||||
label={t('Color scheme')}
|
||||
rules={rulesRequired}
|
||||
initialValue={colorSchemeOptions[0].value}
|
||||
>
|
||||
<Select ariaLabel={t('Color scheme')} options={colorSchemeOptions} />
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
<FormItem noStyle shouldUpdate={shouldFormItemUpdate}>
|
||||
{renderOperatorFields}
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<JustifyEnd>
|
||||
<Button htmlType="submit" buttonStyle="primary">
|
||||
{t('Apply')}
|
||||
</Button>
|
||||
</JustifyEnd>
|
||||
</FormItem>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { PopoverProps } from 'antd/lib/popover';
|
||||
import { ControlComponentProps } from '@superset-ui/chart-controls/lib/shared-controls/components/types';
|
||||
|
||||
export enum COMPARATOR {
|
||||
NONE = 'None',
|
||||
GREATER_THAN = '>',
|
||||
LESS_THAN = '<',
|
||||
GREATER_OR_EQUAL = '≥',
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/* eslint-disable camelcase */
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Tabs from 'src/components/Tabs';
|
||||
import Button from 'src/components/Button';
|
||||
import { NativeSelect as Select } from 'src/components/Select';
|
||||
import { t, styled } from '@superset-ui/core';
|
||||
|
||||
import { Form, FormItem } from 'src/components/Form';
|
||||
import { StyledColumnOption } from 'src/explore/components/optionRenderers';
|
||||
import { ColumnMeta } from '@superset-ui/chart-controls';
|
||||
|
||||
const StyledSelect = styled(Select)`
|
||||
.metric-option {
|
||||
& > svg {
|
||||
min-width: ${({ theme }) => `${theme.gridUnit * 4}px`};
|
||||
}
|
||||
& > .option-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface ColumnSelectPopoverProps {
|
||||
columns: ColumnMeta[];
|
||||
editedColumn?: ColumnMeta;
|
||||
onChange: (column: ColumnMeta) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ColumnSelectPopover = ({
|
||||
columns,
|
||||
editedColumn,
|
||||
onChange,
|
||||
onClose,
|
||||
}: ColumnSelectPopoverProps) => {
|
||||
const [
|
||||
initialCalculatedColumn,
|
||||
initialSimpleColumn,
|
||||
] = editedColumn?.expression
|
||||
? [editedColumn, undefined]
|
||||
: [undefined, editedColumn];
|
||||
const [selectedCalculatedColumn, setSelectedCalculatedColumn] = useState(
|
||||
initialCalculatedColumn,
|
||||
);
|
||||
const [selectedSimpleColumn, setSelectedSimpleColumn] = useState(
|
||||
initialSimpleColumn,
|
||||
);
|
||||
|
||||
const [calculatedColumns, simpleColumns] = useMemo(
|
||||
() =>
|
||||
columns?.reduce(
|
||||
(acc: [ColumnMeta[], ColumnMeta[]], column: ColumnMeta) => {
|
||||
if (column.expression) {
|
||||
acc[0].push(column);
|
||||
} else {
|
||||
acc[1].push(column);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[[], []],
|
||||
),
|
||||
[columns],
|
||||
);
|
||||
|
||||
const onCalculatedColumnChange = useCallback(
|
||||
selectedColumnName => {
|
||||
const selectedColumn = calculatedColumns.find(
|
||||
col => col.column_name === selectedColumnName,
|
||||
);
|
||||
setSelectedCalculatedColumn(selectedColumn);
|
||||
setSelectedSimpleColumn(undefined);
|
||||
},
|
||||
[calculatedColumns],
|
||||
);
|
||||
|
||||
const onSimpleColumnChange = useCallback(
|
||||
selectedColumnName => {
|
||||
const selectedColumn = simpleColumns.find(
|
||||
col => col.column_name === selectedColumnName,
|
||||
);
|
||||
setSelectedCalculatedColumn(undefined);
|
||||
setSelectedSimpleColumn(selectedColumn);
|
||||
},
|
||||
[simpleColumns],
|
||||
);
|
||||
|
||||
const defaultActiveTabKey =
|
||||
initialSimpleColumn || calculatedColumns.length === 0 ? 'simple' : 'saved';
|
||||
|
||||
const onSave = useCallback(() => {
|
||||
const selectedColumn = selectedCalculatedColumn || selectedSimpleColumn;
|
||||
if (!selectedColumn) {
|
||||
return;
|
||||
}
|
||||
onChange(selectedColumn);
|
||||
onClose();
|
||||
}, [onChange, onClose, selectedCalculatedColumn, selectedSimpleColumn]);
|
||||
|
||||
const onResetStateAndClose = useCallback(() => {
|
||||
setSelectedCalculatedColumn(initialCalculatedColumn);
|
||||
setSelectedSimpleColumn(initialSimpleColumn);
|
||||
onClose();
|
||||
}, [initialCalculatedColumn, initialSimpleColumn, onClose]);
|
||||
|
||||
const stateIsValid = selectedCalculatedColumn || selectedSimpleColumn;
|
||||
const hasUnsavedChanges =
|
||||
selectedCalculatedColumn?.column_name !==
|
||||
initialCalculatedColumn?.column_name ||
|
||||
selectedSimpleColumn?.column_name !== initialSimpleColumn?.column_name;
|
||||
|
||||
const filterOption = useCallback(
|
||||
(input, option) =>
|
||||
option?.filterBy.toLowerCase().indexOf(input.toLowerCase()) >= 0,
|
||||
[],
|
||||
);
|
||||
|
||||
const getPopupContainer = useCallback(
|
||||
(triggerNode: any) => triggerNode.parentNode,
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Form layout="vertical" id="metrics-edit-popover">
|
||||
<Tabs
|
||||
id="adhoc-metric-edit-tabs"
|
||||
defaultActiveKey={defaultActiveTabKey}
|
||||
className="adhoc-metric-edit-tabs"
|
||||
allowOverflow
|
||||
>
|
||||
<Tabs.TabPane key="saved" tab={t('Saved')}>
|
||||
<FormItem label={t('Saved expressions')}>
|
||||
<StyledSelect
|
||||
value={selectedCalculatedColumn?.column_name}
|
||||
getPopupContainer={getPopupContainer}
|
||||
onChange={onCalculatedColumnChange}
|
||||
allowClear
|
||||
showSearch
|
||||
autoFocus={!selectedCalculatedColumn}
|
||||
filterOption={filterOption}
|
||||
placeholder={t('%s column(s)', calculatedColumns.length)}
|
||||
>
|
||||
{calculatedColumns.map(calculatedColumn => (
|
||||
<Select.Option
|
||||
value={calculatedColumn.column_name}
|
||||
filterBy={
|
||||
calculatedColumn.verbose_name ||
|
||||
calculatedColumn.column_name
|
||||
}
|
||||
key={calculatedColumn.column_name}
|
||||
>
|
||||
<StyledColumnOption column={calculatedColumn} showType />
|
||||
</Select.Option>
|
||||
))}
|
||||
</StyledSelect>
|
||||
</FormItem>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="simple" tab={t('Simple')}>
|
||||
<FormItem label={t('Column')}>
|
||||
<Select
|
||||
value={selectedSimpleColumn?.column_name}
|
||||
getPopupContainer={getPopupContainer}
|
||||
onChange={onSimpleColumnChange}
|
||||
allowClear
|
||||
showSearch
|
||||
autoFocus={!selectedSimpleColumn}
|
||||
filterOption={filterOption}
|
||||
placeholder={t('%s column(s)', simpleColumns.length)}
|
||||
>
|
||||
{simpleColumns.map(simpleColumn => (
|
||||
<Select.Option
|
||||
value={simpleColumn.column_name}
|
||||
filterBy={
|
||||
simpleColumn.verbose_name || simpleColumn.column_name
|
||||
}
|
||||
key={simpleColumn.column_name}
|
||||
>
|
||||
<StyledColumnOption column={simpleColumn} showType />
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</FormItem>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
<div>
|
||||
<Button buttonSize="small" onClick={onResetStateAndClose} cta>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!stateIsValid}
|
||||
buttonStyle={
|
||||
hasUnsavedChanges && stateIsValid ? 'primary' : 'default'
|
||||
}
|
||||
buttonSize="small"
|
||||
onClick={onSave}
|
||||
cta
|
||||
>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColumnSelectPopover;
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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, { useCallback, useMemo, useState } from 'react';
|
||||
import { ColumnMeta } from '@superset-ui/chart-controls';
|
||||
import Popover from 'src/components/Popover';
|
||||
import { ExplorePopoverContent } from 'src/explore/components/ExploreContentPopover';
|
||||
import ColumnSelectPopover from './ColumnSelectPopover';
|
||||
|
||||
interface ColumnSelectPopoverTriggerProps {
|
||||
columns: ColumnMeta[];
|
||||
editedColumn?: ColumnMeta;
|
||||
onColumnEdit: (editedColumn: ColumnMeta) => void;
|
||||
isControlledComponent?: boolean;
|
||||
visible?: boolean;
|
||||
togglePopover?: (visible: boolean) => void;
|
||||
closePopover?: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ColumnSelectPopoverTrigger = ({
|
||||
columns,
|
||||
editedColumn,
|
||||
onColumnEdit,
|
||||
isControlledComponent,
|
||||
children,
|
||||
...props
|
||||
}: ColumnSelectPopoverTriggerProps) => {
|
||||
const [popoverVisible, setPopoverVisible] = useState(false);
|
||||
|
||||
const togglePopover = useCallback((visible: boolean) => {
|
||||
setPopoverVisible(visible);
|
||||
}, []);
|
||||
|
||||
const closePopover = useCallback(() => {
|
||||
setPopoverVisible(false);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
visible,
|
||||
handleTogglePopover,
|
||||
handleClosePopover,
|
||||
} = isControlledComponent
|
||||
? {
|
||||
visible: props.visible,
|
||||
handleTogglePopover: props.togglePopover!,
|
||||
handleClosePopover: props.closePopover!,
|
||||
}
|
||||
: {
|
||||
visible: popoverVisible,
|
||||
handleTogglePopover: togglePopover,
|
||||
handleClosePopover: closePopover,
|
||||
};
|
||||
|
||||
const overlayContent = useMemo(
|
||||
() => (
|
||||
<ExplorePopoverContent>
|
||||
<ColumnSelectPopover
|
||||
editedColumn={editedColumn}
|
||||
columns={columns}
|
||||
onClose={handleClosePopover}
|
||||
onChange={onColumnEdit}
|
||||
/>
|
||||
</ExplorePopoverContent>
|
||||
),
|
||||
[columns, editedColumn, handleClosePopover, onColumnEdit],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
placement="right"
|
||||
trigger="click"
|
||||
content={overlayContent}
|
||||
defaultVisible={visible}
|
||||
visible={visible}
|
||||
onVisibleChange={handleTogglePopover}
|
||||
destroyTooltipOnHide
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColumnSelectPopoverTrigger;
|
||||
@@ -29,7 +29,7 @@ const defaultProps: LabelProps = {
|
||||
|
||||
test('renders with default props', () => {
|
||||
render(<DndColumnSelect {...defaultProps} />, { useDnd: true });
|
||||
expect(screen.getByText('Drop columns')).toBeInTheDocument();
|
||||
expect(screen.getByText('Drop columns here')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders with value', () => {
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { tn } from '@superset-ui/core';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { FeatureFlag, isFeatureEnabled, tn } from '@superset-ui/core';
|
||||
import { ColumnMeta } from '@superset-ui/chart-controls';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { LabelProps } from 'src/explore/components/controls/DndColumnSelectControl/types';
|
||||
@@ -26,7 +26,8 @@ import OptionWrapper from 'src/explore/components/controls/DndColumnSelectContro
|
||||
import { OptionSelector } from 'src/explore/components/controls/DndColumnSelectControl/utils';
|
||||
import { DatasourcePanelDndItem } from 'src/explore/components/DatasourcePanel/types';
|
||||
import { DndItemType } from 'src/explore/components/DndItemType';
|
||||
import { StyledColumnOption } from 'src/explore/components/optionRenderers';
|
||||
import { useComponentDidUpdate } from 'src/common/hooks/useComponentDidUpdate';
|
||||
import ColumnSelectPopoverTrigger from './ColumnSelectPopoverTrigger';
|
||||
|
||||
export const DndColumnSelect = (props: LabelProps) => {
|
||||
const {
|
||||
@@ -39,13 +40,15 @@ export const DndColumnSelect = (props: LabelProps) => {
|
||||
name,
|
||||
label,
|
||||
} = props;
|
||||
const [newColumnPopoverVisible, setNewColumnPopoverVisible] = useState(false);
|
||||
|
||||
const optionSelector = useMemo(
|
||||
() => new OptionSelector(options, multi, value),
|
||||
[multi, options, value],
|
||||
);
|
||||
|
||||
// synchronize values in case of dataset changes
|
||||
useEffect(() => {
|
||||
const handleOptionsChange = useCallback(() => {
|
||||
const optionSelectorValues = optionSelector.getValues();
|
||||
if (typeof value !== typeof optionSelectorValues) {
|
||||
onChange(optionSelectorValues);
|
||||
@@ -65,9 +68,12 @@ export const DndColumnSelect = (props: LabelProps) => {
|
||||
) {
|
||||
onChange(optionSelectorValues);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [JSON.stringify(value), JSON.stringify(optionSelector.getValues())]);
|
||||
|
||||
// useComponentDidUpdate to avoid running this for the first render, to avoid
|
||||
// calling onChange when the initial value is not valid for the dataset
|
||||
useComponentDidUpdate(handleOptionsChange);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(item: DatasourcePanelDndItem) => {
|
||||
const column = item.value as ColumnMeta;
|
||||
@@ -107,41 +113,120 @@ export const DndColumnSelect = (props: LabelProps) => {
|
||||
[onChange, optionSelector],
|
||||
);
|
||||
|
||||
const popoverOptions = useMemo(
|
||||
() =>
|
||||
Object.values(options).filter(
|
||||
col =>
|
||||
!optionSelector.values
|
||||
.map(val => val.column_name)
|
||||
.includes(col.column_name),
|
||||
),
|
||||
[optionSelector.values, options],
|
||||
);
|
||||
|
||||
const valuesRenderer = useCallback(
|
||||
() =>
|
||||
optionSelector.values.map((column, idx) => (
|
||||
<OptionWrapper
|
||||
key={idx}
|
||||
index={idx}
|
||||
clickClose={onClickClose}
|
||||
onShiftOptions={onShiftOptions}
|
||||
type={`${DndItemType.ColumnOption}_${name}_${label}`}
|
||||
canDelete={canDelete}
|
||||
>
|
||||
<StyledColumnOption column={column} showType />
|
||||
</OptionWrapper>
|
||||
)),
|
||||
optionSelector.values.map((column, idx) =>
|
||||
isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX) ? (
|
||||
<ColumnSelectPopoverTrigger
|
||||
columns={popoverOptions}
|
||||
onColumnEdit={newColumn => {
|
||||
optionSelector.replace(idx, newColumn.column_name);
|
||||
onChange(optionSelector.getValues());
|
||||
}}
|
||||
editedColumn={column}
|
||||
>
|
||||
<OptionWrapper
|
||||
key={idx}
|
||||
index={idx}
|
||||
clickClose={onClickClose}
|
||||
onShiftOptions={onShiftOptions}
|
||||
type={`${DndItemType.ColumnOption}_${name}_${label}`}
|
||||
canDelete={canDelete}
|
||||
column={column}
|
||||
withCaret
|
||||
/>
|
||||
</ColumnSelectPopoverTrigger>
|
||||
) : (
|
||||
<OptionWrapper
|
||||
key={idx}
|
||||
index={idx}
|
||||
clickClose={onClickClose}
|
||||
onShiftOptions={onShiftOptions}
|
||||
type={`${DndItemType.ColumnOption}_${name}_${label}`}
|
||||
canDelete={canDelete}
|
||||
column={column}
|
||||
/>
|
||||
),
|
||||
),
|
||||
[
|
||||
canDelete,
|
||||
label,
|
||||
name,
|
||||
onChange,
|
||||
onClickClose,
|
||||
onShiftOptions,
|
||||
optionSelector.values,
|
||||
optionSelector,
|
||||
popoverOptions,
|
||||
],
|
||||
);
|
||||
|
||||
const addNewColumnWithPopover = useCallback(
|
||||
(newColumn: ColumnMeta) => {
|
||||
optionSelector.add(newColumn.column_name);
|
||||
onChange(optionSelector.getValues());
|
||||
},
|
||||
[onChange, optionSelector],
|
||||
);
|
||||
|
||||
const togglePopover = useCallback((visible: boolean) => {
|
||||
setNewColumnPopoverVisible(visible);
|
||||
}, []);
|
||||
|
||||
const closePopover = useCallback(() => {
|
||||
togglePopover(false);
|
||||
}, [togglePopover]);
|
||||
|
||||
const openPopover = useCallback(() => {
|
||||
togglePopover(true);
|
||||
}, [togglePopover]);
|
||||
|
||||
const defaultGhostButtonText = isFeatureEnabled(
|
||||
FeatureFlag.ENABLE_DND_WITH_CLICK_UX,
|
||||
)
|
||||
? tn(
|
||||
'Drop a column here or click',
|
||||
'Drop columns here or click',
|
||||
multi ? 2 : 1,
|
||||
)
|
||||
: tn('Drop column here', 'Drop columns here', multi ? 2 : 1);
|
||||
|
||||
return (
|
||||
<DndSelectLabel<string | string[], ColumnMeta[]>
|
||||
onDrop={onDrop}
|
||||
canDrop={canDrop}
|
||||
valuesRenderer={valuesRenderer}
|
||||
accept={DndItemType.Column}
|
||||
displayGhostButton={multi || optionSelector.values.length === 0}
|
||||
ghostButtonText={
|
||||
ghostButtonText || tn('Drop column', 'Drop columns', multi ? 2 : 1)
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
<div>
|
||||
<DndSelectLabel<string | string[], ColumnMeta[]>
|
||||
onDrop={onDrop}
|
||||
canDrop={canDrop}
|
||||
valuesRenderer={valuesRenderer}
|
||||
accept={DndItemType.Column}
|
||||
displayGhostButton={multi || optionSelector.values.length === 0}
|
||||
ghostButtonText={ghostButtonText || defaultGhostButtonText}
|
||||
onClickGhostButton={
|
||||
isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX)
|
||||
? openPopover
|
||||
: undefined
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
<ColumnSelectPopoverTrigger
|
||||
columns={popoverOptions}
|
||||
onColumnEdit={addNewColumnWithPopover}
|
||||
isControlledComponent
|
||||
togglePopover={togglePopover}
|
||||
closePopover={closePopover}
|
||||
visible={newColumnPopoverVisible}
|
||||
>
|
||||
<div />
|
||||
</ColumnSelectPopoverTrigger>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ const defaultProps = {
|
||||
|
||||
test('renders with default props', () => {
|
||||
render(<DndFilterSelect {...defaultProps} />, { useDnd: true });
|
||||
expect(screen.getByText('Drop columns or metrics')).toBeInTheDocument();
|
||||
expect(screen.getByText('Drop columns or metrics here')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders with value', () => {
|
||||
@@ -56,7 +56,7 @@ test('renders options with saved metric', () => {
|
||||
render(<DndFilterSelect {...defaultProps} formData={['saved_metric']} />, {
|
||||
useDnd: true,
|
||||
});
|
||||
expect(screen.getByText('Drop columns or metrics')).toBeInTheDocument();
|
||||
expect(screen.getByText('Drop columns or metrics here')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders options with column', () => {
|
||||
@@ -76,7 +76,7 @@ test('renders options with column', () => {
|
||||
useDnd: true,
|
||||
},
|
||||
);
|
||||
expect(screen.getByText('Drop columns or metrics')).toBeInTheDocument();
|
||||
expect(screen.getByText('Drop columns or metrics here')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders options with adhoc metric', () => {
|
||||
@@ -87,5 +87,5 @@ test('renders options with adhoc metric', () => {
|
||||
render(<DndFilterSelect {...defaultProps} formData={[adhocMetric]} />, {
|
||||
useDnd: true,
|
||||
});
|
||||
expect(screen.getByText('Drop columns or metrics')).toBeInTheDocument();
|
||||
expect(screen.getByText('Drop columns or metrics here')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
t,
|
||||
} from '@superset-ui/core';
|
||||
import { ColumnMeta } from '@superset-ui/chart-controls';
|
||||
import { Tooltip } from 'src/components/Tooltip';
|
||||
import {
|
||||
OPERATOR_ENUM_TO_OPERATOR_TYPE,
|
||||
Operators,
|
||||
@@ -299,6 +298,7 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => {
|
||||
() =>
|
||||
values.map((adhocFilter: AdhocFilter, index: number) => {
|
||||
const label = adhocFilter.getDefaultLabel();
|
||||
const tooltipTitle = adhocFilter.getTooltipTitle();
|
||||
return (
|
||||
<AdhocFilterPopoverTrigger
|
||||
key={index}
|
||||
@@ -311,14 +311,14 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => {
|
||||
<OptionWrapper
|
||||
key={index}
|
||||
index={index}
|
||||
label={label}
|
||||
tooltipTitle={tooltipTitle}
|
||||
clickClose={onClickClose}
|
||||
onShiftOptions={onShiftOptions}
|
||||
type={DndItemType.FilterOption}
|
||||
withCaret
|
||||
isExtra={adhocFilter.isExtra}
|
||||
>
|
||||
<Tooltip title={label}>{label}</Tooltip>
|
||||
</OptionWrapper>
|
||||
/>
|
||||
</AdhocFilterPopoverTrigger>
|
||||
);
|
||||
}),
|
||||
@@ -333,6 +333,11 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => {
|
||||
],
|
||||
);
|
||||
|
||||
const handleClickGhostButton = useCallback(() => {
|
||||
setDroppedItem(null);
|
||||
togglePopover(true);
|
||||
}, [togglePopover]);
|
||||
|
||||
const adhocFilter = useMemo(() => {
|
||||
if (droppedItem?.metric_name) {
|
||||
return new AdhocFilter({
|
||||
@@ -351,7 +356,7 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => {
|
||||
const config: Partial<AdhocFilter> = {
|
||||
subject: (droppedItem as ColumnMeta)?.column_name,
|
||||
};
|
||||
if (isFeatureEnabled(FeatureFlag.UX_BETA)) {
|
||||
if (config.subject && isFeatureEnabled(FeatureFlag.UX_BETA)) {
|
||||
config.operator = OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.IN].operation;
|
||||
config.operatorId = Operators.IN;
|
||||
}
|
||||
@@ -367,6 +372,10 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => {
|
||||
[togglePopover],
|
||||
);
|
||||
|
||||
const ghostButtonText = isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX)
|
||||
? t('Drop columns/metrics here or click')
|
||||
: t('Drop columns or metrics here');
|
||||
|
||||
return (
|
||||
<>
|
||||
<DndSelectLabel<OptionValueType, OptionValueType[]>
|
||||
@@ -374,7 +383,12 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => {
|
||||
canDrop={canDrop}
|
||||
valuesRenderer={valuesRenderer}
|
||||
accept={DND_ACCEPTED_TYPES}
|
||||
ghostButtonText={t('Drop columns or metrics')}
|
||||
ghostButtonText={ghostButtonText}
|
||||
onClickGhostButton={
|
||||
isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX)
|
||||
? handleClickGhostButton
|
||||
: undefined
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
<AdhocFilterPopoverTrigger
|
||||
@@ -387,7 +401,6 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => {
|
||||
visible={newFilterPopoverVisible}
|
||||
togglePopover={togglePopover}
|
||||
closePopover={closePopover}
|
||||
createNew
|
||||
>
|
||||
<div />
|
||||
</AdhocFilterPopoverTrigger>
|
||||
|
||||
@@ -31,10 +31,10 @@ const defaultProps = {
|
||||
|
||||
test('renders with default props', () => {
|
||||
render(<DndMetricSelect {...defaultProps} />, { useDnd: true });
|
||||
expect(screen.getByText('Drop column or metric')).toBeInTheDocument();
|
||||
expect(screen.getByText('Drop column or metric here')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders with default props and multi = true', () => {
|
||||
render(<DndMetricSelect {...defaultProps} multi />, { useDnd: true });
|
||||
expect(screen.getByText('Drop columns or metrics')).toBeInTheDocument();
|
||||
expect(screen.getByText('Drop columns or metrics here')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -185,6 +185,9 @@ export const DndMetricSelect = (props: any) => {
|
||||
|
||||
const onMetricEdit = useCallback(
|
||||
(changedMetric: Metric | AdhocMetric, oldMetric: Metric | AdhocMetric) => {
|
||||
if (oldMetric instanceof AdhocMetric && oldMetric.equals(changedMetric)) {
|
||||
return;
|
||||
}
|
||||
const newValue = value.map(value => {
|
||||
if (
|
||||
// compare saved metrics
|
||||
@@ -245,7 +248,10 @@ export const DndMetricSelect = (props: any) => {
|
||||
[props.savedMetrics, props.value],
|
||||
);
|
||||
|
||||
const handleDropLabel = useCallback(() => onChange(value), [onChange, value]);
|
||||
const handleDropLabel = useCallback(
|
||||
() => onChange(multi ? value : value[0]),
|
||||
[multi, onChange, value],
|
||||
);
|
||||
|
||||
const valueRenderer = useCallback(
|
||||
(option: Metric | AdhocMetric | string, index: number) => (
|
||||
@@ -262,12 +268,14 @@ export const DndMetricSelect = (props: any) => {
|
||||
onMoveLabel={moveLabel}
|
||||
onDropLabel={handleDropLabel}
|
||||
type={`${DndItemType.AdhocMetricOption}_${props.name}_${props.label}`}
|
||||
multi={multi}
|
||||
/>
|
||||
),
|
||||
[
|
||||
getSavedMetricOptionsForMetric,
|
||||
handleDropLabel,
|
||||
moveLabel,
|
||||
multi,
|
||||
onMetricEdit,
|
||||
onRemoveMetric,
|
||||
props.columns,
|
||||
@@ -304,6 +312,11 @@ export const DndMetricSelect = (props: any) => {
|
||||
[onNewMetric, togglePopover],
|
||||
);
|
||||
|
||||
const handleClickGhostButton = useCallback(() => {
|
||||
setDroppedItem(null);
|
||||
togglePopover(true);
|
||||
}, [togglePopover]);
|
||||
|
||||
const adhocMetric = useMemo(() => {
|
||||
if (droppedItem?.type === DndItemType.Column) {
|
||||
const itemValue = droppedItem?.value as ColumnMeta;
|
||||
@@ -326,6 +339,18 @@ export const DndMetricSelect = (props: any) => {
|
||||
return new AdhocMetric({ isNew: true });
|
||||
}, [droppedItem]);
|
||||
|
||||
const ghostButtonText = isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX)
|
||||
? tn(
|
||||
'Drop a column/metric here or click',
|
||||
'Drop columns/metrics here or click',
|
||||
multi ? 2 : 1,
|
||||
)
|
||||
: tn(
|
||||
'Drop column or metric here',
|
||||
'Drop columns or metrics here',
|
||||
multi ? 2 : 1,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="metrics-select">
|
||||
<DndSelectLabel<OptionValueType, OptionValueType[]>
|
||||
@@ -333,12 +358,13 @@ export const DndMetricSelect = (props: any) => {
|
||||
canDrop={canDrop}
|
||||
valuesRenderer={valuesRenderer}
|
||||
accept={DND_ACCEPTED_TYPES}
|
||||
ghostButtonText={tn(
|
||||
'Drop column or metric',
|
||||
'Drop columns or metrics',
|
||||
multi ? 2 : 1,
|
||||
)}
|
||||
ghostButtonText={ghostButtonText}
|
||||
displayGhostButton={multi || value.length === 0}
|
||||
onClickGhostButton={
|
||||
isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX)
|
||||
? handleClickGhostButton
|
||||
: undefined
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
<AdhocMetricPopoverTrigger
|
||||
@@ -352,7 +378,6 @@ export const DndMetricSelect = (props: any) => {
|
||||
visible={newMetricPopoverVisible}
|
||||
togglePopover={togglePopover}
|
||||
closePopover={closePopover}
|
||||
createNew
|
||||
>
|
||||
<div />
|
||||
</AdhocMetricPopoverTrigger>
|
||||
|
||||
@@ -33,7 +33,7 @@ const defaultProps = {
|
||||
|
||||
test('renders with default props', async () => {
|
||||
render(<DndSelectLabel {...defaultProps} />, { useDnd: true });
|
||||
expect(await screen.findByText('Drop columns')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Drop columns here')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders ghost button when empty', async () => {
|
||||
|
||||
@@ -53,9 +53,12 @@ export default function DndSelectLabel<T, O>({
|
||||
|
||||
function renderGhostButton() {
|
||||
return (
|
||||
<AddControlLabel cancelHover>
|
||||
<AddControlLabel
|
||||
cancelHover={!props.onClickGhostButton}
|
||||
onClick={props.onClickGhostButton}
|
||||
>
|
||||
<Icons.PlusSmall iconColor={theme.colors.grayscale.light1} />
|
||||
{t(props.ghostButtonText || 'Drop columns')}
|
||||
{t(props.ghostButtonText || 'Drop columns here')}
|
||||
</AddControlLabel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,9 +28,8 @@ test('renders with default props', () => {
|
||||
clickClose={jest.fn()}
|
||||
type={'Column' as DndItemType}
|
||||
onShiftOptions={jest.fn()}
|
||||
>
|
||||
Option
|
||||
</OptionWrapper>,
|
||||
label="Option"
|
||||
/>,
|
||||
{ useDnd: true },
|
||||
);
|
||||
expect(container).toBeInTheDocument();
|
||||
@@ -46,17 +45,15 @@ test('triggers onShiftOptions on drop', () => {
|
||||
clickClose={jest.fn()}
|
||||
type={'Column' as DndItemType}
|
||||
onShiftOptions={onShiftOptions}
|
||||
>
|
||||
Option 1
|
||||
</OptionWrapper>
|
||||
label="Option 1"
|
||||
/>
|
||||
<OptionWrapper
|
||||
index={2}
|
||||
clickClose={jest.fn()}
|
||||
type={'Column' as DndItemType}
|
||||
onShiftOptions={onShiftOptions}
|
||||
>
|
||||
Option 2
|
||||
</OptionWrapper>
|
||||
label="Option 2"
|
||||
/>
|
||||
</>,
|
||||
{ useDnd: true },
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
useDrag,
|
||||
useDrop,
|
||||
@@ -28,8 +28,19 @@ import {
|
||||
OptionProps,
|
||||
OptionItemInterface,
|
||||
} from 'src/explore/components/controls/DndColumnSelectControl/types';
|
||||
import { Tooltip } from 'src/components/Tooltip';
|
||||
import { StyledColumnOption } from 'src/explore/components/optionRenderers';
|
||||
import { styled } from '@superset-ui/core';
|
||||
import { ColumnMeta } from '@superset-ui/chart-controls';
|
||||
import Option from './Option';
|
||||
|
||||
export const OptionLabel = styled.div`
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export default function OptionWrapper(
|
||||
props: OptionProps & {
|
||||
type: string;
|
||||
@@ -38,26 +49,25 @@ export default function OptionWrapper(
|
||||
) {
|
||||
const {
|
||||
index,
|
||||
label,
|
||||
tooltipTitle,
|
||||
column,
|
||||
type,
|
||||
onShiftOptions,
|
||||
clickClose,
|
||||
withCaret,
|
||||
isExtra,
|
||||
canDelete = true,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const labelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const item: OptionItemInterface = useMemo(
|
||||
() => ({
|
||||
dragIndex: index,
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
item: {
|
||||
type,
|
||||
}),
|
||||
[index, type],
|
||||
);
|
||||
const [, drag] = useDrag({
|
||||
item,
|
||||
dragIndex: index,
|
||||
},
|
||||
collect: (monitor: DragSourceMonitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
@@ -85,8 +95,8 @@ export default function OptionWrapper(
|
||||
// Determine mouse position
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
// Get pixels to the top
|
||||
const hoverClientY = clientOffset?.y
|
||||
? clientOffset?.y - hoverBoundingRect.top
|
||||
const hoverClientY = clientOffset
|
||||
? clientOffset.y - hoverBoundingRect.top
|
||||
: 0;
|
||||
// Only perform the move when the mouse has crossed half of the items height
|
||||
// When dragging downwards, only move when the cursor is below 50%
|
||||
@@ -107,6 +117,51 @@ export default function OptionWrapper(
|
||||
},
|
||||
});
|
||||
|
||||
const shouldShowTooltip =
|
||||
(!isDragging && tooltipTitle && label && tooltipTitle !== label) ||
|
||||
(!isDragging &&
|
||||
labelRef &&
|
||||
labelRef.current &&
|
||||
labelRef.current.scrollWidth > labelRef.current.clientWidth);
|
||||
|
||||
const LabelContent = () => {
|
||||
if (!shouldShowTooltip) {
|
||||
return <span>{label}</span>;
|
||||
}
|
||||
return (
|
||||
<Tooltip title={tooltipTitle || label}>
|
||||
<span>{label}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const ColumnOption = () => (
|
||||
<StyledColumnOption
|
||||
column={column as ColumnMeta}
|
||||
labelRef={labelRef}
|
||||
showTooltip={!!shouldShowTooltip}
|
||||
showType
|
||||
/>
|
||||
);
|
||||
|
||||
const Label = () => {
|
||||
if (label) {
|
||||
return (
|
||||
<OptionLabel ref={labelRef}>
|
||||
<LabelContent />
|
||||
</OptionLabel>
|
||||
);
|
||||
}
|
||||
if (column) {
|
||||
return (
|
||||
<OptionLabel>
|
||||
<ColumnOption />
|
||||
</OptionLabel>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
drag(drop(ref));
|
||||
|
||||
return (
|
||||
@@ -118,7 +173,7 @@ export default function OptionWrapper(
|
||||
isExtra={isExtra}
|
||||
canDelete={canDelete}
|
||||
>
|
||||
{children}
|
||||
<Label />
|
||||
</Option>
|
||||
</DragContainer>
|
||||
);
|
||||
|
||||
@@ -23,8 +23,11 @@ import { DatasourcePanelDndItem } from '../../DatasourcePanel/types';
|
||||
import { DndItemType } from '../../DndItemType';
|
||||
|
||||
export interface OptionProps {
|
||||
children: ReactNode;
|
||||
children?: ReactNode;
|
||||
index: number;
|
||||
label?: string;
|
||||
tooltipTitle?: string;
|
||||
column?: ColumnMeta;
|
||||
clickClose: (index: number) => void;
|
||||
withCaret?: boolean;
|
||||
isExtra?: boolean;
|
||||
@@ -57,6 +60,7 @@ export interface DndColumnSelectProps<
|
||||
accept: DndItemType | DndItemType[];
|
||||
ghostButtonText?: string;
|
||||
displayGhostButton?: boolean;
|
||||
onClickGhostButton?: () => void;
|
||||
}
|
||||
|
||||
export type OptionValueType = Record<string, any>;
|
||||
|
||||
@@ -329,7 +329,6 @@ class AdhocFilterControl extends React.Component {
|
||||
options={this.state.options}
|
||||
onFilterEdit={this.onNewFilter}
|
||||
partitionColumn={this.state.partitionColumn}
|
||||
createNew
|
||||
>
|
||||
{trigger}
|
||||
</AdhocFilterPopoverTrigger>
|
||||
|
||||
@@ -29,7 +29,6 @@ interface AdhocFilterPopoverTriggerProps {
|
||||
datasource: Record<string, any>;
|
||||
onFilterEdit: (editedFilter: AdhocFilter) => void;
|
||||
partitionColumn?: string;
|
||||
createNew?: boolean;
|
||||
isControlledComponent?: boolean;
|
||||
visible?: boolean;
|
||||
togglePopover?: (visible: boolean) => void;
|
||||
@@ -104,7 +103,7 @@ class AdhocFilterPopoverTrigger extends React.PureComponent<
|
||||
defaultVisible={visible}
|
||||
visible={visible}
|
||||
onVisibleChange={togglePopover}
|
||||
destroyTooltipOnHide={this.props.createNew}
|
||||
destroyTooltipOnHide
|
||||
>
|
||||
{this.props.children}
|
||||
</Popover>
|
||||
|
||||
@@ -37,6 +37,7 @@ const propTypes = {
|
||||
onDropLabel: PropTypes.func,
|
||||
index: PropTypes.number,
|
||||
type: PropTypes.string,
|
||||
multi: PropTypes.bool,
|
||||
};
|
||||
|
||||
class AdhocMetricOption extends React.PureComponent {
|
||||
@@ -62,6 +63,7 @@ class AdhocMetricOption extends React.PureComponent {
|
||||
onDropLabel,
|
||||
index,
|
||||
type,
|
||||
multi,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
@@ -84,6 +86,7 @@ class AdhocMetricOption extends React.PureComponent {
|
||||
type={type ?? DndItemType.AdhocMetricOption}
|
||||
withCaret
|
||||
isFunction
|
||||
multi={multi}
|
||||
/>
|
||||
</AdhocMetricPopoverTrigger>
|
||||
);
|
||||
|
||||
@@ -35,7 +35,6 @@ export type AdhocMetricPopoverTriggerProps = {
|
||||
savedMetric: savedMetricType;
|
||||
datasourceType: string;
|
||||
children: ReactNode;
|
||||
createNew?: boolean;
|
||||
isControlledComponent?: boolean;
|
||||
visible?: boolean;
|
||||
togglePopover?: (visible: boolean) => void;
|
||||
@@ -232,7 +231,7 @@ class AdhocMetricPopoverTrigger extends React.PureComponent<
|
||||
visible={visible}
|
||||
onVisibleChange={togglePopover}
|
||||
title={popoverTitle}
|
||||
destroyTooltipOnHide={this.props.createNew}
|
||||
destroyTooltipOnHide
|
||||
>
|
||||
{this.props.children}
|
||||
</Popover>
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function MetricDefinitionValue({
|
||||
onDropLabel,
|
||||
index,
|
||||
type,
|
||||
multi,
|
||||
}) {
|
||||
const getSavedMetricByName = metricName =>
|
||||
savedMetrics.find(metric => metric.metric_name === metricName);
|
||||
@@ -76,6 +77,7 @@ export default function MetricDefinitionValue({
|
||||
index,
|
||||
savedMetric: savedMetric ?? {},
|
||||
type,
|
||||
multi,
|
||||
};
|
||||
|
||||
return <AdhocMetricOption {...metricOptionProps} />;
|
||||
|
||||
@@ -16,16 +16,10 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { t, withTheme } from '@superset-ui/core';
|
||||
import { isEqual } from 'lodash';
|
||||
import { ensureIsArray, t, useTheme } from '@superset-ui/core';
|
||||
import ControlHeader from 'src/explore/components/ControlHeader';
|
||||
import {
|
||||
AGGREGATES_OPTIONS,
|
||||
sqlaAutoGeneratedMetricNameRegex,
|
||||
druidAutoGeneratedMetricRegex,
|
||||
} from 'src/explore/constants';
|
||||
import Icons from 'src/components/Icons';
|
||||
import {
|
||||
AddIconButton,
|
||||
@@ -34,7 +28,6 @@ import {
|
||||
LabelsContainer,
|
||||
} from 'src/explore/components/controls/OptionControls';
|
||||
import columnType from './columnType';
|
||||
import MetricDefinitionOption from './MetricDefinitionOption';
|
||||
import MetricDefinitionValue from './MetricDefinitionValue';
|
||||
import AdhocMetric from './AdhocMetric';
|
||||
import savedMetricType from './savedMetricType';
|
||||
@@ -82,9 +75,9 @@ function isDictionaryForAdhocMetric(value) {
|
||||
return value && !(value instanceof AdhocMetric) && value.expressionType;
|
||||
}
|
||||
|
||||
function columnsContainAllMetrics(value, nextProps) {
|
||||
function columnsContainAllMetrics(value, columns, savedMetrics) {
|
||||
const columnNames = new Set(
|
||||
[...(nextProps.columns || []), ...(nextProps.savedMetrics || [])]
|
||||
[...(columns || []), ...(savedMetrics || [])]
|
||||
// eslint-disable-next-line camelcase
|
||||
.map(({ column_name, metric_name }) => column_name || metric_name),
|
||||
);
|
||||
@@ -123,294 +116,227 @@ function coerceAdhocMetrics(value) {
|
||||
});
|
||||
}
|
||||
|
||||
class MetricsControl extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.onMetricEdit = this.onMetricEdit.bind(this);
|
||||
this.onNewMetric = this.onNewMetric.bind(this);
|
||||
this.onRemoveMetric = this.onRemoveMetric.bind(this);
|
||||
this.moveLabel = this.moveLabel.bind(this);
|
||||
this.checkIfAggregateInInput = this.checkIfAggregateInInput.bind(this);
|
||||
this.optionsForSelect = this.optionsForSelect.bind(this);
|
||||
this.selectFilterOption = this.selectFilterOption.bind(this);
|
||||
this.isAutoGeneratedMetric = this.isAutoGeneratedMetric.bind(this);
|
||||
this.optionRenderer = option => <MetricDefinitionOption option={option} />;
|
||||
this.valueRenderer = (option, index) => (
|
||||
const emptySavedMetric = { metric_name: '', expression: '' };
|
||||
|
||||
const MetricsControl = ({
|
||||
onChange,
|
||||
multi,
|
||||
value: propsValue,
|
||||
columns,
|
||||
savedMetrics,
|
||||
datasource,
|
||||
datasourceType,
|
||||
...props
|
||||
}) => {
|
||||
const [value, setValue] = useState(coerceAdhocMetrics(propsValue));
|
||||
const theme = useTheme();
|
||||
|
||||
const handleChange = useCallback(
|
||||
opts => {
|
||||
// if clear out options
|
||||
if (opts === null) {
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const transformedOpts = ensureIsArray(opts);
|
||||
const optionValues = transformedOpts
|
||||
.map(option => {
|
||||
// pre-defined metric
|
||||
if (option.metric_name) {
|
||||
return option.metric_name;
|
||||
}
|
||||
return option;
|
||||
})
|
||||
.filter(option => option);
|
||||
onChange(multi ? optionValues : optionValues[0]);
|
||||
},
|
||||
[multi, onChange],
|
||||
);
|
||||
|
||||
const onNewMetric = useCallback(
|
||||
newMetric => {
|
||||
const newValue = [...value, newMetric];
|
||||
setValue(newValue);
|
||||
handleChange(newValue);
|
||||
},
|
||||
[handleChange, value],
|
||||
);
|
||||
|
||||
const onMetricEdit = useCallback(
|
||||
(changedMetric, oldMetric) => {
|
||||
const newValue = value.map(val => {
|
||||
if (
|
||||
// compare saved metrics
|
||||
val === oldMetric.metric_name ||
|
||||
// compare adhoc metrics
|
||||
typeof val.optionName !== 'undefined'
|
||||
? val.optionName === oldMetric.optionName
|
||||
: false
|
||||
) {
|
||||
return changedMetric;
|
||||
}
|
||||
return val;
|
||||
});
|
||||
setValue(newValue);
|
||||
handleChange(newValue);
|
||||
},
|
||||
[handleChange, value],
|
||||
);
|
||||
|
||||
const onRemoveMetric = useCallback(
|
||||
index => {
|
||||
if (!Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
const valuesCopy = [...value];
|
||||
valuesCopy.splice(index, 1);
|
||||
setValue(valuesCopy);
|
||||
handleChange(valuesCopy);
|
||||
},
|
||||
[handleChange, value],
|
||||
);
|
||||
|
||||
const moveLabel = useCallback(
|
||||
(dragIndex, hoverIndex) => {
|
||||
const newValues = [...value];
|
||||
[newValues[hoverIndex], newValues[dragIndex]] = [
|
||||
newValues[dragIndex],
|
||||
newValues[hoverIndex],
|
||||
];
|
||||
setValue(newValues);
|
||||
},
|
||||
[value],
|
||||
);
|
||||
|
||||
const isAddNewMetricDisabled = useCallback(() => !multi && value.length > 0, [
|
||||
multi,
|
||||
value.length,
|
||||
]);
|
||||
|
||||
const savedMetricOptions = useMemo(
|
||||
() => getOptionsForSavedMetrics(savedMetrics, propsValue, null),
|
||||
[propsValue, savedMetrics],
|
||||
);
|
||||
|
||||
const newAdhocMetric = useMemo(() => new AdhocMetric({ isNew: true }), [
|
||||
value,
|
||||
]);
|
||||
const addNewMetricPopoverTrigger = useCallback(
|
||||
trigger => {
|
||||
if (isAddNewMetricDisabled()) {
|
||||
return trigger;
|
||||
}
|
||||
return (
|
||||
<AdhocMetricPopoverTrigger
|
||||
adhocMetric={newAdhocMetric}
|
||||
onMetricEdit={onNewMetric}
|
||||
columns={columns}
|
||||
savedMetricsOptions={savedMetricOptions}
|
||||
datasource={datasource}
|
||||
savedMetric={emptySavedMetric}
|
||||
datasourceType={datasourceType}
|
||||
>
|
||||
{trigger}
|
||||
</AdhocMetricPopoverTrigger>
|
||||
);
|
||||
},
|
||||
[
|
||||
columns,
|
||||
datasource,
|
||||
datasourceType,
|
||||
isAddNewMetricDisabled,
|
||||
newAdhocMetric,
|
||||
onNewMetric,
|
||||
savedMetricOptions,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Remove all metrics if selected value no longer a valid column
|
||||
// in the dataset. Must use `nextProps` here because Redux reducers may
|
||||
// have already updated the value for this control.
|
||||
if (!columnsContainAllMetrics(propsValue, columns, savedMetrics)) {
|
||||
handleChange([]);
|
||||
}
|
||||
}, [columns, savedMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(coerceAdhocMetrics(propsValue));
|
||||
}, [propsValue]);
|
||||
|
||||
const onDropLabel = useCallback(() => handleChange(value), [
|
||||
handleChange,
|
||||
value,
|
||||
]);
|
||||
|
||||
const valueRenderer = useCallback(
|
||||
(option, index) => (
|
||||
<MetricDefinitionValue
|
||||
key={index}
|
||||
index={index}
|
||||
option={option}
|
||||
onMetricEdit={this.onMetricEdit}
|
||||
onRemoveMetric={this.onRemoveMetric}
|
||||
columns={this.props.columns}
|
||||
datasource={this.props.datasource}
|
||||
savedMetrics={this.props.savedMetrics}
|
||||
onMetricEdit={onMetricEdit}
|
||||
onRemoveMetric={onRemoveMetric}
|
||||
columns={columns}
|
||||
datasource={datasource}
|
||||
savedMetrics={savedMetrics}
|
||||
savedMetricsOptions={getOptionsForSavedMetrics(
|
||||
this.props.savedMetrics,
|
||||
this.props.value,
|
||||
this.props.value?.[index],
|
||||
savedMetrics,
|
||||
value,
|
||||
value?.[index],
|
||||
)}
|
||||
datasourceType={this.props.datasourceType}
|
||||
onMoveLabel={this.moveLabel}
|
||||
onDropLabel={() => this.props.onChange(this.state.value)}
|
||||
datasourceType={datasourceType}
|
||||
onMoveLabel={moveLabel}
|
||||
onDropLabel={onDropLabel}
|
||||
multi={multi}
|
||||
/>
|
||||
);
|
||||
this.select = null;
|
||||
this.selectRef = ref => {
|
||||
if (ref) {
|
||||
this.select = ref.select;
|
||||
} else {
|
||||
this.select = null;
|
||||
}
|
||||
};
|
||||
this.state = {
|
||||
aggregateInInput: null,
|
||||
options: this.optionsForSelect(this.props),
|
||||
value: coerceAdhocMetrics(this.props.value),
|
||||
};
|
||||
}
|
||||
),
|
||||
[
|
||||
columns,
|
||||
datasource,
|
||||
datasourceType,
|
||||
moveLabel,
|
||||
multi,
|
||||
onDropLabel,
|
||||
onMetricEdit,
|
||||
onRemoveMetric,
|
||||
savedMetrics,
|
||||
value,
|
||||
],
|
||||
);
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps) {
|
||||
const { value } = this.props;
|
||||
if (
|
||||
!isEqual(this.props.columns, nextProps.columns) ||
|
||||
!isEqual(this.props.savedMetrics, nextProps.savedMetrics)
|
||||
) {
|
||||
this.setState({ options: this.optionsForSelect(nextProps) });
|
||||
|
||||
// Remove all metrics if selected value no longer a valid column
|
||||
// in the dataset. Must use `nextProps` here because Redux reducers may
|
||||
// have already updated the value for this control.
|
||||
if (!columnsContainAllMetrics(nextProps.value, nextProps)) {
|
||||
this.props.onChange([]);
|
||||
}
|
||||
}
|
||||
if (value !== nextProps.value) {
|
||||
this.setState({ value: coerceAdhocMetrics(nextProps.value) });
|
||||
}
|
||||
}
|
||||
|
||||
onNewMetric(newMetric) {
|
||||
this.setState(
|
||||
prevState => ({
|
||||
...prevState,
|
||||
value: [...prevState.value, newMetric],
|
||||
}),
|
||||
() => {
|
||||
this.onChange(this.state.value);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
onMetricEdit(changedMetric, oldMetric) {
|
||||
this.setState(
|
||||
prevState => ({
|
||||
value: prevState.value.map(value => {
|
||||
if (
|
||||
// compare saved metrics
|
||||
value === oldMetric.metric_name ||
|
||||
// compare adhoc metrics
|
||||
typeof value.optionName !== 'undefined'
|
||||
? value.optionName === oldMetric.optionName
|
||||
: false
|
||||
) {
|
||||
return changedMetric;
|
||||
}
|
||||
return value;
|
||||
}),
|
||||
}),
|
||||
() => {
|
||||
this.onChange(this.state.value);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
onRemoveMetric(index) {
|
||||
if (!Array.isArray(this.state.value)) {
|
||||
return;
|
||||
}
|
||||
const valuesCopy = [...this.state.value];
|
||||
valuesCopy.splice(index, 1);
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
value: valuesCopy,
|
||||
}));
|
||||
this.props.onChange(valuesCopy);
|
||||
}
|
||||
|
||||
onChange(opts) {
|
||||
// if clear out options
|
||||
if (opts === null) {
|
||||
this.props.onChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let transformedOpts;
|
||||
if (Array.isArray(opts)) {
|
||||
transformedOpts = opts;
|
||||
} else {
|
||||
transformedOpts = opts ? [opts] : [];
|
||||
}
|
||||
const optionValues = transformedOpts
|
||||
.map(option => {
|
||||
// pre-defined metric
|
||||
if (option.metric_name) {
|
||||
return option.metric_name;
|
||||
}
|
||||
return option;
|
||||
})
|
||||
.filter(option => option);
|
||||
this.props.onChange(this.props.multi ? optionValues : optionValues[0]);
|
||||
}
|
||||
|
||||
moveLabel(dragIndex, hoverIndex) {
|
||||
const { value } = this.state;
|
||||
|
||||
const newValues = [...value];
|
||||
[newValues[hoverIndex], newValues[dragIndex]] = [
|
||||
newValues[dragIndex],
|
||||
newValues[hoverIndex],
|
||||
];
|
||||
this.setState({ value: newValues });
|
||||
}
|
||||
|
||||
isAddNewMetricDisabled() {
|
||||
return !this.props.multi && this.state.value.length > 0;
|
||||
}
|
||||
|
||||
addNewMetricPopoverTrigger(trigger) {
|
||||
if (this.isAddNewMetricDisabled()) {
|
||||
return trigger;
|
||||
}
|
||||
return (
|
||||
<AdhocMetricPopoverTrigger
|
||||
adhocMetric={new AdhocMetric({ isNew: true })}
|
||||
onMetricEdit={this.onNewMetric}
|
||||
columns={this.props.columns}
|
||||
savedMetricsOptions={getOptionsForSavedMetrics(
|
||||
this.props.savedMetrics,
|
||||
this.props.value,
|
||||
null,
|
||||
return (
|
||||
<div className="metrics-select">
|
||||
<HeaderContainer>
|
||||
<ControlHeader {...props} />
|
||||
{addNewMetricPopoverTrigger(
|
||||
<AddIconButton
|
||||
disabled={isAddNewMetricDisabled()}
|
||||
data-test="add-metric-button"
|
||||
>
|
||||
<Icons.PlusLarge
|
||||
iconSize="s"
|
||||
iconColor={theme.colors.grayscale.light5}
|
||||
/>
|
||||
</AddIconButton>,
|
||||
)}
|
||||
datasource={this.props.datasource}
|
||||
savedMetric={{ metric_name: '', expression: '' }}
|
||||
datasourceType={this.props.datasourceType}
|
||||
createNew
|
||||
>
|
||||
{trigger}
|
||||
</AdhocMetricPopoverTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
checkIfAggregateInInput(input) {
|
||||
const lowercaseInput = input.toLowerCase();
|
||||
const aggregateInInput =
|
||||
AGGREGATES_OPTIONS.find(x =>
|
||||
lowercaseInput.startsWith(`${x.toLowerCase()}(`),
|
||||
) || null;
|
||||
this.clearedAggregateInInput = this.state.aggregateInInput;
|
||||
this.setState({ aggregateInInput });
|
||||
}
|
||||
|
||||
optionsForSelect(props) {
|
||||
const { columns, savedMetrics } = props;
|
||||
const aggregates =
|
||||
columns && columns.length
|
||||
? AGGREGATES_OPTIONS.map(aggregate => ({
|
||||
aggregate_name: aggregate,
|
||||
}))
|
||||
: [];
|
||||
const options = [
|
||||
...(columns || []),
|
||||
...aggregates,
|
||||
...(savedMetrics || []),
|
||||
];
|
||||
|
||||
return options.reduce((results, option) => {
|
||||
if (option.metric_name) {
|
||||
results.push({ ...option, optionName: option.metric_name });
|
||||
} else if (option.column_name) {
|
||||
results.push({ ...option, optionName: `_col_${option.column_name}` });
|
||||
} else if (option.aggregate_name) {
|
||||
results.push({
|
||||
...option,
|
||||
optionName: `_aggregate_${option.aggregate_name}`,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}, []);
|
||||
}
|
||||
|
||||
isAutoGeneratedMetric(savedMetric) {
|
||||
if (this.props.datasourceType === 'druid') {
|
||||
return druidAutoGeneratedMetricRegex.test(savedMetric.verbose_name);
|
||||
}
|
||||
return sqlaAutoGeneratedMetricNameRegex.test(savedMetric.metric_name);
|
||||
}
|
||||
|
||||
selectFilterOption({ data: option }, filterValue) {
|
||||
if (this.state.aggregateInInput) {
|
||||
let endIndex = filterValue.length;
|
||||
if (filterValue.endsWith(')')) {
|
||||
endIndex = filterValue.length - 1;
|
||||
}
|
||||
const valueAfterAggregate = filterValue.substring(
|
||||
filterValue.indexOf('(') + 1,
|
||||
endIndex,
|
||||
);
|
||||
return (
|
||||
option.column_name &&
|
||||
option.column_name.toLowerCase().indexOf(valueAfterAggregate) >= 0
|
||||
);
|
||||
}
|
||||
return (
|
||||
option.optionName &&
|
||||
(!option.metric_name ||
|
||||
!this.isAutoGeneratedMetric(option) ||
|
||||
option.verbose_name) &&
|
||||
(option.optionName.toLowerCase().indexOf(filterValue) >= 0 ||
|
||||
(option.verbose_name &&
|
||||
option.verbose_name.toLowerCase().indexOf(filterValue) >= 0))
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { theme } = this.props;
|
||||
return (
|
||||
<div className="metrics-select">
|
||||
<HeaderContainer>
|
||||
<ControlHeader {...this.props} />
|
||||
{this.addNewMetricPopoverTrigger(
|
||||
<AddIconButton
|
||||
disabled={this.isAddNewMetricDisabled()}
|
||||
data-test="add-metric-button"
|
||||
>
|
||||
<Icons.PlusLarge
|
||||
iconSize="s"
|
||||
iconColor={theme.colors.grayscale.light5}
|
||||
/>
|
||||
</AddIconButton>,
|
||||
)}
|
||||
</HeaderContainer>
|
||||
<LabelsContainer>
|
||||
{this.state.value.length > 0
|
||||
? this.state.value.map((value, index) =>
|
||||
this.valueRenderer(value, index),
|
||||
)
|
||||
: this.addNewMetricPopoverTrigger(
|
||||
<AddControlLabel>
|
||||
<Icons.PlusSmall iconColor={theme.colors.grayscale.light1} />
|
||||
{t('Add metric')}
|
||||
</AddControlLabel>,
|
||||
)}
|
||||
</LabelsContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
</HeaderContainer>
|
||||
<LabelsContainer>
|
||||
{value.length > 0
|
||||
? value.map((value, index) => valueRenderer(value, index))
|
||||
: addNewMetricPopoverTrigger(
|
||||
<AddControlLabel>
|
||||
<Icons.PlusSmall iconColor={theme.colors.grayscale.light1} />
|
||||
{t('Add metric')}
|
||||
</AddControlLabel>,
|
||||
)}
|
||||
</LabelsContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
MetricsControl.propTypes = propTypes;
|
||||
MetricsControl.defaultProps = defaultProps;
|
||||
|
||||
export default withTheme(MetricsControl);
|
||||
export default MetricsControl;
|
||||
|
||||
@@ -45,11 +45,10 @@ export const OptionControlContainer = styled.div<{
|
||||
border-radius: 3px;
|
||||
cursor: ${({ withCaret }) => (withCaret ? 'pointer' : 'default')};
|
||||
`;
|
||||
|
||||
export const Label = styled.div`
|
||||
${({ theme }) => `
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
align-items: center;
|
||||
@@ -71,6 +70,11 @@ export const Label = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
const LabelText = styled.span`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
export const CaretContainer = styled.div`
|
||||
height: 100%;
|
||||
border-left: solid 1px ${({ theme }) => theme.colors.grayscale.dark2}0C;
|
||||
@@ -177,6 +181,7 @@ export const OptionControlLabel = ({
|
||||
index,
|
||||
isExtra,
|
||||
tooltipTitle,
|
||||
multi = true,
|
||||
...props
|
||||
}: {
|
||||
label: string | React.ReactNode;
|
||||
@@ -192,15 +197,24 @@ export const OptionControlLabel = ({
|
||||
index: number;
|
||||
isExtra?: boolean;
|
||||
tooltipTitle: string;
|
||||
multi?: boolean;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const labelRef = useRef<HTMLDivElement>(null);
|
||||
const hasMetricName = savedMetric?.metric_name;
|
||||
const [, drop] = useDrop({
|
||||
accept: type,
|
||||
drop() {
|
||||
if (!multi) {
|
||||
return;
|
||||
}
|
||||
onDropLabel?.();
|
||||
},
|
||||
hover(item: DragItem, monitor: DropTargetMonitor) {
|
||||
if (!multi) {
|
||||
return;
|
||||
}
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
@@ -242,7 +256,7 @@ export const OptionControlLabel = ({
|
||||
item.index = hoverIndex;
|
||||
},
|
||||
});
|
||||
const [, drag] = useDrag({
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
item: {
|
||||
type,
|
||||
index,
|
||||
@@ -254,10 +268,34 @@ export const OptionControlLabel = ({
|
||||
});
|
||||
|
||||
const getLabelContent = () => {
|
||||
if (savedMetric?.metric_name) {
|
||||
return <StyledMetricOption metric={savedMetric} />;
|
||||
const shouldShowTooltip =
|
||||
(!isDragging &&
|
||||
typeof label === 'string' &&
|
||||
tooltipTitle &&
|
||||
label &&
|
||||
tooltipTitle !== label) ||
|
||||
(!isDragging &&
|
||||
labelRef &&
|
||||
labelRef.current &&
|
||||
labelRef.current.scrollWidth > labelRef.current.clientWidth);
|
||||
|
||||
if (savedMetric && hasMetricName) {
|
||||
return (
|
||||
<StyledMetricOption
|
||||
metric={savedMetric}
|
||||
labelRef={labelRef}
|
||||
showTooltip={!!shouldShowTooltip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Tooltip title={tooltipTitle}>{label}</Tooltip>;
|
||||
if (!shouldShowTooltip) {
|
||||
return <LabelText ref={labelRef}>{label}</LabelText>;
|
||||
}
|
||||
return (
|
||||
<Tooltip title={tooltipTitle || label}>
|
||||
<LabelText ref={labelRef}>{label}</LabelText>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const getOptionControlContent = () => (
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('VizTypeControl', () => {
|
||||
|
||||
expect(visualizations).toHaveTextContent(/Time-series Table/);
|
||||
expect(visualizations).toHaveTextContent(/Time-series Chart/);
|
||||
expect(visualizations).toHaveTextContent(/Mixed timeseries chart/);
|
||||
expect(visualizations).toHaveTextContent(/Mixed Time-Series/);
|
||||
expect(visualizations).not.toHaveTextContent(/Line Chart/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,17 +62,22 @@ enum SECTIONS {
|
||||
const DEFAULT_ORDER = [
|
||||
'line',
|
||||
'big_number',
|
||||
'big_number_total',
|
||||
'table',
|
||||
'pivot_table_v2',
|
||||
'echarts_timeseries_line',
|
||||
'echarts_area',
|
||||
'echarts_timeseries_bar',
|
||||
'echarts_timeseries_scatter',
|
||||
'pie',
|
||||
'mixed_timeseries',
|
||||
'filter_box',
|
||||
'dist_bar',
|
||||
'area',
|
||||
'bar',
|
||||
'deck_polygon',
|
||||
'pie',
|
||||
'time_table',
|
||||
'pivot_table_v2',
|
||||
'histogram',
|
||||
'big_number_total',
|
||||
'deck_scatter',
|
||||
'deck_hex',
|
||||
'time_pivot',
|
||||
@@ -116,11 +121,7 @@ const OTHER_CATEGORY = t('Other');
|
||||
|
||||
const ALL_CHARTS = t('All charts');
|
||||
|
||||
const RECOMMENDED_TAGS = [
|
||||
t('Highly-used'),
|
||||
t('ECharts'),
|
||||
t('Advanced-Analytics'),
|
||||
];
|
||||
const RECOMMENDED_TAGS = [t('Popular'), t('ECharts'), t('Advanced-Analytics')];
|
||||
|
||||
export const VIZ_TYPE_CONTROL_TEST_ID = 'viz-type-control';
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '@superset-ui/chart-controls';
|
||||
|
||||
const OptionContainer = styled.div`
|
||||
width: 100%;
|
||||
> span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
/* eslint camelcase: 0 */
|
||||
import { t, SupersetClient } from '@superset-ui/core';
|
||||
import rison from 'rison';
|
||||
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
|
||||
import { addDangerToast, addSuccessToast } from '../../messageToasts/actions';
|
||||
|
||||
export const SET_REPORT = 'SET_REPORT';
|
||||
@@ -102,15 +103,21 @@ export const addReport = report => dispatch => {
|
||||
endpoint: `/api/v1/report/`,
|
||||
jsonPayload: report,
|
||||
})
|
||||
.then(() => {
|
||||
dispatch({ type: ADD_REPORT, report });
|
||||
.then(({ json }) => {
|
||||
dispatch({ type: ADD_REPORT, json });
|
||||
dispatch(addSuccessToast(t('The report has been created')));
|
||||
})
|
||||
.catch(() =>
|
||||
.catch(async e => {
|
||||
const parsedError = await getClientErrorObject(e);
|
||||
const errorMessage = parsedError.message;
|
||||
const errorArr = Object.keys(errorMessage);
|
||||
const error = errorMessage[errorArr[0]][0];
|
||||
dispatch(
|
||||
addDangerToast(t('An error occurred while creating this report.')),
|
||||
),
|
||||
);
|
||||
addDangerToast(
|
||||
t('An error occurred while editing this report: %s', error),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const EDIT_REPORT = 'EDIT_REPORT';
|
||||
|
||||
@@ -25,12 +25,12 @@ import {
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import rison from 'rison';
|
||||
import { uniqBy } from 'lodash';
|
||||
import moment from 'moment';
|
||||
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
|
||||
import {
|
||||
createErrorHandler,
|
||||
createFetchRelated,
|
||||
handleChartDelete,
|
||||
CardStylesOverrides,
|
||||
} from 'src/views/CRUD/utils';
|
||||
import {
|
||||
useChartEditModal,
|
||||
@@ -160,6 +160,9 @@ function ChartList(props: ChartListProps) {
|
||||
const [passwordFields, setPasswordFields] = useState<string[]>([]);
|
||||
const [preparingExport, setPreparingExport] = useState<boolean>(false);
|
||||
|
||||
const { userId } = props.user;
|
||||
const userKey = getFromLocalStorage(userId.toString(), null);
|
||||
|
||||
const openChartImportModal = () => {
|
||||
showImportModal(true);
|
||||
};
|
||||
@@ -270,23 +273,33 @@ function ChartList(props: ChartListProps) {
|
||||
Cell: ({
|
||||
row: {
|
||||
original: {
|
||||
changed_by_name: changedByName,
|
||||
last_saved_by: lastSavedBy,
|
||||
changed_by_url: changedByUrl,
|
||||
},
|
||||
},
|
||||
}: any) => <a href={changedByUrl}>{changedByName}</a>,
|
||||
}: any) => (
|
||||
<a href={changedByUrl}>
|
||||
{lastSavedBy?.first_name
|
||||
? `${lastSavedBy?.first_name} ${lastSavedBy?.last_name}`
|
||||
: null}
|
||||
</a>
|
||||
),
|
||||
Header: t('Modified by'),
|
||||
accessor: 'changed_by.first_name',
|
||||
accessor: 'last_saved_by.first_name',
|
||||
size: 'xl',
|
||||
},
|
||||
{
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { changed_on_delta_humanized: changedOn },
|
||||
original: { last_saved_at: lastSavedAt },
|
||||
},
|
||||
}: any) => <span className="no-wrap">{changedOn}</span>,
|
||||
}: any) => (
|
||||
<span className="no-wrap">
|
||||
{lastSavedAt ? moment.utc(lastSavedAt).fromNow() : null}
|
||||
</span>
|
||||
),
|
||||
Header: t('Last modified'),
|
||||
accessor: 'changed_on_delta_humanized',
|
||||
accessor: 'last_saved_at',
|
||||
size: 'xl',
|
||||
},
|
||||
{
|
||||
@@ -532,29 +545,25 @@ function ChartList(props: ChartListProps) {
|
||||
];
|
||||
|
||||
function renderCard(chart: Chart) {
|
||||
const { userId } = props.user;
|
||||
const userKey = getFromLocalStorage(userId.toString(), null);
|
||||
return (
|
||||
<CardStylesOverrides>
|
||||
<ChartCard
|
||||
chart={chart}
|
||||
showThumbnails={
|
||||
userKey
|
||||
? userKey.thumbnails
|
||||
: isFeatureEnabled(FeatureFlag.THUMBNAILS)
|
||||
}
|
||||
hasPerm={hasPerm}
|
||||
openChartEditModal={openChartEditModal}
|
||||
bulkSelectEnabled={bulkSelectEnabled}
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
refreshData={refreshData}
|
||||
loading={loading}
|
||||
favoriteStatus={favoriteStatus[chart.id]}
|
||||
saveFavoriteStatus={saveFavoriteStatus}
|
||||
handleBulkChartExport={handleBulkChartExport}
|
||||
/>
|
||||
</CardStylesOverrides>
|
||||
<ChartCard
|
||||
chart={chart}
|
||||
showThumbnails={
|
||||
userKey
|
||||
? userKey.thumbnails
|
||||
: isFeatureEnabled(FeatureFlag.THUMBNAILS)
|
||||
}
|
||||
hasPerm={hasPerm}
|
||||
openChartEditModal={openChartEditModal}
|
||||
bulkSelectEnabled={bulkSelectEnabled}
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
refreshData={refreshData}
|
||||
loading={loading}
|
||||
favoriteStatus={favoriteStatus[chart.id]}
|
||||
saveFavoriteStatus={saveFavoriteStatus}
|
||||
handleBulkChartExport={handleBulkChartExport}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const subMenuButtons: SubMenuProps['buttons'] = [];
|
||||
@@ -644,6 +653,11 @@ function ChartList(props: ChartListProps) {
|
||||
loading={loading}
|
||||
pageSize={PAGE_SIZE}
|
||||
renderCard={renderCard}
|
||||
showThumbnails={
|
||||
userKey
|
||||
? userKey.thumbnails
|
||||
: isFeatureEnabled(FeatureFlag.THUMBNAILS)
|
||||
}
|
||||
defaultViewMode={
|
||||
isFeatureEnabled(FeatureFlag.LISTVIEWS_DEFAULT_CARD_VIEW)
|
||||
? 'card'
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
createFetchRelated,
|
||||
createErrorHandler,
|
||||
handleDashboardDelete,
|
||||
CardStylesOverrides,
|
||||
} from 'src/views/CRUD/utils';
|
||||
import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks';
|
||||
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
|
||||
@@ -140,6 +139,9 @@ function DashboardList(props: DashboardListProps) {
|
||||
refreshData();
|
||||
};
|
||||
|
||||
const { userId } = props.user;
|
||||
const userKey = getFromLocalStorage(userId.toString(), null);
|
||||
|
||||
const canCreate = hasPerm('can_write');
|
||||
const canEdit = hasPerm('can_write');
|
||||
const canDelete = hasPerm('can_write');
|
||||
@@ -499,29 +501,25 @@ function DashboardList(props: DashboardListProps) {
|
||||
];
|
||||
|
||||
function renderCard(dashboard: Dashboard) {
|
||||
const { userId } = props.user;
|
||||
const userKey = getFromLocalStorage(userId.toString(), null);
|
||||
return (
|
||||
<CardStylesOverrides>
|
||||
<DashboardCard
|
||||
dashboard={dashboard}
|
||||
hasPerm={hasPerm}
|
||||
bulkSelectEnabled={bulkSelectEnabled}
|
||||
refreshData={refreshData}
|
||||
showThumbnails={
|
||||
userKey
|
||||
? userKey.thumbnails
|
||||
: isFeatureEnabled(FeatureFlag.THUMBNAILS)
|
||||
}
|
||||
loading={loading}
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
openDashboardEditModal={openDashboardEditModal}
|
||||
saveFavoriteStatus={saveFavoriteStatus}
|
||||
favoriteStatus={favoriteStatus[dashboard.id]}
|
||||
handleBulkDashboardExport={handleBulkDashboardExport}
|
||||
/>
|
||||
</CardStylesOverrides>
|
||||
<DashboardCard
|
||||
dashboard={dashboard}
|
||||
hasPerm={hasPerm}
|
||||
bulkSelectEnabled={bulkSelectEnabled}
|
||||
refreshData={refreshData}
|
||||
showThumbnails={
|
||||
userKey
|
||||
? userKey.thumbnails
|
||||
: isFeatureEnabled(FeatureFlag.THUMBNAILS)
|
||||
}
|
||||
loading={loading}
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
openDashboardEditModal={openDashboardEditModal}
|
||||
saveFavoriteStatus={saveFavoriteStatus}
|
||||
favoriteStatus={favoriteStatus[dashboard.id]}
|
||||
handleBulkDashboardExport={handleBulkDashboardExport}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -614,6 +612,11 @@ function DashboardList(props: DashboardListProps) {
|
||||
initialSort={initialSort}
|
||||
loading={loading}
|
||||
pageSize={PAGE_SIZE}
|
||||
showThumbnails={
|
||||
userKey
|
||||
? userKey.thumbnails
|
||||
: isFeatureEnabled(FeatureFlag.THUMBNAILS)
|
||||
}
|
||||
renderCard={renderCard}
|
||||
defaultViewMode={
|
||||
isFeatureEnabled(FeatureFlag.LISTVIEWS_DEFAULT_CARD_VIEW)
|
||||
|
||||
@@ -61,6 +61,8 @@ interface FieldPropTypes {
|
||||
onParametersUploadFileChange: (value: any) => string;
|
||||
changeMethods: { onParametersChange: (value: any) => string } & {
|
||||
onChange: (value: any) => string;
|
||||
} & {
|
||||
onQueryChange: (value: any) => string;
|
||||
} & { onParametersUploadFileChange: (value: any) => string } & {
|
||||
onAddTableCatalog: () => void;
|
||||
onRemoveTableCatalog: (idx: number) => void;
|
||||
@@ -415,15 +417,15 @@ const queryField = ({
|
||||
db,
|
||||
}: FieldPropTypes) => (
|
||||
<ValidatedInput
|
||||
id="query"
|
||||
name="query"
|
||||
id="query_input"
|
||||
name="query_input"
|
||||
required={required}
|
||||
value={db?.parameters?.query}
|
||||
value={db?.query_input || ''}
|
||||
validationMethods={{ onBlur: getValidation }}
|
||||
errorMessage={validationErrors?.query}
|
||||
placeholder="e.g. param1=value1¶m2=value2"
|
||||
label="Additional Parameters"
|
||||
onChange={changeMethods.onParametersChange}
|
||||
onChange={changeMethods.onQueryChange}
|
||||
helpText={t('Add additional custom parameters')}
|
||||
/>
|
||||
);
|
||||
@@ -475,6 +477,7 @@ const DatabaseConnectionForm = ({
|
||||
dbModel: { parameters },
|
||||
onParametersChange,
|
||||
onChange,
|
||||
onQueryChange,
|
||||
onParametersUploadFileChange,
|
||||
onAddTableCatalog,
|
||||
onRemoveTableCatalog,
|
||||
@@ -496,6 +499,9 @@ const DatabaseConnectionForm = ({
|
||||
onChange: (
|
||||
event: FormEvent<InputProps> | { target: HTMLInputElement },
|
||||
) => void;
|
||||
onQueryChange: (
|
||||
event: FormEvent<InputProps> | { target: HTMLInputElement },
|
||||
) => void;
|
||||
onParametersUploadFileChange?: (
|
||||
event: FormEvent<InputProps> | { target: HTMLInputElement },
|
||||
) => void;
|
||||
@@ -523,6 +529,7 @@ const DatabaseConnectionForm = ({
|
||||
changeMethods: {
|
||||
onParametersChange,
|
||||
onChange,
|
||||
onQueryChange,
|
||||
onParametersUploadFileChange,
|
||||
onAddTableCatalog,
|
||||
onRemoveTableCatalog,
|
||||
|
||||
@@ -169,9 +169,9 @@ const ExtraOptions = ({
|
||||
<StyledInputContainer css={no_margin_bottom}>
|
||||
<div className="input-container">
|
||||
<IndeterminateCheckbox
|
||||
id="cost_query_enabled"
|
||||
id="cost_estimate_enabled"
|
||||
indeterminate={false}
|
||||
checked={!!db?.extra_json?.cost_query_enabled}
|
||||
checked={!!db?.extra_json?.cost_estimate_enabled}
|
||||
onChange={onExtraInputChange}
|
||||
labelText={t('Enable query cost estimation')}
|
||||
/>
|
||||
|
||||
@@ -76,6 +76,18 @@ import {
|
||||
} from './styles';
|
||||
import ModalHeader, { DOCUMENTATION_LINK } from './ModalHeader';
|
||||
|
||||
const engineSpecificAlertMapping = {
|
||||
gsheets: {
|
||||
message: 'Why do I need to create a database?',
|
||||
description:
|
||||
'To begin using your Google Sheets, you need to create a database first. ' +
|
||||
'Databases are used as a way to identify ' +
|
||||
'your data so that it can be queried and visualized. This ' +
|
||||
'database will hold all of your individual Google Sheets ' +
|
||||
'you choose to connect here.',
|
||||
},
|
||||
};
|
||||
|
||||
const errorAlertMapping = {
|
||||
CONNECTION_MISSING_PARAMETERS_ERROR: {
|
||||
message: 'Missing Required Fields',
|
||||
@@ -134,6 +146,7 @@ enum ActionType {
|
||||
extraEditorChange,
|
||||
addTableCatalogSheet,
|
||||
removeTableCatalogSheet,
|
||||
queryChange,
|
||||
}
|
||||
|
||||
interface DBReducerPayloadType {
|
||||
@@ -151,6 +164,7 @@ type DBReducerActionType =
|
||||
| ActionType.extraEditorChange
|
||||
| ActionType.extraInputChange
|
||||
| ActionType.textChange
|
||||
| ActionType.queryChange
|
||||
| ActionType.inputChange
|
||||
| ActionType.editorChange
|
||||
| ActionType.parametersChange;
|
||||
@@ -193,7 +207,8 @@ function dbReducer(
|
||||
const trimmedState = {
|
||||
...(state || {}),
|
||||
};
|
||||
let query = '';
|
||||
let query = {};
|
||||
let query_input = '';
|
||||
let deserializeExtraJSON = {};
|
||||
let extra_json: DatabaseObject['extra_json'];
|
||||
|
||||
@@ -306,6 +321,15 @@ function dbReducer(
|
||||
...trimmedState,
|
||||
[action.payload.name]: action.payload.json,
|
||||
};
|
||||
case ActionType.queryChange:
|
||||
return {
|
||||
...trimmedState,
|
||||
parameters: {
|
||||
...trimmedState.parameters,
|
||||
query: Object.fromEntries(new URLSearchParams(action.payload.value)),
|
||||
},
|
||||
query_input: action.payload.value,
|
||||
};
|
||||
case ActionType.textChange:
|
||||
return {
|
||||
...trimmedState,
|
||||
@@ -327,6 +351,12 @@ function dbReducer(
|
||||
};
|
||||
}
|
||||
|
||||
// convert query to a string and store in query_input
|
||||
query = action.payload?.parameters?.query || {};
|
||||
query_input = Object.entries(query)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('&');
|
||||
|
||||
if (
|
||||
action.payload.backend === 'bigquery' &&
|
||||
action.payload.configuration_method ===
|
||||
@@ -338,11 +368,12 @@ function dbReducer(
|
||||
configuration_method: action.payload.configuration_method,
|
||||
extra_json: deserializeExtraJSON,
|
||||
parameters: {
|
||||
query,
|
||||
credentials_info: JSON.stringify(
|
||||
action.payload?.parameters?.credentials_info || '',
|
||||
),
|
||||
query,
|
||||
},
|
||||
query_input,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -364,37 +395,18 @@ function dbReducer(
|
||||
name: e,
|
||||
value: engineParamsCatalog[e],
|
||||
})),
|
||||
query_input,
|
||||
} as DatabaseObject;
|
||||
}
|
||||
|
||||
if (action.payload?.parameters?.query) {
|
||||
// convert query into URI params string
|
||||
query = new URLSearchParams(
|
||||
action.payload.parameters.query as string,
|
||||
).toString();
|
||||
|
||||
return {
|
||||
...action.payload,
|
||||
encrypted_extra: action.payload.encrypted_extra || '',
|
||||
engine: action.payload.backend || trimmedState.engine,
|
||||
configuration_method: action.payload.configuration_method,
|
||||
extra_json: deserializeExtraJSON,
|
||||
parameters: {
|
||||
...action.payload.parameters,
|
||||
query,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...action.payload,
|
||||
encrypted_extra: action.payload.encrypted_extra || '',
|
||||
engine: action.payload.backend || trimmedState.engine,
|
||||
configuration_method: action.payload.configuration_method,
|
||||
extra_json: deserializeExtraJSON,
|
||||
parameters: {
|
||||
...action.payload.parameters,
|
||||
},
|
||||
parameters: action.payload.parameters,
|
||||
query_input,
|
||||
};
|
||||
|
||||
case ActionType.dbSelected:
|
||||
@@ -454,10 +466,11 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
||||
const sslForced = isFeatureEnabled(
|
||||
FeatureFlag.FORCE_DATABASE_CONNECTIONS_SSL,
|
||||
);
|
||||
const hasAlert =
|
||||
connectionAlert || !!(db?.engine && engineSpecificAlertMapping[db.engine]);
|
||||
const useSqlAlchemyForm =
|
||||
db?.configuration_method === CONFIGURATION_METHOD.SQLALCHEMY_URI;
|
||||
const useTabLayout = isEditMode || useSqlAlchemyForm;
|
||||
|
||||
// Database fetch logic
|
||||
const {
|
||||
state: { loading: dbLoading, resource: dbFetched, error: dbErrors },
|
||||
@@ -471,9 +484,9 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
||||
addDangerToast,
|
||||
);
|
||||
const isDynamic = (engine: string | undefined) =>
|
||||
availableDbs?.databases.filter(
|
||||
availableDbs?.databases?.find(
|
||||
(DB: DatabaseObject) => DB.backend === engine || DB.engine === engine,
|
||||
)[0].parameters !== undefined;
|
||||
)?.parameters !== undefined;
|
||||
const showDBError = validationErrors || dbErrors;
|
||||
const isEmpty = (data?: Object | null) =>
|
||||
data && Object.keys(data).length === 0;
|
||||
@@ -527,21 +540,6 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (dbToUpdate?.parameters?.query) {
|
||||
// convert query params into dictionary
|
||||
dbToUpdate.parameters.query = JSON.parse(
|
||||
`{"${decodeURI((dbToUpdate?.parameters?.query as string) || '')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/&/g, '","')
|
||||
.replace(/=/g, '":"')}"}`,
|
||||
);
|
||||
} else if (
|
||||
dbToUpdate?.parameters?.query === '' &&
|
||||
'query' in dbModel.parameters.properties
|
||||
) {
|
||||
dbToUpdate.parameters.query = {};
|
||||
}
|
||||
|
||||
const engine = dbToUpdate.backend || dbToUpdate.engine;
|
||||
if (engine === 'bigquery' && dbToUpdate.parameters?.credentials_info) {
|
||||
// wrap encrypted_extra in credentials_info only for BigQuery
|
||||
@@ -834,6 +832,37 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
||||
setTabKey(key);
|
||||
};
|
||||
|
||||
const renderStepTwoAlert = () => {
|
||||
const { hostname } = window.location;
|
||||
let ipAlert = connectionAlert?.REGIONAL_IPS?.default || '';
|
||||
const regionalIPs = connectionAlert?.REGIONAL_IPS || {};
|
||||
Object.entries(regionalIPs).forEach(([regex, ipRange]) => {
|
||||
if (regex.match(hostname)) {
|
||||
ipAlert = ipRange;
|
||||
}
|
||||
});
|
||||
return (
|
||||
db?.engine && (
|
||||
<StyledAlertMargin>
|
||||
<Alert
|
||||
closable={false}
|
||||
css={(theme: SupersetTheme) => antDAlertStyles(theme)}
|
||||
type="info"
|
||||
showIcon
|
||||
message={
|
||||
engineSpecificAlertMapping[db.engine]?.message ||
|
||||
connectionAlert?.DEFAULT?.message
|
||||
}
|
||||
description={
|
||||
engineSpecificAlertMapping[db.engine]?.description ||
|
||||
connectionAlert?.DEFAULT?.description + ipAlert
|
||||
}
|
||||
/>
|
||||
</StyledAlertMargin>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const errorAlert = () => {
|
||||
if (
|
||||
isEmpty(dbErrors) ||
|
||||
@@ -929,6 +958,12 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
||||
value: target.value,
|
||||
})
|
||||
}
|
||||
onQueryChange={({ target }: { target: HTMLInputElement }) =>
|
||||
onChange(ActionType.queryChange, {
|
||||
name: target.name,
|
||||
value: target.value,
|
||||
})
|
||||
}
|
||||
onAddTableCatalog={() =>
|
||||
setDB({ type: ActionType.addTableCatalogSheet })
|
||||
}
|
||||
@@ -1050,6 +1085,12 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
||||
value: target.value,
|
||||
})
|
||||
}
|
||||
onQueryChange={({ target }: { target: HTMLInputElement }) =>
|
||||
onChange(ActionType.queryChange, {
|
||||
name: target.name,
|
||||
value: target.value,
|
||||
})
|
||||
}
|
||||
onAddTableCatalog={() =>
|
||||
setDB({ type: ActionType.addTableCatalogSheet })
|
||||
}
|
||||
@@ -1188,18 +1229,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
||||
dbName={dbName}
|
||||
dbModel={dbModel}
|
||||
/>
|
||||
{connectionAlert && (
|
||||
<StyledAlertMargin>
|
||||
<Alert
|
||||
closable={false}
|
||||
css={(theme: SupersetTheme) => antDAlertStyles(theme)}
|
||||
type="info"
|
||||
showIcon
|
||||
message={t('IP Allowlist')}
|
||||
description={connectionAlert.ALLOWED_IPS}
|
||||
/>
|
||||
</StyledAlertMargin>
|
||||
)}
|
||||
{hasAlert && renderStepTwoAlert()}
|
||||
<DatabaseConnectionForm
|
||||
db={db}
|
||||
sslForced={sslForced}
|
||||
@@ -1207,6 +1237,12 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
||||
onAddTableCatalog={() => {
|
||||
setDB({ type: ActionType.addTableCatalogSheet });
|
||||
}}
|
||||
onQueryChange={({ target }: { target: HTMLInputElement }) =>
|
||||
onChange(ActionType.queryChange, {
|
||||
name: target.name,
|
||||
value: target.value,
|
||||
})
|
||||
}
|
||||
onRemoveTableCatalog={(idx: number) => {
|
||||
setDB({
|
||||
type: ActionType.removeTableCatalogSheet,
|
||||
|
||||
@@ -45,15 +45,12 @@ export type DatabaseObject = {
|
||||
password?: string;
|
||||
encryption?: boolean;
|
||||
credentials_info?: string;
|
||||
query?: string | object;
|
||||
catalog?: {};
|
||||
query?: Record<string, string>;
|
||||
catalog?: Record<string, string>;
|
||||
};
|
||||
configuration_method: CONFIGURATION_METHOD;
|
||||
engine?: string;
|
||||
|
||||
// Gsheets temporary storage
|
||||
catalog?: Array<CatalogObject>;
|
||||
|
||||
// Performance
|
||||
cache_timeout?: string;
|
||||
allow_run_async?: boolean;
|
||||
@@ -85,11 +82,14 @@ export type DatabaseObject = {
|
||||
allows_virtual_table_explore?: boolean; // in SQL Lab
|
||||
schemas_allowed_for_csv_upload?: string[]; // in Security
|
||||
cancel_query_on_windows_unload?: boolean; // in Performance
|
||||
version?: string;
|
||||
|
||||
// todo: ask beto where this should live
|
||||
cost_query_enabled?: boolean; // in SQL Lab
|
||||
version?: string;
|
||||
cost_estimate_enabled?: boolean; // in SQL Lab
|
||||
};
|
||||
|
||||
// Temporary storage
|
||||
catalog?: Array<CatalogObject>;
|
||||
query_input?: string;
|
||||
extra?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
// storage keys for welcome page sticky tabs..
|
||||
// storage keys for welcome page sticky tabs and tables
|
||||
export const HOMEPAGE_CHART_FILTER = 'homepage_chart_filter';
|
||||
export const HOMEPAGE_ACTIVITY_FILTER = 'homepage_activity_filter';
|
||||
export const HOMEPAGE_DASHBOARD_FILTER = 'homepage_dashboard_filter';
|
||||
export const HOMEPAGE_COLLAPSE_STATE = 'homepage_collapse_state';
|
||||
|
||||
@@ -32,7 +32,7 @@ export enum TableTabTypes {
|
||||
export type Filters = {
|
||||
col: string;
|
||||
opr: string;
|
||||
value: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
export interface DashboardTableProps {
|
||||
|
||||
@@ -132,10 +132,19 @@ export const getRecentAcitivtyObjs = (
|
||||
) =>
|
||||
SupersetClient.get({ endpoint: recent }).then(recentsRes => {
|
||||
const res: any = {};
|
||||
const filters = [
|
||||
{
|
||||
col: 'created_by',
|
||||
opr: 'rel_o_m',
|
||||
value: 0,
|
||||
},
|
||||
];
|
||||
const newBatch = [
|
||||
SupersetClient.get({ endpoint: `/api/v1/chart/?q=${getParams()}` }),
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/?q=${getParams()}`,
|
||||
endpoint: `/api/v1/chart/?q=${getParams(filters)}`,
|
||||
}),
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/?q=${getParams(filters)}`,
|
||||
}),
|
||||
];
|
||||
return Promise.all(newBatch)
|
||||
@@ -269,15 +278,12 @@ export function shortenSQL(sql: string, maxLines: number) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// loading card count for homepage
|
||||
export const loadingCardCount = 5;
|
||||
|
||||
const breakpoints = [576, 768, 992, 1200];
|
||||
export const mq = breakpoints.map(bp => `@media (max-width: ${bp}px)`);
|
||||
|
||||
export const CardStylesOverrides = styled.div`
|
||||
.ant-card-cover > div {
|
||||
height: 264px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const CardContainer = styled.div<{
|
||||
showThumbnails?: boolean | undefined;
|
||||
}>`
|
||||
@@ -286,7 +292,7 @@ export const CardContainer = styled.div<{
|
||||
display: grid;
|
||||
grid-gap: ${theme.gridUnit * 12}px ${theme.gridUnit * 4}px;
|
||||
grid-template-columns: repeat(auto-fit, 300px);
|
||||
max-height: ${showThumbnails ? '314' : '140'}px;
|
||||
max-height: ${showThumbnails ? '314' : '148'}px;
|
||||
margin-top: ${theme.gridUnit * -6}px;
|
||||
padding: ${
|
||||
showThumbnails
|
||||
|
||||
@@ -21,9 +21,9 @@ import moment from 'moment';
|
||||
import { styled, t } from '@superset-ui/core';
|
||||
import { setInLocalStorage } from 'src/utils/localStorageHelpers';
|
||||
|
||||
import Loading from 'src/components/Loading';
|
||||
import ListViewCard from 'src/components/ListViewCard';
|
||||
import SubMenu from 'src/components/Menu/SubMenu';
|
||||
import { LoadingCards, ActivityData } from 'src/views/CRUD/welcome/Welcome';
|
||||
import {
|
||||
CardStyles,
|
||||
getEditedObjects,
|
||||
@@ -34,7 +34,7 @@ import { Chart } from 'src/types/Chart';
|
||||
import { Dashboard, SavedQueryObject } from 'src/views/CRUD/types';
|
||||
|
||||
import Icons from 'src/components/Icons';
|
||||
import { ActivityData } from './Welcome';
|
||||
|
||||
import EmptyState from './EmptyState';
|
||||
|
||||
/**
|
||||
@@ -230,7 +230,7 @@ export default function ActivityTable({
|
||||
const doneFetching = loadedCount < 3;
|
||||
|
||||
if ((loadingState && !editedObjs) || doneFetching) {
|
||||
return <Loading position="inline" />;
|
||||
return <LoadingCards />;
|
||||
}
|
||||
return (
|
||||
<Styles>
|
||||
|
||||
@@ -35,6 +35,7 @@ import PropertiesModal from 'src/explore/components/PropertiesModal';
|
||||
import { User } from 'src/types/bootstrapTypes';
|
||||
import { CardContainer, PAGE_SIZE } from 'src/views/CRUD/utils';
|
||||
import { HOMEPAGE_CHART_FILTER } from 'src/views/CRUD/storageKeys';
|
||||
import { LoadingCards } from 'src/views/CRUD/welcome/Welcome';
|
||||
import ChartCard from 'src/views/CRUD/chart/ChartCard';
|
||||
import Chart from 'src/types/Chart';
|
||||
import handleResourceExport from 'src/utils/export';
|
||||
@@ -131,6 +132,12 @@ function ChartTable({
|
||||
operator: 'chart_is_favorite',
|
||||
value: true,
|
||||
});
|
||||
} else if (filterName === 'Examples') {
|
||||
filters.push({
|
||||
id: 'created_by',
|
||||
operator: 'rel_o_m',
|
||||
value: 0,
|
||||
});
|
||||
}
|
||||
return filters;
|
||||
};
|
||||
@@ -177,7 +184,7 @@ function ChartTable({
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) return <Loading position="inline" />;
|
||||
if (loading) return <LoadingCards cover={showThumbnails} />;
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{sliceCurrentlyEditing && (
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
setInLocalStorage,
|
||||
getFromLocalStorage,
|
||||
} from 'src/utils/localStorageHelpers';
|
||||
import { LoadingCards } from 'src/views/CRUD/welcome/Welcome';
|
||||
import {
|
||||
createErrorHandler,
|
||||
CardContainer,
|
||||
@@ -132,8 +133,8 @@ function DashboardTable({
|
||||
const filters = [];
|
||||
if (filterName === 'Mine') {
|
||||
filters.push({
|
||||
id: 'owners',
|
||||
operator: 'rel_m_m',
|
||||
id: 'created_by',
|
||||
operator: 'rel_o_m',
|
||||
value: `${user?.userId}`,
|
||||
});
|
||||
} else if (filterName === 'Favorite') {
|
||||
@@ -142,6 +143,12 @@ function DashboardTable({
|
||||
operator: 'dashboard_is_favorite',
|
||||
value: true,
|
||||
});
|
||||
} else if (filterName === 'Examples') {
|
||||
filters.push({
|
||||
id: 'created_by',
|
||||
operator: 'rel_o_m',
|
||||
value: 0,
|
||||
});
|
||||
}
|
||||
return filters;
|
||||
};
|
||||
@@ -189,7 +196,7 @@ function DashboardTable({
|
||||
filters: getFilters(filter),
|
||||
});
|
||||
|
||||
if (loading) return <Loading position="inline" />;
|
||||
if (loading) return <LoadingCards cover={showThumbnails} />;
|
||||
return (
|
||||
<>
|
||||
<SubMenu
|
||||
|
||||
@@ -22,7 +22,7 @@ import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/light';
|
||||
import sql from 'react-syntax-highlighter/dist/cjs/languages/hljs/sql';
|
||||
import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github';
|
||||
import withToasts from 'src/messageToasts/enhancers/withToasts';
|
||||
import Loading from 'src/components/Loading';
|
||||
import { LoadingCards } from 'src/views/CRUD/welcome/Welcome';
|
||||
import { Dropdown, Menu } from 'src/common/components';
|
||||
import { useListViewResource, copyQueryLink } from 'src/views/CRUD/hooks';
|
||||
import ListViewCard from 'src/components/ListViewCard';
|
||||
@@ -240,7 +240,7 @@ const SavedQueries = ({
|
||||
</Menu>
|
||||
);
|
||||
|
||||
if (loading) return <Loading position="inline" />;
|
||||
if (loading) return <LoadingCards cover={showThumbnails} />;
|
||||
return (
|
||||
<>
|
||||
{queryDeleteModal && (
|
||||
|
||||
@@ -25,15 +25,20 @@ import {
|
||||
getFromLocalStorage,
|
||||
setInLocalStorage,
|
||||
} from 'src/utils/localStorageHelpers';
|
||||
import ListViewCard from 'src/components/ListViewCard';
|
||||
import withToasts from 'src/messageToasts/enhancers/withToasts';
|
||||
import Loading from 'src/components/Loading';
|
||||
import {
|
||||
createErrorHandler,
|
||||
getRecentAcitivtyObjs,
|
||||
mq,
|
||||
CardContainer,
|
||||
getUserOwnedObjects,
|
||||
loadingCardCount,
|
||||
} from 'src/views/CRUD/utils';
|
||||
import { HOMEPAGE_ACTIVITY_FILTER } from 'src/views/CRUD/storageKeys';
|
||||
import {
|
||||
HOMEPAGE_ACTIVITY_FILTER,
|
||||
HOMEPAGE_COLLAPSE_STATE,
|
||||
} from 'src/views/CRUD/storageKeys';
|
||||
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
|
||||
import { Switch } from 'src/common/components';
|
||||
|
||||
@@ -54,6 +59,10 @@ export interface ActivityData {
|
||||
Examples?: Array<object>;
|
||||
}
|
||||
|
||||
interface LoadingProps {
|
||||
cover?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_TAB_ARR = ['2', '3'];
|
||||
|
||||
const WelcomeContainer = styled.div`
|
||||
@@ -96,6 +105,12 @@ const WelcomeContainer = styled.div`
|
||||
div.ant-collapse-item:last-child .ant-collapse-header {
|
||||
padding-bottom: ${({ theme }) => theme.gridUnit * 9}px;
|
||||
}
|
||||
.loading-cards {
|
||||
margin-top: ${({ theme }) => theme.gridUnit * 8}px;
|
||||
.ant-card-cover > div {
|
||||
height: 168px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const WelcomeNav = styled.div`
|
||||
@@ -118,6 +133,14 @@ const WelcomeNav = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
export const LoadingCards = ({ cover }: LoadingProps) => (
|
||||
<CardContainer showThumbnails={cover} className="loading-cards">
|
||||
{[...new Array(loadingCardCount)].map(() => (
|
||||
<ListViewCard cover={cover ? false : <></>} description="" loading />
|
||||
))}
|
||||
</CardContainer>
|
||||
);
|
||||
|
||||
function Welcome({ user, addDangerToast }: WelcomeProps) {
|
||||
const userid = user.userId;
|
||||
const id = userid.toString();
|
||||
@@ -137,16 +160,18 @@ function Welcome({ user, addDangerToast }: WelcomeProps) {
|
||||
null,
|
||||
);
|
||||
const [loadedCount, setLoadedCount] = useState(0);
|
||||
const [activeState, setActiveState] = useState<Array<string>>(
|
||||
DEFAULT_TAB_ARR,
|
||||
);
|
||||
|
||||
const collapseState = getFromLocalStorage(HOMEPAGE_COLLAPSE_STATE, null);
|
||||
const [activeState, setActiveState] = useState<Array<string>>(collapseState);
|
||||
|
||||
const handleCollapse = (state: Array<string>) => {
|
||||
setActiveState(state);
|
||||
setInLocalStorage(HOMEPAGE_COLLAPSE_STATE, state);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const activeTab = getFromLocalStorage(HOMEPAGE_ACTIVITY_FILTER, null);
|
||||
setActiveState(collapseState || DEFAULT_TAB_ARR);
|
||||
getRecentAcitivtyObjs(user.userId, recent, addDangerToast)
|
||||
.then(res => {
|
||||
const data: ActivityData | null = {};
|
||||
@@ -216,7 +241,7 @@ function Welcome({ user, addDangerToast }: WelcomeProps) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (queryData?.length) {
|
||||
if (!collapseState && queryData?.length) {
|
||||
setActiveState(activeState => [...activeState, '4']);
|
||||
}
|
||||
setActivityData(activityData => ({
|
||||
@@ -230,7 +255,7 @@ function Welcome({ user, addDangerToast }: WelcomeProps) {
|
||||
}, [chartData, queryData, dashboardData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activityData?.Viewed?.length) {
|
||||
if (!collapseState && activityData?.Viewed?.length) {
|
||||
setActiveState(activeState => ['1', ...activeState]);
|
||||
}
|
||||
}, [activityData]);
|
||||
@@ -263,12 +288,12 @@ function Welcome({ user, addDangerToast }: WelcomeProps) {
|
||||
loadedCount={loadedCount}
|
||||
/>
|
||||
) : (
|
||||
<Loading position="inline" />
|
||||
<LoadingCards />
|
||||
)}
|
||||
</Collapse.Panel>
|
||||
<Collapse.Panel header={t('Dashboards')} key="2">
|
||||
{!dashboardData || isRecentActivityLoading ? (
|
||||
<Loading position="inline" />
|
||||
<LoadingCards cover={checked} />
|
||||
) : (
|
||||
<DashboardTable
|
||||
user={user}
|
||||
@@ -280,7 +305,7 @@ function Welcome({ user, addDangerToast }: WelcomeProps) {
|
||||
</Collapse.Panel>
|
||||
<Collapse.Panel header={t('Charts')} key="3">
|
||||
{!chartData || isRecentActivityLoading ? (
|
||||
<Loading position="inline" />
|
||||
<LoadingCards cover={checked} />
|
||||
) : (
|
||||
<ChartTable
|
||||
showThumbnails={checked}
|
||||
@@ -292,7 +317,7 @@ function Welcome({ user, addDangerToast }: WelcomeProps) {
|
||||
</Collapse.Panel>
|
||||
<Collapse.Panel header={t('Saved queries')} key="4">
|
||||
{!queryData ? (
|
||||
<Loading position="inline" />
|
||||
<LoadingCards cover={checked} />
|
||||
) : (
|
||||
<SavedQueries
|
||||
showThumbnails={checked}
|
||||
|
||||
@@ -27,7 +27,6 @@ const metadata = new ChartMetadata({
|
||||
'Compare multiple time series charts (as sparklines) and related metrics quickly.',
|
||||
),
|
||||
tags: [
|
||||
t('Advanced-Analytics'),
|
||||
t('Multi-Variables'),
|
||||
t('Comparison'),
|
||||
t('Legacy'),
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { isFeatureEnabled, Preset } from '@superset-ui/core';
|
||||
import { isFeatureEnabled, Preset, FeatureFlag } from '@superset-ui/core';
|
||||
import {
|
||||
BigNumberChartPlugin,
|
||||
BigNumberTotalChartPlugin,
|
||||
@@ -56,7 +56,13 @@ import { DeckGLChartPreset } from '@superset-ui/legacy-preset-chart-deckgl';
|
||||
import {
|
||||
EchartsPieChartPlugin,
|
||||
EchartsBoxPlotChartPlugin,
|
||||
EchartsAreaChartPlugin,
|
||||
EchartsTimeseriesChartPlugin,
|
||||
EchartsTimeseriesBarChartPlugin,
|
||||
EchartsTimeseriesLineChartPlugin,
|
||||
EchartsTimeseriesScatterChartPlugin,
|
||||
EchartsTimeseriesSmoothLineChartPlugin,
|
||||
EchartsTimeseriesStepChartPlugin,
|
||||
EchartsGraphChartPlugin,
|
||||
EchartsGaugeChartPlugin,
|
||||
EchartsRadarChartPlugin,
|
||||
@@ -76,7 +82,6 @@ import {
|
||||
import { PivotTableChartPlugin as PivotTableChartPluginV2 } from '@superset-ui/plugin-chart-pivot-table';
|
||||
import FilterBoxChartPlugin from '../FilterBox/FilterBoxChartPlugin';
|
||||
import TimeTableChartPlugin from '../TimeTable/TimeTableChartPlugin';
|
||||
import { FeatureFlag } from '../../featureFlags';
|
||||
|
||||
export default class MainPreset extends Preset {
|
||||
constructor() {
|
||||
@@ -134,9 +139,27 @@ export default class MainPreset extends Preset {
|
||||
new TreemapChartPlugin().configure({ key: 'treemap' }),
|
||||
new WordCloudChartPlugin().configure({ key: 'word_cloud' }),
|
||||
new WorldMapChartPlugin().configure({ key: 'world_map' }),
|
||||
new EchartsAreaChartPlugin().configure({
|
||||
key: 'echarts_area',
|
||||
}),
|
||||
new EchartsTimeseriesChartPlugin().configure({
|
||||
key: 'echarts_timeseries',
|
||||
}),
|
||||
new EchartsTimeseriesBarChartPlugin().configure({
|
||||
key: 'echarts_timeseries_bar',
|
||||
}),
|
||||
new EchartsTimeseriesLineChartPlugin().configure({
|
||||
key: 'echarts_timeseries_line',
|
||||
}),
|
||||
new EchartsTimeseriesSmoothLineChartPlugin().configure({
|
||||
key: 'echarts_timeseries_smooth',
|
||||
}),
|
||||
new EchartsTimeseriesScatterChartPlugin().configure({
|
||||
key: 'echarts_timeseries_scatter',
|
||||
}),
|
||||
new EchartsTimeseriesStepChartPlugin().configure({
|
||||
key: 'echarts_timeseries_step',
|
||||
}),
|
||||
new SelectFilterPlugin().configure({ key: 'filter_select' }),
|
||||
new RangeFilterPlugin().configure({ key: 'filter_range' }),
|
||||
new TimeFilterPlugin().configure({ key: 'filter_time' }),
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"importHelpers": false,
|
||||
"jsx": "preserve",
|
||||
"lib": ["dom", "esnext"],
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"noImplicitAny": true,
|
||||
|
||||
@@ -107,7 +107,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
RouteMethod.IMPORT,
|
||||
RouteMethod.RELATED,
|
||||
"bulk_delete", # not using RouteMethod since locally defined
|
||||
"post_data",
|
||||
"data",
|
||||
"get_data",
|
||||
"data_from_cache",
|
||||
"viz_types",
|
||||
@@ -152,6 +152,10 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
"description_markeddown",
|
||||
"edit_url",
|
||||
"id",
|
||||
"last_saved_at",
|
||||
"last_saved_by.id",
|
||||
"last_saved_by.first_name",
|
||||
"last_saved_by.last_name",
|
||||
"owners.first_name",
|
||||
"owners.id",
|
||||
"owners.last_name",
|
||||
@@ -170,12 +174,20 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
"changed_on_delta_humanized",
|
||||
"datasource_id",
|
||||
"datasource_name",
|
||||
"last_saved_at",
|
||||
"last_saved_by.id",
|
||||
"last_saved_by.first_name",
|
||||
"last_saved_by.last_name",
|
||||
"slice_name",
|
||||
"viz_type",
|
||||
]
|
||||
search_columns = [
|
||||
"created_by",
|
||||
"changed_by",
|
||||
"last_saved_at",
|
||||
"last_saved_by.id",
|
||||
"last_saved_by.first_name",
|
||||
"last_saved_by.last_name",
|
||||
"datasource_id",
|
||||
"datasource_name",
|
||||
"datasource_type",
|
||||
@@ -490,10 +502,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
# Post-process the data so it matches the data presented in the chart.
|
||||
# This is needed for sending reports based on text charts that do the
|
||||
# post-processing of data, eg, the pivot table.
|
||||
if (
|
||||
result_type == ChartDataResultType.POST_PROCESSED
|
||||
and result_format == ChartDataResultFormat.CSV
|
||||
):
|
||||
if result_type == ChartDataResultType.POST_PROCESSED:
|
||||
result = apply_post_process(result, form_data)
|
||||
|
||||
if result_format == ChartDataResultFormat.CSV:
|
||||
@@ -641,7 +650,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.data",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def post_data(self) -> Response:
|
||||
def data(self) -> Response:
|
||||
"""
|
||||
Takes a query context constructed in the client and returns payload
|
||||
data response for the given query.
|
||||
@@ -989,6 +998,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
)
|
||||
# If not screenshot then send request to compute thumb to celery
|
||||
if not screenshot:
|
||||
self.incr_stats("async", self.thumbnail.__name__)
|
||||
logger.info(
|
||||
"Triggering thumbnail compute (chart id: %s) ASYNC", str(chart.id)
|
||||
)
|
||||
@@ -996,11 +1006,13 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
return self.response(202, message="OK Async")
|
||||
# If digests
|
||||
if chart.digest != digest:
|
||||
self.incr_stats("redirect", self.thumbnail.__name__)
|
||||
return redirect(
|
||||
url_for(
|
||||
f"{self.__class__.__name__}.thumbnail", pk=pk, digest=chart.digest
|
||||
)
|
||||
)
|
||||
self.incr_stats("from_cache", self.thumbnail.__name__)
|
||||
return Response(
|
||||
FileWrapper(screenshot), mimetype="image/png", direct_passthrough=True
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from flask_appbuilder.models.sqla import Model
|
||||
@@ -27,15 +28,15 @@ from superset.charts.commands.exceptions import (
|
||||
DashboardsNotFoundValidationError,
|
||||
)
|
||||
from superset.charts.dao import ChartDAO
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.utils import get_datasource_by_id, populate_owners
|
||||
from superset.commands.base import BaseCommand, CreateMixin
|
||||
from superset.commands.utils import get_datasource_by_id
|
||||
from superset.dao.exceptions import DAOCreateFailedError
|
||||
from superset.dashboards.dao import DashboardDAO
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CreateChartCommand(BaseCommand):
|
||||
class CreateChartCommand(CreateMixin, BaseCommand):
|
||||
def __init__(self, user: User, data: Dict[str, Any]):
|
||||
self._actor = user
|
||||
self._properties = data.copy()
|
||||
@@ -43,6 +44,8 @@ class CreateChartCommand(BaseCommand):
|
||||
def run(self) -> Model:
|
||||
self.validate()
|
||||
try:
|
||||
self._properties["last_saved_at"] = datetime.now()
|
||||
self._properties["last_saved_by"] = self._actor
|
||||
chart = ChartDAO.create(self._properties)
|
||||
except DAOCreateFailedError as ex:
|
||||
logger.exception(ex.exception)
|
||||
@@ -70,7 +73,7 @@ class CreateChartCommand(BaseCommand):
|
||||
self._properties["dashboards"] = dashboards
|
||||
|
||||
try:
|
||||
owners = populate_owners(self._actor, owner_ids)
|
||||
owners = self.populate_owners(self._actor, owner_ids)
|
||||
self._properties["owners"] = owners
|
||||
except ValidationError as ex:
|
||||
exceptions.append(ex)
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from flask_appbuilder.models.sqla import Model
|
||||
@@ -30,8 +31,8 @@ from superset.charts.commands.exceptions import (
|
||||
DatasourceTypeUpdateRequiredValidationError,
|
||||
)
|
||||
from superset.charts.dao import ChartDAO
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.utils import get_datasource_by_id, populate_owners
|
||||
from superset.commands.base import BaseCommand, UpdateMixin
|
||||
from superset.commands.utils import get_datasource_by_id
|
||||
from superset.dao.exceptions import DAOUpdateFailedError
|
||||
from superset.dashboards.dao import DashboardDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
@@ -41,7 +42,13 @@ from superset.views.base import check_ownership
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpdateChartCommand(BaseCommand):
|
||||
def is_query_context_update(properties: Dict[str, Any]) -> bool:
|
||||
return set(properties) == {"query_context", "query_context_generation"} and bool(
|
||||
properties.get("query_context_generation")
|
||||
)
|
||||
|
||||
|
||||
class UpdateChartCommand(UpdateMixin, BaseCommand):
|
||||
def __init__(self, user: User, model_id: int, data: Dict[str, Any]):
|
||||
self._actor = user
|
||||
self._model_id = model_id
|
||||
@@ -51,6 +58,9 @@ class UpdateChartCommand(BaseCommand):
|
||||
def run(self) -> Model:
|
||||
self.validate()
|
||||
try:
|
||||
if self._properties.get("query_context_generation") is None:
|
||||
self._properties["last_saved_at"] = datetime.now()
|
||||
self._properties["last_saved_by"] = self._actor
|
||||
chart = ChartDAO.update(self._model, self._properties)
|
||||
except DAOUpdateFailedError as ex:
|
||||
logger.exception(ex.exception)
|
||||
@@ -73,11 +83,14 @@ class UpdateChartCommand(BaseCommand):
|
||||
self._model = ChartDAO.find_by_id(self._model_id)
|
||||
if not self._model:
|
||||
raise ChartNotFoundError()
|
||||
# Check ownership
|
||||
try:
|
||||
check_ownership(self._model)
|
||||
except SupersetSecurityException:
|
||||
raise ChartForbiddenError()
|
||||
|
||||
# Check ownership; when only updating query context we ignore
|
||||
# ownership so the update can be performed by report workers
|
||||
if not is_query_context_update(self._properties):
|
||||
try:
|
||||
check_ownership(self._model)
|
||||
except SupersetSecurityException:
|
||||
raise ChartForbiddenError()
|
||||
|
||||
# Validate/Populate datasource
|
||||
if datasource_id is not None:
|
||||
@@ -96,7 +109,7 @@ class UpdateChartCommand(BaseCommand):
|
||||
|
||||
# Validate/Populate owner
|
||||
try:
|
||||
owners = populate_owners(self._actor, owner_ids)
|
||||
owners = self.populate_owners(self._actor, owner_ids)
|
||||
self._properties["owners"] = owners
|
||||
except ValidationError as ex:
|
||||
exceptions.append(ex)
|
||||
|
||||
@@ -27,60 +27,154 @@ for these chart types.
|
||||
"""
|
||||
|
||||
from io import StringIO
|
||||
from typing import Any, Callable, Dict, Optional, Union
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from superset.utils.core import DTTM_ALIAS, extract_dataframe_dtypes, get_metric_name
|
||||
from superset.utils.core import (
|
||||
ChartDataResultFormat,
|
||||
DTTM_ALIAS,
|
||||
extract_dataframe_dtypes,
|
||||
get_metric_name,
|
||||
)
|
||||
|
||||
|
||||
def sql_like_sum(series: pd.Series) -> pd.Series:
|
||||
def get_column_key(label: Tuple[str, ...], metrics: List[str]) -> Tuple[Any, ...]:
|
||||
"""
|
||||
A SUM aggregation function that mimics the behavior from SQL.
|
||||
Sort columns when combining metrics.
|
||||
|
||||
MultiIndex labels have the metric name as the last element in the
|
||||
tuple. We want to sort these according to the list of passed metrics.
|
||||
"""
|
||||
return series.sum(min_count=1)
|
||||
parts: List[Any] = list(label)
|
||||
metric = parts[-1]
|
||||
parts[-1] = metrics.index(metric)
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def pivot_table(df: pd.DataFrame, form_data: Dict[str, Any]) -> pd.DataFrame:
|
||||
"""
|
||||
Pivot table.
|
||||
"""
|
||||
if form_data.get("granularity") == "all" and DTTM_ALIAS in df:
|
||||
del df[DTTM_ALIAS]
|
||||
def pivot_df( # pylint: disable=too-many-locals, too-many-arguments, too-many-statements, too-many-branches
|
||||
df: pd.DataFrame,
|
||||
rows: List[str],
|
||||
columns: List[str],
|
||||
metrics: List[str],
|
||||
aggfunc: str = "Sum",
|
||||
transpose_pivot: bool = False,
|
||||
combine_metrics: bool = False,
|
||||
show_rows_total: bool = False,
|
||||
show_columns_total: bool = False,
|
||||
apply_metrics_on_rows: bool = False,
|
||||
) -> pd.DataFrame:
|
||||
metric_name = f"Total ({aggfunc})"
|
||||
|
||||
metrics = [get_metric_name(m) for m in form_data["metrics"]]
|
||||
aggfuncs: Dict[str, Union[str, Callable[[Any], Any]]] = {}
|
||||
for metric in metrics:
|
||||
aggfunc = form_data.get("pandas_aggfunc") or "sum"
|
||||
if pd.api.types.is_numeric_dtype(df[metric]):
|
||||
if aggfunc == "sum":
|
||||
aggfunc = sql_like_sum
|
||||
elif aggfunc not in {"min", "max"}:
|
||||
aggfunc = "max"
|
||||
aggfuncs[metric] = aggfunc
|
||||
if transpose_pivot:
|
||||
rows, columns = columns, rows
|
||||
|
||||
groupby = form_data.get("groupby") or []
|
||||
columns = form_data.get("columns") or []
|
||||
if form_data.get("transpose_pivot"):
|
||||
groupby, columns = columns, groupby
|
||||
# to apply the metrics on the rows we pivot the dataframe, apply the
|
||||
# metrics to the columns, and pivot the dataframe back before
|
||||
# returning it
|
||||
if apply_metrics_on_rows:
|
||||
rows, columns = columns, rows
|
||||
axis = {"columns": 0, "rows": 1}
|
||||
else:
|
||||
axis = {"columns": 1, "rows": 0}
|
||||
|
||||
df = df.pivot_table(
|
||||
index=groupby,
|
||||
columns=columns,
|
||||
values=metrics,
|
||||
aggfunc=aggfuncs,
|
||||
margins=form_data.get("pivot_margins"),
|
||||
)
|
||||
# pivot data; we'll compute totals and subtotals later
|
||||
if rows or columns:
|
||||
df = df.pivot_table(
|
||||
index=rows,
|
||||
columns=columns,
|
||||
values=metrics,
|
||||
aggfunc=pivot_v2_aggfunc_map[aggfunc],
|
||||
margins=False,
|
||||
)
|
||||
else:
|
||||
# if there's no rows nor columns we have a single value; update
|
||||
# the index with the metric name so it shows up in the table
|
||||
df.index = pd.Index([*df.index[:-1], metric_name], name="metric")
|
||||
|
||||
# Re-order the columns adhering to the metric ordering.
|
||||
df = df[metrics]
|
||||
# if no rows were passed the metrics will be in the rows, so we
|
||||
# need to move them back to columns
|
||||
if columns and not rows:
|
||||
df = df.stack().to_frame().T
|
||||
df = df[metrics]
|
||||
df.index = pd.Index([*df.index[:-1], metric_name], name="metric")
|
||||
|
||||
# Display metrics side by side with each column
|
||||
if form_data.get("combine_metric"):
|
||||
df = df.stack(0).unstack().reindex(level=-1, columns=metrics)
|
||||
# combining metrics changes the column hierarchy, moving the metric
|
||||
# from the top to the bottom, eg:
|
||||
#
|
||||
# ('SUM(col)', 'age', 'name') => ('age', 'name', 'SUM(col)')
|
||||
if combine_metrics and isinstance(df.columns, pd.MultiIndex):
|
||||
# move metrics to the lowest level
|
||||
new_order = [*range(1, df.columns.nlevels), 0]
|
||||
df = df.reorder_levels(new_order, axis=1)
|
||||
|
||||
# flatten column names
|
||||
df.columns = [" ".join(column) for column in df.columns]
|
||||
# sort columns, combining metrics for each group
|
||||
decorated_columns = [(col, i) for i, col in enumerate(df.columns)]
|
||||
grouped_columns = sorted(
|
||||
decorated_columns, key=lambda t: get_column_key(t[0], metrics)
|
||||
)
|
||||
indexes = [i for col, i in grouped_columns]
|
||||
df = df[df.columns[indexes]]
|
||||
elif rows:
|
||||
# if metrics were not combined we sort the dataframe by the list
|
||||
# of metrics defined by the user
|
||||
df = df[metrics]
|
||||
|
||||
# compute fractions, if needed
|
||||
if aggfunc.endswith(" as Fraction of Total"):
|
||||
total = df.sum().sum()
|
||||
df = df.astype(total.dtypes) / total
|
||||
elif aggfunc.endswith(" as Fraction of Columns"):
|
||||
total = df.sum(axis=axis["rows"])
|
||||
df = df.astype(total.dtypes).div(total, axis=axis["columns"])
|
||||
elif aggfunc.endswith(" as Fraction of Rows"):
|
||||
total = df.sum(axis=axis["columns"])
|
||||
df = df.astype(total.dtypes).div(total, axis=axis["rows"])
|
||||
|
||||
# convert to a MultiIndex to simplify logic
|
||||
if not isinstance(df.index, pd.MultiIndex):
|
||||
df.index = pd.MultiIndex.from_tuples([(str(i),) for i in df.index])
|
||||
if not isinstance(df.columns, pd.MultiIndex):
|
||||
df.columns = pd.MultiIndex.from_tuples([(str(i),) for i in df.columns])
|
||||
|
||||
if show_rows_total:
|
||||
# add subtotal for each group and overall total; we start from the
|
||||
# overall group, and iterate deeper into subgroups
|
||||
groups = df.columns
|
||||
for level in range(df.columns.nlevels):
|
||||
subgroups = {group[:level] for group in groups}
|
||||
for subgroup in subgroups:
|
||||
slice_ = df.columns.get_loc(subgroup)
|
||||
subtotal = pivot_v2_aggfunc_map[aggfunc](df.iloc[:, slice_], axis=1)
|
||||
depth = df.columns.nlevels - len(subgroup) - 1
|
||||
total = metric_name if level == 0 else "Subtotal"
|
||||
subtotal_name = tuple([*subgroup, total, *([""] * depth)])
|
||||
# insert column after subgroup
|
||||
df.insert(int(slice_.stop), subtotal_name, subtotal)
|
||||
|
||||
if rows and show_columns_total:
|
||||
# add subtotal for each group and overall total; we start from the
|
||||
# overall group, and iterate deeper into subgroups
|
||||
groups = df.index
|
||||
for level in range(df.index.nlevels):
|
||||
subgroups = {group[:level] for group in groups}
|
||||
for subgroup in subgroups:
|
||||
slice_ = df.index.get_loc(subgroup)
|
||||
subtotal = pivot_v2_aggfunc_map[aggfunc](
|
||||
df.iloc[slice_, :].apply(pd.to_numeric), axis=0
|
||||
)
|
||||
depth = df.index.nlevels - len(subgroup) - 1
|
||||
total = metric_name if level == 0 else "Subtotal"
|
||||
subtotal.name = tuple([*subgroup, total, *([""] * depth)])
|
||||
# insert row after subgroup
|
||||
df = pd.concat(
|
||||
[df[: slice_.stop], subtotal.to_frame().T, df[slice_.stop :]]
|
||||
)
|
||||
|
||||
# if we want to apply the metrics on the rows we need to pivot the
|
||||
# dataframe back
|
||||
if apply_metrics_on_rows:
|
||||
df = df.T
|
||||
|
||||
return df
|
||||
|
||||
@@ -125,61 +219,49 @@ def pivot_table_v2( # pylint: disable=too-many-branches
|
||||
if form_data.get("granularity_sqla") == "all" and DTTM_ALIAS in df:
|
||||
del df[DTTM_ALIAS]
|
||||
|
||||
# TODO (betodealmeida): implement metricsLayout
|
||||
metrics = [get_metric_name(m) for m in form_data["metrics"]]
|
||||
aggregate_function = form_data.get("aggregateFunction", "Sum")
|
||||
groupby = form_data.get("groupbyRows") or []
|
||||
columns = form_data.get("groupbyColumns") or []
|
||||
if form_data.get("transposePivot"):
|
||||
groupby, columns = columns, groupby
|
||||
|
||||
df = df.pivot_table(
|
||||
index=groupby,
|
||||
columns=columns,
|
||||
values=metrics,
|
||||
aggfunc=pivot_v2_aggfunc_map[aggregate_function],
|
||||
margins=True,
|
||||
return pivot_df(
|
||||
df,
|
||||
rows=form_data.get("groupbyRows") or [],
|
||||
columns=form_data.get("groupbyColumns") or [],
|
||||
metrics=[get_metric_name(m) for m in form_data["metrics"]],
|
||||
aggfunc=form_data.get("aggregateFunction", "Sum"),
|
||||
transpose_pivot=bool(form_data.get("transposePivot")),
|
||||
combine_metrics=bool(form_data.get("combineMetric")),
|
||||
show_rows_total=bool(form_data.get("rowTotals")),
|
||||
show_columns_total=bool(form_data.get("colTotals")),
|
||||
apply_metrics_on_rows=form_data.get("metricsLayout") == "ROWS",
|
||||
)
|
||||
|
||||
# The pandas `pivot_table` method either brings both row/column
|
||||
# totals, or none at all. We pass `margin=True` to get both, and
|
||||
# remove any dimension that was not requests.
|
||||
if not form_data.get("rowTotals"):
|
||||
df.drop(df.columns[len(df.columns) - 1], axis=1, inplace=True)
|
||||
if not form_data.get("colTotals"):
|
||||
df = df[:-1]
|
||||
|
||||
# Compute fractions, if needed. If `colTotals` or `rowTotals` are
|
||||
# present we need to adjust for including them in the sum
|
||||
if aggregate_function.endswith(" as Fraction of Total"):
|
||||
total = df.sum().sum()
|
||||
df = df.astype(total.dtypes) / total
|
||||
if form_data.get("colTotals"):
|
||||
df *= 2
|
||||
if form_data.get("rowTotals"):
|
||||
df *= 2
|
||||
elif aggregate_function.endswith(" as Fraction of Columns"):
|
||||
total = df.sum(axis=0)
|
||||
df = df.astype(total.dtypes).div(total, axis=1)
|
||||
if form_data.get("colTotals"):
|
||||
df *= 2
|
||||
elif aggregate_function.endswith(" as Fraction of Rows"):
|
||||
total = df.sum(axis=1)
|
||||
df = df.astype(total.dtypes).div(total, axis=0)
|
||||
if form_data.get("rowTotals"):
|
||||
df *= 2
|
||||
def pivot_table(df: pd.DataFrame, form_data: Dict[str, Any]) -> pd.DataFrame:
|
||||
"""
|
||||
Pivot table (v1).
|
||||
"""
|
||||
if form_data.get("granularity") == "all" and DTTM_ALIAS in df:
|
||||
del df[DTTM_ALIAS]
|
||||
|
||||
# Re-order the columns adhering to the metric ordering.
|
||||
df = df[metrics]
|
||||
# v1 func names => v2 func names
|
||||
func_map = {
|
||||
"sum": "Sum",
|
||||
"mean": "Average",
|
||||
"min": "Minimum",
|
||||
"max": "Maximum",
|
||||
"std": "Sample Standard Deviation",
|
||||
"var": "Sample Variance",
|
||||
}
|
||||
|
||||
# Display metrics side by side with each column
|
||||
if form_data.get("combineMetric"):
|
||||
df = df.stack(0).unstack().reindex(level=-1, columns=metrics)
|
||||
|
||||
# flatten column names
|
||||
df.columns = [" ".join(column) for column in df.columns]
|
||||
|
||||
return df
|
||||
return pivot_df(
|
||||
df,
|
||||
rows=form_data.get("groupby") or [],
|
||||
columns=form_data.get("columns") or [],
|
||||
metrics=[get_metric_name(m) for m in form_data["metrics"]],
|
||||
aggfunc=func_map.get(form_data.get("pandas_aggfunc", "sum"), "Sum"),
|
||||
transpose_pivot=bool(form_data.get("transpose_pivot")),
|
||||
combine_metrics=bool(form_data.get("combine_metric")),
|
||||
show_rows_total=bool(form_data.get("pivot_margins")),
|
||||
show_columns_total=bool(form_data.get("pivot_margins")),
|
||||
apply_metrics_on_rows=False,
|
||||
)
|
||||
|
||||
|
||||
post_processors = {
|
||||
@@ -200,16 +282,42 @@ def apply_post_process(
|
||||
post_processor = post_processors[viz_type]
|
||||
|
||||
for query in result["queries"]:
|
||||
df = pd.read_csv(StringIO(query["data"]))
|
||||
if query["result_format"] == ChartDataResultFormat.JSON:
|
||||
df = pd.DataFrame.from_dict(query["data"])
|
||||
elif query["result_format"] == ChartDataResultFormat.CSV:
|
||||
df = pd.read_csv(StringIO(query["data"]))
|
||||
else:
|
||||
raise Exception(f"Result format {query['result_format']} not supported")
|
||||
|
||||
processed_df = post_processor(df, form_data)
|
||||
|
||||
buf = StringIO()
|
||||
processed_df.to_csv(buf)
|
||||
buf.seek(0)
|
||||
|
||||
query["data"] = buf.getvalue()
|
||||
query["colnames"] = list(processed_df.columns)
|
||||
query["indexnames"] = list(processed_df.index)
|
||||
query["coltypes"] = extract_dataframe_dtypes(processed_df)
|
||||
query["rowcount"] = len(processed_df.index)
|
||||
|
||||
# Flatten hierarchical columns/index since they are represented as
|
||||
# `Tuple[str]`. Otherwise encoding to JSON later will fail because
|
||||
# maps cannot have tuples as their keys in JSON.
|
||||
processed_df.columns = [
|
||||
" ".join(str(name) for name in column).strip()
|
||||
if isinstance(column, tuple)
|
||||
else column
|
||||
for column in processed_df.columns
|
||||
]
|
||||
processed_df.index = [
|
||||
" ".join(str(name) for name in index).strip()
|
||||
if isinstance(index, tuple)
|
||||
else index
|
||||
for index in processed_df.index
|
||||
]
|
||||
|
||||
if query["result_format"] == ChartDataResultFormat.JSON:
|
||||
query["data"] = processed_df.to_dict()
|
||||
elif query["result_format"] == ChartDataResultFormat.CSV:
|
||||
buf = StringIO()
|
||||
processed_df.to_csv(buf)
|
||||
buf.seek(0)
|
||||
query["data"] = buf.getvalue()
|
||||
|
||||
return result
|
||||
|
||||
@@ -82,6 +82,11 @@ query_context_description = (
|
||||
"in order to generate the data the visualization, and in what "
|
||||
"format the data should be returned."
|
||||
)
|
||||
query_context_generation_description = (
|
||||
"The query context generation represents whether the query_context"
|
||||
"is user generated or not so that it does not update user modfied"
|
||||
"state."
|
||||
)
|
||||
cache_timeout_description = (
|
||||
"Duration (in seconds) of the caching timeout "
|
||||
"for this chart. Note this defaults to the datasource/table"
|
||||
@@ -177,6 +182,9 @@ class ChartPostSchema(Schema):
|
||||
allow_none=True,
|
||||
validate=utils.validate_json,
|
||||
)
|
||||
query_context_generation = fields.Boolean(
|
||||
description=query_context_generation_description, allow_none=True
|
||||
)
|
||||
cache_timeout = fields.Integer(
|
||||
description=cache_timeout_description, allow_none=True
|
||||
)
|
||||
@@ -212,6 +220,9 @@ class ChartPutSchema(Schema):
|
||||
query_context = fields.String(
|
||||
description=query_context_description, allow_none=True
|
||||
)
|
||||
query_context_generation = fields.Boolean(
|
||||
description=query_context_generation_description, allow_none=True
|
||||
)
|
||||
cache_timeout = fields.Integer(
|
||||
description=cache_timeout_description, allow_none=True
|
||||
)
|
||||
@@ -294,6 +305,13 @@ class ChartDataAdhocMetricSchema(Schema):
|
||||
"will be generated.",
|
||||
example="metric_aec60732-fac0-4b17-b736-93f1a5c93e30",
|
||||
)
|
||||
timeGrain = fields.String(
|
||||
description="Optional time grain for temporal filters", example="PT1M",
|
||||
)
|
||||
isExtra = fields.Boolean(
|
||||
description="Indicates if the filter has been added by a filter component as "
|
||||
"opposed to being a part of the original query."
|
||||
)
|
||||
|
||||
|
||||
class ChartDataAggregateConfigField(fields.Dict):
|
||||
@@ -772,6 +790,13 @@ class ChartDataFilterSchema(Schema):
|
||||
"integer, decimal or list, depending on the operator.",
|
||||
example=["China", "France", "Japan"],
|
||||
)
|
||||
grain = fields.String(
|
||||
description="Optional time grain for temporal filters", example="PT1M",
|
||||
)
|
||||
isExtra = fields.Boolean(
|
||||
description="Indicates if the filter has been added by a filter component as "
|
||||
"opposed to being a part of the original query."
|
||||
)
|
||||
|
||||
|
||||
class ChartDataExtrasSchema(Schema):
|
||||
|
||||
@@ -124,8 +124,9 @@ def load_examples_run(
|
||||
|
||||
examples.load_css_templates()
|
||||
|
||||
print("Loading energy related dataset")
|
||||
examples.load_energy(only_metadata, force)
|
||||
if load_test_data:
|
||||
print("Loading energy related dataset")
|
||||
examples.load_energy(only_metadata, force)
|
||||
|
||||
print("Loading [World Bank's Health Nutrition and Population Stats]")
|
||||
examples.load_world_bank_health_n_pop(only_metadata, force)
|
||||
@@ -133,25 +134,17 @@ def load_examples_run(
|
||||
print("Loading [Birth names]")
|
||||
examples.load_birth_names(only_metadata, force)
|
||||
|
||||
print("Loading [Tabbed dashboard]")
|
||||
examples.load_tabbed_dashboard(only_metadata)
|
||||
if load_test_data:
|
||||
print("Loading [Tabbed dashboard]")
|
||||
examples.load_tabbed_dashboard(only_metadata)
|
||||
|
||||
if not load_test_data:
|
||||
print("Loading [Random time series data]")
|
||||
examples.load_random_time_series_data(only_metadata, force)
|
||||
|
||||
print("Loading [Random long/lat data]")
|
||||
examples.load_long_lat_data(only_metadata, force)
|
||||
|
||||
print("Loading [Country Map data]")
|
||||
examples.load_country_map_data(only_metadata, force)
|
||||
|
||||
print("Loading [Multiformat time series]")
|
||||
examples.load_multiformat_time_series(only_metadata, force)
|
||||
|
||||
print("Loading [Paris GeoJson]")
|
||||
examples.load_paris_iris_geojson(only_metadata, force)
|
||||
|
||||
print("Loading [San Francisco population polygons]")
|
||||
examples.load_sf_population_polygons(only_metadata, force)
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
|
||||
from superset.commands.utils import populate_owners
|
||||
|
||||
|
||||
class BaseCommand(ABC):
|
||||
@@ -37,3 +41,38 @@ class BaseCommand(ABC):
|
||||
Will raise exception if validation fails
|
||||
:raises: CommandException
|
||||
"""
|
||||
|
||||
|
||||
class CreateMixin:
|
||||
@staticmethod
|
||||
def populate_owners(
|
||||
user: User, owner_ids: Optional[List[int]] = None
|
||||
) -> List[User]:
|
||||
"""
|
||||
Populate list of owners, defaulting to the current user if `owner_ids` is
|
||||
undefined or empty. If current user is missing in `owner_ids`, current user
|
||||
is added unless belonging to the Admin role.
|
||||
|
||||
:param user: current user
|
||||
:param owner_ids: list of owners by id's
|
||||
:raises OwnersNotFoundValidationError: if at least one owner can't be resolved
|
||||
:returns: Final list of owners
|
||||
"""
|
||||
return populate_owners(user, owner_ids, default_to_user=True)
|
||||
|
||||
|
||||
class UpdateMixin:
|
||||
@staticmethod
|
||||
def populate_owners(
|
||||
user: User, owner_ids: Optional[List[int]] = None
|
||||
) -> List[User]:
|
||||
"""
|
||||
Populate list of owners. If current user is missing in `owner_ids`, current user
|
||||
is added unless belonging to the Admin role.
|
||||
|
||||
:param user: current user
|
||||
:param owner_ids: list of owners by id's
|
||||
:raises OwnersNotFoundValidationError: if at least one owner can't be resolved
|
||||
:returns: Final list of owners
|
||||
"""
|
||||
return populate_owners(user, owner_ids, default_to_user=False)
|
||||
|
||||
@@ -29,17 +29,25 @@ from superset.datasets.commands.exceptions import DatasetNotFoundError
|
||||
from superset.extensions import db, security_manager
|
||||
|
||||
|
||||
def populate_owners(user: User, owner_ids: Optional[List[int]] = None) -> List[User]:
|
||||
def populate_owners(
|
||||
user: User, owner_ids: Optional[List[int]], default_to_user: bool,
|
||||
) -> List[User]:
|
||||
"""
|
||||
Helper function for commands, will fetch all users from owners id's
|
||||
Can raise ValidationError
|
||||
:param user: The current user
|
||||
:param owner_ids: A List of owners by id's
|
||||
:param user: current user
|
||||
:param owner_ids: list of owners by id's
|
||||
:param default_to_user: make user the owner if `owner_ids` is None or empty
|
||||
:raises OwnersNotFoundValidationError: if at least one owner id can't be resolved
|
||||
:returns: Final list of owners
|
||||
"""
|
||||
owner_ids = owner_ids or []
|
||||
owners = list()
|
||||
if not owner_ids:
|
||||
if not owner_ids and default_to_user:
|
||||
return [user]
|
||||
if user.id not in owner_ids:
|
||||
if user.id not in owner_ids and "admin" not in [
|
||||
role.name.lower() for role in user.roles
|
||||
]:
|
||||
# make sure non-admins can't remove themselves as owner by mistake
|
||||
owners.append(user)
|
||||
for owner_id in owner_ids:
|
||||
owner = security_manager.get_user_by_id(owner_id)
|
||||
|
||||
@@ -100,8 +100,10 @@ def _get_full(
|
||||
status = payload["status"]
|
||||
if status != QueryStatus.FAILED:
|
||||
payload["colnames"] = list(df.columns)
|
||||
payload["indexnames"] = list(df.index)
|
||||
payload["coltypes"] = extract_dataframe_dtypes(df)
|
||||
payload["data"] = query_context.get_data(df)
|
||||
payload["result_format"] = query_context.result_format
|
||||
del payload["df"]
|
||||
|
||||
filters = query_obj.filter
|
||||
|
||||
@@ -36,6 +36,7 @@ from superset.utils.core import (
|
||||
get_metric_names,
|
||||
is_adhoc_metric,
|
||||
json_int_dttm_ser,
|
||||
QueryObjectFilterClause,
|
||||
)
|
||||
from superset.utils.date_parser import get_since_until, parse_human_timedelta
|
||||
from superset.utils.hashing import md5_sha_from_dict
|
||||
@@ -85,7 +86,7 @@ class QueryObject:
|
||||
metrics: Optional[List[Metric]]
|
||||
row_limit: int
|
||||
row_offset: int
|
||||
filter: List[Dict[str, Any]]
|
||||
filter: List[QueryObjectFilterClause]
|
||||
timeseries_limit: int
|
||||
timeseries_limit_metric: Optional[Metric]
|
||||
order_desc: bool
|
||||
@@ -108,7 +109,7 @@ class QueryObject:
|
||||
granularity: Optional[str] = None,
|
||||
metrics: Optional[List[Metric]] = None,
|
||||
groupby: Optional[List[str]] = None,
|
||||
filters: Optional[List[Dict[str, Any]]] = None,
|
||||
filters: Optional[List[QueryObjectFilterClause]] = None,
|
||||
time_range: Optional[str] = None,
|
||||
time_shift: Optional[str] = None,
|
||||
is_timeseries: Optional[bool] = None,
|
||||
|
||||
@@ -384,6 +384,7 @@ DEFAULT_FEATURE_FLAGS: Dict[str, bool] = {
|
||||
"OMNIBAR": False,
|
||||
"DASHBOARD_RBAC": False,
|
||||
"ENABLE_EXPLORE_DRAG_AND_DROP": False,
|
||||
"ENABLE_DND_WITH_CLICK_UX": False,
|
||||
# Enabling ALERTS_ATTACH_REPORTS, the system sends email and slack message
|
||||
# with screenshot and link
|
||||
# Disables ALERTS_ATTACH_REPORTS, the system DOES NOT generate screenshot
|
||||
@@ -1229,6 +1230,9 @@ GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL = "ws://127.0.0.1:8080/"
|
||||
#
|
||||
DATASET_HEALTH_CHECK: Optional[Callable[["SqlaTable"], str]] = None
|
||||
|
||||
# Do not show user info or profile in the menu
|
||||
MENU_HIDE_USER_INFO = False
|
||||
|
||||
# SQLalchemy link doc reference
|
||||
SQLALCHEMY_DOCS_URL = "https://docs.sqlalchemy.org/en/13/core/engines.html"
|
||||
SQLALCHEMY_DISPLAY_TEXT = "SQLAlchemy docs"
|
||||
|
||||
@@ -375,6 +375,8 @@ class BaseDatasource(
|
||||
return None
|
||||
if value == "<empty string>":
|
||||
return ""
|
||||
if target_column_type == utils.GenericDataType.BOOLEAN:
|
||||
return utils.cast_to_boolean(value)
|
||||
return value
|
||||
|
||||
if isinstance(values, (list, tuple)):
|
||||
|
||||
@@ -86,7 +86,11 @@ from superset.models.helpers import AuditMixinNullable, QueryResult
|
||||
from superset.sql_parse import ParsedQuery
|
||||
from superset.typing import AdhocMetric, Metric, OrderBy, QueryObjectDict
|
||||
from superset.utils import core as utils
|
||||
from superset.utils.core import GenericDataType, remove_duplicates
|
||||
from superset.utils.core import (
|
||||
GenericDataType,
|
||||
QueryObjectFilterClause,
|
||||
remove_duplicates,
|
||||
)
|
||||
|
||||
config = app.config
|
||||
metadata = Model.metadata # pylint: disable=no-member
|
||||
@@ -303,13 +307,15 @@ class TableColumn(Model, BaseColumn):
|
||||
|
||||
pdf = self.python_date_format
|
||||
is_epoch = pdf in ("epoch_s", "epoch_ms")
|
||||
column_spec = self.db_engine_spec.get_column_spec(self.type)
|
||||
type_ = column_spec.sqla_type if column_spec else DateTime
|
||||
if not self.expression and not time_grain and not is_epoch:
|
||||
sqla_col = column(self.column_name, type_=DateTime)
|
||||
sqla_col = column(self.column_name, type_=type_)
|
||||
return self.table.make_sqla_column_compatible(sqla_col, label)
|
||||
if self.expression:
|
||||
col = literal_column(self.expression)
|
||||
col = literal_column(self.expression, type_=type_)
|
||||
else:
|
||||
col = column(self.column_name)
|
||||
col = column(self.column_name, type_=type_)
|
||||
time_expr = self.db_engine_spec.get_timestamp_expr(
|
||||
col, pdf, time_grain, self.type
|
||||
)
|
||||
@@ -496,7 +502,7 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
table_name = Column(String(250), nullable=False)
|
||||
main_dttm_col = Column(String(250))
|
||||
database_id = Column(Integer, ForeignKey("dbs.id"), nullable=False)
|
||||
fetch_values_predicate = Column(String(1000))
|
||||
fetch_values_predicate = Column(Text)
|
||||
owners = relationship(owner_class, secondary=sqlatable_user, backref="tables")
|
||||
database: Database = relationship(
|
||||
"Database",
|
||||
@@ -935,7 +941,7 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
columns: Optional[List[str]] = None,
|
||||
groupby: Optional[List[str]] = None,
|
||||
filter: Optional[ # pylint: disable=redefined-builtin
|
||||
List[Dict[str, Any]]
|
||||
List[QueryObjectFilterClause]
|
||||
] = None,
|
||||
is_timeseries: bool = True,
|
||||
timeseries_limit: int = 15,
|
||||
@@ -1056,6 +1062,8 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
# filter out the pseudo column __timestamp from columns
|
||||
columns = columns or []
|
||||
columns = [col for col in columns if col != utils.DTTM_ALIAS]
|
||||
time_grain = extras.get("time_grain_sqla")
|
||||
dttm_col = columns_by_name.get(granularity) if granularity else None
|
||||
|
||||
if need_groupby:
|
||||
# dedup columns while preserving order
|
||||
@@ -1063,7 +1071,6 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
for selected in columns:
|
||||
# if groupby field/expr equals granularity field/expr
|
||||
if selected == granularity:
|
||||
time_grain = extras.get("time_grain_sqla")
|
||||
sqla_col = columns_by_name[selected]
|
||||
outer = sqla_col.get_timestamp_expression(time_grain, selected)
|
||||
# if groupby field equals a selected column
|
||||
@@ -1087,15 +1094,13 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
groupby_exprs_with_timestamp = OrderedDict(groupby_exprs_sans_timestamp.items())
|
||||
|
||||
if granularity:
|
||||
if granularity not in columns_by_name:
|
||||
if granularity not in columns_by_name or not dttm_col:
|
||||
raise QueryObjectValidationError(
|
||||
_(
|
||||
'Time column "%(col)s" does not exist in dataset',
|
||||
col=granularity,
|
||||
)
|
||||
)
|
||||
dttm_col = columns_by_name[granularity]
|
||||
time_grain = extras.get("time_grain_sqla")
|
||||
time_filters = []
|
||||
|
||||
if is_timeseries:
|
||||
@@ -1150,7 +1155,12 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
col = flt["col"]
|
||||
val = flt.get("val")
|
||||
op = flt["op"].upper()
|
||||
col_obj = columns_by_name.get(col)
|
||||
col_obj = (
|
||||
dttm_col
|
||||
if col == utils.DTTM_ALIAS and is_timeseries and dttm_col
|
||||
else columns_by_name.get(col)
|
||||
)
|
||||
filter_grain = flt.get("grain")
|
||||
|
||||
if is_feature_enabled("ENABLE_TEMPLATE_REMOVE_FILTERS"):
|
||||
if col in removed_filters:
|
||||
@@ -1158,6 +1168,10 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
continue
|
||||
|
||||
if col_obj:
|
||||
if filter_grain:
|
||||
sqla_col = col_obj.get_timestamp_expression(filter_grain)
|
||||
else:
|
||||
sqla_col = col_obj.get_sqla_col()
|
||||
col_spec = db_engine_spec.get_column_spec(col_obj.type)
|
||||
is_list_target = op in (
|
||||
utils.FilterOperator.IN.value,
|
||||
@@ -1180,24 +1194,24 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
)
|
||||
if None in eq:
|
||||
eq = [x for x in eq if x is not None]
|
||||
is_null_cond = col_obj.get_sqla_col().is_(None)
|
||||
is_null_cond = sqla_col.is_(None)
|
||||
if eq:
|
||||
cond = or_(is_null_cond, col_obj.get_sqla_col().in_(eq))
|
||||
cond = or_(is_null_cond, sqla_col.in_(eq))
|
||||
else:
|
||||
cond = is_null_cond
|
||||
else:
|
||||
cond = col_obj.get_sqla_col().in_(eq)
|
||||
cond = sqla_col.in_(eq)
|
||||
if op == utils.FilterOperator.NOT_IN.value:
|
||||
cond = ~cond
|
||||
where_clause_and.append(cond)
|
||||
elif op == utils.FilterOperator.IS_NULL.value:
|
||||
where_clause_and.append(col_obj.get_sqla_col().is_(None))
|
||||
where_clause_and.append(sqla_col.is_(None))
|
||||
elif op == utils.FilterOperator.IS_NOT_NULL.value:
|
||||
where_clause_and.append(col_obj.get_sqla_col().isnot(None))
|
||||
where_clause_and.append(sqla_col.isnot(None))
|
||||
elif op == utils.FilterOperator.IS_TRUE.value:
|
||||
where_clause_and.append(col_obj.get_sqla_col().is_(True))
|
||||
where_clause_and.append(sqla_col.is_(True))
|
||||
elif op == utils.FilterOperator.IS_FALSE.value:
|
||||
where_clause_and.append(col_obj.get_sqla_col().is_(False))
|
||||
where_clause_and.append(sqla_col.is_(False))
|
||||
else:
|
||||
if eq is None:
|
||||
raise QueryObjectValidationError(
|
||||
@@ -1207,21 +1221,21 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
)
|
||||
)
|
||||
if op == utils.FilterOperator.EQUALS.value:
|
||||
where_clause_and.append(col_obj.get_sqla_col() == eq)
|
||||
where_clause_and.append(sqla_col == eq)
|
||||
elif op == utils.FilterOperator.NOT_EQUALS.value:
|
||||
where_clause_and.append(col_obj.get_sqla_col() != eq)
|
||||
where_clause_and.append(sqla_col != eq)
|
||||
elif op == utils.FilterOperator.GREATER_THAN.value:
|
||||
where_clause_and.append(col_obj.get_sqla_col() > eq)
|
||||
where_clause_and.append(sqla_col > eq)
|
||||
elif op == utils.FilterOperator.LESS_THAN.value:
|
||||
where_clause_and.append(col_obj.get_sqla_col() < eq)
|
||||
where_clause_and.append(sqla_col < eq)
|
||||
elif op == utils.FilterOperator.GREATER_THAN_OR_EQUALS.value:
|
||||
where_clause_and.append(col_obj.get_sqla_col() >= eq)
|
||||
where_clause_and.append(sqla_col >= eq)
|
||||
elif op == utils.FilterOperator.LESS_THAN_OR_EQUALS.value:
|
||||
where_clause_and.append(col_obj.get_sqla_col() <= eq)
|
||||
where_clause_and.append(sqla_col <= eq)
|
||||
elif op == utils.FilterOperator.LIKE.value:
|
||||
where_clause_and.append(col_obj.get_sqla_col().like(eq))
|
||||
where_clause_and.append(sqla_col.like(eq))
|
||||
elif op == utils.FilterOperator.ILIKE.value:
|
||||
where_clause_and.append(col_obj.get_sqla_col().ilike(eq))
|
||||
where_clause_and.append(sqla_col.ilike(eq))
|
||||
else:
|
||||
raise QueryObjectValidationError(
|
||||
_("Invalid filter operation type: %(op)s", op=op)
|
||||
@@ -1281,6 +1295,7 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
and timeseries_limit
|
||||
and not time_groupby_inline
|
||||
and groupby_exprs_sans_timestamp
|
||||
and dttm_col
|
||||
):
|
||||
if db_engine_spec.allows_joins:
|
||||
# some sql dialects require for order by expressions
|
||||
|
||||
@@ -125,6 +125,7 @@ MODEL_API_RW_METHOD_PERMISSION_MAP = {
|
||||
"get_datasets": "read",
|
||||
"function_names": "read",
|
||||
"available": "read",
|
||||
"get_data": "read",
|
||||
}
|
||||
|
||||
EXTRA_FORM_DATA_APPEND_KEYS = {
|
||||
|
||||
@@ -820,10 +820,12 @@ class DashboardRestApi(BaseSupersetModelRestApi):
|
||||
).get_from_cache(cache=thumbnail_cache)
|
||||
# If the screenshot does not exist, request one from the workers
|
||||
if not screenshot:
|
||||
self.incr_stats("async", self.thumbnail.__name__)
|
||||
cache_dashboard_thumbnail.delay(dashboard_url, dashboard.digest, force=True)
|
||||
return self.response(202, message="OK Async")
|
||||
# If digests
|
||||
if dashboard.digest != digest:
|
||||
self.incr_stats("redirect", self.thumbnail.__name__)
|
||||
return redirect(
|
||||
url_for(
|
||||
f"{self.__class__.__name__}.thumbnail",
|
||||
@@ -831,6 +833,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
|
||||
digest=dashboard.digest,
|
||||
)
|
||||
)
|
||||
self.incr_stats("from_cache", self.thumbnail.__name__)
|
||||
return Response(
|
||||
FileWrapper(screenshot), mimetype="image/png", direct_passthrough=True
|
||||
)
|
||||
|
||||
@@ -21,8 +21,8 @@ from flask_appbuilder.models.sqla import Model
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.utils import populate_owners, populate_roles
|
||||
from superset.commands.base import BaseCommand, CreateMixin
|
||||
from superset.commands.utils import populate_roles
|
||||
from superset.dao.exceptions import DAOCreateFailedError
|
||||
from superset.dashboards.commands.exceptions import (
|
||||
DashboardCreateFailedError,
|
||||
@@ -34,7 +34,7 @@ from superset.dashboards.dao import DashboardDAO
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CreateDashboardCommand(BaseCommand):
|
||||
class CreateDashboardCommand(CreateMixin, BaseCommand):
|
||||
def __init__(self, user: User, data: Dict[str, Any]):
|
||||
self._actor = user
|
||||
self._properties = data.copy()
|
||||
@@ -60,7 +60,7 @@ class CreateDashboardCommand(BaseCommand):
|
||||
exceptions.append(DashboardSlugExistsValidationError())
|
||||
|
||||
try:
|
||||
owners = populate_owners(self._actor, owner_ids)
|
||||
owners = self.populate_owners(self._actor, owner_ids)
|
||||
self._properties["owners"] = owners
|
||||
except ValidationError as ex:
|
||||
exceptions.append(ex)
|
||||
|
||||
@@ -21,8 +21,8 @@ from flask_appbuilder.models.sqla import Model
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.utils import populate_owners, populate_roles
|
||||
from superset.commands.base import BaseCommand, UpdateMixin
|
||||
from superset.commands.utils import populate_roles
|
||||
from superset.dao.exceptions import DAOUpdateFailedError
|
||||
from superset.dashboards.commands.exceptions import (
|
||||
DashboardForbiddenError,
|
||||
@@ -39,7 +39,7 @@ from superset.views.base import check_ownership
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpdateDashboardCommand(BaseCommand):
|
||||
class UpdateDashboardCommand(UpdateMixin, BaseCommand):
|
||||
def __init__(self, user: User, model_id: int, data: Dict[str, Any]):
|
||||
self._actor = user
|
||||
self._model_id = model_id
|
||||
@@ -80,7 +80,7 @@ class UpdateDashboardCommand(BaseCommand):
|
||||
if owners_ids is None:
|
||||
owners_ids = [owner.id for owner in self._model.owners]
|
||||
try:
|
||||
owners = populate_owners(self._actor, owners_ids)
|
||||
owners = self.populate_owners(self._actor, owners_ids)
|
||||
self._properties["owners"] = owners
|
||||
except ValidationError as ex:
|
||||
exceptions.append(ex)
|
||||
|
||||
@@ -22,8 +22,7 @@ from flask_appbuilder.security.sqla.models import User
|
||||
from marshmallow import ValidationError
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.utils import populate_owners
|
||||
from superset.commands.base import BaseCommand, CreateMixin
|
||||
from superset.dao.exceptions import DAOCreateFailedError
|
||||
from superset.datasets.commands.exceptions import (
|
||||
DatabaseNotFoundValidationError,
|
||||
@@ -38,7 +37,7 @@ from superset.extensions import db, security_manager
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CreateDatasetCommand(BaseCommand):
|
||||
class CreateDatasetCommand(CreateMixin, BaseCommand):
|
||||
def __init__(self, user: User, data: Dict[str, Any]):
|
||||
self._actor = user
|
||||
self._properties = data.copy()
|
||||
@@ -90,7 +89,7 @@ class CreateDatasetCommand(BaseCommand):
|
||||
exceptions.append(TableNotFoundValidationError(table_name))
|
||||
|
||||
try:
|
||||
owners = populate_owners(self._actor, owner_ids)
|
||||
owners = self.populate_owners(self._actor, owner_ids)
|
||||
self._properties["owners"] = owners
|
||||
except ValidationError as ex:
|
||||
exceptions.append(ex)
|
||||
|
||||
@@ -22,8 +22,7 @@ from flask_appbuilder.models.sqla import Model
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.utils import populate_owners
|
||||
from superset.commands.base import BaseCommand, UpdateMixin
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.dao.exceptions import DAOUpdateFailedError
|
||||
from superset.datasets.commands.exceptions import (
|
||||
@@ -47,7 +46,7 @@ from superset.views.base import check_ownership
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpdateDatasetCommand(BaseCommand):
|
||||
class UpdateDatasetCommand(UpdateMixin, BaseCommand):
|
||||
def __init__(
|
||||
self,
|
||||
user: User,
|
||||
@@ -101,7 +100,7 @@ class UpdateDatasetCommand(BaseCommand):
|
||||
exceptions.append(DatabaseChangeValidationError())
|
||||
# Validate/Populate owner
|
||||
try:
|
||||
owners = populate_owners(self._actor, owner_ids)
|
||||
owners = self.populate_owners(self._actor, owner_ids)
|
||||
self._properties["owners"] = owners
|
||||
except ValidationError as ex:
|
||||
exceptions.append(ex)
|
||||
|
||||
@@ -44,7 +44,7 @@ from flask import current_app, g
|
||||
from flask_babel import gettext as __, lazy_gettext as _
|
||||
from marshmallow import fields, Schema
|
||||
from marshmallow.validate import Range
|
||||
from sqlalchemy import column, DateTime, select, types
|
||||
from sqlalchemy import column, select, types
|
||||
from sqlalchemy.engine.base import Engine
|
||||
from sqlalchemy.engine.interfaces import Compiled, Dialect
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
@@ -381,7 +381,7 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
|
||||
elif pdf == "epoch_ms":
|
||||
time_expr = time_expr.replace("{col}", cls.epoch_ms_to_dttm())
|
||||
|
||||
return TimestampExpression(time_expr, col, type_=DateTime)
|
||||
return TimestampExpression(time_expr, col, type_=col.type)
|
||||
|
||||
@classmethod
|
||||
def get_time_grains(cls) -> Tuple[TimeGrain, ...]:
|
||||
@@ -1410,7 +1410,8 @@ class BasicParametersMixin:
|
||||
parameters: BasicParametersType,
|
||||
encryted_extra: Optional[Dict[str, str]] = None,
|
||||
) -> str:
|
||||
query = parameters.get("query", {})
|
||||
# make a copy so that we don't update the original
|
||||
query = parameters.get("query", {}).copy()
|
||||
if parameters.get("encryption"):
|
||||
if not cls.encryption_parameters:
|
||||
raise Exception("Unable to build a URL with encryption enabled")
|
||||
@@ -1433,6 +1434,11 @@ class BasicParametersMixin:
|
||||
cls, uri: str, encrypted_extra: Optional[Dict[str, Any]] = None
|
||||
) -> BasicParametersType:
|
||||
url = make_url(uri)
|
||||
query = {
|
||||
key: value
|
||||
for (key, value) in url.query.items()
|
||||
if (key, value) not in cls.encryption_parameters.items()
|
||||
}
|
||||
encryption = all(
|
||||
item in url.query.items() for item in cls.encryption_parameters.items()
|
||||
)
|
||||
@@ -1442,7 +1448,7 @@ class BasicParametersMixin:
|
||||
"host": url.host,
|
||||
"port": url.port,
|
||||
"database": url.database,
|
||||
"query": url.query,
|
||||
"query": query,
|
||||
"encryption": encryption,
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ from .helpers import get_example_data, get_table_connector_registry
|
||||
|
||||
|
||||
def load_bart_lines(only_metadata: bool = False, force: bool = False) -> None:
|
||||
tbl_name = "bart_lines"
|
||||
tbl_name = "San Franciso BART Lines"
|
||||
database = get_example_database()
|
||||
table_exists = database.has_table_by_name(tbl_name)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
# under the License.
|
||||
dashboard_title: COVID Vaccine Dashboard
|
||||
description: null
|
||||
css: ''
|
||||
css: ""
|
||||
slug: null
|
||||
uuid: f4065089-110a-41fa-8dd7-9ce98a65e250
|
||||
position:
|
||||
@@ -25,111 +25,63 @@ position:
|
||||
id: CHART-63bEuxjDMJ
|
||||
meta:
|
||||
chartId: 3961
|
||||
height: 76
|
||||
height: 72
|
||||
sliceName: Vaccine Candidates per Country
|
||||
sliceNameOverride: Map of Vaccine Candidates
|
||||
uuid: ddc91df6-fb40-4826-bdca-16b85af1c024
|
||||
width: 7
|
||||
width: 12
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
- ROW-zvw7luvEL
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
- ROW-zvw7luvEL
|
||||
type: CHART
|
||||
CHART-F-fkth0Dnv:
|
||||
children: []
|
||||
id: CHART-F-fkth0Dnv
|
||||
meta:
|
||||
chartId: 3960
|
||||
height: 76
|
||||
height: 60
|
||||
sliceName: Vaccine Candidates per Country
|
||||
sliceNameOverride: Treemap of Vaccine Candidates per Country
|
||||
uuid: e2f5a8a7-feb0-4f79-bc6b-01fe55b98b3c
|
||||
width: 5
|
||||
width: 8
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
- ROW-zvw7luvEL
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
- ROW-xSeNAspgw
|
||||
type: CHART
|
||||
CHART-RjD_ygqtwH:
|
||||
children: []
|
||||
id: CHART-RjD_ygqtwH
|
||||
meta:
|
||||
chartId: 3957
|
||||
height: 59
|
||||
height: 72
|
||||
sliceName: Vaccine Candidates per Phase
|
||||
sliceNameOverride: Vaccine Candidates per Phase
|
||||
uuid: 30b73c65-85e7-455f-bb24-801bb0cdc670
|
||||
width: 2
|
||||
width: 3
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
- ROW-xSeNAspgw
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
- ROW-zvw7luvEL
|
||||
type: CHART
|
||||
CHART-aGfmWtliqA:
|
||||
children: []
|
||||
id: CHART-aGfmWtliqA
|
||||
meta:
|
||||
chartId: 3956
|
||||
height: 59
|
||||
height: 72
|
||||
sliceName: Vaccine Candidates per Phase
|
||||
uuid: 392f293e-0892-4316-bd41-c927b65606a4
|
||||
width: 4
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
- ROW-xSeNAspgw
|
||||
type: CHART
|
||||
CHART-dCUpAcPsji:
|
||||
children: []
|
||||
id: CHART-dCUpAcPsji
|
||||
meta:
|
||||
chartId: 3963
|
||||
height: 82
|
||||
sliceName: Vaccine Candidates per Country & Stage
|
||||
sliceNameOverride: Heatmap of Countries & Clinical Stages
|
||||
uuid: cd111331-d286-4258-9020-c7949a109ed2
|
||||
width: 4
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
- ROW-zhOlQLQnB
|
||||
type: CHART
|
||||
CHART-eirDduqb1A:
|
||||
children: []
|
||||
id: CHART-eirDduqb1A
|
||||
meta:
|
||||
chartId: 3965
|
||||
height: 59
|
||||
sliceName: Filtering Vaccines
|
||||
sliceNameOverride: Filter Box of Vaccines
|
||||
uuid: c29381ce-0e99-4cf3-bf0f-5f55d6b94176
|
||||
width: 3
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
- ROW-xSeNAspgw
|
||||
type: CHART
|
||||
CHART-fYo7IyvKZQ:
|
||||
children: []
|
||||
id: CHART-fYo7IyvKZQ
|
||||
meta:
|
||||
chartId: 3964
|
||||
height: 82
|
||||
sliceName: Vaccine Candidates per Country & Stage
|
||||
sliceNameOverride: Sunburst of Country & Clinical Stages
|
||||
uuid: f69c556f-15fe-4a82-a8bb-69d5b6954123
|
||||
width: 5
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
- ROW-zhOlQLQnB
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
- ROW-zvw7luvEL
|
||||
type: CHART
|
||||
CHART-j4hUvP5dDD:
|
||||
children: []
|
||||
@@ -140,19 +92,51 @@ position:
|
||||
sliceName: Vaccine Candidates per Approach & Stage
|
||||
sliceNameOverride: Heatmap of Aproaches & Clinical Stages
|
||||
uuid: 0c953c84-0c9a-418d-be9f-2894d2a2cee0
|
||||
width: 3
|
||||
width: 4
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
- ROW-zhOlQLQnB
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
- ROW-dieUdkeUw
|
||||
type: CHART
|
||||
CHART-dCUpAcPsji:
|
||||
children: []
|
||||
id: CHART-dCUpAcPsji
|
||||
meta:
|
||||
chartId: 3963
|
||||
height: 72
|
||||
sliceName: Vaccine Candidates per Country & Stage
|
||||
sliceNameOverride: Heatmap of Countries & Clinical Stages
|
||||
uuid: cd111331-d286-4258-9020-c7949a109ed2
|
||||
width: 4
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
- ROW-dieUdkeUw
|
||||
type: CHART
|
||||
CHART-eirDduqb1A:
|
||||
children: []
|
||||
id: CHART-eirDduqb1A
|
||||
meta:
|
||||
chartId: 3965
|
||||
height: 60
|
||||
sliceName: Filtering Vaccines
|
||||
sliceNameOverride: Filter Box of Vaccines
|
||||
uuid: c29381ce-0e99-4cf3-bf0f-5f55d6b94176
|
||||
width: 4
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
- ROW-xSeNAspgw
|
||||
type: CHART
|
||||
DASHBOARD_VERSION_KEY: v2
|
||||
GRID_ID:
|
||||
children: []
|
||||
id: GRID_ID
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- ROOT_ID
|
||||
type: GRID
|
||||
HEADER_ID:
|
||||
id: HEADER_ID
|
||||
@@ -163,7 +147,7 @@ position:
|
||||
children: []
|
||||
id: MARKDOWN-VjQQ5SFj5v
|
||||
meta:
|
||||
code: '# COVID-19 Vaccine Dashboard
|
||||
code: "# COVID-19 Vaccine Dashboard
|
||||
|
||||
|
||||
Everywhere you look, you see negative news about COVID-19. This is to be expected;
|
||||
@@ -193,129 +177,157 @@ position:
|
||||
country responsible for each vaccine effort.
|
||||
|
||||
|
||||
_Note that this dataset was last updated on 12/23/2020_.
|
||||
_Note that this dataset was last updated on 07/2021_.
|
||||
|
||||
|
||||
'
|
||||
height: 59
|
||||
width: 3
|
||||
"
|
||||
height: 72
|
||||
width: 4
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
- ROW-xSeNAspgw
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
- ROW-zhOlQLQnB
|
||||
type: MARKDOWN
|
||||
CHART-fYo7IyvKZQ:
|
||||
children: []
|
||||
id: CHART-fYo7IyvKZQ
|
||||
meta:
|
||||
chartId: 3964
|
||||
height: 72
|
||||
sliceName: Vaccine Candidates per Country & Stage
|
||||
sliceNameOverride: Sunburst of Country & Clinical Stages
|
||||
uuid: f69c556f-15fe-4a82-a8bb-69d5b6954123
|
||||
width: 4
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
- ROW-dieUdkeUw
|
||||
type: CHART
|
||||
ROOT_ID:
|
||||
children:
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TABS-wUKya7eQ0Z
|
||||
id: ROOT_ID
|
||||
type: ROOT
|
||||
ROW-xSeNAspgw:
|
||||
children:
|
||||
- MARKDOWN-VjQQ5SFj5v
|
||||
- CHART-aGfmWtliqA
|
||||
- CHART-RjD_ygqtwH
|
||||
- CHART-eirDduqb1A
|
||||
id: ROW-xSeNAspgw
|
||||
meta:
|
||||
'0': ROOT_ID
|
||||
background: BACKGROUND_TRANSPARENT
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
type: ROW
|
||||
ROW-zhOlQLQnB:
|
||||
children:
|
||||
- CHART-j4hUvP5dDD
|
||||
- CHART-dCUpAcPsji
|
||||
- CHART-fYo7IyvKZQ
|
||||
- MARKDOWN-VjQQ5SFj5v
|
||||
- CHART-RjD_ygqtwH
|
||||
- CHART-aGfmWtliqA
|
||||
id: ROW-zhOlQLQnB
|
||||
meta:
|
||||
'0': ROOT_ID
|
||||
"0": ROOT_ID
|
||||
background: BACKGROUND_TRANSPARENT
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
type: ROW
|
||||
ROW-xSeNAspgw:
|
||||
children:
|
||||
- CHART-eirDduqb1A
|
||||
- CHART-F-fkth0Dnv
|
||||
id: ROW-xSeNAspgw
|
||||
meta:
|
||||
"0": ROOT_ID
|
||||
background: BACKGROUND_TRANSPARENT
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
type: ROW
|
||||
ROW-dieUdkeUw:
|
||||
children:
|
||||
- CHART-dCUpAcPsji
|
||||
- CHART-fYo7IyvKZQ
|
||||
- CHART-j4hUvP5dDD
|
||||
id: ROW-xSeNAspgw
|
||||
meta:
|
||||
"0": ROOT_ID
|
||||
background: BACKGROUND_TRANSPARENT
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
type: ROW
|
||||
ROW-zvw7luvEL:
|
||||
children:
|
||||
- CHART-63bEuxjDMJ
|
||||
- CHART-F-fkth0Dnv
|
||||
- CHART-63bEuxjDMJ
|
||||
id: ROW-zvw7luvEL
|
||||
meta:
|
||||
'0': ROOT_ID
|
||||
"0": ROOT_ID
|
||||
background: BACKGROUND_TRANSPARENT
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- TAB-BCIJF4NvgQ
|
||||
type: ROW
|
||||
TAB-BCIJF4NvgQ:
|
||||
children:
|
||||
- ROW-xSeNAspgw
|
||||
- ROW-zvw7luvEL
|
||||
- ROW-zhOlQLQnB
|
||||
- ROW-zhOlQLQnB
|
||||
- ROW-xSeNAspgw
|
||||
- ROW-zvw7luvEL
|
||||
- ROW-dieUdkeUw
|
||||
id: TAB-BCIJF4NvgQ
|
||||
meta:
|
||||
text: Overview
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
- ROOT_ID
|
||||
- TABS-wUKya7eQ0Z
|
||||
type: TAB
|
||||
TABS-wUKya7eQ0Z:
|
||||
children:
|
||||
- TAB-BCIJF4NvgQ
|
||||
- TAB-BCIJF4NvgQ
|
||||
id: TABS-wUKya7eQ0Z
|
||||
meta: {}
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- ROOT_ID
|
||||
type: TABS
|
||||
metadata:
|
||||
timed_refresh_immune_slices: []
|
||||
expanded_slices: {}
|
||||
refresh_frequency: 0
|
||||
default_filters: '{}'
|
||||
default_filters: "{}"
|
||||
color_scheme: supersetColors
|
||||
label_colors:
|
||||
'0': '#D3B3DA'
|
||||
'1': '#9EE5E5'
|
||||
0. Pre-clinical: '#1FA8C9'
|
||||
2. Phase II or Combined I/II: '#454E7C'
|
||||
1. Phase I: '#5AC189'
|
||||
3. Phase III: '#FF7F44'
|
||||
4. Authorized: '#666666'
|
||||
root: '#1FA8C9'
|
||||
Protein subunit: '#454E7C'
|
||||
Phase II: '#5AC189'
|
||||
Pre-clinical: '#FF7F44'
|
||||
Phase III: '#666666'
|
||||
Phase I: '#E04355'
|
||||
Phase I/II: '#FCC700'
|
||||
Inactivated virus: '#A868B7'
|
||||
Virus-like particle: '#3CCCCB'
|
||||
Replicating bacterial vector: '#A38F79'
|
||||
DNA-based: '#8FD3E4'
|
||||
RNA-based vaccine: '#A1A6BD'
|
||||
Authorized: '#ACE1C4'
|
||||
Non-replicating viral vector: '#FEC0A1'
|
||||
Replicating viral vector: '#B2B2B2'
|
||||
Unknown: '#EFA1AA'
|
||||
Live attenuated virus: '#FDE380'
|
||||
COUNT(*): '#D1C6BC'
|
||||
"0": "#D3B3DA"
|
||||
"1": "#9EE5E5"
|
||||
0. Pre-clinical: "#1FA8C9"
|
||||
2. Phase II or Combined I/II: "#454E7C"
|
||||
1. Phase I: "#5AC189"
|
||||
3. Phase III: "#FF7F44"
|
||||
4. Authorized: "#666666"
|
||||
root: "#1FA8C9"
|
||||
Protein subunit: "#454E7C"
|
||||
Phase II: "#5AC189"
|
||||
Pre-clinical: "#FF7F44"
|
||||
Phase III: "#666666"
|
||||
Phase I: "#E04355"
|
||||
Phase I/II: "#FCC700"
|
||||
Inactivated virus: "#A868B7"
|
||||
Virus-like particle: "#3CCCCB"
|
||||
Replicating bacterial vector: "#A38F79"
|
||||
DNA-based: "#8FD3E4"
|
||||
RNA-based vaccine: "#A1A6BD"
|
||||
Authorized: "#ACE1C4"
|
||||
Non-replicating viral vector: "#FEC0A1"
|
||||
Replicating viral vector: "#B2B2B2"
|
||||
Unknown: "#EFA1AA"
|
||||
Live attenuated virus: "#FDE380"
|
||||
COUNT(*): "#D1C6BC"
|
||||
filter_scopes:
|
||||
'3965':
|
||||
"3965":
|
||||
Country_Name:
|
||||
scope:
|
||||
- ROOT_ID
|
||||
- ROOT_ID
|
||||
immune: []
|
||||
Product_Category:
|
||||
scope:
|
||||
- ROOT_ID
|
||||
- ROOT_ID
|
||||
immune: []
|
||||
Clinical Stage:
|
||||
scope:
|
||||
- ROOT_ID
|
||||
- ROOT_ID
|
||||
immune: []
|
||||
version: 1.0.0
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user