mirror of
https://github.com/apache/superset.git
synced 2026-07-04 05:45:32 +00:00
Compare commits
17 Commits
feat/datas
...
enxdev/ref
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bec3d94a5b | ||
|
|
a49a15f990 | ||
|
|
eb39ddbfe3 | ||
|
|
974d36d35e | ||
|
|
b64e3254fc | ||
|
|
9907db9e1a | ||
|
|
b4dd64aa24 | ||
|
|
6e049225f9 | ||
|
|
831369a44b | ||
|
|
7c9c30db1d | ||
|
|
0c6d868483 | ||
|
|
777760b096 | ||
|
|
e8ad096173 | ||
|
|
2f6f5c6778 | ||
|
|
c805c96f5a | ||
|
|
a3ec4080e6 | ||
|
|
3f6e511048 |
@@ -29,7 +29,7 @@ ARG BUILD_TRANSLATIONS="false"
|
||||
######################################################################
|
||||
# superset-node-ci used as a base for building frontend assets and CI
|
||||
######################################################################
|
||||
FROM --platform=${BUILDPLATFORM} node:20-bullseye-slim AS superset-node-ci
|
||||
FROM --platform=${BUILDPLATFORM} node:20-bookworm-slim AS superset-node-ci
|
||||
ARG BUILD_TRANSLATIONS
|
||||
ENV BUILD_TRANSLATIONS=${BUILD_TRANSLATIONS}
|
||||
ARG DEV_MODE="false" # Skip frontend build in dev mode
|
||||
|
||||
@@ -452,7 +452,7 @@ cd ../
|
||||
|
||||
|
||||
# Compile translations for the backend
|
||||
./scripts/translations/generate_po_files.sh
|
||||
./scripts/translations/generate_mo_files.sh
|
||||
|
||||
# update build version number
|
||||
sed -i '' "s/version_string = .*/version_string = \"$SUPERSET_VERSION\"/" setup.py
|
||||
|
||||
@@ -199,6 +199,7 @@ Join our growing community!
|
||||
### Travel
|
||||
|
||||
- [Agoda](https://www.agoda.com/) [@lostseaway, @maiake, @obombayo]
|
||||
- [HomeToGo](https://hometogo.com/) [@pedromartinsteenstrup]
|
||||
- [Skyscanner](https://www.skyscanner.net/) [@cleslie, @stanhoucke]
|
||||
|
||||
### Others
|
||||
|
||||
@@ -333,6 +333,31 @@ const config: Config = {
|
||||
// src: 'https://www.bugherd.com/sidebarv2.js?apikey=enilpiu7bgexxsnoqfjtxa',
|
||||
// async: true,
|
||||
// },
|
||||
'/script/matomo.js',
|
||||
{
|
||||
src: 'https://widget.kapa.ai/kapa-widget.bundle.js',
|
||||
async: true,
|
||||
'data-website-id': 'c6a8a8b8-3127-48f9-97a7-51e9e10d20d0',
|
||||
'data-project-name': 'Apache Superset',
|
||||
'data-project-color': '#1AA1C2',
|
||||
'data-project-logo':
|
||||
'https://images.seeklogo.com/logo-png/50/2/superset-icon-logo-png_seeklogo-500354.png',
|
||||
'data-modal-override-open-id': 'ask-ai-input',
|
||||
'data-modal-override-open-class': 'search-input',
|
||||
'data-modal-open-by-default': 'true',
|
||||
'data-modal-disclaimer':
|
||||
'This is a custom LLM for Apache Superset with access to all [documentation](superset.apache.org/docs/intro/), [GitHub Open Issues, PRs and READMEs](github.com/apache/superset). Companies deploy assistants like this ([built by kapa.ai](https://kapa.ai)) on docs via [website widget](https://docs.kapa.ai/integrations/website-widget) (Docker, Reddit), in [support forms](https://docs.kapa.ai/integrations/support-form-deflector) for ticket deflection (Monday.com, Mapbox), or as [Slack bots](https://docs.kapa.ai/integrations/slack-bot) with private sources.',
|
||||
'data-modal-example-questions':
|
||||
'How do I use Docker Compose?,How to run Supersets on kubernetes?',
|
||||
'data-button-text-color': '#FFFFFF',
|
||||
'data-modal-header-bg-color': '#1AA1C2',
|
||||
'data-modal-title-color': '#FFFFFF',
|
||||
'data-modal-title': 'Superset Ask AI',
|
||||
'data-modal-disclaimer-text-color': '#000000',
|
||||
'data-consent-required': 'true',
|
||||
'data-consent-screen-disclaimer':
|
||||
"By clicking \"I agree, let's chat\", you consent to the use of the AI assistant in accordance with kapa.ai's [Privacy Policy](https://www.kapa.ai/content/privacy-policy). This service uses reCAPTCHA, which requires your consent to Google's [Privacy Policy](https://policies.google.com/privacy) and [Terms of Service](https://policies.google.com/terms). By proceeding, you explicitly agree to both kapa.ai's and Google's privacy policies.",
|
||||
},
|
||||
],
|
||||
customFields: {
|
||||
matomoUrl: 'https://analytics.apache.org',
|
||||
|
||||
@@ -256,13 +256,3 @@ a > span > svg {
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Edit Button */
|
||||
|
||||
.edit-page-link {
|
||||
position: sticky;
|
||||
bottom: 0px;
|
||||
right: 0px;
|
||||
border-radius: 10px;
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import styled from '@emotion/styled';
|
||||
import DocItem from '@theme-original/DocItem';
|
||||
|
||||
const EditPageLink = styled('a')`
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
right: 10px;
|
||||
padding: 1rem;
|
||||
padding-left: 4rem;
|
||||
background-color: #444;
|
||||
border-radius: 10px;
|
||||
z-index: 9999;
|
||||
background-image: url('/img/github-dark.png');
|
||||
background-size: 2rem;
|
||||
background-position: 1rem center;
|
||||
background-repeat: no-repeat;
|
||||
transition: background-color 0.3s; /* Smooth transition for hover effect */
|
||||
bpx-shadow: 0 0 0 0 rgba(0, 0, 0, 0); /* Smooth transition for hover effect */
|
||||
scale: 0.9;
|
||||
transition: all 0.3s;
|
||||
transform-origin: bottom right;
|
||||
|
||||
&:hover {
|
||||
background-color: #333;
|
||||
box-shadow: 5px 5px 10px 0 rgba(0, 0, 0, 0.3);
|
||||
scale: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function DocItemWrapper(props) {
|
||||
return (
|
||||
<>
|
||||
<EditPageLink
|
||||
href={props.content.metadata.editUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Edit this page on GitHub
|
||||
</EditPageLink>
|
||||
<DocItem {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
superset-frontend/package-lock.json
generated
1
superset-frontend/package-lock.json
generated
@@ -48823,7 +48823,6 @@
|
||||
"@types/react": "*",
|
||||
"@types/react-loadable": "*",
|
||||
"@types/tinycolor2": "*",
|
||||
"nanoid": "^5.0.9",
|
||||
"react": "^17.0.2",
|
||||
"react-loadable": "^5.5.0",
|
||||
"tinycolor2": "*"
|
||||
|
||||
@@ -42,7 +42,7 @@ const FlexRowContainer = styled.div`
|
||||
`;
|
||||
|
||||
export interface MetricOptionProps {
|
||||
metric: Omit<Metric, 'id' | 'uuid'> & { label?: string };
|
||||
metric: Omit<Metric, 'id'> & { label?: string };
|
||||
openInNewWindow?: boolean;
|
||||
showFormula?: boolean;
|
||||
showType?: boolean;
|
||||
|
||||
@@ -97,7 +97,7 @@ export const getColumnTooltipNode = (
|
||||
);
|
||||
};
|
||||
|
||||
type MetricType = Omit<Metric, 'id' | 'uuid'> & { label?: string };
|
||||
type MetricType = Omit<Metric, 'id'> & { label?: string };
|
||||
|
||||
export const getMetricTooltipNode = (
|
||||
metric: MetricType,
|
||||
|
||||
@@ -121,7 +121,6 @@ export const TestDataset: Dataset = {
|
||||
main_dttm_col: 'ds',
|
||||
metrics: [
|
||||
{
|
||||
uuid: '123',
|
||||
certification_details: null,
|
||||
certified_by: null,
|
||||
d3format: null,
|
||||
|
||||
@@ -32,7 +32,6 @@ describe('defineSavedMetrics', () => {
|
||||
{
|
||||
metric_name: 'COUNT(*) non-default-dataset-metric',
|
||||
expression: 'COUNT(*) non-default-dataset-metric',
|
||||
uuid: '1',
|
||||
},
|
||||
],
|
||||
type: DatasourceType.Table,
|
||||
@@ -49,7 +48,6 @@ describe('defineSavedMetrics', () => {
|
||||
{
|
||||
metric_name: 'COUNT(*) non-default-dataset-metric',
|
||||
expression: 'COUNT(*) non-default-dataset-metric',
|
||||
uuid: '1',
|
||||
},
|
||||
]);
|
||||
// @ts-ignore
|
||||
|
||||
@@ -24,24 +24,15 @@ describe('mainMetric', () => {
|
||||
expect(mainMetric(null)).toBeUndefined();
|
||||
});
|
||||
it('prefers the "count" metric when first', () => {
|
||||
const metrics = [
|
||||
{ metric_name: 'count', uuid: '1' },
|
||||
{ metric_name: 'foo', uuid: '2' },
|
||||
];
|
||||
const metrics = [{ metric_name: 'count' }, { metric_name: 'foo' }];
|
||||
expect(mainMetric(metrics)).toBe('count');
|
||||
});
|
||||
it('prefers the "count" metric when not first', () => {
|
||||
const metrics = [
|
||||
{ metric_name: 'foo', uuid: '1' },
|
||||
{ metric_name: 'count', uuid: '2' },
|
||||
];
|
||||
const metrics = [{ metric_name: 'foo' }, { metric_name: 'count' }];
|
||||
expect(mainMetric(metrics)).toBe('count');
|
||||
});
|
||||
it('selects the first metric when "count" is not an option', () => {
|
||||
const metrics = [
|
||||
{ metric_name: 'foo', uuid: '2' },
|
||||
{ metric_name: 'not_count', uuid: '2' },
|
||||
];
|
||||
const metrics = [{ metric_name: 'foo' }, { metric_name: 'not_count' }];
|
||||
expect(mainMetric(metrics)).toBe('foo');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,7 +81,6 @@
|
||||
"@types/react": "*",
|
||||
"@types/react-loadable": "*",
|
||||
"@types/tinycolor2": "*",
|
||||
"nanoid": "^5.0.9",
|
||||
"react": "^17.0.2",
|
||||
"react-loadable": "^5.5.0",
|
||||
"tinycolor2": "*"
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Column } from './Column';
|
||||
import { Metric } from './Metric';
|
||||
|
||||
@@ -59,7 +58,6 @@ export const DEFAULT_METRICS: Metric[] = [
|
||||
{
|
||||
metric_name: 'COUNT(*)',
|
||||
expression: 'COUNT(*)',
|
||||
uuid: nanoid(),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ export type SavedMetric = string;
|
||||
*/
|
||||
export interface Metric {
|
||||
id?: number;
|
||||
uuid: string;
|
||||
metric_name: string;
|
||||
expression?: Maybe<string>;
|
||||
certification_details?: Maybe<string>;
|
||||
|
||||
@@ -20,10 +20,10 @@ import { DatasourceType, DEFAULT_METRICS } from '@superset-ui/core';
|
||||
|
||||
test('DEFAULT_METRICS', () => {
|
||||
expect(DEFAULT_METRICS).toEqual([
|
||||
expect.objectContaining({
|
||||
{
|
||||
metric_name: 'COUNT(*)',
|
||||
expression: 'COUNT(*)',
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -149,7 +149,6 @@ describe('BigNumberWithTrendline', () => {
|
||||
label: 'value',
|
||||
metric_name: 'value',
|
||||
d3format: '.2f',
|
||||
uuid: '1',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -175,7 +174,6 @@ describe('BigNumberWithTrendline', () => {
|
||||
metric_name: 'value',
|
||||
d3format: '.2f',
|
||||
currency: { symbol: 'USD', symbolPosition: 'prefix' },
|
||||
uuid: '1',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -17,15 +17,16 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent } from 'react';
|
||||
import { Dispatch } from 'redux';
|
||||
import {
|
||||
ensureIsArray,
|
||||
FeatureFlag,
|
||||
isFeatureEnabled,
|
||||
logging,
|
||||
SqlaFormData,
|
||||
QueryFormData,
|
||||
styled,
|
||||
t,
|
||||
SqlaFormData,
|
||||
ClientErrorObject,
|
||||
ChartDataResponse,
|
||||
} from '@superset-ui/core';
|
||||
@@ -39,53 +40,11 @@ import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { isCurrentUserBot } from 'src/utils/isBot';
|
||||
import { ChartSource } from 'src/types/ChartSource';
|
||||
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
|
||||
import { Dispatch } from 'redux';
|
||||
import { Annotation } from 'src/explore/components/controls/AnnotationLayerControl';
|
||||
import ChartRenderer from './ChartRenderer';
|
||||
import { ChartErrorMessage } from './ChartErrorMessage';
|
||||
import { getChartRequiredFieldsMissingMessage } from '../../utils/getChartRequiredFieldsMissingMessage';
|
||||
|
||||
export type ChartErrorType = Partial<ClientErrorObject>;
|
||||
export interface ChartProps {
|
||||
annotationData?: Annotation;
|
||||
actions: Actions;
|
||||
chartId: string;
|
||||
datasource?: {
|
||||
database?: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
dashboardId?: number;
|
||||
initialValues?: object;
|
||||
formData: QueryFormData;
|
||||
labelColors?: string;
|
||||
sharedLabelColors?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
setControlValue: Function;
|
||||
timeout?: number;
|
||||
vizType: string;
|
||||
triggerRender?: boolean;
|
||||
force?: boolean;
|
||||
isFiltersInitialized?: boolean;
|
||||
chartAlert?: string;
|
||||
chartStatus?: string;
|
||||
chartStackTrace?: string;
|
||||
queriesResponse: ChartDataResponse[];
|
||||
triggerQuery?: boolean;
|
||||
chartIsStale?: boolean;
|
||||
errorMessage?: React.ReactNode;
|
||||
addFilter?: (type: string) => void;
|
||||
onQuery?: () => void;
|
||||
onFilterMenuOpen?: (chartId: string, column: string) => void;
|
||||
onFilterMenuClose?: (chartId: string, column: string) => void;
|
||||
ownState: boolean;
|
||||
postTransformProps?: Function;
|
||||
datasetsStatus?: 'loading' | 'error' | 'complete';
|
||||
isInView?: boolean;
|
||||
emitCrossFilters?: boolean;
|
||||
}
|
||||
|
||||
export type Actions = {
|
||||
logEvent(
|
||||
LOG_ACTIONS_RENDER_CHART: string,
|
||||
@@ -111,7 +70,51 @@ export type Actions = {
|
||||
dashboardId: number | undefined,
|
||||
ownState: boolean,
|
||||
): Dispatch;
|
||||
chartRenderingSucceeded(arg0: { key: string }): Dispatch;
|
||||
updateDataMask(chartId: string, dataMask: { dataMask: any }): Dispatch;
|
||||
};
|
||||
|
||||
export type ChartErrorType = Partial<ClientErrorObject>;
|
||||
export interface ChartProps {
|
||||
annotationData?: Annotation;
|
||||
actions: Actions;
|
||||
chartId: string;
|
||||
datasource?: {
|
||||
database?: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
dashboardId?: number;
|
||||
initialValues?: object;
|
||||
formData: QueryFormData;
|
||||
labelColors?: string;
|
||||
sharedLabelColors?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
setControlValue: Function;
|
||||
timeout?: number;
|
||||
vizType: string;
|
||||
triggerRender?: boolean;
|
||||
force?: boolean;
|
||||
chartAlert?: string;
|
||||
chartStatus?: string;
|
||||
chartStackTrace?: string;
|
||||
queriesResponse: ChartDataResponse[];
|
||||
triggerQuery?: boolean;
|
||||
chartIsStale?: boolean;
|
||||
addFilter?: (type: string) => void;
|
||||
onQuery?: () => void;
|
||||
onFilterMenuOpen?: (chartId: string, column: string) => void;
|
||||
onFilterMenuClose?: (chartId: string, column: string) => void;
|
||||
ownState: boolean;
|
||||
postTransformProps?: Function;
|
||||
datasetsStatus?: 'loading' | 'error' | 'complete';
|
||||
emitCrossFilters?: boolean;
|
||||
errorMessage?: React.ReactNode;
|
||||
isInView?: boolean;
|
||||
filters?: string | string[];
|
||||
}
|
||||
|
||||
const BLANK = {};
|
||||
const NONEXISTENT_DATASET = t(
|
||||
'The dataset associated with this chart no longer exists',
|
||||
@@ -172,6 +175,12 @@ const MessageSpan = styled.span`
|
||||
color: ${({ theme }) => theme.colors.grayscale.base};
|
||||
`;
|
||||
|
||||
const MonospaceDiv = styled.div`
|
||||
font-family: ${({ theme }) => theme.typography.families.monospace};
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
class Chart extends PureComponent<ChartProps, {}> {
|
||||
static defaultProps = defaultProps;
|
||||
|
||||
@@ -267,7 +276,8 @@ class Chart extends PureComponent<ChartProps, {}> {
|
||||
key={chartId}
|
||||
chartId={chartId}
|
||||
error={error}
|
||||
subtitle={message}
|
||||
subtitle={<MonospaceDiv>{message}</MonospaceDiv>}
|
||||
copyText={message}
|
||||
link={queryResponse ? queryResponse.link : undefined}
|
||||
source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
|
||||
stackTrace={chartStackTrace}
|
||||
@@ -296,7 +306,11 @@ class Chart extends PureComponent<ChartProps, {}> {
|
||||
isCurrentUserBot() ? (
|
||||
<ChartRenderer
|
||||
{...this.props}
|
||||
source={this.props.dashboardId ? 'dashboard' : 'explore'}
|
||||
source={
|
||||
this.props.dashboardId
|
||||
? ChartSource.Dashboard
|
||||
: ChartSource.Explore
|
||||
}
|
||||
data-test={this.props.vizType}
|
||||
/>
|
||||
) : (
|
||||
@@ -331,7 +345,6 @@ class Chart extends PureComponent<ChartProps, {}> {
|
||||
if (errorMessage && ensureIsArray(queriesResponse).length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
size="large"
|
||||
title={t('Add required control values to preview chart')}
|
||||
description={getChartRequiredFieldsMissingMessage(true)}
|
||||
image="chart.svg"
|
||||
@@ -347,7 +360,6 @@ class Chart extends PureComponent<ChartProps, {}> {
|
||||
) {
|
||||
return (
|
||||
<EmptyState
|
||||
size="large"
|
||||
title={t('Your chart is ready to go!')}
|
||||
description={
|
||||
<span>
|
||||
|
||||
@@ -1,381 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { snakeCase, isEqual, cloneDeep } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import { createRef, Component } from 'react';
|
||||
import {
|
||||
SuperChart,
|
||||
logging,
|
||||
Behavior,
|
||||
t,
|
||||
getChartMetadataRegistry,
|
||||
VizType,
|
||||
isFeatureEnabled,
|
||||
FeatureFlag,
|
||||
} from '@superset-ui/core';
|
||||
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
|
||||
import { EmptyState } from 'src/components/EmptyState';
|
||||
import { ChartSource } from 'src/types/ChartSource';
|
||||
import ChartContextMenu from './ChartContextMenu/ChartContextMenu';
|
||||
|
||||
const propTypes = {
|
||||
annotationData: PropTypes.object,
|
||||
actions: PropTypes.object,
|
||||
chartId: PropTypes.number.isRequired,
|
||||
datasource: PropTypes.object,
|
||||
initialValues: PropTypes.object,
|
||||
formData: PropTypes.object.isRequired,
|
||||
latestQueryFormData: PropTypes.object,
|
||||
labelsColor: PropTypes.object,
|
||||
labelsColorMap: PropTypes.object,
|
||||
height: PropTypes.number,
|
||||
width: PropTypes.number,
|
||||
setControlValue: PropTypes.func,
|
||||
vizType: PropTypes.string.isRequired,
|
||||
triggerRender: PropTypes.bool,
|
||||
// state
|
||||
chartAlert: PropTypes.string,
|
||||
chartStatus: PropTypes.string,
|
||||
queriesResponse: PropTypes.arrayOf(PropTypes.object),
|
||||
triggerQuery: PropTypes.bool,
|
||||
chartIsStale: PropTypes.bool,
|
||||
// dashboard callbacks
|
||||
addFilter: PropTypes.func,
|
||||
setDataMask: PropTypes.func,
|
||||
onFilterMenuOpen: PropTypes.func,
|
||||
onFilterMenuClose: PropTypes.func,
|
||||
ownState: PropTypes.object,
|
||||
postTransformProps: PropTypes.func,
|
||||
source: PropTypes.oneOf([ChartSource.Dashboard, ChartSource.Explore]),
|
||||
emitCrossFilters: PropTypes.bool,
|
||||
};
|
||||
|
||||
const BLANK = {};
|
||||
|
||||
const BIG_NO_RESULT_MIN_WIDTH = 300;
|
||||
const BIG_NO_RESULT_MIN_HEIGHT = 220;
|
||||
|
||||
const behaviors = [Behavior.InteractiveChart];
|
||||
|
||||
const defaultProps = {
|
||||
addFilter: () => BLANK,
|
||||
onFilterMenuOpen: () => BLANK,
|
||||
onFilterMenuClose: () => BLANK,
|
||||
initialValues: BLANK,
|
||||
setControlValue() {},
|
||||
triggerRender: false,
|
||||
};
|
||||
|
||||
class ChartRenderer extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const suppressContextMenu = getChartMetadataRegistry().get(
|
||||
props.formData.viz_type ?? props.vizType,
|
||||
)?.suppressContextMenu;
|
||||
this.state = {
|
||||
showContextMenu:
|
||||
props.source === ChartSource.Dashboard &&
|
||||
!suppressContextMenu &&
|
||||
isFeatureEnabled(FeatureFlag.DrillToDetail),
|
||||
inContextMenu: false,
|
||||
legendState: undefined,
|
||||
};
|
||||
this.hasQueryResponseChange = false;
|
||||
|
||||
this.contextMenuRef = createRef();
|
||||
|
||||
this.handleAddFilter = this.handleAddFilter.bind(this);
|
||||
this.handleRenderSuccess = this.handleRenderSuccess.bind(this);
|
||||
this.handleRenderFailure = this.handleRenderFailure.bind(this);
|
||||
this.handleSetControlValue = this.handleSetControlValue.bind(this);
|
||||
this.handleOnContextMenu = this.handleOnContextMenu.bind(this);
|
||||
this.handleContextMenuSelected = this.handleContextMenuSelected.bind(this);
|
||||
this.handleContextMenuClosed = this.handleContextMenuClosed.bind(this);
|
||||
this.handleLegendStateChanged = this.handleLegendStateChanged.bind(this);
|
||||
this.onContextMenuFallback = this.onContextMenuFallback.bind(this);
|
||||
|
||||
this.hooks = {
|
||||
onAddFilter: this.handleAddFilter,
|
||||
onContextMenu: this.state.showContextMenu
|
||||
? this.handleOnContextMenu
|
||||
: undefined,
|
||||
onError: this.handleRenderFailure,
|
||||
setControlValue: this.handleSetControlValue,
|
||||
onFilterMenuOpen: this.props.onFilterMenuOpen,
|
||||
onFilterMenuClose: this.props.onFilterMenuClose,
|
||||
onLegendStateChanged: this.handleLegendStateChanged,
|
||||
setDataMask: dataMask => {
|
||||
this.props.actions?.updateDataMask(this.props.chartId, dataMask);
|
||||
},
|
||||
};
|
||||
|
||||
// TODO: queriesResponse comes from Redux store but it's being edited by
|
||||
// the plugins, hence we need to clone it to avoid state mutation
|
||||
// until we change the reducers to use Redux Toolkit with Immer
|
||||
this.mutableQueriesResponse = cloneDeep(this.props.queriesResponse);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
const resultsReady =
|
||||
nextProps.queriesResponse &&
|
||||
['success', 'rendered'].indexOf(nextProps.chartStatus) > -1 &&
|
||||
!nextProps.queriesResponse?.[0]?.error;
|
||||
|
||||
if (resultsReady) {
|
||||
if (!isEqual(this.state, nextState)) {
|
||||
return true;
|
||||
}
|
||||
this.hasQueryResponseChange =
|
||||
nextProps.queriesResponse !== this.props.queriesResponse;
|
||||
|
||||
if (this.hasQueryResponseChange) {
|
||||
this.mutableQueriesResponse = cloneDeep(nextProps.queriesResponse);
|
||||
}
|
||||
|
||||
return (
|
||||
this.hasQueryResponseChange ||
|
||||
!isEqual(nextProps.datasource, this.props.datasource) ||
|
||||
nextProps.annotationData !== this.props.annotationData ||
|
||||
nextProps.ownState !== this.props.ownState ||
|
||||
nextProps.filterState !== this.props.filterState ||
|
||||
nextProps.height !== this.props.height ||
|
||||
nextProps.width !== this.props.width ||
|
||||
nextProps.triggerRender ||
|
||||
nextProps.labelsColor !== this.props.labelsColor ||
|
||||
nextProps.labelsColorMap !== this.props.labelsColorMap ||
|
||||
nextProps.formData.color_scheme !== this.props.formData.color_scheme ||
|
||||
nextProps.formData.stack !== this.props.formData.stack ||
|
||||
nextProps.cacheBusterProp !== this.props.cacheBusterProp ||
|
||||
nextProps.emitCrossFilters !== this.props.emitCrossFilters
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
handleAddFilter(col, vals, merge = true, refresh = true) {
|
||||
this.props.addFilter(col, vals, merge, refresh);
|
||||
}
|
||||
|
||||
handleRenderSuccess() {
|
||||
const { actions, chartStatus, chartId, vizType } = this.props;
|
||||
if (['loading', 'rendered'].indexOf(chartStatus) < 0) {
|
||||
actions.chartRenderingSucceeded(chartId);
|
||||
}
|
||||
|
||||
// only log chart render time which is triggered by query results change
|
||||
// currently we don't log chart re-render time, like window resize etc
|
||||
if (this.hasQueryResponseChange) {
|
||||
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||
slice_id: chartId,
|
||||
viz_type: vizType,
|
||||
start_offset: this.renderStartTime,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - this.renderStartTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleRenderFailure(error, info) {
|
||||
const { actions, chartId } = this.props;
|
||||
logging.warn(error);
|
||||
actions.chartRenderingFailed(
|
||||
error.toString(),
|
||||
chartId,
|
||||
info ? info.componentStack : null,
|
||||
);
|
||||
|
||||
// only trigger render log when query is changed
|
||||
if (this.hasQueryResponseChange) {
|
||||
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||
slice_id: chartId,
|
||||
has_err: true,
|
||||
error_details: error.toString(),
|
||||
start_offset: this.renderStartTime,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - this.renderStartTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleSetControlValue(...args) {
|
||||
const { setControlValue } = this.props;
|
||||
if (setControlValue) {
|
||||
setControlValue(...args);
|
||||
}
|
||||
}
|
||||
|
||||
handleOnContextMenu(offsetX, offsetY, filters) {
|
||||
this.contextMenuRef.current.open(offsetX, offsetY, filters);
|
||||
this.setState({ inContextMenu: true });
|
||||
}
|
||||
|
||||
handleContextMenuSelected() {
|
||||
this.setState({ inContextMenu: false });
|
||||
}
|
||||
|
||||
handleContextMenuClosed() {
|
||||
this.setState({ inContextMenu: false });
|
||||
}
|
||||
|
||||
handleLegendStateChanged(legendState) {
|
||||
this.setState({ legendState });
|
||||
}
|
||||
|
||||
// When viz plugins don't handle `contextmenu` event, fallback handler
|
||||
// calls `handleOnContextMenu` with no `filters` param.
|
||||
onContextMenuFallback(event) {
|
||||
if (!this.state.inContextMenu) {
|
||||
event.preventDefault();
|
||||
this.handleOnContextMenu(event.clientX, event.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { chartAlert, chartStatus, chartId, emitCrossFilters } = this.props;
|
||||
|
||||
// Skip chart rendering
|
||||
if (chartStatus === 'loading' || !!chartAlert || chartStatus === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.renderStartTime = Logger.getTimestamp();
|
||||
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
datasource,
|
||||
annotationData,
|
||||
initialValues,
|
||||
ownState,
|
||||
filterState,
|
||||
chartIsStale,
|
||||
formData,
|
||||
latestQueryFormData,
|
||||
postTransformProps,
|
||||
} = this.props;
|
||||
|
||||
const currentFormData =
|
||||
chartIsStale && latestQueryFormData ? latestQueryFormData : formData;
|
||||
const vizType = currentFormData.viz_type || this.props.vizType;
|
||||
|
||||
// It's bad practice to use unprefixed `vizType` as classnames for chart
|
||||
// container. It may cause css conflicts as in the case of legacy table chart.
|
||||
// When migrating charts, we should gradually add a `superset-chart-` prefix
|
||||
// to each one of them.
|
||||
const snakeCaseVizType = snakeCase(vizType);
|
||||
const chartClassName =
|
||||
vizType === VizType.Table
|
||||
? `superset-chart-${snakeCaseVizType}`
|
||||
: snakeCaseVizType;
|
||||
|
||||
const webpackHash =
|
||||
process.env.WEBPACK_MODE === 'development'
|
||||
? `-${
|
||||
// eslint-disable-next-line camelcase
|
||||
typeof __webpack_require__ !== 'undefined' &&
|
||||
// eslint-disable-next-line camelcase, no-undef
|
||||
typeof __webpack_require__.h === 'function' &&
|
||||
// eslint-disable-next-line no-undef, camelcase
|
||||
__webpack_require__.h()
|
||||
}`
|
||||
: '';
|
||||
|
||||
let noResultsComponent;
|
||||
const noResultTitle = t('No results were returned for this query');
|
||||
const noResultDescription =
|
||||
this.props.source === ChartSource.Explore
|
||||
? t(
|
||||
'Make sure that the controls are configured properly and the datasource contains data for the selected time range',
|
||||
)
|
||||
: undefined;
|
||||
const noResultImage = 'chart.svg';
|
||||
if (width > BIG_NO_RESULT_MIN_WIDTH && height > BIG_NO_RESULT_MIN_HEIGHT) {
|
||||
noResultsComponent = (
|
||||
<EmptyState
|
||||
size="large"
|
||||
title={noResultTitle}
|
||||
description={noResultDescription}
|
||||
image={noResultImage}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
noResultsComponent = (
|
||||
<EmptyState size="small" title={noResultTitle} image={noResultImage} />
|
||||
);
|
||||
}
|
||||
|
||||
// Check for Behavior.DRILL_TO_DETAIL to tell if chart can receive Drill to
|
||||
// Detail props or if it'll cause side-effects (e.g. excessive re-renders).
|
||||
const drillToDetailProps = getChartMetadataRegistry()
|
||||
.get(formData.viz_type)
|
||||
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail)
|
||||
? { inContextMenu: this.state.inContextMenu }
|
||||
: {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{this.state.showContextMenu && (
|
||||
<ChartContextMenu
|
||||
ref={this.contextMenuRef}
|
||||
id={chartId}
|
||||
formData={currentFormData}
|
||||
onSelection={this.handleContextMenuSelected}
|
||||
onClose={this.handleContextMenuClosed}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
onContextMenu={
|
||||
this.state.showContextMenu ? this.onContextMenuFallback : undefined
|
||||
}
|
||||
>
|
||||
<SuperChart
|
||||
disableErrorBoundary
|
||||
key={`${chartId}${webpackHash}`}
|
||||
id={`chart-id-${chartId}`}
|
||||
className={chartClassName}
|
||||
chartType={vizType}
|
||||
width={width}
|
||||
height={height}
|
||||
annotationData={annotationData}
|
||||
datasource={datasource}
|
||||
initialValues={initialValues}
|
||||
formData={currentFormData}
|
||||
ownState={ownState}
|
||||
filterState={filterState}
|
||||
hooks={this.hooks}
|
||||
behaviors={behaviors}
|
||||
queriesData={this.mutableQueriesResponse}
|
||||
onRenderSuccess={this.handleRenderSuccess}
|
||||
onRenderFailure={this.handleRenderFailure}
|
||||
noResults={noResultsComponent}
|
||||
postTransformProps={postTransformProps}
|
||||
emitCrossFilters={emitCrossFilters}
|
||||
legendState={this.state.legendState}
|
||||
{...drillToDetailProps}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ChartRenderer.propTypes = propTypes;
|
||||
ChartRenderer.defaultProps = defaultProps;
|
||||
|
||||
export default ChartRenderer;
|
||||
418
superset-frontend/src/components/Chart/ChartRenderer.tsx
Normal file
418
superset-frontend/src/components/Chart/ChartRenderer.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* 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 { snakeCase, cloneDeep, isEqual } from 'lodash';
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
SuperChart,
|
||||
logging,
|
||||
Behavior,
|
||||
t,
|
||||
isFeatureEnabled,
|
||||
FeatureFlag,
|
||||
getChartMetadataRegistry,
|
||||
QueryFormData,
|
||||
ChartDataResponse,
|
||||
VizType as enumVizType,
|
||||
JsonObject,
|
||||
FilterState,
|
||||
} from '@superset-ui/core';
|
||||
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
|
||||
import { EmptyState } from 'src/components/EmptyState';
|
||||
import { ChartSource } from 'src/types/ChartSource';
|
||||
import { Annotation } from 'src/explore/components/controls/AnnotationLayerControl';
|
||||
import ChartContextMenu from './ChartContextMenu/ChartContextMenu';
|
||||
import { Actions } from './Chart';
|
||||
|
||||
type ChartRendererProps = {
|
||||
dashboardId?: number;
|
||||
latestQueryFormData?: QueryFormData;
|
||||
labelsColorMap?: string;
|
||||
setDataMask?: (dataMask: any) => void;
|
||||
source?: ChartSource;
|
||||
annotationData?: Annotation;
|
||||
actions: Actions;
|
||||
chartId: string;
|
||||
datasource?: {
|
||||
database?: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
initialValues?: object;
|
||||
formData: QueryFormData;
|
||||
labelsColor?: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
setControlValue?: Function;
|
||||
vizType: string;
|
||||
triggerRender?: boolean;
|
||||
chartAlert?: string;
|
||||
chartStatus?: string;
|
||||
queriesResponse?: ChartDataResponse[];
|
||||
triggerQuery?: boolean;
|
||||
chartIsStale?: boolean;
|
||||
filterState?: FilterState[];
|
||||
addFilter?: (
|
||||
col: string,
|
||||
vals: string | string[],
|
||||
merge: boolean,
|
||||
refresh: boolean,
|
||||
) => void;
|
||||
onFilterMenuOpen?: (chartId: string, column: string) => void;
|
||||
onFilterMenuClose?: (chartId: string, column: string) => void;
|
||||
ownState?: boolean | JsonObject;
|
||||
postTransformProps?: Function;
|
||||
emitCrossFilters?: boolean;
|
||||
};
|
||||
|
||||
const BLANK = {};
|
||||
const BIG_NO_RESULT_MIN_WIDTH = 300;
|
||||
const BIG_NO_RESULT_MIN_HEIGHT = 220;
|
||||
const behaviors = [Behavior.InteractiveChart];
|
||||
|
||||
const ChartRenderer = (props: ChartRendererProps) => {
|
||||
const {
|
||||
annotationData,
|
||||
actions,
|
||||
chartId,
|
||||
datasource,
|
||||
initialValues = BLANK,
|
||||
formData,
|
||||
latestQueryFormData,
|
||||
height,
|
||||
width,
|
||||
setControlValue,
|
||||
vizType,
|
||||
chartStatus,
|
||||
queriesResponse = [],
|
||||
chartIsStale,
|
||||
chartAlert,
|
||||
addFilter = () => BLANK,
|
||||
setDataMask,
|
||||
onFilterMenuOpen = () => BLANK,
|
||||
onFilterMenuClose = () => BLANK,
|
||||
ownState,
|
||||
filterState,
|
||||
postTransformProps,
|
||||
source,
|
||||
emitCrossFilters,
|
||||
triggerRender,
|
||||
labelsColor,
|
||||
labelsColorMap,
|
||||
} = props;
|
||||
|
||||
if (chartStatus === 'loading' || !!chartAlert || chartStatus === null) {
|
||||
return null;
|
||||
}
|
||||
const suppressContextMenu = getChartMetadataRegistry().get(
|
||||
formData.viz_type ?? vizType,
|
||||
)?.suppressContextMenu;
|
||||
|
||||
const [showContextMenu, setShowContextMenu] = useState<Boolean>(false);
|
||||
const [inContextMenu, setInContextMenu] = useState(false);
|
||||
const [legendState, setLegendState] = useState<any>(undefined);
|
||||
const contextMenuRef = useRef<any>(null);
|
||||
const prevProps = useRef(props);
|
||||
const mutableQueriesResponse = useRef(cloneDeep(queriesResponse));
|
||||
const [hasQueryResponseChange, setHasQueryResponseChange] = useState(false);
|
||||
const renderStartTime = useRef<number>(0);
|
||||
|
||||
const resultsReady = useMemo(
|
||||
() =>
|
||||
queriesResponse &&
|
||||
['success', 'rendered'].includes(chartStatus) &&
|
||||
!queriesResponse?.[0]?.error,
|
||||
[queriesResponse, chartStatus],
|
||||
);
|
||||
|
||||
const queryResponseChanged = useMemo(
|
||||
() => queriesResponse !== prevProps.current.queriesResponse,
|
||||
[queriesResponse],
|
||||
);
|
||||
|
||||
const shouldRender = useMemo(
|
||||
() =>
|
||||
resultsReady &&
|
||||
(queryResponseChanged ||
|
||||
!isEqual(datasource, prevProps.current.datasource) ||
|
||||
annotationData !== prevProps.current.annotationData ||
|
||||
ownState !== prevProps.current.ownState ||
|
||||
filterState !== prevProps.current.filterState ||
|
||||
height !== prevProps.current.height ||
|
||||
width !== prevProps.current.width ||
|
||||
triggerRender ||
|
||||
labelsColor !== prevProps.current.labelsColor ||
|
||||
labelsColorMap !== prevProps.current.labelsColorMap ||
|
||||
formData.color_scheme !== prevProps.current.formData.color_scheme ||
|
||||
formData.stack !== prevProps.current.formData.stack ||
|
||||
emitCrossFilters !== prevProps.current.emitCrossFilters),
|
||||
[resultsReady, queryResponseChanged, props],
|
||||
);
|
||||
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (queryResponseChanged) {
|
||||
setHasQueryResponseChange(true);
|
||||
mutableQueriesResponse.current = cloneDeep(queriesResponse);
|
||||
}
|
||||
}, [queryResponseChanged, queriesResponse]);
|
||||
|
||||
useEffect(() => {
|
||||
prevProps.current = props;
|
||||
}, [props]);
|
||||
|
||||
useEffect(() => {
|
||||
mutableQueriesResponse.current = cloneDeep(queriesResponse);
|
||||
}, [queriesResponse]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowContextMenu(
|
||||
source === ChartSource.Dashboard &&
|
||||
!suppressContextMenu &&
|
||||
isFeatureEnabled(FeatureFlag.DrillToDetail),
|
||||
);
|
||||
}, [source, suppressContextMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
// only log chart render time which is triggered by query results change
|
||||
// currently we don't log chart re-render time, like window resize etc
|
||||
if (hasQueryResponseChange) {
|
||||
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||
slice_id: chartId,
|
||||
has_err: false,
|
||||
error_details: '',
|
||||
start_offset: renderStartTime.current,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - renderStartTime.current,
|
||||
});
|
||||
}
|
||||
}, [hasQueryResponseChange]);
|
||||
|
||||
/**
|
||||
* Hooks region
|
||||
*/
|
||||
const handleAddFilter = useCallback(
|
||||
(col: string, vals: string | string[], merge = true, refresh = true) => {
|
||||
alert(col);
|
||||
console.log(col, vals, merge, refresh);
|
||||
addFilter(col, vals, merge, refresh);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleOnContextMenu = useCallback(
|
||||
(offsetX: number, offsetY: number, filters: undefined) => {
|
||||
if (contextMenuRef.current) {
|
||||
contextMenuRef.current.open(offsetX, offsetY, filters);
|
||||
}
|
||||
// setInContextMenu({ inContextMenu: true });
|
||||
// setInContextMenu(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleSetControlValue = useCallback(
|
||||
(...args: string[]) => {
|
||||
if (setControlValue) {
|
||||
setControlValue(...args);
|
||||
}
|
||||
},
|
||||
[setControlValue],
|
||||
);
|
||||
const handleRenderFailure = (
|
||||
error: { toString: () => string },
|
||||
info: { componentStack: string } | null,
|
||||
) => {
|
||||
logging.warn(error);
|
||||
actions.chartRenderingFailed(
|
||||
error.toString(),
|
||||
chartId,
|
||||
info ? info.componentStack : null,
|
||||
);
|
||||
|
||||
// only trigger render log when query is changed
|
||||
if (hasQueryResponseChange) {
|
||||
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||
slice_id: chartId,
|
||||
has_err: true,
|
||||
error_details: error.toString(),
|
||||
start_offset: renderStartTime.current,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - renderStartTime.current,
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleRenderSuccess = useCallback(() => {
|
||||
if (!['loading', 'rendered'].includes(chartStatus || '')) {
|
||||
actions.chartRenderingSucceeded({ key: chartId });
|
||||
}
|
||||
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||
slice_id: chartId,
|
||||
has_err: false,
|
||||
error_details: '',
|
||||
start_offset: renderStartTime.current,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - renderStartTime.current,
|
||||
});
|
||||
}, [actions, chartId, chartStatus, vizType]);
|
||||
// end Hooks region
|
||||
|
||||
const currentFormData =
|
||||
chartIsStale && latestQueryFormData ? latestQueryFormData : formData;
|
||||
|
||||
const snakeCaseVizType = snakeCase(currentFormData.viz_type || vizType);
|
||||
|
||||
const chartClassName =
|
||||
vizType === enumVizType.Table
|
||||
? `superset-chart-${snakeCaseVizType}`
|
||||
: snakeCaseVizType;
|
||||
|
||||
const webpackHash =
|
||||
process.env.WEBPACK_MODE === 'development'
|
||||
? `-${
|
||||
// eslint-disable-next-line camelcase
|
||||
// @ts-ignore
|
||||
typeof __webpack_require__ !== 'undefined' &&
|
||||
// @ts-ignore
|
||||
typeof __webpack_require__.h === 'function' &&
|
||||
// eslint-disable-next-line camelcase, no-undef
|
||||
// @ts-ignore
|
||||
typeof __webpack_require__.h === 'function' &&
|
||||
// eslint-disable-next-line no-undef, camelcase
|
||||
// @ts-ignore
|
||||
__webpack_require__.h()
|
||||
}`
|
||||
: '';
|
||||
|
||||
let noResultsComponent;
|
||||
const noResultTitle = t('No results were returned for this query');
|
||||
const noResultDescription =
|
||||
source === ChartSource.Explore
|
||||
? t(
|
||||
'Make sure that the controls are configured properly and the datasource contains data for the selected time range',
|
||||
)
|
||||
: undefined;
|
||||
const noResultImage = 'chart.svg';
|
||||
|
||||
if (
|
||||
typeof width === 'number' &&
|
||||
typeof height === 'number' &&
|
||||
width > BIG_NO_RESULT_MIN_WIDTH &&
|
||||
height > BIG_NO_RESULT_MIN_HEIGHT
|
||||
) {
|
||||
noResultsComponent = (
|
||||
<EmptyState
|
||||
size="large"
|
||||
title={noResultTitle}
|
||||
description={noResultDescription}
|
||||
image={noResultImage}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
noResultsComponent = (
|
||||
<EmptyState size="small" title={noResultTitle} image={noResultImage} />
|
||||
);
|
||||
}
|
||||
|
||||
// Check for Behavior.DRILL_TO_DETAIL to tell if chart can receive Drill to
|
||||
// Detail props or if it'll cause side-effects (e.g. excessive re-renders).
|
||||
const drillToDetailProps = getChartMetadataRegistry()
|
||||
.get(formData.viz_type)
|
||||
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail)
|
||||
? { inContextMenu }
|
||||
: {};
|
||||
|
||||
const hooks = useMemo(
|
||||
() => ({
|
||||
onAddFilter: handleAddFilter,
|
||||
onContextMenu: showContextMenu ? handleOnContextMenu : undefined,
|
||||
onError: handleRenderFailure,
|
||||
setControlValue: handleSetControlValue,
|
||||
onFilterMenuOpen,
|
||||
onFilterMenuClose,
|
||||
onLegendChange: setLegendState,
|
||||
setDataMask: (dataMask: any) =>
|
||||
actions?.updateDataMask(chartId, { dataMask }),
|
||||
}),
|
||||
[
|
||||
handleAddFilter,
|
||||
showContextMenu,
|
||||
handleOnContextMenu,
|
||||
handleRenderFailure,
|
||||
handleSetControlValue,
|
||||
onFilterMenuOpen,
|
||||
onFilterMenuClose,
|
||||
setLegendState,
|
||||
setDataMask,
|
||||
chartId,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showContextMenu && (
|
||||
<ChartContextMenu
|
||||
ref={contextMenuRef}
|
||||
id={chartId as unknown as number}
|
||||
formData={currentFormData}
|
||||
onSelection={() => setInContextMenu(false)}
|
||||
onClose={() => setInContextMenu(false)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
onContextMenu={
|
||||
showContextMenu
|
||||
? event => {
|
||||
event.preventDefault();
|
||||
handleOnContextMenu(event.clientX, event.clientY, undefined);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SuperChart
|
||||
disableErrorBoundary
|
||||
key={`${chartId}${webpackHash}`}
|
||||
id={`chart-id-${chartId}`}
|
||||
className={chartClassName}
|
||||
chartType={vizType}
|
||||
width={width}
|
||||
height={height}
|
||||
annotationData={annotationData}
|
||||
datasource={datasource}
|
||||
initialValues={initialValues}
|
||||
formData={currentFormData}
|
||||
ownState={ownState}
|
||||
filterState={filterState}
|
||||
hooks={hooks}
|
||||
behaviors={behaviors}
|
||||
queriesData={mutableQueriesResponse.current}
|
||||
onRenderSuccess={handleRenderSuccess}
|
||||
onRenderFailure={handleRenderFailure}
|
||||
noResults={noResultsComponent}
|
||||
postTransformProps={postTransformProps}
|
||||
// emitCrossFilters={emitCrossFilters}
|
||||
legendState={legendState}
|
||||
{...drillToDetailProps}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartRenderer;
|
||||
@@ -157,6 +157,7 @@ const Chart = props => {
|
||||
state.datasources[chart.form_data.datasource]) ||
|
||||
PLACEHOLDER_DATASOURCE,
|
||||
);
|
||||
const dashboardInfo = useSelector(state => state.dashboardInfo);
|
||||
|
||||
const [descriptionHeight, setDescriptionHeight] = useState(0);
|
||||
const [height, setHeight] = useState(props.height);
|
||||
@@ -304,6 +305,8 @@ const Chart = props => {
|
||||
],
|
||||
);
|
||||
|
||||
formData.dashboardId = dashboardInfo.id;
|
||||
|
||||
const onExploreChart = useCallback(
|
||||
async clickEvent => {
|
||||
const isOpenInNewTab =
|
||||
|
||||
@@ -74,6 +74,7 @@ const defaultState = {
|
||||
datasources: mockDatasource,
|
||||
dashboardState: { editMode: false, expandedSlices: {} },
|
||||
dashboardInfo: {
|
||||
id: props.dashboardId,
|
||||
superset_can_explore: false,
|
||||
superset_can_share: false,
|
||||
superset_can_csv: false,
|
||||
@@ -165,7 +166,9 @@ test('should call exportChart when exportCSV is clicked', async () => {
|
||||
expect(stubbedExportCSV).toHaveBeenCalledTimes(1);
|
||||
expect(stubbedExportCSV).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
formData: expect.anything(),
|
||||
formData: expect.objectContaining({
|
||||
dashboardId: 111,
|
||||
}),
|
||||
resultType: 'full',
|
||||
resultFormat: 'csv',
|
||||
}),
|
||||
@@ -195,6 +198,7 @@ test('should call exportChart with row_limit props.maxRows when exportFullCSV is
|
||||
expect.objectContaining({
|
||||
formData: expect.objectContaining({
|
||||
row_limit: 666,
|
||||
dashboardId: 111,
|
||||
}),
|
||||
resultType: 'full',
|
||||
resultFormat: 'csv',
|
||||
@@ -249,6 +253,7 @@ test('should call exportChart with row_limit props.maxRows when exportFullXLSX i
|
||||
expect.objectContaining({
|
||||
formData: expect.objectContaining({
|
||||
row_limit: 666,
|
||||
dashboardId: 111,
|
||||
}),
|
||||
resultType: 'full',
|
||||
resultFormat: 'xlsx',
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { VariableSizeList as List } from 'react-window';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { FlattenedItem, Folder } from './types';
|
||||
import DatasourcePanelItem from './DatasourcePanelItem';
|
||||
|
||||
const BORDER_WIDTH = 2;
|
||||
const HEADER_ITEM_HEIGHT = 50;
|
||||
const METRIC_OR_COLUMN_ITEM_HEIGHT = 32;
|
||||
const DIVIDER_ITEM_HEIGHT = 16;
|
||||
|
||||
const flattenFolderStructure = (
|
||||
folders: Folder[],
|
||||
depth = 0,
|
||||
folderMap: Map<string, Folder> = new Map(),
|
||||
): { flattenedItems: FlattenedItem[]; folderMap: Map<string, Folder> } => {
|
||||
const flattenedItems: FlattenedItem[] = [];
|
||||
|
||||
folders.forEach((folder, idx) => {
|
||||
folderMap.set(folder.id, folder);
|
||||
|
||||
flattenedItems.push({
|
||||
type: 'header',
|
||||
folderId: folder.id,
|
||||
depth,
|
||||
height: HEADER_ITEM_HEIGHT,
|
||||
});
|
||||
|
||||
if (!folder.isCollapsed) {
|
||||
folder.items.forEach(item => {
|
||||
flattenedItems.push({
|
||||
type: 'item',
|
||||
folderId: folder.id,
|
||||
depth,
|
||||
item,
|
||||
height: METRIC_OR_COLUMN_ITEM_HEIGHT,
|
||||
});
|
||||
});
|
||||
|
||||
if (folder.subFolders && folder.subFolders.length > 0) {
|
||||
const { flattenedItems: subItems } = flattenFolderStructure(
|
||||
folder.subFolders,
|
||||
depth + 1,
|
||||
folderMap,
|
||||
);
|
||||
|
||||
flattenedItems.push(...subItems);
|
||||
}
|
||||
}
|
||||
if (depth === 0 && idx !== folders.length - 1) {
|
||||
flattenedItems.push({
|
||||
type: 'divider',
|
||||
folderId: folder.id,
|
||||
depth,
|
||||
height: DIVIDER_ITEM_HEIGHT,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { flattenedItems, folderMap };
|
||||
};
|
||||
|
||||
interface DatasourceItemsProps {
|
||||
width: number;
|
||||
height: number;
|
||||
folders: Folder[];
|
||||
}
|
||||
export const DatasourceItems = ({
|
||||
width,
|
||||
height,
|
||||
folders,
|
||||
}: DatasourceItemsProps) => {
|
||||
const [folderStructure, setFolderStructure] = useState<Folder[]>(folders);
|
||||
|
||||
useEffect(() => {
|
||||
setFolderStructure(prev => (prev !== folders ? folders : prev));
|
||||
}, [folders]);
|
||||
|
||||
const { flattenedItems, folderMap } = useMemo(
|
||||
() => flattenFolderStructure(folderStructure),
|
||||
[folderStructure],
|
||||
);
|
||||
|
||||
const handleToggleCollapse = useCallback((folderId: string) => {
|
||||
setFolderStructure(prevFolders => {
|
||||
const updatedFolders = cloneDeep(prevFolders);
|
||||
|
||||
const updateFolder = (folders: Folder[] | undefined): boolean => {
|
||||
if (!folders) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < folders.length; i += 1) {
|
||||
if (folders[i].id === folderId) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
folders[i].isCollapsed = !folders[i].isCollapsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (folders[i].subFolders && updateFolder(folders[i].subFolders)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
updateFolder(updatedFolders);
|
||||
return updatedFolders;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getItemSize = useCallback(
|
||||
(index: number) => flattenedItems[index].height,
|
||||
[flattenedItems],
|
||||
);
|
||||
|
||||
return (
|
||||
<List
|
||||
width={width - BORDER_WIDTH}
|
||||
height={height}
|
||||
itemSize={getItemSize}
|
||||
itemCount={flattenedItems.length}
|
||||
itemData={{
|
||||
flattenedItems,
|
||||
folderMap,
|
||||
width,
|
||||
onToggleCollapse: handleToggleCollapse,
|
||||
}}
|
||||
overscanCount={5}
|
||||
>
|
||||
{DatasourcePanelItem}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
@@ -59,48 +59,6 @@ const datasource: IDatasource = {
|
||||
datasource_name: 'table1',
|
||||
};
|
||||
|
||||
const datasourceWithFolders: IDatasource = {
|
||||
...datasource,
|
||||
folders: [
|
||||
{
|
||||
name: 'Test folder',
|
||||
type: 'folder',
|
||||
uuid: '1',
|
||||
children: [
|
||||
{
|
||||
name: 'Test nested folder',
|
||||
type: 'folder',
|
||||
uuid: '1.1',
|
||||
children: [
|
||||
{
|
||||
type: 'metric',
|
||||
uuid: metrics[0].uuid,
|
||||
name: metrics[0].metric_name,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Second test folder',
|
||||
type: 'folder',
|
||||
uuid: '2',
|
||||
children: [
|
||||
{
|
||||
type: 'column',
|
||||
uuid: columns[0].uuid,
|
||||
name: columns[0].column_name,
|
||||
},
|
||||
{
|
||||
type: 'column',
|
||||
uuid: columns[1].uuid,
|
||||
name: columns[1].column_name,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
createdOn: '2021-04-27T18:12:38.952304',
|
||||
email: 'admin',
|
||||
@@ -132,18 +90,6 @@ const props: DatasourcePanelProps = {
|
||||
width: 300,
|
||||
};
|
||||
|
||||
const propsWithFolders = {
|
||||
...props,
|
||||
datasource: datasourceWithFolders,
|
||||
controls: {
|
||||
...props.controls,
|
||||
datasource: {
|
||||
...props.controls.datasource,
|
||||
datasource: datasourceWithFolders,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const metricProps = {
|
||||
savedMetrics: [],
|
||||
columns: [],
|
||||
@@ -179,9 +125,13 @@ test('should render the metrics', async () => {
|
||||
</ExploreContainer>,
|
||||
{ useRedux: true, useDnd: true },
|
||||
);
|
||||
const metricsNum = metrics.length;
|
||||
metrics.forEach(metric =>
|
||||
expect(screen.getByText(metric.metric_name)).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(`Showing ${metricsNum} of ${metricsNum}`),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the columns', async () => {
|
||||
@@ -192,9 +142,13 @@ test('should render the columns', async () => {
|
||||
</ExploreContainer>,
|
||||
{ useRedux: true, useDnd: true },
|
||||
);
|
||||
const columnsNum = columns.length;
|
||||
columns.forEach(col =>
|
||||
expect(screen.getByText(col.column_name)).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(`Showing ${columnsNum} of ${columnsNum}`),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('DatasourcePanel', () => {
|
||||
@@ -356,139 +310,3 @@ test('should render only droppable metrics and columns', async () => {
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('Renders with custom folders', () => {
|
||||
render(
|
||||
<ExploreContainer>
|
||||
<DatasourcePanel {...propsWithFolders} />
|
||||
<DndMetricSelect {...metricProps} />
|
||||
</ExploreContainer>,
|
||||
{
|
||||
useRedux: true,
|
||||
useDnd: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test folder')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test nested folder')).toBeInTheDocument();
|
||||
expect(screen.getByText('Second test folder')).toBeInTheDocument();
|
||||
expect(screen.getByText('Metrics')).toBeInTheDocument();
|
||||
expect(screen.getByText('Columns')).toBeInTheDocument();
|
||||
|
||||
columns.forEach(col => {
|
||||
expect(screen.getByText(col.column_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
metrics.forEach(metric => {
|
||||
expect(screen.getByText(metric.metric_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getAllByTestId('DatasourcePanelDragOption').length).toEqual(5);
|
||||
expect(screen.getAllByTestId('datasource-panel-divider').length).toEqual(3);
|
||||
});
|
||||
|
||||
test('Collapse folders', () => {
|
||||
render(
|
||||
<ExploreContainer>
|
||||
<DatasourcePanel {...propsWithFolders} />
|
||||
<DndMetricSelect {...metricProps} />
|
||||
</ExploreContainer>,
|
||||
{
|
||||
useRedux: true,
|
||||
useDnd: true,
|
||||
},
|
||||
);
|
||||
|
||||
userEvent.click(screen.getAllByRole('button')[0]);
|
||||
|
||||
expect(screen.getByText('Test folder')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Test nested folder')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Second test folder')).toBeInTheDocument();
|
||||
expect(screen.getByText('Metrics')).toBeInTheDocument();
|
||||
expect(screen.getByText('Columns')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText(metrics[0].metric_name)).not.toBeInTheDocument();
|
||||
|
||||
userEvent.click(screen.getAllByRole('button')[0]);
|
||||
|
||||
expect(screen.getByText('Test folder')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test nested folder')).toBeInTheDocument();
|
||||
expect(screen.getByText('Second test folder')).toBeInTheDocument();
|
||||
expect(screen.getByText('Metrics')).toBeInTheDocument();
|
||||
expect(screen.getByText('Columns')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(metrics[0].metric_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Default Metrics and Columns folders dont render when all metrics and columns are assigned to custom folders', () => {
|
||||
const datasourceWithFullFolders: IDatasource = {
|
||||
...datasource,
|
||||
folders: [
|
||||
{
|
||||
name: 'Test folder',
|
||||
type: 'folder',
|
||||
uuid: '1',
|
||||
children: [
|
||||
{
|
||||
name: 'Test nested folder',
|
||||
type: 'folder',
|
||||
uuid: '1.1',
|
||||
children: metrics.map(m => ({
|
||||
type: 'metric' as const,
|
||||
uuid: m.uuid,
|
||||
name: m.metric_name,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Second test folder',
|
||||
type: 'folder',
|
||||
uuid: '2',
|
||||
children: columns.map(c => ({
|
||||
type: 'column',
|
||||
uuid: c.uuid,
|
||||
name: c.column_name,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
const propsWithFullFolders = {
|
||||
...props,
|
||||
datasource: datasourceWithFullFolders,
|
||||
controls: {
|
||||
...props.controls,
|
||||
datasource: {
|
||||
...props.controls.datasource,
|
||||
datasource: datasourceWithFullFolders,
|
||||
},
|
||||
},
|
||||
};
|
||||
render(
|
||||
<ExploreContainer>
|
||||
<DatasourcePanel {...propsWithFullFolders} />
|
||||
<DndMetricSelect {...metricProps} />
|
||||
</ExploreContainer>,
|
||||
{
|
||||
useRedux: true,
|
||||
useDnd: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test folder')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test nested folder')).toBeInTheDocument();
|
||||
expect(screen.getByText('Second test folder')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Metrics')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Columns')).not.toBeInTheDocument();
|
||||
|
||||
columns.forEach(col => {
|
||||
expect(screen.getByText(col.column_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
metrics.forEach(metric => {
|
||||
expect(screen.getByText(metric.metric_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getAllByTestId('DatasourcePanelDragOption').length).toEqual(5);
|
||||
expect(screen.getAllByTestId('datasource-panel-divider').length).toEqual(1);
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ import DatasourcePanelDragOption from '.';
|
||||
test('should render', async () => {
|
||||
render(
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'test', uuid: '1' }}
|
||||
value={{ metric_name: 'test' }}
|
||||
type={DndItemType.Metric}
|
||||
/>,
|
||||
{ useDnd: true },
|
||||
@@ -38,7 +38,7 @@ test('should render', async () => {
|
||||
test('should have attribute draggable:true', async () => {
|
||||
render(
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'test', uuid: '1' }}
|
||||
value={{ metric_name: 'test' }}
|
||||
type={DndItemType.Metric}
|
||||
/>,
|
||||
{ useDnd: true },
|
||||
|
||||
@@ -20,84 +20,178 @@ import {
|
||||
columns,
|
||||
metrics,
|
||||
} from 'src/explore/components/DatasourcePanel/fixtures';
|
||||
import { screen, userEvent, render } from 'spec/helpers/testing-library';
|
||||
import DatasourcePanelItem, {
|
||||
DatasourcePanelItemProps,
|
||||
} from './DatasourcePanelItem';
|
||||
import { fireEvent, render, within } from 'spec/helpers/testing-library';
|
||||
import DatasourcePanelItem from './DatasourcePanelItem';
|
||||
|
||||
const mockData: DatasourcePanelItemProps['data'] = {
|
||||
flattenedItems: [
|
||||
{ type: 'header', depth: 0, folderId: '1', height: 50 },
|
||||
...metrics.map((m, idx) => ({
|
||||
type: 'item' as const,
|
||||
depth: 0,
|
||||
folderId: '1',
|
||||
height: 32,
|
||||
index: idx,
|
||||
item: { ...m, type: 'metric' as const },
|
||||
})),
|
||||
{ type: 'divider', depth: 0, folderId: '1', height: 16 },
|
||||
{ type: 'header', depth: 0, folderId: '2', height: 50 },
|
||||
...columns.map((m, idx) => ({
|
||||
type: 'item' as const,
|
||||
depth: 0,
|
||||
folderId: '2',
|
||||
height: 32,
|
||||
index: idx,
|
||||
item: { ...m, type: 'column' as const },
|
||||
})),
|
||||
],
|
||||
folderMap: new Map([
|
||||
[
|
||||
'1',
|
||||
{
|
||||
id: '1',
|
||||
isCollapsed: false,
|
||||
name: 'Metrics',
|
||||
items: metrics.map(m => ({ ...m, type: 'metric' })),
|
||||
},
|
||||
],
|
||||
[
|
||||
'2',
|
||||
{
|
||||
id: '2',
|
||||
isCollapsed: false,
|
||||
name: 'Columns',
|
||||
items: columns.map(c => ({ ...c, type: 'column' })),
|
||||
},
|
||||
],
|
||||
]),
|
||||
const mockData = {
|
||||
metricSlice: metrics,
|
||||
columnSlice: columns,
|
||||
totalMetrics: Math.max(metrics.length, 10),
|
||||
totalColumns: Math.max(columns.length, 13),
|
||||
width: 300,
|
||||
onToggleCollapse: jest.fn(),
|
||||
showAllMetrics: false,
|
||||
onShowAllMetricsChange: jest.fn(),
|
||||
showAllColumns: false,
|
||||
onShowAllColumnsChange: jest.fn(),
|
||||
collapseMetrics: false,
|
||||
onCollapseMetricsChange: jest.fn(),
|
||||
collapseColumns: false,
|
||||
onCollapseColumnsChange: jest.fn(),
|
||||
hiddenMetricCount: 0,
|
||||
hiddenColumnCount: 0,
|
||||
};
|
||||
|
||||
const setup = (data: DatasourcePanelItemProps['data'] = mockData) =>
|
||||
render(
|
||||
<>
|
||||
{data.flattenedItems.map((_, index) => (
|
||||
<DatasourcePanelItem index={index} data={data} style={{}} />
|
||||
))}
|
||||
</>,
|
||||
test('renders each item accordingly', () => {
|
||||
const { getByText, getByTestId, rerender, container } = render(
|
||||
<DatasourcePanelItem index={0} data={mockData} style={{}} />,
|
||||
{ useDnd: true },
|
||||
);
|
||||
|
||||
test('renders each item accordingly', () => {
|
||||
setup();
|
||||
expect(screen.getByText('Metrics')).toBeInTheDocument();
|
||||
expect(screen.getByText('metric_end_certified')).toBeInTheDocument();
|
||||
expect(screen.getByText('metric_end')).toBeInTheDocument();
|
||||
expect(getByText('Metrics')).toBeInTheDocument();
|
||||
rerender(<DatasourcePanelItem index={1} data={mockData} style={{}} />);
|
||||
expect(
|
||||
getByText(
|
||||
`Showing ${mockData.metricSlice.length} of ${mockData.totalMetrics}`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
mockData.metricSlice.forEach((metric, metricIndex) => {
|
||||
rerender(
|
||||
<DatasourcePanelItem
|
||||
index={metricIndex + 2}
|
||||
data={mockData}
|
||||
style={{}}
|
||||
/>,
|
||||
);
|
||||
expect(getByTestId('DatasourcePanelDragOption')).toBeInTheDocument();
|
||||
expect(
|
||||
within(getByTestId('DatasourcePanelDragOption')).getByText(
|
||||
metric.metric_name,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
rerender(
|
||||
<DatasourcePanelItem
|
||||
index={2 + mockData.metricSlice.length}
|
||||
data={mockData}
|
||||
style={{}}
|
||||
/>,
|
||||
);
|
||||
expect(container).toHaveTextContent('');
|
||||
|
||||
expect(screen.getByText('Columns')).toBeInTheDocument();
|
||||
expect(screen.getByText('bootcamp_attend')).toBeInTheDocument();
|
||||
expect(screen.getByText('calc_first_time_dev')).toBeInTheDocument();
|
||||
expect(screen.getByText('aaaaaaaaaaa')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId('datasource-panel-divider')).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId('DatasourcePanelDragOption').length).toEqual(5);
|
||||
const startIndexOfColumnSection = mockData.metricSlice.length + 3;
|
||||
rerender(
|
||||
<DatasourcePanelItem
|
||||
index={startIndexOfColumnSection}
|
||||
data={mockData}
|
||||
style={{}}
|
||||
/>,
|
||||
);
|
||||
expect(getByText('Columns')).toBeInTheDocument();
|
||||
rerender(
|
||||
<DatasourcePanelItem
|
||||
index={startIndexOfColumnSection + 1}
|
||||
data={mockData}
|
||||
style={{}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
getByText(
|
||||
`Showing ${mockData.columnSlice.length} of ${mockData.totalColumns}`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
mockData.columnSlice.forEach((column, columnIndex) => {
|
||||
rerender(
|
||||
<DatasourcePanelItem
|
||||
index={startIndexOfColumnSection + columnIndex + 2}
|
||||
data={mockData}
|
||||
style={{}}
|
||||
/>,
|
||||
);
|
||||
expect(getByTestId('DatasourcePanelDragOption')).toBeInTheDocument();
|
||||
expect(
|
||||
within(getByTestId('DatasourcePanelDragOption')).getByText(
|
||||
column.column_name,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('can collapse metrics and columns', () => {
|
||||
setup();
|
||||
userEvent.click(screen.getAllByRole('button')[0]);
|
||||
expect(mockData.onToggleCollapse).toHaveBeenCalled();
|
||||
mockData.onCollapseMetricsChange.mockClear();
|
||||
mockData.onCollapseColumnsChange.mockClear();
|
||||
const { queryByText, getByRole, rerender } = render(
|
||||
<DatasourcePanelItem index={0} data={mockData} style={{}} />,
|
||||
{ useDnd: true },
|
||||
);
|
||||
fireEvent.click(getByRole('button'));
|
||||
expect(mockData.onCollapseMetricsChange).toHaveBeenCalled();
|
||||
expect(mockData.onCollapseColumnsChange).not.toHaveBeenCalled();
|
||||
|
||||
const startIndexOfColumnSection = mockData.metricSlice.length + 3;
|
||||
rerender(
|
||||
<DatasourcePanelItem
|
||||
index={startIndexOfColumnSection}
|
||||
data={mockData}
|
||||
style={{}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(getByRole('button'));
|
||||
expect(mockData.onCollapseColumnsChange).toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<DatasourcePanelItem
|
||||
index={1}
|
||||
data={{
|
||||
...mockData,
|
||||
collapseMetrics: true,
|
||||
}}
|
||||
style={{}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
queryByText(
|
||||
`Showing ${mockData.metricSlice.length} of ${mockData.totalMetrics}`,
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<DatasourcePanelItem
|
||||
index={2}
|
||||
data={{
|
||||
...mockData,
|
||||
collapseMetrics: true,
|
||||
}}
|
||||
style={{}}
|
||||
/>,
|
||||
);
|
||||
expect(queryByText('Columns')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows ineligible items count', () => {
|
||||
const hiddenColumnCount = 3;
|
||||
const hiddenMetricCount = 1;
|
||||
const dataWithHiddenItems = {
|
||||
...mockData,
|
||||
hiddenColumnCount,
|
||||
hiddenMetricCount,
|
||||
};
|
||||
const { getByText, rerender } = render(
|
||||
<DatasourcePanelItem index={1} data={dataWithHiddenItems} style={{}} />,
|
||||
{ useDnd: true },
|
||||
);
|
||||
expect(
|
||||
getByText(`${hiddenMetricCount} ineligible item(s) are hidden`),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const startIndexOfColumnSection = mockData.metricSlice.length + 3;
|
||||
rerender(
|
||||
<DatasourcePanelItem
|
||||
index={startIndexOfColumnSection + 1}
|
||||
data={dataWithHiddenItems}
|
||||
style={{}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
getByText(`${hiddenColumnCount} ineligible item(s) are hidden`),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -16,24 +16,71 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { CSSProperties } from 'react';
|
||||
import { CSSProperties, FC } from 'react';
|
||||
|
||||
import { css, styled, useTheme } from '@superset-ui/core';
|
||||
import { css, Metric, styled, t, useTheme } from '@superset-ui/core';
|
||||
|
||||
import Icons from 'src/components/Icons';
|
||||
import DatasourcePanelDragOption from './DatasourcePanelDragOption';
|
||||
import { DndItemType } from '../DndItemType';
|
||||
import { DndItemValue, FlattenedItem, Folder } from './types';
|
||||
import { DndItemValue } from './types';
|
||||
|
||||
export type DataSourcePanelColumn = {
|
||||
is_dttm?: boolean | null;
|
||||
description?: string | null;
|
||||
expression?: string | null;
|
||||
is_certified?: number | null;
|
||||
column_name?: string | null;
|
||||
name?: string | null;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
index: number;
|
||||
style: CSSProperties;
|
||||
data: {
|
||||
metricSlice: Metric[];
|
||||
columnSlice: DataSourcePanelColumn[];
|
||||
totalMetrics: number;
|
||||
totalColumns: number;
|
||||
width: number;
|
||||
showAllMetrics: boolean;
|
||||
onShowAllMetricsChange: (showAll: boolean) => void;
|
||||
showAllColumns: boolean;
|
||||
onShowAllColumnsChange: (showAll: boolean) => void;
|
||||
collapseMetrics: boolean;
|
||||
onCollapseMetricsChange: (collapse: boolean) => void;
|
||||
collapseColumns: boolean;
|
||||
onCollapseColumnsChange: (collapse: boolean) => void;
|
||||
hiddenMetricCount: number;
|
||||
hiddenColumnCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const DEFAULT_MAX_COLUMNS_LENGTH = 50;
|
||||
export const DEFAULT_MAX_METRICS_LENGTH = 50;
|
||||
export const ITEM_HEIGHT = 30;
|
||||
|
||||
const Button = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
text-decoration: underline;
|
||||
color: ${({ theme }) => theme.colors.primary.dark1};
|
||||
`;
|
||||
|
||||
const ButtonContainer = styled.div`
|
||||
text-align: center;
|
||||
padding-top: 2px;
|
||||
`;
|
||||
|
||||
const LabelWrapper = styled.div`
|
||||
${({ theme }) => css`
|
||||
color: ${theme.colors.grayscale.dark1};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: ${theme.typography.sizes.s}px;
|
||||
background-color: ${theme.colors.grayscale.light4};
|
||||
margin: ${theme.gridUnit * 2}px 0;
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
border-radius: 4px;
|
||||
padding: 0 ${theme.gridUnit}px;
|
||||
|
||||
&:first-of-type {
|
||||
@@ -76,95 +123,146 @@ const SectionHeaderButton = styled.button`
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-inline: 0;
|
||||
padding-inline: 0px;
|
||||
`;
|
||||
|
||||
const SectionHeader = styled.span`
|
||||
${({ theme }) => css`
|
||||
color: ${theme.colors.grayscale.dark1};
|
||||
${({ theme }) => `
|
||||
font-size: ${theme.typography.sizes.m}px;
|
||||
font-weight: ${theme.typography.weights.medium};
|
||||
line-height: 1.3;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Divider = styled.div`
|
||||
${({ theme }) => css`
|
||||
height: 16px;
|
||||
border-bottom: 1px solid ${theme.colors.grayscale.light3};
|
||||
const Box = styled.div`
|
||||
${({ theme }) => `
|
||||
border: 1px ${theme.colors.grayscale.light4} solid;
|
||||
border-radius: ${theme.gridUnit}px;
|
||||
font-size: ${theme.typography.sizes.s}px;
|
||||
padding: ${theme.gridUnit}px;
|
||||
color: ${theme.colors.grayscale.light1};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
`}
|
||||
`;
|
||||
|
||||
export interface DatasourcePanelItemProps {
|
||||
index: number;
|
||||
style: CSSProperties;
|
||||
data: {
|
||||
flattenedItems: FlattenedItem[];
|
||||
folderMap: Map<string, Folder>;
|
||||
width: number;
|
||||
onToggleCollapse: (folderId: string) => void;
|
||||
};
|
||||
}
|
||||
const DatasourcePanelItem: FC<Props> = ({ index, style, data }) => {
|
||||
const {
|
||||
metricSlice: _metricSlice,
|
||||
columnSlice,
|
||||
totalMetrics,
|
||||
totalColumns,
|
||||
width,
|
||||
showAllMetrics,
|
||||
onShowAllMetricsChange,
|
||||
showAllColumns,
|
||||
onShowAllColumnsChange,
|
||||
collapseMetrics,
|
||||
onCollapseMetricsChange,
|
||||
collapseColumns,
|
||||
onCollapseColumnsChange,
|
||||
hiddenMetricCount,
|
||||
hiddenColumnCount,
|
||||
} = data;
|
||||
const metricSlice = collapseMetrics ? [] : _metricSlice;
|
||||
|
||||
const DatasourcePanelItem = ({
|
||||
index,
|
||||
style,
|
||||
data,
|
||||
}: DatasourcePanelItemProps) => {
|
||||
const { flattenedItems, folderMap, width, onToggleCollapse } = data;
|
||||
const item = flattenedItems[index];
|
||||
const EXTRA_LINES = collapseMetrics ? 1 : 2;
|
||||
const isColumnSection = collapseMetrics
|
||||
? index >= 1
|
||||
: index > metricSlice.length + EXTRA_LINES;
|
||||
const HEADER_LINE = isColumnSection
|
||||
? metricSlice.length + EXTRA_LINES + 1
|
||||
: 0;
|
||||
const SUBTITLE_LINE = HEADER_LINE + 1;
|
||||
const BOTTOM_LINE =
|
||||
(isColumnSection ? columnSlice.length : metricSlice.length) +
|
||||
(collapseMetrics ? HEADER_LINE : SUBTITLE_LINE) +
|
||||
1;
|
||||
const collapsed = isColumnSection ? collapseColumns : collapseMetrics;
|
||||
const setCollapse = isColumnSection
|
||||
? onCollapseColumnsChange
|
||||
: onCollapseMetricsChange;
|
||||
const showAll = isColumnSection ? showAllColumns : showAllMetrics;
|
||||
const setShowAll = isColumnSection
|
||||
? onShowAllColumnsChange
|
||||
: onShowAllMetricsChange;
|
||||
const theme = useTheme();
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
const folder = folderMap.get(item.folderId);
|
||||
if (!folder) return null;
|
||||
|
||||
const indentation = item.depth * theme.gridUnit * 4;
|
||||
const hiddenCount = isColumnSection ? hiddenColumnCount : hiddenMetricCount;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
paddingLeft: theme.gridUnit * 4 + indentation,
|
||||
paddingRight: theme.gridUnit * 4,
|
||||
}}
|
||||
style={style}
|
||||
css={css`
|
||||
padding: 0 ${theme.gridUnit * 4}px;
|
||||
`}
|
||||
>
|
||||
{item.type === 'header' && (
|
||||
<SectionHeaderButton onClick={() => onToggleCollapse(folder.id)}>
|
||||
<SectionHeader>{folder.name}</SectionHeader>
|
||||
{folder.isCollapsed ? (
|
||||
{index === HEADER_LINE && (
|
||||
<SectionHeaderButton onClick={() => setCollapse(!collapsed)}>
|
||||
<SectionHeader>
|
||||
{isColumnSection ? t('Columns') : t('Metrics')}
|
||||
</SectionHeader>
|
||||
{collapsed ? (
|
||||
<Icons.DownOutlined iconSize="s" />
|
||||
) : (
|
||||
<Icons.UpOutlined iconSize="s" />
|
||||
)}
|
||||
</SectionHeaderButton>
|
||||
)}
|
||||
|
||||
{item.type === 'item' && item.item && (
|
||||
{index === SUBTITLE_LINE && !collapsed && (
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
gap: ${theme.gridUnit * 2}px;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className="field-length"
|
||||
css={css`
|
||||
flex-shrink: 0;
|
||||
`}
|
||||
>
|
||||
{isColumnSection
|
||||
? t(`Showing %s of %s`, columnSlice?.length, totalColumns)
|
||||
: t(`Showing %s of %s`, metricSlice?.length, totalMetrics)}
|
||||
</div>
|
||||
{hiddenCount > 0 && (
|
||||
<Box>{t(`%s ineligible item(s) are hidden`, hiddenCount)}</Box>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{index > SUBTITLE_LINE && index < BOTTOM_LINE && (
|
||||
<LabelWrapper
|
||||
key={
|
||||
(item.item.type === 'column'
|
||||
? item.item.column_name
|
||||
: item.item.metric_name) + String(width)
|
||||
(isColumnSection
|
||||
? columnSlice[index - SUBTITLE_LINE - 1].column_name
|
||||
: metricSlice[index - SUBTITLE_LINE - 1].metric_name) +
|
||||
String(width)
|
||||
}
|
||||
className="column"
|
||||
>
|
||||
<DatasourcePanelDragOption
|
||||
value={item.item as DndItemValue}
|
||||
type={
|
||||
item.item.type === 'column'
|
||||
? DndItemType.Column
|
||||
: DndItemType.Metric
|
||||
value={
|
||||
isColumnSection
|
||||
? (columnSlice[index - SUBTITLE_LINE - 1] as DndItemValue)
|
||||
: metricSlice[index - SUBTITLE_LINE - 1]
|
||||
}
|
||||
type={isColumnSection ? DndItemType.Column : DndItemType.Metric}
|
||||
/>
|
||||
</LabelWrapper>
|
||||
)}
|
||||
|
||||
{item.type === 'divider' && (
|
||||
<Divider data-test="datasource-panel-divider" />
|
||||
)}
|
||||
{index === BOTTOM_LINE &&
|
||||
!collapsed &&
|
||||
(isColumnSection
|
||||
? totalColumns > DEFAULT_MAX_COLUMNS_LENGTH
|
||||
: totalMetrics > DEFAULT_MAX_METRICS_LENGTH) && (
|
||||
<ButtonContainer>
|
||||
<Button onClick={() => setShowAll(!showAll)}>
|
||||
{showAll ? t('Show less...') : t('Show all...')}
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,7 +26,6 @@ export const columns = [
|
||||
filterable: true,
|
||||
groupby: true,
|
||||
id: 516,
|
||||
uuid: '516',
|
||||
is_dttm: false,
|
||||
python_date_format: null,
|
||||
type: 'DOUBLE',
|
||||
@@ -41,7 +40,6 @@ export const columns = [
|
||||
filterable: true,
|
||||
groupby: true,
|
||||
id: 477,
|
||||
uuid: '477',
|
||||
is_dttm: false,
|
||||
python_date_format: null,
|
||||
type: 'VARCHAR',
|
||||
@@ -54,8 +52,7 @@ export const columns = [
|
||||
expression: null,
|
||||
filterable: true,
|
||||
groupby: true,
|
||||
id: 517,
|
||||
uuid: '517',
|
||||
id: 516,
|
||||
is_dttm: false,
|
||||
python_date_format: null,
|
||||
type: 'INT',
|
||||
@@ -73,7 +70,6 @@ const metricsFiltered = {
|
||||
description: null,
|
||||
expression: '',
|
||||
id: 56,
|
||||
uuid: '56',
|
||||
is_certified: true,
|
||||
metric_name: 'metric_end_certified',
|
||||
verbose_name: '',
|
||||
@@ -88,7 +84,6 @@ const metricsFiltered = {
|
||||
description: null,
|
||||
expression: '',
|
||||
id: 57,
|
||||
uuid: '57',
|
||||
is_certified: false,
|
||||
metric_name: 'metric_end',
|
||||
verbose_name: '',
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
|
||||
import { ControlConfig } from '@superset-ui/chart-controls';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
|
||||
import { matchSorter, rankings } from 'match-sorter';
|
||||
import Alert from 'src/components/Alert';
|
||||
@@ -38,19 +39,22 @@ import { FAST_DEBOUNCE } from 'src/constants';
|
||||
import { ExploreActions } from 'src/explore/actions/exploreActions';
|
||||
import Control from 'src/explore/components/Control';
|
||||
import { useDebounceValue } from 'src/hooks/useDebounceValue';
|
||||
import DatasourcePanelItem, {
|
||||
ITEM_HEIGHT,
|
||||
DataSourcePanelColumn,
|
||||
DEFAULT_MAX_COLUMNS_LENGTH,
|
||||
DEFAULT_MAX_METRICS_LENGTH,
|
||||
} from './DatasourcePanelItem';
|
||||
import { DndItemType } from '../DndItemType';
|
||||
import { DatasourceFolder, DatasourcePanelColumn, DndItemValue } from './types';
|
||||
import { DndItemValue } from './types';
|
||||
import { DropzoneContext } from '../ExploreContainer';
|
||||
import { DatasourceItems } from './DatasourceItems';
|
||||
import { transformDatasourceWithFolders } from './transformDatasourceFolders';
|
||||
|
||||
interface DatasourceControl extends Omit<ControlConfig, 'hidden'> {
|
||||
datasource?: IDatasource;
|
||||
}
|
||||
export interface IDatasource {
|
||||
metrics: Metric[];
|
||||
columns: DatasourcePanelColumn[];
|
||||
folders?: DatasourceFolder[];
|
||||
columns: DataSourcePanelColumn[];
|
||||
id: number;
|
||||
type: DatasourceType;
|
||||
database: {
|
||||
@@ -122,18 +126,8 @@ const StyledInfoboxWrapper = styled.div`
|
||||
|
||||
const BORDER_WIDTH = 2;
|
||||
|
||||
const sortColumns = (slice: DatasourcePanelColumn[]) =>
|
||||
[...slice]
|
||||
.sort((col1, col2) => {
|
||||
if (col1?.is_dttm && !col2?.is_dttm) {
|
||||
return -1;
|
||||
}
|
||||
if (col2?.is_dttm && !col1?.is_dttm) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
.sort((a, b) => (b?.is_certified ?? 0) - (a?.is_certified ?? 0));
|
||||
const sortCertifiedFirst = (slice: DataSourcePanelColumn[]) =>
|
||||
slice.sort((a, b) => (b?.is_certified ?? 0) - (a?.is_certified ?? 0));
|
||||
|
||||
export default function DataSourcePanel({
|
||||
datasource,
|
||||
@@ -143,7 +137,7 @@ export default function DataSourcePanel({
|
||||
width,
|
||||
}: Props) {
|
||||
const [dropzones] = useContext(DropzoneContext);
|
||||
const { columns: _columns, metrics, folders: _folders } = datasource;
|
||||
const { columns: _columns, metrics } = datasource;
|
||||
|
||||
const allowedColumns = useMemo(() => {
|
||||
const validators = Object.values(dropzones);
|
||||
@@ -158,6 +152,21 @@ export default function DataSourcePanel({
|
||||
);
|
||||
}, [dropzones, _columns]);
|
||||
|
||||
// display temporal column first
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
[...allowedColumns].sort((col1, col2) => {
|
||||
if (col1?.is_dttm && !col2?.is_dttm) {
|
||||
return -1;
|
||||
}
|
||||
if (col2?.is_dttm && !col1?.is_dttm) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
[allowedColumns],
|
||||
);
|
||||
|
||||
const allowedMetrics = useMemo(() => {
|
||||
const validators = Object.values(dropzones);
|
||||
return metrics.filter(metric =>
|
||||
@@ -167,15 +176,21 @@ export default function DataSourcePanel({
|
||||
);
|
||||
}, [dropzones, metrics]);
|
||||
|
||||
const hiddenColumnCount = _columns.length - allowedColumns.length;
|
||||
const hiddenMetricCount = metrics.length - allowedMetrics.length;
|
||||
const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [showAllMetrics, setShowAllMetrics] = useState(false);
|
||||
const [showAllColumns, setShowAllColumns] = useState(false);
|
||||
const [collapseMetrics, setCollapseMetrics] = useState(false);
|
||||
const [collapseColumns, setCollapseColumns] = useState(false);
|
||||
const searchKeyword = useDebounceValue(inputValue, FAST_DEBOUNCE);
|
||||
|
||||
const filteredColumns = useMemo(() => {
|
||||
if (!searchKeyword) {
|
||||
return allowedColumns ?? [];
|
||||
return columns ?? [];
|
||||
}
|
||||
return matchSorter(allowedColumns, searchKeyword, {
|
||||
return matchSorter(columns, searchKeyword, {
|
||||
keys: [
|
||||
{
|
||||
key: 'verbose_name',
|
||||
@@ -196,7 +211,7 @@ export default function DataSourcePanel({
|
||||
],
|
||||
keepDiacritics: true,
|
||||
});
|
||||
}, [allowedColumns, searchKeyword]);
|
||||
}, [columns, searchKeyword]);
|
||||
|
||||
const filteredMetrics = useMemo(() => {
|
||||
if (!searchKeyword) {
|
||||
@@ -229,15 +244,22 @@ export default function DataSourcePanel({
|
||||
});
|
||||
}, [allowedMetrics, searchKeyword]);
|
||||
|
||||
const sortedColumns = useMemo(
|
||||
() => sortColumns(filteredColumns),
|
||||
[filteredColumns],
|
||||
const metricSlice = useMemo(
|
||||
() =>
|
||||
showAllMetrics
|
||||
? filteredMetrics
|
||||
: filteredMetrics?.slice?.(0, DEFAULT_MAX_METRICS_LENGTH),
|
||||
[filteredMetrics, showAllMetrics],
|
||||
);
|
||||
|
||||
const folders = useMemo(
|
||||
const columnSlice = useMemo(
|
||||
() =>
|
||||
transformDatasourceWithFolders(filteredMetrics, sortedColumns, _folders),
|
||||
[_folders, filteredMetrics, sortedColumns],
|
||||
showAllColumns
|
||||
? sortCertifiedFirst(filteredColumns)
|
||||
: sortCertifiedFirst(
|
||||
filteredColumns?.slice?.(0, DEFAULT_MAX_COLUMNS_LENGTH),
|
||||
),
|
||||
[filteredColumns, showAllColumns],
|
||||
);
|
||||
|
||||
const showInfoboxCheck = () => {
|
||||
@@ -302,17 +324,57 @@ export default function DataSourcePanel({
|
||||
)}
|
||||
<AutoSizer>
|
||||
{({ height }: { height: number }) => (
|
||||
<DatasourceItems
|
||||
<List
|
||||
width={width - BORDER_WIDTH}
|
||||
height={height}
|
||||
folders={folders}
|
||||
/>
|
||||
itemSize={ITEM_HEIGHT}
|
||||
itemCount={
|
||||
(collapseMetrics ? 0 : metricSlice?.length) +
|
||||
(collapseColumns ? 0 : columnSlice.length) +
|
||||
2 + // Each section header row
|
||||
(collapseMetrics ? 0 : 2) +
|
||||
(collapseColumns ? 0 : 2)
|
||||
}
|
||||
itemData={{
|
||||
metricSlice,
|
||||
columnSlice,
|
||||
width,
|
||||
totalMetrics: filteredMetrics.length,
|
||||
totalColumns: filteredColumns.length,
|
||||
showAllMetrics,
|
||||
onShowAllMetricsChange: setShowAllMetrics,
|
||||
showAllColumns,
|
||||
onShowAllColumnsChange: setShowAllColumns,
|
||||
collapseMetrics,
|
||||
onCollapseMetricsChange: setCollapseMetrics,
|
||||
collapseColumns,
|
||||
onCollapseColumnsChange: setCollapseColumns,
|
||||
hiddenMetricCount,
|
||||
hiddenColumnCount,
|
||||
}}
|
||||
overscanCount={5}
|
||||
>
|
||||
{DatasourcePanelItem}
|
||||
</List>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
[inputValue, datasourceIsSaveable, width, folders],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
columnSlice,
|
||||
inputValue,
|
||||
filteredColumns.length,
|
||||
filteredMetrics.length,
|
||||
metricSlice,
|
||||
showAllColumns,
|
||||
showAllMetrics,
|
||||
collapseMetrics,
|
||||
collapseColumns,
|
||||
datasourceIsSaveable,
|
||||
width,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Metric } from '@superset-ui/core';
|
||||
import { transformDatasourceWithFolders } from './transformDatasourceFolders';
|
||||
import { DatasourceFolder, DatasourcePanelColumn } from './types';
|
||||
|
||||
const mockMetrics: Metric[] = [
|
||||
{ metric_name: 'metric1', uuid: 'metric1-uuid', expression: 'SUM(col1)' },
|
||||
{ metric_name: 'metric2', uuid: 'metric2-uuid', expression: 'AVG(col2)' },
|
||||
{ metric_name: 'metric3', uuid: 'metric3-uuid', expression: 'COUNT(*)' },
|
||||
];
|
||||
|
||||
const mockColumns: DatasourcePanelColumn[] = [
|
||||
{ column_name: 'column1', uuid: 'column1-uuid', type: 'STRING' },
|
||||
{ column_name: 'column2', uuid: 'column2-uuid', type: 'NUMERIC' },
|
||||
{ column_name: 'column3', uuid: 'column3-uuid', type: 'DATETIME' },
|
||||
];
|
||||
|
||||
test('transforms data into default folders when no folder config is provided', () => {
|
||||
const result = transformDatasourceWithFolders(
|
||||
mockMetrics,
|
||||
mockColumns,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
expect(result[0].id).toBe('metrics-default');
|
||||
expect(result[0].name).toBe('Metrics');
|
||||
expect(result[0].items).toHaveLength(3);
|
||||
expect(result[0].items[0].uuid).toBe('metric1-uuid');
|
||||
expect(result[0].items[0].type).toBe('metric');
|
||||
|
||||
expect(result[1].id).toBe('columns-default');
|
||||
expect(result[1].name).toBe('Columns');
|
||||
expect(result[1].items).toHaveLength(3);
|
||||
expect(result[1].items[0].uuid).toBe('column1-uuid');
|
||||
expect(result[1].items[0].type).toBe('column');
|
||||
});
|
||||
|
||||
test('transforms data according to folder configuration', () => {
|
||||
const folderConfig: DatasourceFolder[] = [
|
||||
{
|
||||
uuid: 'folder1',
|
||||
type: 'folder',
|
||||
name: 'Important Metrics',
|
||||
description: 'Key metrics folder',
|
||||
children: [
|
||||
{ type: 'metric', uuid: 'metric1-uuid', name: 'metric1' },
|
||||
{ type: 'metric', uuid: 'metric2-uuid', name: 'metric2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
uuid: 'folder2',
|
||||
type: 'folder',
|
||||
name: 'Key Dimensions',
|
||||
children: [{ type: 'column', uuid: 'column1-uuid', name: 'column1' }],
|
||||
},
|
||||
];
|
||||
|
||||
const result = transformDatasourceWithFolders(
|
||||
mockMetrics,
|
||||
mockColumns,
|
||||
folderConfig,
|
||||
);
|
||||
|
||||
// We expect 4 folders:
|
||||
// 1. Important Metrics (from config)
|
||||
// 2. Key Dimensions (from config)
|
||||
// 3. Metrics (default for unassigned metrics)
|
||||
// 4. Columns (default for unassigned columns)
|
||||
expect(result).toHaveLength(4);
|
||||
|
||||
expect(result[0].id).toBe('folder1');
|
||||
expect(result[0].name).toBe('Important Metrics');
|
||||
expect(result[0].description).toBe('Key metrics folder');
|
||||
expect(result[0].items).toHaveLength(2);
|
||||
expect(result[0].items[0].uuid).toBe('metric1-uuid');
|
||||
|
||||
expect(result[1].id).toBe('folder2');
|
||||
expect(result[1].name).toBe('Key Dimensions');
|
||||
expect(result[1].items).toHaveLength(1);
|
||||
expect(result[1].items[0].uuid).toBe('column1-uuid');
|
||||
|
||||
expect(result[2].id).toBe('metrics-default');
|
||||
expect(result[2].items).toHaveLength(1);
|
||||
expect(result[2].items[0].uuid).toBe('metric3-uuid');
|
||||
|
||||
expect(result[3].id).toBe('columns-default');
|
||||
expect(result[3].items).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('handles nested folder structures', () => {
|
||||
const folderConfig: DatasourceFolder[] = [
|
||||
{
|
||||
uuid: 'parent-folder',
|
||||
type: 'folder',
|
||||
name: 'Parent Folder',
|
||||
children: [
|
||||
{
|
||||
uuid: 'child-folder',
|
||||
type: 'folder',
|
||||
name: 'Child Folder',
|
||||
children: [{ type: 'metric', uuid: 'metric1-uuid', name: 'metric1' }],
|
||||
},
|
||||
{ type: 'column', uuid: 'column1-uuid', name: 'column1' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = transformDatasourceWithFolders(
|
||||
mockMetrics,
|
||||
mockColumns,
|
||||
folderConfig,
|
||||
);
|
||||
|
||||
expect(result[0].id).toBe('parent-folder');
|
||||
expect(result[0].name).toBe('Parent Folder');
|
||||
expect(result[0].items).toHaveLength(1);
|
||||
expect(result[0].subFolders).toHaveLength(1);
|
||||
|
||||
const childFolder = result[0].subFolders![0];
|
||||
expect(childFolder.id).toBe('child-folder');
|
||||
expect(childFolder.name).toBe('Child Folder');
|
||||
expect(childFolder.items).toHaveLength(1);
|
||||
expect(childFolder.parentId).toBe('parent-folder');
|
||||
});
|
||||
|
||||
test('handles empty children arrays', () => {
|
||||
const folderConfig: DatasourceFolder[] = [
|
||||
{
|
||||
uuid: 'empty-folder',
|
||||
type: 'folder',
|
||||
name: 'Empty Folder',
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = transformDatasourceWithFolders(
|
||||
mockMetrics,
|
||||
mockColumns,
|
||||
folderConfig,
|
||||
);
|
||||
|
||||
expect(result[0].id).toBe('empty-folder');
|
||||
expect(result[0].name).toBe('Empty Folder');
|
||||
expect(result[0].items).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('handles non-existent metric and column UUIDs in folder config', () => {
|
||||
const folderConfig: DatasourceFolder[] = [
|
||||
{
|
||||
uuid: 'folder1',
|
||||
type: 'folder',
|
||||
name: 'Test Folder',
|
||||
children: [
|
||||
{
|
||||
type: 'metric',
|
||||
uuid: 'non-existent-metric',
|
||||
name: 'Missing Metric',
|
||||
},
|
||||
{
|
||||
type: 'column',
|
||||
uuid: 'non-existent-column',
|
||||
name: 'Missing Column',
|
||||
},
|
||||
{ type: 'metric', uuid: 'metric1-uuid', name: 'Existing Metric' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = transformDatasourceWithFolders(
|
||||
mockMetrics,
|
||||
mockColumns,
|
||||
folderConfig,
|
||||
);
|
||||
|
||||
expect(result[0].id).toBe('folder1');
|
||||
expect(result[0].items).toHaveLength(1);
|
||||
expect(result[0].items[0].uuid).toBe('metric1-uuid');
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Metric, t } from '@superset-ui/core';
|
||||
import {
|
||||
ColumnItem,
|
||||
DatasourceFolder,
|
||||
DatasourcePanelColumn,
|
||||
Folder,
|
||||
MetricItem,
|
||||
} from './types';
|
||||
|
||||
const transformToFolderStructure = (
|
||||
metrics: MetricItem[],
|
||||
columns: ColumnItem[],
|
||||
folderConfig: DatasourceFolder[] | undefined,
|
||||
): Folder[] => {
|
||||
const metricsMap = new Map<string, MetricItem>();
|
||||
const columnsMap = new Map<string, ColumnItem>();
|
||||
|
||||
const assignedMetricUuids = new Set<string>();
|
||||
const assignedColumnUuids = new Set<string>();
|
||||
|
||||
metrics.forEach(metric => {
|
||||
metricsMap.set(metric.uuid, metric);
|
||||
});
|
||||
|
||||
columns.forEach(column => {
|
||||
columnsMap.set(column.uuid, column);
|
||||
});
|
||||
|
||||
const processFolder = (
|
||||
datasourceFolder: DatasourceFolder,
|
||||
parentId?: string,
|
||||
): Folder => {
|
||||
const folder: Folder = {
|
||||
id: datasourceFolder.uuid,
|
||||
name: datasourceFolder.name,
|
||||
description: datasourceFolder.description,
|
||||
isCollapsed: false,
|
||||
items: [],
|
||||
parentId,
|
||||
};
|
||||
|
||||
if (datasourceFolder.children && datasourceFolder.children.length > 0) {
|
||||
if (!folder.subFolders) {
|
||||
folder.subFolders = [];
|
||||
}
|
||||
|
||||
datasourceFolder.children.forEach(child => {
|
||||
if (child.type === 'folder') {
|
||||
folder.subFolders!.push(
|
||||
processFolder(child as DatasourceFolder, folder.id),
|
||||
);
|
||||
} else if (child.type === 'metric') {
|
||||
const metric = metricsMap.get(child.uuid);
|
||||
if (metric) {
|
||||
folder.items.push(metric);
|
||||
assignedMetricUuids.add(metric.uuid);
|
||||
}
|
||||
} else if (child.type === 'column') {
|
||||
const column = columnsMap.get(child.uuid);
|
||||
if (column) {
|
||||
folder.items.push(column);
|
||||
assignedColumnUuids.add(column.uuid);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return folder;
|
||||
};
|
||||
|
||||
if (!folderConfig) {
|
||||
return [
|
||||
{
|
||||
id: 'metrics-default',
|
||||
name: t('Metrics'),
|
||||
isCollapsed: false,
|
||||
items: metrics,
|
||||
},
|
||||
{
|
||||
id: 'columns-default',
|
||||
name: t('Columns'),
|
||||
isCollapsed: false,
|
||||
items: columns,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const folders = folderConfig.map(config => processFolder(config));
|
||||
|
||||
const unassignedMetrics = metrics.filter(
|
||||
metric => !assignedMetricUuids.has(metric.uuid),
|
||||
);
|
||||
const unassignedColumns = columns.filter(
|
||||
column => !assignedColumnUuids.has(column.uuid),
|
||||
);
|
||||
|
||||
if (unassignedMetrics.length > 0) {
|
||||
folders.push({
|
||||
id: 'metrics-default',
|
||||
name: t('Metrics'),
|
||||
isCollapsed: false,
|
||||
items: unassignedMetrics,
|
||||
});
|
||||
}
|
||||
|
||||
if (unassignedColumns.length > 0) {
|
||||
folders.push({
|
||||
id: 'columns-default',
|
||||
name: t('Columns'),
|
||||
isCollapsed: false,
|
||||
items: unassignedColumns,
|
||||
});
|
||||
}
|
||||
|
||||
return folders;
|
||||
};
|
||||
|
||||
export const transformDatasourceWithFolders = (
|
||||
metrics: Metric[],
|
||||
columns: DatasourcePanelColumn[],
|
||||
folderConfig: DatasourceFolder[] | undefined,
|
||||
): Folder[] => {
|
||||
const metricsWithType: MetricItem[] = metrics.map(metric => ({
|
||||
...metric,
|
||||
type: 'metric',
|
||||
}));
|
||||
const columnsWithType: ColumnItem[] = columns.map(column => ({
|
||||
...column,
|
||||
type: 'column',
|
||||
}));
|
||||
|
||||
return transformToFolderStructure(
|
||||
metricsWithType,
|
||||
columnsWithType,
|
||||
folderConfig,
|
||||
);
|
||||
};
|
||||
@@ -35,55 +35,3 @@ export function isDatasourcePanelDndItem(
|
||||
export function isSavedMetric(item: any): item is Metric {
|
||||
return item?.metric_name;
|
||||
}
|
||||
|
||||
export type DatasourcePanelColumn = {
|
||||
uuid: string;
|
||||
id?: number;
|
||||
is_dttm?: boolean | null;
|
||||
description?: string | null;
|
||||
expression?: string | null;
|
||||
is_certified?: number | null;
|
||||
column_name?: string | null;
|
||||
name?: string | null;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export type DatasourceFolder = {
|
||||
uuid: string;
|
||||
type: 'folder';
|
||||
name: string;
|
||||
description?: string;
|
||||
children?: (
|
||||
| DatasourceFolder
|
||||
| { type: 'metric'; uuid: string; name: string }
|
||||
| { type: 'column'; uuid: string; name: string }
|
||||
)[];
|
||||
};
|
||||
|
||||
export type MetricItem = Metric & {
|
||||
type: 'metric';
|
||||
};
|
||||
|
||||
export type ColumnItem = DatasourcePanelColumn & {
|
||||
type: 'column';
|
||||
};
|
||||
|
||||
export type FolderItem = MetricItem | ColumnItem;
|
||||
|
||||
export interface Folder {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
isCollapsed: boolean;
|
||||
items: FolderItem[];
|
||||
subFolders?: Folder[];
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
export interface FlattenedItem {
|
||||
type: 'header' | 'item' | 'divider';
|
||||
folderId: string;
|
||||
depth: number;
|
||||
item?: FolderItem;
|
||||
height: number;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ test('should only propagate dragging state when dragging the panel option', () =
|
||||
const { container, getByText } = render(
|
||||
<ExploreContainer>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'panel option', uuid: '1' }}
|
||||
value={{ metric_name: 'panel option' }}
|
||||
type={DndItemType.Metric}
|
||||
/>
|
||||
<OptionControlLabel
|
||||
|
||||
@@ -202,11 +202,7 @@ test('cannot drop a column that is not part of the simple column selection', ()
|
||||
type={DndItemType.Column}
|
||||
/>
|
||||
<DatasourcePanelDragOption
|
||||
value={{
|
||||
metric_name: 'metric_a',
|
||||
expression: 'AGG(metric_a)',
|
||||
uuid: '1',
|
||||
}}
|
||||
value={{ metric_name: 'metric_a', expression: 'AGG(metric_a)' }}
|
||||
type={DndItemType.Metric}
|
||||
/>
|
||||
{setup({
|
||||
@@ -381,11 +377,11 @@ describe('when disallow_adhoc_metrics is set', () => {
|
||||
type={DndItemType.Column}
|
||||
/>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'metric_a', uuid: '1' }}
|
||||
value={{ metric_name: 'metric_a' }}
|
||||
type={DndItemType.Metric}
|
||||
/>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'avg__num', uuid: '2' }}
|
||||
value={{ metric_name: 'avg__num' }}
|
||||
type={DndItemType.AdhocMetricOption}
|
||||
/>
|
||||
{setup({
|
||||
|
||||
@@ -334,7 +334,7 @@ test('cannot drop a duplicated item', () => {
|
||||
const { getByTestId } = render(
|
||||
<>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'metric_a', uuid: '1' }}
|
||||
value={{ metric_name: 'metric_a' }}
|
||||
type={DndItemType.Metric}
|
||||
/>
|
||||
<DndMetricSelect {...defaultProps} value={metricValues} multi />
|
||||
@@ -362,7 +362,7 @@ test('can drop a saved metric when disallow_adhoc_metrics', () => {
|
||||
const { getByTestId } = render(
|
||||
<>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'metric_a', uuid: '1' }}
|
||||
value={{ metric_name: 'metric_a' }}
|
||||
type={DndItemType.Metric}
|
||||
/>
|
||||
<DndMetricSelect
|
||||
@@ -395,15 +395,15 @@ test('cannot drop non-saved metrics when disallow_adhoc_metrics', () => {
|
||||
const { getByTestId, getAllByTestId } = render(
|
||||
<>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'metric_a', uuid: '1' }}
|
||||
value={{ metric_name: 'metric_a' }}
|
||||
type={DndItemType.Metric}
|
||||
/>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'metric_c', uuid: '2' }}
|
||||
value={{ metric_name: 'metric_c' }}
|
||||
type={DndItemType.Metric}
|
||||
/>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ column_name: 'column_1', uuid: '3' }}
|
||||
value={{ column_name: 'column_1' }}
|
||||
type={DndItemType.Column}
|
||||
/>
|
||||
<DndMetricSelect
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
ensureIsArray,
|
||||
GenericDataType,
|
||||
@@ -78,7 +77,6 @@ const coerceMetrics = (
|
||||
return {
|
||||
metric_name: metric,
|
||||
error_text: t('This metric might be incompatible with current dataset'),
|
||||
uuid: nanoid(),
|
||||
};
|
||||
}
|
||||
if (!isDictionaryForAdhocMetric(metric)) {
|
||||
|
||||
@@ -51,10 +51,7 @@ describe('controlUtils', () => {
|
||||
id: 1,
|
||||
type: DatasourceType.Table,
|
||||
columns: [{ column_name: 'a' }],
|
||||
metrics: [
|
||||
{ metric_name: 'first', uuid: '1' },
|
||||
{ metric_name: 'second', uuid: '2' },
|
||||
],
|
||||
metrics: [{ metric_name: 'first' }, { metric_name: 'second' }],
|
||||
column_formats: {},
|
||||
currency_formats: {},
|
||||
verbose_map: {},
|
||||
|
||||
@@ -33,7 +33,7 @@ const sampleDatasource: Dataset = {
|
||||
{ column_name: 'sample_column_3' },
|
||||
{ column_name: 'sample_column_4' },
|
||||
],
|
||||
metrics: [{ metric_name: 'saved_metric_2', uuid: '1' }],
|
||||
metrics: [{ metric_name: 'saved_metric_2' }],
|
||||
column_formats: {},
|
||||
currency_formats: {},
|
||||
verbose_map: {},
|
||||
|
||||
@@ -133,10 +133,7 @@ export const exploreInitialData: ExplorePageInitialData = {
|
||||
id: 8,
|
||||
type: DatasourceType.Table,
|
||||
columns: [{ column_name: 'a' }],
|
||||
metrics: [
|
||||
{ metric_name: 'first', uuid: '1' },
|
||||
{ metric_name: 'second', uuid: '2' },
|
||||
],
|
||||
metrics: [{ metric_name: 'first' }, { metric_name: 'second' }],
|
||||
column_formats: {},
|
||||
currency_formats: {},
|
||||
verbose_map: {},
|
||||
|
||||
@@ -41,7 +41,7 @@ export type ColumnObject = {
|
||||
|
||||
type MetricObject = {
|
||||
id: number;
|
||||
uuid: string;
|
||||
uuid: number;
|
||||
expression?: string;
|
||||
description?: string;
|
||||
metric_name: string;
|
||||
|
||||
@@ -249,11 +249,11 @@ class SyncPermissionsCommand(BaseCommand):
|
||||
self, catalog: str | None, schemas: Iterable[str]
|
||||
) -> None:
|
||||
# rename existing catalog permission
|
||||
new_catalog_perm_name = security_manager.get_catalog_perm(
|
||||
self.db_connection.name,
|
||||
catalog,
|
||||
)
|
||||
if catalog:
|
||||
new_catalog_perm_name = security_manager.get_catalog_perm(
|
||||
self.db_connection.name,
|
||||
catalog,
|
||||
)
|
||||
new_catalog_vm = add_vm(db.session, security_manager, new_catalog_perm_name)
|
||||
perm = security_manager.get_catalog_perm(
|
||||
self.old_db_connection_name,
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
import logging
|
||||
from collections import Counter
|
||||
from functools import partial
|
||||
from typing import Any, cast, Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from flask_appbuilder.models.sqla import Model
|
||||
from marshmallow import ValidationError
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from superset import is_feature_enabled, security_manager
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand, UpdateMixin
|
||||
from superset.commands.dataset.exceptions import (
|
||||
DatabaseChangeValidationError,
|
||||
@@ -39,9 +39,8 @@ from superset.commands.dataset.exceptions import (
|
||||
DatasetNotFoundError,
|
||||
DatasetUpdateFailedError,
|
||||
)
|
||||
from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
from superset.datasets.schemas import FolderSchema
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.sql_parse import Table
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
@@ -128,30 +127,16 @@ class UpdateDatasetCommand(UpdateMixin, BaseCommand):
|
||||
except ValidationError as ex:
|
||||
exceptions.append(ex)
|
||||
|
||||
self._validate_semantics(exceptions)
|
||||
|
||||
if exceptions:
|
||||
raise DatasetInvalidError(exceptions=exceptions)
|
||||
|
||||
def _validate_semantics(self, exceptions: list[ValidationError]) -> None:
|
||||
# we know we have a valid model
|
||||
self._model = cast(SqlaTable, self._model)
|
||||
|
||||
# Validate columns
|
||||
if columns := self._properties.get("columns"):
|
||||
self._validate_columns(columns, exceptions)
|
||||
|
||||
# Validate metrics
|
||||
if metrics := self._properties.get("metrics"):
|
||||
self._validate_metrics(metrics, exceptions)
|
||||
|
||||
if folders := self._properties.get("folders"):
|
||||
try:
|
||||
validate_folders(folders, self._model.metrics, self._model.columns)
|
||||
except ValidationError as ex:
|
||||
exceptions.append(ex)
|
||||
|
||||
# dump schema to convert UUID to string
|
||||
schema = FolderSchema(many=True)
|
||||
self._properties["folders"] = schema.dump(folders)
|
||||
if exceptions:
|
||||
raise DatasetInvalidError(exceptions=exceptions)
|
||||
|
||||
def _validate_columns(
|
||||
self, columns: list[dict[str, Any]], exceptions: list[ValidationError]
|
||||
@@ -204,60 +189,3 @@ class UpdateDatasetCommand(UpdateMixin, BaseCommand):
|
||||
if count > 1
|
||||
]
|
||||
return duplicates
|
||||
|
||||
|
||||
def validate_folders( # noqa: C901
|
||||
folders: list[FolderSchema],
|
||||
metrics: list[SqlMetric],
|
||||
columns: list[TableColumn],
|
||||
) -> None:
|
||||
"""
|
||||
Additional folder validation.
|
||||
|
||||
The marshmallow schema will validate the folder structure, but we still need to
|
||||
check that UUIDs are valid, names are unique and not reserved, and that there are
|
||||
no cycles.
|
||||
"""
|
||||
if not is_feature_enabled("DATASET_FOLDERS"):
|
||||
raise ValidationError("Dataset folders are not enabled")
|
||||
|
||||
existing = {
|
||||
"metric": {metric.uuid: metric.metric_name for metric in metrics},
|
||||
"column": {column.uuid: column.column_name for column in columns},
|
||||
}
|
||||
|
||||
queue: list[tuple[FolderSchema, list[str]]] = [(folder, []) for folder in folders]
|
||||
seen_uuids = set()
|
||||
seen_fqns = set() # fully qualified names
|
||||
while queue:
|
||||
obj, path = queue.pop(0)
|
||||
uuid, name, type = obj["uuid"], obj["name"], obj["type"]
|
||||
|
||||
if uuid in path:
|
||||
raise ValidationError(f"Cycle detected: {uuid} appears in its ancestry")
|
||||
|
||||
if uuid in seen_uuids:
|
||||
raise ValidationError(f"Duplicate UUID in folder structure: {uuid}")
|
||||
seen_uuids.add(uuid)
|
||||
|
||||
# folders can have duplicate name as long as they're not siblings
|
||||
fqn = tuple(path + [name])
|
||||
if type == "folder" and fqn in seen_fqns:
|
||||
raise ValidationError(f"Duplicate folder name: {name}")
|
||||
seen_fqns.add(fqn)
|
||||
|
||||
if type == "folder" and name.lower() in {
|
||||
"metrics",
|
||||
"columns",
|
||||
}:
|
||||
raise ValidationError(f"Folder cannot have name '{name}'")
|
||||
|
||||
if type in {"metric", "column"}:
|
||||
if uuid not in existing[type]:
|
||||
raise ValidationError(f"Invalid UUID for {type} '{name}': {uuid}")
|
||||
if name != existing[type][uuid]:
|
||||
raise ValidationError(f"Mismatched name '{name}' for UUID '{uuid}'")
|
||||
|
||||
if children := obj.get("children"):
|
||||
path.append(uuid)
|
||||
queue.extend((folder, path) for folder in children)
|
||||
|
||||
@@ -561,9 +561,6 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
|
||||
"SLACK_ENABLE_AVATARS": False,
|
||||
# Allow users to optionally specify date formats in email subjects, which will be parsed if enabled. # noqa: E501
|
||||
"DATE_FORMAT_IN_EMAIL_SUBJECT": False,
|
||||
# Allow metrics and columns to be grouped into (potentially nested) folders in the
|
||||
# chart builder
|
||||
"DATASET_FOLDERS": False,
|
||||
}
|
||||
|
||||
# ------------------------------
|
||||
@@ -820,6 +817,8 @@ EXPLORE_FORM_DATA_CACHE_CONFIG: CacheConfig = {
|
||||
STORE_CACHE_KEYS_IN_METADATA_DB = False
|
||||
|
||||
# CORS Options
|
||||
# NOTE: enabling this requires installing the cors-related python dependencies
|
||||
# `pip install .[cors]` or `pip install apache-superset[cors]`, depending
|
||||
ENABLE_CORS = False
|
||||
CORS_OPTIONS: dict[Any, Any] = {}
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ from sqlalchemy.sql import column, ColumnElement, literal_column, table
|
||||
from sqlalchemy.sql.elements import ColumnClause, TextClause
|
||||
from sqlalchemy.sql.expression import Label
|
||||
from sqlalchemy.sql.selectable import Alias, TableClause
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from superset import app, db, is_feature_enabled, security_manager
|
||||
from superset.commands.dataset.exceptions import DatasetNotFoundError
|
||||
@@ -401,7 +400,6 @@ class BaseDatasource(AuditMixinNullable, ImportExportMixin): # pylint: disable=
|
||||
# one to many
|
||||
"columns": [o.data for o in self.columns],
|
||||
"metrics": [o.data for o in self.metrics],
|
||||
"folders": self.folders,
|
||||
# TODO deprecate, move logic to JS
|
||||
"order_by_choices": self.order_by_choices,
|
||||
"owners": [owner.id for owner in self.owners],
|
||||
@@ -1020,7 +1018,6 @@ class TableColumn(AuditMixinNullable, ImportExportMixin, CertificationMixin, Mod
|
||||
"filterable",
|
||||
"groupby",
|
||||
"id",
|
||||
"uuid",
|
||||
"is_certified",
|
||||
"is_dttm",
|
||||
"python_date_format",
|
||||
@@ -1068,7 +1065,7 @@ class SqlMetric(AuditMixinNullable, ImportExportMixin, CertificationMixin, Model
|
||||
"extra",
|
||||
"warning_text",
|
||||
]
|
||||
update_from_object_fields = [s for s in export_fields if s != "table_id"]
|
||||
update_from_object_fields = list(s for s in export_fields if s != "table_id") # noqa: C400
|
||||
export_parent = "table"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
@@ -1120,7 +1117,6 @@ class SqlMetric(AuditMixinNullable, ImportExportMixin, CertificationMixin, Model
|
||||
"description",
|
||||
"expression",
|
||||
"id",
|
||||
"uuid",
|
||||
"is_certified",
|
||||
"metric_name",
|
||||
"warning_markdown",
|
||||
@@ -1197,7 +1193,6 @@ class SqlaTable(
|
||||
extra = Column(Text)
|
||||
normalize_columns = Column(Boolean, default=False)
|
||||
always_filter_main_dttm = Column(Boolean, default=False)
|
||||
folders = Column(JSON, nullable=True)
|
||||
|
||||
baselink = "tablemodelview"
|
||||
|
||||
|
||||
@@ -831,6 +831,7 @@ class ImportV1DatabaseExtraSchema(Schema):
|
||||
disable_drill_to_detail = fields.Boolean(required=False)
|
||||
allow_multi_catalog = fields.Boolean(required=False)
|
||||
version = fields.String(required=False, allow_none=True)
|
||||
schema_options = fields.Dict(keys=fields.Str(), values=fields.Raw())
|
||||
|
||||
|
||||
class ImportV1DatabaseSchema(Schema):
|
||||
|
||||
@@ -194,10 +194,8 @@ class DatasetRestApi(BaseSupersetModelRestApi):
|
||||
"metrics.id",
|
||||
"metrics.metric_name",
|
||||
"metrics.metric_type",
|
||||
"metrics.uuid",
|
||||
"metrics.verbose_name",
|
||||
"metrics.warning_text",
|
||||
"folders",
|
||||
"datasource_type",
|
||||
"url",
|
||||
"extra",
|
||||
@@ -623,11 +621,9 @@ class DatasetRestApi(BaseSupersetModelRestApi):
|
||||
return self.response(201, id=new_model.id, result=item)
|
||||
except DatasetInvalidError as ex:
|
||||
return self.response_422(
|
||||
message=(
|
||||
ex.normalized_messages()
|
||||
if isinstance(ex, ValidationError)
|
||||
else str(ex)
|
||||
)
|
||||
message=ex.normalized_messages()
|
||||
if isinstance(ex, ValidationError)
|
||||
else str(ex)
|
||||
)
|
||||
except DatasetCreateFailedError as ex:
|
||||
logger.error(
|
||||
@@ -1180,16 +1176,14 @@ class DatasetRestApi(BaseSupersetModelRestApi):
|
||||
|
||||
def render_item_list(item_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
(
|
||||
{
|
||||
**item,
|
||||
"rendered_expression": processor.process_template(
|
||||
item["expression"]
|
||||
),
|
||||
}
|
||||
if item.get("expression")
|
||||
else item
|
||||
)
|
||||
{
|
||||
**item,
|
||||
"rendered_expression": processor.process_template(
|
||||
item["expression"]
|
||||
),
|
||||
}
|
||||
if item.get("expression")
|
||||
else item
|
||||
for item in item_list
|
||||
]
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from typing import Any
|
||||
from dateutil.parser import isoparse
|
||||
from flask_babel import lazy_gettext as _
|
||||
from marshmallow import fields, pre_load, Schema, ValidationError
|
||||
from marshmallow.validate import Length, OneOf
|
||||
from marshmallow.validate import Length
|
||||
|
||||
from superset.exceptions import SupersetMarshmallowValidationError
|
||||
from superset.utils import json
|
||||
@@ -88,18 +88,6 @@ class DatasetMetricsPutSchema(Schema):
|
||||
uuid = fields.UUID(allow_none=True)
|
||||
|
||||
|
||||
class FolderSchema(Schema):
|
||||
uuid = fields.UUID()
|
||||
type = fields.String(
|
||||
required=False,
|
||||
validate=OneOf(["metric", "column", "folder"]),
|
||||
)
|
||||
name = fields.String(required=True, validate=Length(1, 250))
|
||||
description = fields.String(allow_none=True, validate=Length(0, 1000))
|
||||
# folder can contain metrics, columns, and subfolders:
|
||||
children = fields.List(fields.Nested(lambda: FolderSchema()), allow_none=True)
|
||||
|
||||
|
||||
class DatasetPostSchema(Schema):
|
||||
database = fields.Integer(required=True)
|
||||
catalog = fields.String(allow_none=True, validate=Length(0, 250))
|
||||
@@ -133,7 +121,6 @@ class DatasetPutSchema(Schema):
|
||||
owners = fields.List(fields.Integer())
|
||||
columns = fields.List(fields.Nested(DatasetColumnsPutSchema))
|
||||
metrics = fields.List(fields.Nested(DatasetMetricsPutSchema))
|
||||
folders = fields.List(fields.Nested(FolderSchema), required=False)
|
||||
extra = fields.String(allow_none=True)
|
||||
is_managed_externally = fields.Boolean(allow_none=True, dump_default=False)
|
||||
external_url = fields.String(allow_none=True)
|
||||
|
||||
@@ -188,8 +188,10 @@ class GSheetsEngineSpec(ShillelaghEngineSpec):
|
||||
"""
|
||||
Remove `oauth2_client_info` from `encrypted_extra`.
|
||||
"""
|
||||
if "oauth2_client_info" in params.get("encrypted_extra", {}):
|
||||
del params["encrypted_extra"]["oauth2_client_info"]
|
||||
ShillelaghEngineSpec.update_params_from_encrypted_extra(database, params)
|
||||
|
||||
if "oauth2_client_info" in params:
|
||||
del params["oauth2_client_info"]
|
||||
|
||||
@classmethod
|
||||
def get_parameters_from_uri(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# 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.
|
||||
slice_name: Items Sold
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
viz_type: big_number
|
||||
params:
|
||||
datasource: 21__table
|
||||
viz_type: big_number
|
||||
slice_id: 115
|
||||
x_axis: order_date
|
||||
metric:
|
||||
aggregate: SUM
|
||||
column:
|
||||
column_name: quantity_ordered
|
||||
description: null
|
||||
expression: null
|
||||
filterable: true
|
||||
groupby: true
|
||||
id: 914
|
||||
is_dttm: false
|
||||
python_date_format: null
|
||||
type: BIGINT
|
||||
verbose_name: null
|
||||
expressionType: SIMPLE
|
||||
hasCustomLabel: false
|
||||
isNew: false
|
||||
label: SUM(Sales)
|
||||
optionName: metric_twq59hf4ej_g70qjfmehsq
|
||||
sqlExpression: null
|
||||
adhoc_filters:
|
||||
- clause: WHERE
|
||||
comparator: No filter
|
||||
expressionType: SIMPLE
|
||||
operator: TEMPORAL_RANGE
|
||||
subject: order_date
|
||||
show_trend_line: true
|
||||
start_y_axis_at_zero: true
|
||||
color_picker:
|
||||
a: 1
|
||||
b: 135
|
||||
g: 122
|
||||
r: 0
|
||||
header_font_size: 0.4
|
||||
subheader_font_size: 0.15
|
||||
y_axis_format: SMART_NUMBER
|
||||
time_format: smart_date
|
||||
rolling_type: cumsum
|
||||
extra_form_data: {}
|
||||
dashboards:
|
||||
- 9
|
||||
query_context: '{"datasource":{"id":21,"type":"table"},"force":false,"queries":[{"filters":[{"col":"order_date","op":"TEMPORAL_RANGE","val":"No
|
||||
filter"}],"extras":{"having":"","where":""},"applied_time_extras":{},"columns":[{"columnType":"BASE_AXIS","sqlExpression":"order_date","label":"order_date","expressionType":"SQL"}],"metrics":[{"aggregate":"SUM","column":{"column_name":"quantity_ordered","description":null,"expression":null,"filterable":true,"groupby":true,"id":914,"is_dttm":false,"python_date_format":null,"type":"BIGINT","verbose_name":null},"expressionType":"SIMPLE","hasCustomLabel":false,"isNew":false,"label":"SUM(Sales)","optionName":"metric_twq59hf4ej_g70qjfmehsq","sqlExpression":null}],"annotation_layers":[],"series_limit":0,"order_desc":true,"url_params":{},"custom_params":{},"custom_form_data":{},"post_processing":[{"operation":"pivot","options":{"index":["order_date"],"columns":[],"aggregates":{"SUM(Sales)":{"operator":"mean"}},"drop_missing_columns":true}},{"operation":"cum","options":{"operator":"sum","columns":{"SUM(Sales)":"SUM(Sales)"}}},{"operation":"flatten"}]}],"form_data":{"datasource":"21__table","viz_type":"big_number","slice_id":115,"x_axis":"order_date","metric":{"aggregate":"SUM","column":{"column_name":"quantity_ordered","description":null,"expression":null,"filterable":true,"groupby":true,"id":914,"is_dttm":false,"python_date_format":null,"type":"BIGINT","verbose_name":null},"expressionType":"SIMPLE","hasCustomLabel":false,"isNew":false,"label":"SUM(Sales)","optionName":"metric_twq59hf4ej_g70qjfmehsq","sqlExpression":null},"adhoc_filters":[{"clause":"WHERE","comparator":"No
|
||||
filter","expressionType":"SIMPLE","operator":"TEMPORAL_RANGE","subject":"order_date"}],"show_trend_line":true,"start_y_axis_at_zero":true,"color_picker":{"a":1,"b":135,"g":122,"r":0},"header_font_size":0.4,"subheader_font_size":0.15,"y_axis_format":"SMART_NUMBER","time_format":"smart_date","rolling_type":"cumsum","extra_form_data":{},"dashboards":[9],"force":false,"result_format":"json","result_type":"full"},"result_format":"json","result_type":"full"}'
|
||||
cache_timeout: null
|
||||
uuid: c3d643cd-fd6f-4659-a5b7-59402487a8d0
|
||||
version: 1.0.0
|
||||
dataset_uuid: e8623bb9-5e00-f531-506a-19607f5f8005
|
||||
@@ -0,0 +1,80 @@
|
||||
# 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.
|
||||
slice_name: Items by Product Line
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
viz_type: table
|
||||
params:
|
||||
datasource: 21__table
|
||||
viz_type: table
|
||||
slice_id: 111
|
||||
query_mode: aggregate
|
||||
groupby:
|
||||
- product_line
|
||||
temporal_columns_lookup:
|
||||
order_date: true
|
||||
metrics:
|
||||
- aggregate: SUM
|
||||
column:
|
||||
column_name: quantity_ordered
|
||||
description: null
|
||||
expression: null
|
||||
filterable: true
|
||||
groupby: true
|
||||
id: 914
|
||||
is_dttm: false
|
||||
optionName: _col_QuantityOrdered
|
||||
python_date_format: null
|
||||
type: BIGINT
|
||||
verbose_name: null
|
||||
expressionType: SIMPLE
|
||||
hasCustomLabel: true
|
||||
isNew: false
|
||||
label: '# of Products Sold'
|
||||
optionName: metric_skdbciwba6g_z1r5w1pxlqj
|
||||
sqlExpression: null
|
||||
all_columns: []
|
||||
percent_metrics: null
|
||||
adhoc_filters:
|
||||
- clause: WHERE
|
||||
subject: order_date
|
||||
operator: TEMPORAL_RANGE
|
||||
comparator: No filter
|
||||
expressionType: SIMPLE
|
||||
order_by_cols: []
|
||||
row_limit: null
|
||||
order_desc: true
|
||||
table_timestamp_format: smart_date
|
||||
allow_render_html: true
|
||||
show_cell_bars: true
|
||||
color_pn: true
|
||||
comparison_color_scheme: Green
|
||||
comparison_type: values
|
||||
extra_form_data: {}
|
||||
dashboards:
|
||||
- 9
|
||||
query_context: '{"datasource":{"id":21,"type":"table"},"force":false,"queries":[{"filters":[{"col":"order_date","op":"TEMPORAL_RANGE","val":"No
|
||||
filter"}],"extras":{"having":"","where":""},"applied_time_extras":{},"columns":["product_line"],"metrics":[{"aggregate":"SUM","column":{"column_name":"quantity_ordered","description":null,"expression":null,"filterable":true,"groupby":true,"id":914,"is_dttm":false,"optionName":"_col_QuantityOrdered","python_date_format":null,"type":"BIGINT","verbose_name":null},"expressionType":"SIMPLE","hasCustomLabel":true,"isNew":false,"label":"#
|
||||
of Products Sold","optionName":"metric_skdbciwba6g_z1r5w1pxlqj","sqlExpression":null}],"orderby":[[{"aggregate":"SUM","column":{"column_name":"quantity_ordered","description":null,"expression":null,"filterable":true,"groupby":true,"id":914,"is_dttm":false,"optionName":"_col_QuantityOrdered","python_date_format":null,"type":"BIGINT","verbose_name":null},"expressionType":"SIMPLE","hasCustomLabel":true,"isNew":false,"label":"#
|
||||
of Products Sold","optionName":"metric_skdbciwba6g_z1r5w1pxlqj","sqlExpression":null},false]],"annotation_layers":[],"series_limit":0,"order_desc":true,"url_params":{},"custom_params":{},"custom_form_data":{},"post_processing":[],"time_offsets":[]}],"form_data":{"datasource":"21__table","viz_type":"table","slice_id":111,"query_mode":"aggregate","groupby":["product_line"],"temporal_columns_lookup":{"order_date":true},"metrics":[{"aggregate":"SUM","column":{"column_name":"quantity_ordered","description":null,"expression":null,"filterable":true,"groupby":true,"id":914,"is_dttm":false,"optionName":"_col_QuantityOrdered","python_date_format":null,"type":"BIGINT","verbose_name":null},"expressionType":"SIMPLE","hasCustomLabel":true,"isNew":false,"label":"#
|
||||
of Products Sold","optionName":"metric_skdbciwba6g_z1r5w1pxlqj","sqlExpression":null}],"all_columns":[],"percent_metrics":null,"adhoc_filters":[{"clause":"WHERE","subject":"order_date","operator":"TEMPORAL_RANGE","comparator":"No
|
||||
filter","expressionType":"SIMPLE"}],"order_by_cols":[],"row_limit":null,"order_desc":true,"table_timestamp_format":"smart_date","allow_render_html":true,"show_cell_bars":true,"color_pn":true,"comparison_color_scheme":"Green","comparison_type":"values","extra_form_data":{},"dashboards":[9],"force":false,"result_format":"json","result_type":"full"},"result_format":"json","result_type":"full"}'
|
||||
cache_timeout: null
|
||||
uuid: b8b7ca30-6291-44b0-bc64-ba42e2892b86
|
||||
version: 1.0.0
|
||||
dataset_uuid: e8623bb9-5e00-f531-506a-19607f5f8005
|
||||
@@ -15,6 +15,9 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
slice_name: Number of Deals (for each Combination)
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
viz_type: heatmap_v2
|
||||
params:
|
||||
adhoc_filters: []
|
||||
@@ -42,10 +45,12 @@ params:
|
||||
viz_type: heatmap_v2
|
||||
xscale_interval: null
|
||||
value_bounds:
|
||||
- null
|
||||
- null
|
||||
- null
|
||||
- null
|
||||
y_axis_format: SMART_NUMBER
|
||||
yscale_interval: null
|
||||
annotation_layers: []
|
||||
query_context: null
|
||||
cache_timeout: null
|
||||
uuid: bd20fc69-dd51-46c1-99b5-09e37a434bf1
|
||||
version: 1.0.0
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
slice_name: Overall Sales (By Product Line)
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
viz_type: pie
|
||||
params:
|
||||
adhoc_filters: []
|
||||
@@ -61,6 +64,12 @@ params:
|
||||
time_range: No filter
|
||||
url_params: {}
|
||||
viz_type: pie
|
||||
annotation_layers: []
|
||||
query_context: '{"datasource":{"id":21,"type":"table"},"force":false,"queries":[{"time_range":"No
|
||||
filter","granularity":"order_date","filters":[],"extras":{"having":"","where":""},"applied_time_extras":{},"columns":["product_line"],"metrics":[{"aggregate":"SUM","column":{"column_name":"sales","description":null,"expression":null,"filterable":true,"groupby":true,"id":917,"is_dttm":false,"optionName":"_col_Sales","python_date_format":null,"type":"DOUBLE
|
||||
PRECISION","verbose_name":null},"expressionType":"SIMPLE","hasCustomLabel":false,"isNew":false,"label":"(Sales)","optionName":"metric_3sk6pfj3m7i_64h77bs4sly","sqlExpression":null}],"annotation_layers":[],"series_limit":0,"order_desc":true,"url_params":{},"custom_params":{},"custom_form_data":{}}],"form_data":{"adhoc_filters":[],"annotation_layers":[],"color_scheme":"supersetColors","datasource":"21__table","donut":true,"granularity_sqla":"order_date","groupby":["product_line"],"innerRadius":41,"label_line":true,"label_type":"key","labels_outside":true,"metric":{"aggregate":"SUM","column":{"column_name":"sales","description":null,"expression":null,"filterable":true,"groupby":true,"id":917,"is_dttm":false,"optionName":"_col_Sales","python_date_format":null,"type":"DOUBLE
|
||||
PRECISION","verbose_name":null},"expressionType":"SIMPLE","hasCustomLabel":false,"isNew":false,"label":"(Sales)","optionName":"metric_3sk6pfj3m7i_64h77bs4sly","sqlExpression":null},"number_format":"SMART_NUMBER","outerRadius":65,"queryFields":{"groupby":"groupby","metric":"metrics"},"row_limit":null,"show_labels":true,"show_labels_threshold":2,"show_legend":false,"slice_id":120,"time_range":"No
|
||||
filter","url_params":{},"viz_type":"pie","force":false,"result_format":"json","result_type":"full"},"result_format":"json","result_type":"full"}'
|
||||
cache_timeout: null
|
||||
uuid: 09c497e0-f442-1121-c9e7-671e37750424
|
||||
version: 1.0.0
|
||||
|
||||
@@ -15,60 +15,84 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
slice_name: Proportion of Revenue by Product Line
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
viz_type: echarts_area
|
||||
params:
|
||||
adhoc_filters: []
|
||||
annotation_layers: []
|
||||
bottom_margin: auto
|
||||
color_scheme: supersetColors
|
||||
comparison_type: values
|
||||
contribution: true
|
||||
datasource: 23__table
|
||||
granularity_sqla: order_date
|
||||
groupby:
|
||||
- product_line
|
||||
label_colors: {}
|
||||
line_interpolation: linear
|
||||
metrics:
|
||||
- aggregate: SUM
|
||||
column:
|
||||
column_name: sales
|
||||
description: null
|
||||
expression: null
|
||||
filterable: true
|
||||
groupby: true
|
||||
id: 917
|
||||
is_dttm: false
|
||||
optionName: _col_Sales
|
||||
python_date_format: null
|
||||
type: DOUBLE PRECISION
|
||||
verbose_name: null
|
||||
expressionType: SIMPLE
|
||||
hasCustomLabel: false
|
||||
isNew: false
|
||||
label: (Sales)
|
||||
optionName: metric_3is69ofceho_6d0ezok7ry6
|
||||
sqlExpression: null
|
||||
order_desc: true
|
||||
queryFields:
|
||||
groupby: groupby
|
||||
metrics: metrics
|
||||
rich_tooltip: true
|
||||
rolling_type: None
|
||||
row_limit: null
|
||||
show_brush: auto
|
||||
show_legend: true
|
||||
stacked_style: stack
|
||||
time_grain_sqla: P1M
|
||||
time_range: "2003-01-01T00:00:00 : 2005-06-01T00:00:00"
|
||||
url_params: {}
|
||||
datasource: 21__table
|
||||
viz_type: echarts_area
|
||||
x_axis_format: smart_date
|
||||
x_ticks_layout: auto
|
||||
y_axis_bounds:
|
||||
- null
|
||||
- null
|
||||
slice_id: 116
|
||||
x_axis: order_date
|
||||
time_grain_sqla: P1M
|
||||
x_axis_sort_asc: true
|
||||
x_axis_sort_series: name
|
||||
x_axis_sort_series_ascending: true
|
||||
metrics:
|
||||
- aggregate: SUM
|
||||
column:
|
||||
column_name: sales
|
||||
description: null
|
||||
expression: null
|
||||
filterable: true
|
||||
groupby: true
|
||||
id: 917
|
||||
is_dttm: false
|
||||
optionName: _col_Sales
|
||||
python_date_format: null
|
||||
type: DOUBLE PRECISION
|
||||
verbose_name: null
|
||||
expressionType: SIMPLE
|
||||
hasCustomLabel: false
|
||||
isNew: false
|
||||
label: (Sales)
|
||||
optionName: metric_3is69ofceho_6d0ezok7ry6
|
||||
sqlExpression: null
|
||||
groupby:
|
||||
- product_line
|
||||
adhoc_filters:
|
||||
- clause: WHERE
|
||||
subject: order_date
|
||||
operator: TEMPORAL_RANGE
|
||||
comparator: '2003-01-01T00:00:00 : 2005-06-01T00:00:00'
|
||||
expressionType: SIMPLE
|
||||
row_limit: null
|
||||
truncate_metric: true
|
||||
show_empty_columns: true
|
||||
rolling_type: null
|
||||
comparison_type: values
|
||||
annotation_layers: []
|
||||
forecastPeriods: 10
|
||||
forecastInterval: 0.8
|
||||
x_axis_title_margin: 15
|
||||
y_axis_title_margin: 15
|
||||
y_axis_title_position: Left
|
||||
sort_series_type: sum
|
||||
color_scheme: supersetColors
|
||||
time_shift_color: true
|
||||
seriesType: line
|
||||
opacity: 0.2
|
||||
stack: Stack
|
||||
only_total: true
|
||||
markerSize: 6
|
||||
show_legend: true
|
||||
legendType: scroll
|
||||
legendOrientation: top
|
||||
x_axis_time_format: smart_date
|
||||
rich_tooltip: true
|
||||
showTooltipTotal: true
|
||||
tooltipTimeFormat: smart_date
|
||||
y_axis_format: SMART_NUMBER
|
||||
truncateXAxis: true
|
||||
extra_form_data: {}
|
||||
dashboards:
|
||||
- 9
|
||||
query_context: '{"datasource":{"id":21,"type":"table"},"force":false,"queries":[{"filters":[{"col":"order_date","op":"TEMPORAL_RANGE","val":"2003-01-01T00:00:00
|
||||
: 2005-06-01T00:00:00"}],"extras":{"time_grain_sqla":"P1M","having":"","where":""},"applied_time_extras":{},"columns":[{"timeGrain":"P1M","columnType":"BASE_AXIS","sqlExpression":"order_date","label":"order_date","expressionType":"SQL"},"product_line"],"metrics":[{"aggregate":"SUM","column":{"column_name":"sales","description":null,"expression":null,"filterable":true,"groupby":true,"id":917,"is_dttm":false,"optionName":"_col_Sales","python_date_format":null,"type":"DOUBLE
|
||||
PRECISION","verbose_name":null},"expressionType":"SIMPLE","hasCustomLabel":false,"isNew":false,"label":"(Sales)","optionName":"metric_3is69ofceho_6d0ezok7ry6","sqlExpression":null}],"orderby":[[{"aggregate":"SUM","column":{"column_name":"sales","description":null,"expression":null,"filterable":true,"groupby":true,"id":917,"is_dttm":false,"optionName":"_col_Sales","python_date_format":null,"type":"DOUBLE
|
||||
PRECISION","verbose_name":null},"expressionType":"SIMPLE","hasCustomLabel":false,"isNew":false,"label":"(Sales)","optionName":"metric_3is69ofceho_6d0ezok7ry6","sqlExpression":null},false]],"annotation_layers":[],"series_columns":["product_line"],"series_limit":0,"order_desc":true,"url_params":{},"custom_params":{},"custom_form_data":{},"time_offsets":[],"post_processing":[{"operation":"pivot","options":{"index":["order_date"],"columns":["product_line"],"aggregates":{"(Sales)":{"operator":"mean"}},"drop_missing_columns":false}},{"operation":"rename","options":{"columns":{"(Sales)":null},"level":0,"inplace":true}},{"operation":"flatten"}]}],"form_data":{"datasource":"21__table","viz_type":"echarts_area","slice_id":116,"x_axis":"order_date","time_grain_sqla":"P1M","x_axis_sort_asc":true,"x_axis_sort_series":"name","x_axis_sort_series_ascending":true,"metrics":[{"aggregate":"SUM","column":{"column_name":"sales","description":null,"expression":null,"filterable":true,"groupby":true,"id":917,"is_dttm":false,"optionName":"_col_Sales","python_date_format":null,"type":"DOUBLE
|
||||
PRECISION","verbose_name":null},"expressionType":"SIMPLE","hasCustomLabel":false,"isNew":false,"label":"(Sales)","optionName":"metric_3is69ofceho_6d0ezok7ry6","sqlExpression":null}],"groupby":["product_line"],"adhoc_filters":[{"clause":"WHERE","subject":"order_date","operator":"TEMPORAL_RANGE","comparator":"2003-01-01T00:00:00
|
||||
: 2005-06-01T00:00:00","expressionType":"SIMPLE"}],"row_limit":null,"truncate_metric":true,"show_empty_columns":true,"rolling_type":null,"comparison_type":"values","annotation_layers":[],"forecastPeriods":10,"forecastInterval":0.8,"x_axis_title_margin":15,"y_axis_title_margin":15,"y_axis_title_position":"Left","sort_series_type":"sum","color_scheme":"supersetColors","time_shift_color":true,"seriesType":"line","opacity":0.2,"stack":"Stack","only_total":true,"markerSize":6,"show_legend":true,"legendType":"scroll","legendOrientation":"top","x_axis_time_format":"smart_date","rich_tooltip":true,"showTooltipTotal":true,"tooltipTimeFormat":"smart_date","y_axis_format":"SMART_NUMBER","truncateXAxis":true,"extra_form_data":{},"dashboards":[9],"force":false,"result_format":"json","result_type":"full"},"result_format":"json","result_type":"full"}'
|
||||
cache_timeout: null
|
||||
uuid: 08aff161-f60c-4cb3-a225-dc9b1140d2e3
|
||||
version: 1.0.0
|
||||
|
||||
@@ -15,71 +15,86 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
slice_name: Quarterly Sales
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
viz_type: echarts_timeseries_bar
|
||||
params:
|
||||
adhoc_filters: []
|
||||
annotation_layers: []
|
||||
bottom_margin: auto
|
||||
color_scheme: supersetColors
|
||||
comparison_type: null
|
||||
datasource: 23__table
|
||||
granularity_sqla: order_date
|
||||
groupby: []
|
||||
label_colors:
|
||||
Classic Cars: "#5AC189"
|
||||
Motorcycles: "#666666"
|
||||
Planes: "#FCC700"
|
||||
QuantityOrdered: "#454E7C"
|
||||
SUM(Sales): "#1FA8C9"
|
||||
Ships: "#A868B7"
|
||||
Trains: "#3CCCCB"
|
||||
Trucks and Buses: "#E04355"
|
||||
Vintage Cars: "#FF7F44"
|
||||
left_margin: auto
|
||||
line_interpolation: linear
|
||||
metrics:
|
||||
- aggregate: SUM
|
||||
column:
|
||||
column_name: sales
|
||||
description: null
|
||||
expression: null
|
||||
filterable: true
|
||||
groupby: true
|
||||
id: 917
|
||||
is_dttm: false
|
||||
optionName: _col_Sales
|
||||
python_date_format: null
|
||||
type: DOUBLE PRECISION
|
||||
verbose_name: null
|
||||
expressionType: SIMPLE
|
||||
hasCustomLabel: false
|
||||
isNew: false
|
||||
label: SUM(Sales)
|
||||
optionName: metric_tjn8bh6y44_7o4etwsqhal
|
||||
sqlExpression: null
|
||||
order_desc: true
|
||||
queryFields:
|
||||
groupby: groupby
|
||||
metrics: metrics
|
||||
rich_tooltip: true
|
||||
rolling_type: null
|
||||
row_limit: 10000
|
||||
show_brush: auto
|
||||
show_legend: false
|
||||
slice_id: 668
|
||||
time_compare: null
|
||||
time_grain_sqla: P3M
|
||||
time_range: No filter
|
||||
url_params: {}
|
||||
datasource: 21__table
|
||||
viz_type: echarts_timeseries_bar
|
||||
x_axis_format: "%m/%d/%Y"
|
||||
x_axis_label: Quarter starting
|
||||
x_ticks_layout: auto
|
||||
y_axis_bounds:
|
||||
- null
|
||||
- null
|
||||
slice_id: 118
|
||||
x_axis: order_date
|
||||
time_grain_sqla: P3M
|
||||
x_axis_sort_asc: true
|
||||
x_axis_sort_series: name
|
||||
x_axis_sort_series_ascending: true
|
||||
metrics:
|
||||
- aggregate: SUM
|
||||
column:
|
||||
column_name: sales
|
||||
description: null
|
||||
expression: null
|
||||
filterable: true
|
||||
groupby: true
|
||||
id: 917
|
||||
is_dttm: false
|
||||
optionName: _col_Sales
|
||||
python_date_format: null
|
||||
type: DOUBLE PRECISION
|
||||
verbose_name: null
|
||||
expressionType: SIMPLE
|
||||
hasCustomLabel: false
|
||||
isNew: false
|
||||
label: SUM(Sales)
|
||||
optionName: metric_tjn8bh6y44_7o4etwsqhal
|
||||
sqlExpression: null
|
||||
groupby:
|
||||
- status
|
||||
adhoc_filters:
|
||||
- clause: WHERE
|
||||
subject: order_date
|
||||
operator: TEMPORAL_RANGE
|
||||
comparator: No filter
|
||||
expressionType: SIMPLE
|
||||
row_limit: 10000
|
||||
truncate_metric: true
|
||||
show_empty_columns: true
|
||||
rolling_type: null
|
||||
time_compare: null
|
||||
comparison_type: null
|
||||
annotation_layers: []
|
||||
forecastPeriods: 10
|
||||
forecastInterval: 0.8
|
||||
orientation: vertical
|
||||
x_axis_title_margin: 15
|
||||
y_axis_title_margin: 15
|
||||
y_axis_title_position: Left
|
||||
sort_series_type: sum
|
||||
color_scheme: supersetColors
|
||||
time_shift_color: true
|
||||
stack: Stack
|
||||
only_total: true
|
||||
show_legend: true
|
||||
legendType: scroll
|
||||
legendOrientation: top
|
||||
x_axis_time_format: smart_date
|
||||
y_axis_format: null
|
||||
y_axis_label: Total Sales
|
||||
y_axis_bounds:
|
||||
- null
|
||||
- null
|
||||
truncateXAxis: true
|
||||
rich_tooltip: true
|
||||
showTooltipTotal: true
|
||||
tooltipTimeFormat: smart_date
|
||||
extra_form_data: {}
|
||||
dashboards:
|
||||
- 9
|
||||
query_context: '{"datasource":{"id":21,"type":"table"},"force":false,"queries":[{"filters":[{"col":"order_date","op":"TEMPORAL_RANGE","val":"No
|
||||
filter"}],"extras":{"time_grain_sqla":"P3M","having":"","where":""},"applied_time_extras":{},"columns":[{"timeGrain":"P3M","columnType":"BASE_AXIS","sqlExpression":"order_date","label":"order_date","expressionType":"SQL"},"status"],"metrics":[{"aggregate":"SUM","column":{"column_name":"sales","description":null,"expression":null,"filterable":true,"groupby":true,"id":917,"is_dttm":false,"optionName":"_col_Sales","python_date_format":null,"type":"DOUBLE
|
||||
PRECISION","verbose_name":null},"expressionType":"SIMPLE","hasCustomLabel":false,"isNew":false,"label":"SUM(Sales)","optionName":"metric_tjn8bh6y44_7o4etwsqhal","sqlExpression":null}],"orderby":[[{"aggregate":"SUM","column":{"column_name":"sales","description":null,"expression":null,"filterable":true,"groupby":true,"id":917,"is_dttm":false,"optionName":"_col_Sales","python_date_format":null,"type":"DOUBLE
|
||||
PRECISION","verbose_name":null},"expressionType":"SIMPLE","hasCustomLabel":false,"isNew":false,"label":"SUM(Sales)","optionName":"metric_tjn8bh6y44_7o4etwsqhal","sqlExpression":null},false]],"annotation_layers":[],"row_limit":10000,"series_columns":["status"],"series_limit":0,"order_desc":true,"url_params":{},"custom_params":{},"custom_form_data":{},"time_offsets":[],"post_processing":[{"operation":"pivot","options":{"index":["order_date"],"columns":["status"],"aggregates":{"SUM(Sales)":{"operator":"mean"}},"drop_missing_columns":false}},{"operation":"rename","options":{"columns":{"SUM(Sales)":null},"level":0,"inplace":true}},{"operation":"flatten"}]}],"form_data":{"datasource":"21__table","viz_type":"echarts_timeseries_bar","slice_id":118,"x_axis":"order_date","time_grain_sqla":"P3M","x_axis_sort_asc":true,"x_axis_sort_series":"name","x_axis_sort_series_ascending":true,"metrics":[{"aggregate":"SUM","column":{"column_name":"sales","description":null,"expression":null,"filterable":true,"groupby":true,"id":917,"is_dttm":false,"optionName":"_col_Sales","python_date_format":null,"type":"DOUBLE
|
||||
PRECISION","verbose_name":null},"expressionType":"SIMPLE","hasCustomLabel":false,"isNew":false,"label":"SUM(Sales)","optionName":"metric_tjn8bh6y44_7o4etwsqhal","sqlExpression":null}],"groupby":["status"],"adhoc_filters":[{"clause":"WHERE","subject":"order_date","operator":"TEMPORAL_RANGE","comparator":"No
|
||||
filter","expressionType":"SIMPLE"}],"row_limit":10000,"truncate_metric":true,"show_empty_columns":true,"rolling_type":null,"time_compare":null,"comparison_type":null,"annotation_layers":[],"forecastPeriods":10,"forecastInterval":0.8,"orientation":"vertical","x_axis_title_margin":15,"y_axis_title_margin":15,"y_axis_title_position":"Left","sort_series_type":"sum","color_scheme":"supersetColors","time_shift_color":true,"stack":"Stack","only_total":true,"show_legend":true,"legendType":"scroll","legendOrientation":"top","x_axis_time_format":"smart_date","y_axis_format":null,"y_axis_bounds":[null,null],"truncateXAxis":true,"rich_tooltip":true,"showTooltipTotal":true,"tooltipTimeFormat":"smart_date","extra_form_data":{},"dashboards":[9],"force":false,"result_format":"json","result_type":"full"},"result_format":"json","result_type":"full"}'
|
||||
cache_timeout: null
|
||||
uuid: 692aca26-a526-85db-c94c-411c91cc1077
|
||||
version: 1.0.0
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# 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.
|
||||
slice_name: Quarterly Sales (By Product Line)
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
viz_type: echarts_timeseries_bar
|
||||
params:
|
||||
datasource: 21__table
|
||||
viz_type: echarts_timeseries_bar
|
||||
slice_id: 113
|
||||
x_axis: order_date
|
||||
time_grain_sqla: P3M
|
||||
metrics:
|
||||
- aggregate: SUM
|
||||
column:
|
||||
column_name: sales
|
||||
description: null
|
||||
expression: null
|
||||
filterable: true
|
||||
groupby: true
|
||||
id: 917
|
||||
is_dttm: false
|
||||
optionName: _col_Sales
|
||||
python_date_format: null
|
||||
type: DOUBLE PRECISION
|
||||
verbose_name: null
|
||||
expressionType: SIMPLE
|
||||
hasCustomLabel: false
|
||||
isNew: false
|
||||
label: SUM(Sales)
|
||||
optionName: metric_tjn8bh6y44_7o4etwsqhal
|
||||
sqlExpression: null
|
||||
groupby:
|
||||
- product_line
|
||||
adhoc_filters:
|
||||
- expressionType: SIMPLE
|
||||
subject: order_date
|
||||
operator: TEMPORAL_RANGE
|
||||
comparator: No filter
|
||||
clause: WHERE
|
||||
sqlExpression: null
|
||||
isExtra: false
|
||||
isNew: false
|
||||
datasourceWarning: false
|
||||
filterOptionName: filter_skx80xwzof_2l0t7nomekl
|
||||
order_desc: true
|
||||
row_limit: 10000
|
||||
truncate_metric: true
|
||||
show_empty_columns: true
|
||||
rolling_type: null
|
||||
time_compare: null
|
||||
comparison_type: null
|
||||
annotation_layers: []
|
||||
forecastPeriods: 10
|
||||
forecastInterval: 0.8
|
||||
orientation: vertical
|
||||
x_axis_title_margin: 15
|
||||
y_axis_title_margin: 15
|
||||
y_axis_title_position: Left
|
||||
sort_series_type: sum
|
||||
color_scheme: supersetColors
|
||||
time_shift_color: true
|
||||
only_total: true
|
||||
show_legend: true
|
||||
legendType: scroll
|
||||
legendOrientation: top
|
||||
x_axis_time_format: smart_date
|
||||
y_axis_format: null
|
||||
y_axis_bounds:
|
||||
- null
|
||||
- null
|
||||
truncateXAxis: true
|
||||
rich_tooltip: true
|
||||
showTooltipTotal: true
|
||||
tooltipTimeFormat: smart_date
|
||||
extra_form_data: {}
|
||||
dashboards:
|
||||
- 9
|
||||
query_context: '{"datasource":{"id":21,"type":"table"},"force":false,"queries":[{"filters":[{"col":"order_date","op":"TEMPORAL_RANGE","val":"No
|
||||
filter"}],"extras":{"time_grain_sqla":"P3M","having":"","where":""},"applied_time_extras":{},"columns":[{"timeGrain":"P3M","columnType":"BASE_AXIS","sqlExpression":"order_date","label":"order_date","expressionType":"SQL"},"product_line"],"metrics":[{"aggregate":"SUM","column":{"column_name":"sales","description":null,"expression":null,"filterable":true,"groupby":true,"id":917,"is_dttm":false,"optionName":"_col_Sales","python_date_format":null,"type":"DOUBLE
|
||||
PRECISION","verbose_name":null},"expressionType":"SIMPLE","hasCustomLabel":false,"isNew":false,"label":"SUM(Sales)","optionName":"metric_tjn8bh6y44_7o4etwsqhal","sqlExpression":null}],"orderby":[[{"aggregate":"SUM","column":{"column_name":"sales","description":null,"expression":null,"filterable":true,"groupby":true,"id":917,"is_dttm":false,"optionName":"_col_Sales","python_date_format":null,"type":"DOUBLE
|
||||
PRECISION","verbose_name":null},"expressionType":"SIMPLE","hasCustomLabel":false,"isNew":false,"label":"SUM(Sales)","optionName":"metric_tjn8bh6y44_7o4etwsqhal","sqlExpression":null},false]],"annotation_layers":[],"row_limit":10000,"series_columns":["product_line"],"series_limit":0,"order_desc":true,"url_params":{},"custom_params":{},"custom_form_data":{},"time_offsets":[],"post_processing":[{"operation":"pivot","options":{"index":["order_date"],"columns":["product_line"],"aggregates":{"SUM(Sales)":{"operator":"mean"}},"drop_missing_columns":false}},{"operation":"rename","options":{"columns":{"SUM(Sales)":null},"level":0,"inplace":true}},{"operation":"flatten"}]}],"form_data":{"datasource":"21__table","viz_type":"echarts_timeseries_bar","slice_id":113,"x_axis":"order_date","time_grain_sqla":"P3M","metrics":[{"aggregate":"SUM","column":{"column_name":"sales","description":null,"expression":null,"filterable":true,"groupby":true,"id":917,"is_dttm":false,"optionName":"_col_Sales","python_date_format":null,"type":"DOUBLE
|
||||
PRECISION","verbose_name":null},"expressionType":"SIMPLE","hasCustomLabel":false,"isNew":false,"label":"SUM(Sales)","optionName":"metric_tjn8bh6y44_7o4etwsqhal","sqlExpression":null}],"groupby":["product_line"],"adhoc_filters":[{"expressionType":"SIMPLE","subject":"order_date","operator":"TEMPORAL_RANGE","comparator":"No
|
||||
filter","clause":"WHERE","sqlExpression":null,"isExtra":false,"isNew":false,"datasourceWarning":false,"filterOptionName":"filter_skx80xwzof_2l0t7nomekl"}],"order_desc":true,"row_limit":10000,"truncate_metric":true,"show_empty_columns":true,"rolling_type":null,"time_compare":null,"comparison_type":null,"annotation_layers":[],"forecastPeriods":10,"forecastInterval":0.8,"orientation":"vertical","x_axis_title_margin":15,"y_axis_title_margin":15,"y_axis_title_position":"Left","sort_series_type":"sum","color_scheme":"supersetColors","time_shift_color":true,"only_total":true,"show_legend":true,"legendType":"scroll","legendOrientation":"top","x_axis_time_format":"smart_date","y_axis_format":null,"y_axis_bounds":[null,null],"truncateXAxis":true,"rich_tooltip":true,"showTooltipTotal":true,"tooltipTimeFormat":"smart_date","extra_form_data":{},"dashboards":[9],"force":false,"result_format":"json","result_type":"full"},"result_format":"json","result_type":"full"}'
|
||||
cache_timeout: null
|
||||
uuid: db9609e4-9b78-4a32-87a7-4d9e19d51cd8
|
||||
version: 1.0.0
|
||||
dataset_uuid: e8623bb9-5e00-f531-506a-19607f5f8005
|
||||
@@ -15,6 +15,9 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
slice_name: Revenue by Deal Size
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
viz_type: echarts_timeseries_bar
|
||||
params:
|
||||
adhoc_filters: []
|
||||
@@ -27,30 +30,30 @@ params:
|
||||
datasource: 23__table
|
||||
granularity_sqla: order_date
|
||||
groupby:
|
||||
- deal_size
|
||||
- deal_size
|
||||
label_colors: {}
|
||||
left_margin: auto
|
||||
line_interpolation: linear
|
||||
metrics:
|
||||
- aggregate: SUM
|
||||
column:
|
||||
column_name: sales
|
||||
description: null
|
||||
expression: null
|
||||
filterable: true
|
||||
groupby: true
|
||||
id: 917
|
||||
is_dttm: false
|
||||
optionName: _col_Sales
|
||||
python_date_format: null
|
||||
type: DOUBLE PRECISION
|
||||
verbose_name: null
|
||||
expressionType: SIMPLE
|
||||
hasCustomLabel: false
|
||||
isNew: false
|
||||
label: (Sales)
|
||||
optionName: metric_3is69ofceho_6d0ezok7ry6
|
||||
sqlExpression: null
|
||||
- aggregate: SUM
|
||||
column:
|
||||
column_name: sales
|
||||
description: null
|
||||
expression: null
|
||||
filterable: true
|
||||
groupby: true
|
||||
id: 917
|
||||
is_dttm: false
|
||||
optionName: _col_Sales
|
||||
python_date_format: null
|
||||
type: DOUBLE PRECISION
|
||||
verbose_name: null
|
||||
expressionType: SIMPLE
|
||||
hasCustomLabel: false
|
||||
isNew: false
|
||||
label: (Sales)
|
||||
optionName: metric_3is69ofceho_6d0ezok7ry6
|
||||
sqlExpression: null
|
||||
order_desc: true
|
||||
queryFields:
|
||||
groupby: groupby
|
||||
@@ -61,15 +64,16 @@ params:
|
||||
show_brush: auto
|
||||
show_legend: true
|
||||
time_grain_sqla: P1M
|
||||
time_range: "2003-01-01T00:00:00 : 2005-06-01T00:00:00"
|
||||
time_range: '2003-01-01T00:00:00 : 2005-06-01T00:00:00'
|
||||
url_params: {}
|
||||
viz_type: echarts_timeseries_bar
|
||||
x_axis_format: smart_date
|
||||
x_ticks_layout: auto
|
||||
y_axis_bounds:
|
||||
- null
|
||||
- null
|
||||
- null
|
||||
- null
|
||||
y_axis_format: SMART_NUMBER
|
||||
query_context: null
|
||||
cache_timeout: null
|
||||
uuid: f065a533-2e13-42b9-bd19-801a21700dff
|
||||
version: 1.0.0
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
slice_name: Seasonality of Revenue (per Product Line)
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
viz_type: horizon
|
||||
params:
|
||||
adhoc_filters: []
|
||||
@@ -53,6 +56,8 @@ params:
|
||||
time_range: No filter
|
||||
url_params: {}
|
||||
viz_type: horizon
|
||||
annotation_layers: []
|
||||
query_context: null
|
||||
cache_timeout: null
|
||||
uuid: cf0da099-b3ab-4d94-ab62-cf353ac3c611
|
||||
version: 1.0.0
|
||||
|
||||
@@ -15,12 +15,15 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
slice_name: Total Revenue
|
||||
viz_type: big_number_total
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
viz_type: big_number
|
||||
params:
|
||||
adhoc_filters: []
|
||||
datasource: 23__table
|
||||
granularity_sqla: order_date
|
||||
header_font_size: 0.4
|
||||
datasource: 21__table
|
||||
viz_type: big_number
|
||||
slice_id: 114
|
||||
x_axis: order_date
|
||||
metric:
|
||||
aggregate: SUM
|
||||
column:
|
||||
@@ -41,14 +44,35 @@ params:
|
||||
label: (Sales)
|
||||
optionName: metric_twq59hf4ej_g70qjfmehsq
|
||||
sqlExpression: null
|
||||
queryFields:
|
||||
metric: metrics
|
||||
subheader: ''
|
||||
adhoc_filters:
|
||||
- clause: WHERE
|
||||
comparator: No filter
|
||||
expressionType: SIMPLE
|
||||
operator: TEMPORAL_RANGE
|
||||
subject: order_date
|
||||
show_trend_line: true
|
||||
start_y_axis_at_zero: true
|
||||
color_picker:
|
||||
a: 1
|
||||
b: 135
|
||||
g: 122
|
||||
r: 0
|
||||
header_font_size: 0.4
|
||||
subheader_font_size: 0.15
|
||||
time_range: No filter
|
||||
url_params: {}
|
||||
viz_type: big_number_total
|
||||
y_axis_format: $,.2f
|
||||
y_axis_format: .3s
|
||||
currency_format:
|
||||
symbolPosition: prefix
|
||||
symbol: USD
|
||||
time_format: smart_date
|
||||
rolling_type: cumsum
|
||||
extra_form_data: {}
|
||||
dashboards:
|
||||
- 9
|
||||
query_context: '{"datasource":{"id":21,"type":"table"},"force":false,"queries":[{"filters":[{"col":"order_date","op":"TEMPORAL_RANGE","val":"No
|
||||
filter"}],"extras":{"having":"","where":""},"applied_time_extras":{},"columns":[{"columnType":"BASE_AXIS","sqlExpression":"order_date","label":"order_date","expressionType":"SQL"}],"metrics":[{"aggregate":"SUM","column":{"column_name":"sales","description":null,"expression":null,"filterable":true,"groupby":true,"id":917,"is_dttm":false,"optionName":"_col_Sales","python_date_format":null,"type":"DOUBLE
|
||||
PRECISION","verbose_name":null},"expressionType":"SIMPLE","hasCustomLabel":false,"isNew":false,"label":"(Sales)","optionName":"metric_twq59hf4ej_g70qjfmehsq","sqlExpression":null}],"annotation_layers":[],"series_limit":0,"order_desc":true,"url_params":{},"custom_params":{},"custom_form_data":{},"post_processing":[{"operation":"pivot","options":{"index":["order_date"],"columns":[],"aggregates":{"(Sales)":{"operator":"mean"}},"drop_missing_columns":true}},{"operation":"cum","options":{"operator":"sum","columns":{"(Sales)":"(Sales)"}}},{"operation":"flatten"}]}],"form_data":{"datasource":"21__table","viz_type":"big_number","slice_id":114,"x_axis":"order_date","metric":{"aggregate":"SUM","column":{"column_name":"sales","description":null,"expression":null,"filterable":true,"groupby":true,"id":917,"is_dttm":false,"optionName":"_col_Sales","python_date_format":null,"type":"DOUBLE
|
||||
PRECISION","verbose_name":null},"expressionType":"SIMPLE","hasCustomLabel":false,"isNew":false,"label":"(Sales)","optionName":"metric_twq59hf4ej_g70qjfmehsq","sqlExpression":null},"adhoc_filters":[{"clause":"WHERE","comparator":"No
|
||||
filter","expressionType":"SIMPLE","operator":"TEMPORAL_RANGE","subject":"order_date"}],"show_trend_line":true,"start_y_axis_at_zero":true,"color_picker":{"a":1,"b":135,"g":122,"r":0},"header_font_size":0.4,"subheader_font_size":0.15,"y_axis_format":".3s","currency_format":{"symbolPosition":"prefix","symbol":"USD"},"time_format":"smart_date","rolling_type":"cumsum","extra_form_data":{},"dashboards":[9],"force":false,"result_format":"json","result_type":"full"},"result_format":"json","result_type":"full"}'
|
||||
cache_timeout: null
|
||||
uuid: 7b12a243-88e0-4dc5-ac33-9a840bb0ac5a
|
||||
version: 1.0.0
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
# under the License.
|
||||
dashboard_title: Sales Dashboard
|
||||
description: null
|
||||
css: ""
|
||||
css: ''
|
||||
slug: null
|
||||
certified_by: ""
|
||||
certification_details: ""
|
||||
certified_by: ''
|
||||
certification_details: ''
|
||||
published: true
|
||||
uuid: 04f79081-fb49-7bac-7f14-cc76cd2ad93b
|
||||
position:
|
||||
@@ -27,195 +27,195 @@ position:
|
||||
children: []
|
||||
id: CHART-1NOOLm5YPs
|
||||
meta:
|
||||
chartId: 2805
|
||||
chartId: 115
|
||||
height: 25
|
||||
sliceName: Total Items Sold
|
||||
sliceName: Items Sold
|
||||
sliceNameOverride: Total Products Sold
|
||||
uuid: c3d643cd-fd6f-4659-a5b7-59402487a8d0
|
||||
width: 2
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
- ROW-Tyv02UA_6W
|
||||
- COLUMN-8Rp54B6ikC
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
- ROW-Tyv02UA_6W
|
||||
- COLUMN-8Rp54B6ikC
|
||||
type: CHART
|
||||
CHART-AYpv8gFi_q:
|
||||
children: []
|
||||
id: CHART-AYpv8gFi_q
|
||||
meta:
|
||||
chartId: 2810
|
||||
chartId: 112
|
||||
height: 70
|
||||
sliceName: Number of Deals (for each Combination)
|
||||
uuid: bd20fc69-dd51-46c1-99b5-09e37a434bf1
|
||||
width: 6
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-4fthLQmdX
|
||||
- ROW-0l1WcDzW3
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-4fthLQmdX
|
||||
- ROW-0l1WcDzW3
|
||||
type: CHART
|
||||
CHART-KKT9BsnUst:
|
||||
children: []
|
||||
id: CHART-KKT9BsnUst
|
||||
meta:
|
||||
chartId: 2806
|
||||
height: 59
|
||||
chartId: 113
|
||||
height: 50
|
||||
sliceName: Quarterly Sales (By Product Line)
|
||||
sliceNameOverride: Quarterly Revenue (By Product Line)
|
||||
uuid: db9609e4-9b78-4a32-87a7-4d9e19d51cd8
|
||||
width: 7
|
||||
width: 6
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
- ROW-oAtmu5grZ
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
- ROW-oAtmu5grZ
|
||||
type: CHART
|
||||
CHART-OJ9aWDmn1q:
|
||||
children: []
|
||||
id: CHART-OJ9aWDmn1q
|
||||
meta:
|
||||
chartId: 2808
|
||||
chartId: 116
|
||||
height: 70
|
||||
sliceName: Proportion of Revenue by Product Line
|
||||
sliceNameOverride: Proportion of Monthly Revenue by Product Line
|
||||
uuid: 08aff161-f60c-4cb3-a225-dc9b1140d2e3
|
||||
width: 6
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-4fthLQmdX
|
||||
- ROW-0l1WcDzW3
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-4fthLQmdX
|
||||
- ROW-0l1WcDzW3
|
||||
type: CHART
|
||||
CHART-YFg-9wHE7s:
|
||||
children: []
|
||||
id: CHART-YFg-9wHE7s
|
||||
meta:
|
||||
chartId: 2811
|
||||
chartId: 119
|
||||
height: 49
|
||||
sliceName: Seasonality of Revenue (per Product Line)
|
||||
uuid: cf0da099-b3ab-4d94-ab62-cf353ac3c611
|
||||
width: 6
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-4fthLQmdX
|
||||
- ROW-E7MDSGfnm
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-4fthLQmdX
|
||||
- ROW-E7MDSGfnm
|
||||
type: CHART
|
||||
CHART-_LMKI0D3tj:
|
||||
children: []
|
||||
id: CHART-_LMKI0D3tj
|
||||
meta:
|
||||
chartId: 2809
|
||||
chartId: 117
|
||||
height: 49
|
||||
sliceName: Revenue by Deal Size
|
||||
sliceNameOverride: Monthly Revenue by Deal SIze
|
||||
uuid: f065a533-2e13-42b9-bd19-801a21700dff
|
||||
width: 6
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-4fthLQmdX
|
||||
- ROW-E7MDSGfnm
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-4fthLQmdX
|
||||
- ROW-E7MDSGfnm
|
||||
type: CHART
|
||||
CHART-id4RGv80N-:
|
||||
children: []
|
||||
id: CHART-id4RGv80N-
|
||||
meta:
|
||||
chartId: 2807
|
||||
height: 59
|
||||
sliceName: Total Items Sold (By Product Line)
|
||||
sliceNameOverride: Total Products Sold (By Product Line)
|
||||
chartId: 111
|
||||
height: 50
|
||||
sliceName: Items by Product Line
|
||||
sliceNameOverride: Products Sold By Product Line
|
||||
uuid: b8b7ca30-6291-44b0-bc64-ba42e2892b86
|
||||
width: 2
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
- ROW-oAtmu5grZ
|
||||
- COLUMN-G6_2DvG8aK
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
- ROW-oAtmu5grZ
|
||||
- COLUMN-G6_2DvG8aK
|
||||
type: CHART
|
||||
CHART-j24u8ve41b:
|
||||
children: []
|
||||
id: CHART-j24u8ve41b
|
||||
meta:
|
||||
chartId: 670
|
||||
height: 59
|
||||
chartId: 120
|
||||
height: 50
|
||||
sliceName: Overall Sales (By Product Line)
|
||||
sliceNameOverride: Total Revenue (By Product Line)
|
||||
sliceNameOverride: Total Revenue By Product
|
||||
uuid: 09c497e0-f442-1121-c9e7-671e37750424
|
||||
width: 3
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
- ROW-oAtmu5grZ
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
- ROW-oAtmu5grZ
|
||||
type: CHART
|
||||
CHART-lFanAaYKBK:
|
||||
children: []
|
||||
id: CHART-lFanAaYKBK
|
||||
meta:
|
||||
chartId: 2804
|
||||
chartId: 114
|
||||
height: 26
|
||||
sliceName: Total Revenue
|
||||
uuid: 7b12a243-88e0-4dc5-ac33-9a840bb0ac5a
|
||||
width: 3
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
- ROW-Tyv02UA_6W
|
||||
- COLUMN-8Rp54B6ikC
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
- ROW-Tyv02UA_6W
|
||||
- COLUMN-8Rp54B6ikC
|
||||
type: CHART
|
||||
CHART-vomBOiI7U9:
|
||||
children: []
|
||||
id: CHART-vomBOiI7U9
|
||||
meta:
|
||||
chartId: 668
|
||||
chartId: 118
|
||||
height: 53
|
||||
sliceName: Quarterly Sales
|
||||
sliceNameOverride: Quarterly Revenue
|
||||
uuid: 692aca26-a526-85db-c94c-411c91cc1077
|
||||
width: 7
|
||||
width: 6
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
- ROW-Tyv02UA_6W
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
- ROW-Tyv02UA_6W
|
||||
type: CHART
|
||||
COLUMN-8Rp54B6ikC:
|
||||
children:
|
||||
- CHART-lFanAaYKBK
|
||||
- CHART-1NOOLm5YPs
|
||||
- CHART-lFanAaYKBK
|
||||
- CHART-1NOOLm5YPs
|
||||
id: COLUMN-8Rp54B6ikC
|
||||
meta:
|
||||
background: BACKGROUND_TRANSPARENT
|
||||
width: 2
|
||||
width: 3
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
- ROW-Tyv02UA_6W
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
- ROW-Tyv02UA_6W
|
||||
type: COLUMN
|
||||
COLUMN-G6_2DvG8aK:
|
||||
children:
|
||||
- CHART-id4RGv80N-
|
||||
- CHART-id4RGv80N-
|
||||
id: COLUMN-G6_2DvG8aK
|
||||
meta:
|
||||
background: BACKGROUND_TRANSPARENT
|
||||
width: 2
|
||||
width: 3
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
- ROW-oAtmu5grZ
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
- ROW-oAtmu5grZ
|
||||
type: COLUMN
|
||||
DASHBOARD_VERSION_KEY: v2
|
||||
GRID_ID:
|
||||
children: []
|
||||
id: GRID_ID
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- ROOT_ID
|
||||
type: GRID
|
||||
HEADER_ID:
|
||||
id: HEADER_ID
|
||||
@@ -226,10 +226,9 @@ position:
|
||||
children: []
|
||||
id: MARKDOWN--AtDSWnapE
|
||||
meta:
|
||||
code:
|
||||
"# \U0001F697 Vehicle Sales Dashboard \U0001F3CD\n\nThis example dashboard\
|
||||
\ provides insight into the business operations of vehicle seller. The dataset\
|
||||
\ powering this dashboard can be found [here on Kaggle](https://www.kaggle.com/kyanyoga/sample-sales-data).\n\
|
||||
code: "# \U0001F697 Vehicle Sales \U0001F3CD\n\nThis example dashboard provides\
|
||||
\ insight into the business operations of vehicle seller. The dataset powering\
|
||||
\ this dashboard can be found [here on Kaggle](https://www.kaggle.com/kyanyoga/sample-sales-data).\n\
|
||||
\n### Timeline\n\nThe dataset contains data on all orders from the 2003 and\
|
||||
\ 2004 fiscal years, and some orders from 2005.\n\n### Products Sold\n\nThis\
|
||||
\ shop mainly sells the following products:\n\n- \U0001F697 Classic Cars\n\
|
||||
@@ -239,113 +238,498 @@ position:
|
||||
height: 53
|
||||
width: 3
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
- ROW-Tyv02UA_6W
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
- ROW-Tyv02UA_6W
|
||||
type: MARKDOWN
|
||||
ROOT_ID:
|
||||
children:
|
||||
- TABS-e5Ruro0cjP
|
||||
- TABS-e5Ruro0cjP
|
||||
id: ROOT_ID
|
||||
type: ROOT
|
||||
ROW-0l1WcDzW3:
|
||||
children:
|
||||
- CHART-OJ9aWDmn1q
|
||||
- CHART-AYpv8gFi_q
|
||||
- CHART-OJ9aWDmn1q
|
||||
- CHART-AYpv8gFi_q
|
||||
id: ROW-0l1WcDzW3
|
||||
meta:
|
||||
background: BACKGROUND_TRANSPARENT
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-4fthLQmdX
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-4fthLQmdX
|
||||
type: ROW
|
||||
ROW-E7MDSGfnm:
|
||||
children:
|
||||
- CHART-YFg-9wHE7s
|
||||
- CHART-_LMKI0D3tj
|
||||
- CHART-YFg-9wHE7s
|
||||
- CHART-_LMKI0D3tj
|
||||
id: ROW-E7MDSGfnm
|
||||
meta:
|
||||
background: BACKGROUND_TRANSPARENT
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-4fthLQmdX
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-4fthLQmdX
|
||||
type: ROW
|
||||
ROW-Tyv02UA_6W:
|
||||
children:
|
||||
- COLUMN-8Rp54B6ikC
|
||||
- CHART-vomBOiI7U9
|
||||
- MARKDOWN--AtDSWnapE
|
||||
- COLUMN-8Rp54B6ikC
|
||||
- CHART-vomBOiI7U9
|
||||
- MARKDOWN--AtDSWnapE
|
||||
id: ROW-Tyv02UA_6W
|
||||
meta:
|
||||
background: BACKGROUND_TRANSPARENT
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
type: ROW
|
||||
ROW-oAtmu5grZ:
|
||||
children:
|
||||
- COLUMN-G6_2DvG8aK
|
||||
- CHART-KKT9BsnUst
|
||||
- CHART-j24u8ve41b
|
||||
- COLUMN-G6_2DvG8aK
|
||||
- CHART-KKT9BsnUst
|
||||
- CHART-j24u8ve41b
|
||||
id: ROW-oAtmu5grZ
|
||||
meta:
|
||||
background: BACKGROUND_TRANSPARENT
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- TAB-d-E0Zc1cTH
|
||||
type: ROW
|
||||
TAB-4fthLQmdX:
|
||||
children:
|
||||
- ROW-0l1WcDzW3
|
||||
- ROW-E7MDSGfnm
|
||||
- ROW-0l1WcDzW3
|
||||
- ROW-E7MDSGfnm
|
||||
id: TAB-4fthLQmdX
|
||||
meta:
|
||||
text: "\U0001F9ED Exploratory"
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
type: TAB
|
||||
TAB-d-E0Zc1cTH:
|
||||
children:
|
||||
- ROW-Tyv02UA_6W
|
||||
- ROW-oAtmu5grZ
|
||||
- ROW-Tyv02UA_6W
|
||||
- ROW-oAtmu5grZ
|
||||
id: TAB-d-E0Zc1cTH
|
||||
meta:
|
||||
text: "\U0001F3AF Sales Overview"
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
- ROOT_ID
|
||||
- TABS-e5Ruro0cjP
|
||||
type: TAB
|
||||
TABS-e5Ruro0cjP:
|
||||
children:
|
||||
- TAB-d-E0Zc1cTH
|
||||
- TAB-4fthLQmdX
|
||||
- TAB-d-E0Zc1cTH
|
||||
- TAB-4fthLQmdX
|
||||
id: TABS-e5Ruro0cjP
|
||||
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:
|
||||
Medium: "#1FA8C9"
|
||||
Small: "#454E7C"
|
||||
Large: "#5AC189"
|
||||
SUM(SALES): "#1FA8C9"
|
||||
Classic Cars: "#454E7C"
|
||||
Vintage Cars: "#5AC189"
|
||||
Motorcycles: "#FF7F44"
|
||||
Trucks and Buses: "#666666"
|
||||
Planes: "#E04355"
|
||||
Ships: "#FCC700"
|
||||
Trains: "#A868B7"
|
||||
Medium: '#1FA8C9'
|
||||
Small: '#454E7C'
|
||||
Large: '#5AC189'
|
||||
SUM(SALES): '#1FA8C9'
|
||||
Classic Cars: '#454E7C'
|
||||
Vintage Cars: '#5AC189'
|
||||
Motorcycles: '#FF7F44'
|
||||
Trucks and Buses: '#666666'
|
||||
Planes: '#E04355'
|
||||
Ships: '#FCC700'
|
||||
Trains: '#A868B7'
|
||||
native_filter_configuration:
|
||||
- id: NATIVE_FILTER-HX2lV--YaAZRQfJ_yfYB2
|
||||
controlValues:
|
||||
enableEmptyFilter: false
|
||||
defaultToFirstItem: false
|
||||
multiSelect: true
|
||||
searchAllOptions: false
|
||||
inverseSelection: false
|
||||
name: Country
|
||||
filterType: filter_select
|
||||
targets:
|
||||
- column:
|
||||
name: country
|
||||
datasetUuid: e8623bb9-5e00-f531-506a-19607f5f8005
|
||||
defaultDataMask:
|
||||
extraFormData: {}
|
||||
filterState: {}
|
||||
ownState: {}
|
||||
cascadeParentIds: []
|
||||
scope:
|
||||
rootPath:
|
||||
- ROOT_ID
|
||||
excluded: []
|
||||
type: NATIVE_FILTER
|
||||
description: ''
|
||||
chartsInScope:
|
||||
- 111
|
||||
- 112
|
||||
- 113
|
||||
- 114
|
||||
- 115
|
||||
- 116
|
||||
- 117
|
||||
- 118
|
||||
- 119
|
||||
- 120
|
||||
tabsInScope:
|
||||
- TAB-d-E0Zc1cTH
|
||||
- TAB-4fthLQmdX
|
||||
- id: NATIVE_FILTER-oCF7UtoHuDIBg44q5peth
|
||||
controlValues:
|
||||
enableEmptyFilter: false
|
||||
name: Order Quantity
|
||||
filterType: filter_range
|
||||
targets:
|
||||
- column:
|
||||
name: quantity_ordered
|
||||
datasetUuid: e8623bb9-5e00-f531-506a-19607f5f8005
|
||||
defaultDataMask:
|
||||
extraFormData: {}
|
||||
filterState: {}
|
||||
ownState: {}
|
||||
cascadeParentIds: []
|
||||
scope:
|
||||
rootPath:
|
||||
- ROOT_ID
|
||||
excluded: []
|
||||
type: NATIVE_FILTER
|
||||
description: ''
|
||||
chartsInScope:
|
||||
- 111
|
||||
- 112
|
||||
- 113
|
||||
- 114
|
||||
- 115
|
||||
- 116
|
||||
- 117
|
||||
- 118
|
||||
- 119
|
||||
- 120
|
||||
tabsInScope:
|
||||
- TAB-d-E0Zc1cTH
|
||||
- TAB-4fthLQmdX
|
||||
- id: NATIVE_FILTER-V_UJOthxN8gCeYSD0id9b
|
||||
controlValues:
|
||||
enableEmptyFilter: false
|
||||
name: Time Range
|
||||
filterType: filter_time
|
||||
targets:
|
||||
- {}
|
||||
defaultDataMask:
|
||||
extraFormData: {}
|
||||
filterState: {}
|
||||
ownState: {}
|
||||
cascadeParentIds: []
|
||||
scope:
|
||||
rootPath:
|
||||
- ROOT_ID
|
||||
excluded: []
|
||||
type: NATIVE_FILTER
|
||||
description: ''
|
||||
chartsInScope:
|
||||
- 111
|
||||
- 112
|
||||
- 113
|
||||
- 114
|
||||
- 115
|
||||
- 116
|
||||
- 117
|
||||
- 118
|
||||
- 119
|
||||
- 120
|
||||
tabsInScope:
|
||||
- TAB-d-E0Zc1cTH
|
||||
- TAB-4fthLQmdX
|
||||
- id: NATIVE_FILTER-t8xOh3el1KBWWiCIF5hIN
|
||||
controlValues:
|
||||
enableEmptyFilter: false
|
||||
name: Time Grain
|
||||
filterType: filter_timegrain
|
||||
targets:
|
||||
- datasetUuid: e8623bb9-5e00-f531-506a-19607f5f8005
|
||||
defaultDataMask:
|
||||
extraFormData: {}
|
||||
filterState: {}
|
||||
ownState: {}
|
||||
cascadeParentIds: []
|
||||
scope:
|
||||
excluded: []
|
||||
rootPath:
|
||||
- ROOT_ID
|
||||
type: NATIVE_FILTER
|
||||
description: ''
|
||||
chartsInScope:
|
||||
- 111
|
||||
- 112
|
||||
- 113
|
||||
- 114
|
||||
- 115
|
||||
- 116
|
||||
- 117
|
||||
- 118
|
||||
- 119
|
||||
- 120
|
||||
tabsInScope:
|
||||
- TAB-d-E0Zc1cTH
|
||||
- TAB-4fthLQmdX
|
||||
- id: NATIVE_FILTER-9tGcTjqhNxOgX2AEPLVil
|
||||
controlValues:
|
||||
enableEmptyFilter: false
|
||||
defaultToFirstItem: false
|
||||
multiSelect: true
|
||||
searchAllOptions: false
|
||||
inverseSelection: false
|
||||
name: Postal Code
|
||||
filterType: filter_select
|
||||
targets:
|
||||
- column:
|
||||
name: postal_code
|
||||
datasetUuid: e8623bb9-5e00-f531-506a-19607f5f8005
|
||||
defaultDataMask:
|
||||
extraFormData: {}
|
||||
filterState: {}
|
||||
ownState: {}
|
||||
cascadeParentIds: []
|
||||
scope:
|
||||
rootPath:
|
||||
- ROOT_ID
|
||||
excluded: []
|
||||
type: NATIVE_FILTER
|
||||
description: ''
|
||||
chartsInScope:
|
||||
- 111
|
||||
- 112
|
||||
- 113
|
||||
- 114
|
||||
- 115
|
||||
- 116
|
||||
- 117
|
||||
- 118
|
||||
- 119
|
||||
- 120
|
||||
tabsInScope:
|
||||
- TAB-d-E0Zc1cTH
|
||||
- TAB-4fthLQmdX
|
||||
- id: NATIVE_FILTER-pGnu5e_bg1IGz2wdzIuCA
|
||||
controlValues:
|
||||
enableEmptyFilter: false
|
||||
defaultToFirstItem: false
|
||||
multiSelect: true
|
||||
searchAllOptions: false
|
||||
inverseSelection: false
|
||||
name: State
|
||||
filterType: filter_select
|
||||
targets:
|
||||
- column:
|
||||
name: state
|
||||
datasetUuid: e8623bb9-5e00-f531-506a-19607f5f8005
|
||||
defaultDataMask:
|
||||
extraFormData: {}
|
||||
filterState: {}
|
||||
ownState: {}
|
||||
cascadeParentIds: []
|
||||
scope:
|
||||
rootPath:
|
||||
- ROOT_ID
|
||||
excluded: []
|
||||
type: NATIVE_FILTER
|
||||
description: ''
|
||||
chartsInScope:
|
||||
- 111
|
||||
- 112
|
||||
- 113
|
||||
- 114
|
||||
- 115
|
||||
- 116
|
||||
- 117
|
||||
- 118
|
||||
- 119
|
||||
- 120
|
||||
tabsInScope:
|
||||
- TAB-d-E0Zc1cTH
|
||||
- TAB-4fthLQmdX
|
||||
- id: NATIVE_FILTER-EVb_e9pndL9UByuZt0z_w
|
||||
controlValues:
|
||||
enableEmptyFilter: false
|
||||
name: MSRP
|
||||
filterType: filter_range
|
||||
targets:
|
||||
- column:
|
||||
name: msrp
|
||||
datasetUuid: e8623bb9-5e00-f531-506a-19607f5f8005
|
||||
defaultDataMask:
|
||||
extraFormData: {}
|
||||
filterState: {}
|
||||
ownState: {}
|
||||
cascadeParentIds: []
|
||||
scope:
|
||||
rootPath:
|
||||
- ROOT_ID
|
||||
excluded: []
|
||||
type: NATIVE_FILTER
|
||||
description: ''
|
||||
chartsInScope:
|
||||
- 111
|
||||
- 112
|
||||
- 113
|
||||
- 114
|
||||
- 115
|
||||
- 116
|
||||
- 117
|
||||
- 118
|
||||
- 119
|
||||
- 120
|
||||
tabsInScope:
|
||||
- TAB-d-E0Zc1cTH
|
||||
- TAB-4fthLQmdX
|
||||
shared_label_colors:
|
||||
- Classic Cars
|
||||
- Motorcycles
|
||||
- Planes
|
||||
- Ships
|
||||
- Trains
|
||||
- Trucks and Buses
|
||||
- Vintage Cars
|
||||
map_label_colors:
|
||||
Shipped: '#1FA8C9'
|
||||
Cancelled: '#454E7C'
|
||||
On Hold: '#5AC189'
|
||||
Resolved: '#FF7F44'
|
||||
In Process: '#666666'
|
||||
Disputed: '#E04355'
|
||||
color_scheme_domain:
|
||||
- '#1FA8C9'
|
||||
- '#454E7C'
|
||||
- '#5AC189'
|
||||
- '#FF7F44'
|
||||
- '#666666'
|
||||
- '#E04355'
|
||||
- '#FCC700'
|
||||
- '#A868B7'
|
||||
- '#3CCCCB'
|
||||
- '#A38F79'
|
||||
- '#8FD3E4'
|
||||
- '#A1A6BD'
|
||||
- '#ACE1C4'
|
||||
- '#FEC0A1'
|
||||
- '#B2B2B2'
|
||||
- '#EFA1AA'
|
||||
- '#FDE380'
|
||||
- '#D3B3DA'
|
||||
- '#9EE5E5'
|
||||
- '#D1C6BC'
|
||||
cross_filters_enabled: true
|
||||
chart_configuration:
|
||||
'111':
|
||||
id: 111
|
||||
crossFilters:
|
||||
scope: global
|
||||
chartsInScope:
|
||||
- 112
|
||||
- 113
|
||||
- 114
|
||||
- 115
|
||||
- 116
|
||||
- 117
|
||||
- 118
|
||||
- 119
|
||||
- 120
|
||||
'113':
|
||||
id: 113
|
||||
crossFilters:
|
||||
scope: global
|
||||
chartsInScope:
|
||||
- 111
|
||||
- 112
|
||||
- 114
|
||||
- 115
|
||||
- 116
|
||||
- 117
|
||||
- 118
|
||||
- 119
|
||||
- 120
|
||||
'116':
|
||||
id: 116
|
||||
crossFilters:
|
||||
scope: global
|
||||
chartsInScope:
|
||||
- 111
|
||||
- 112
|
||||
- 113
|
||||
- 114
|
||||
- 115
|
||||
- 117
|
||||
- 118
|
||||
- 119
|
||||
- 120
|
||||
'117':
|
||||
id: 117
|
||||
crossFilters:
|
||||
scope: global
|
||||
chartsInScope:
|
||||
- 111
|
||||
- 112
|
||||
- 113
|
||||
- 114
|
||||
- 115
|
||||
- 116
|
||||
- 118
|
||||
- 119
|
||||
- 120
|
||||
'118':
|
||||
id: 118
|
||||
crossFilters:
|
||||
scope: global
|
||||
chartsInScope:
|
||||
- 111
|
||||
- 112
|
||||
- 113
|
||||
- 114
|
||||
- 115
|
||||
- 116
|
||||
- 117
|
||||
- 119
|
||||
- 120
|
||||
'120':
|
||||
id: 120
|
||||
crossFilters:
|
||||
scope: global
|
||||
chartsInScope:
|
||||
- 111
|
||||
- 112
|
||||
- 113
|
||||
- 114
|
||||
- 115
|
||||
- 116
|
||||
- 117
|
||||
- 118
|
||||
- 119
|
||||
global_chart_configuration:
|
||||
scope:
|
||||
rootPath:
|
||||
- ROOT_ID
|
||||
excluded: []
|
||||
chartsInScope:
|
||||
- 111
|
||||
- 112
|
||||
- 113
|
||||
- 114
|
||||
- 115
|
||||
- 116
|
||||
- 117
|
||||
- 118
|
||||
- 119
|
||||
- 120
|
||||
version: 1.0.0
|
||||
|
||||
@@ -20,6 +20,7 @@ description: null
|
||||
default_endpoint: null
|
||||
offset: 0
|
||||
cache_timeout: null
|
||||
catalog: null
|
||||
schema: null
|
||||
sql: null
|
||||
params: null
|
||||
@@ -27,6 +28,8 @@ template_params: null
|
||||
filter_select_enabled: true
|
||||
fetch_values_predicate: null
|
||||
extra: null
|
||||
normalize_columns: false
|
||||
always_filter_main_dttm: false
|
||||
uuid: e8623bb9-5e00-f531-506a-19607f5f8005
|
||||
metrics:
|
||||
- metric_name: count
|
||||
@@ -35,6 +38,7 @@ metrics:
|
||||
expression: COUNT(*)
|
||||
description: null
|
||||
d3format: null
|
||||
currency: null
|
||||
extra: null
|
||||
warning_text: null
|
||||
columns:
|
||||
@@ -43,251 +47,301 @@ columns:
|
||||
is_dttm: true
|
||||
is_active: true
|
||||
type: TIMESTAMP WITHOUT TIME ZONE
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: price_each
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: DOUBLE PRECISION
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: sales
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: DOUBLE PRECISION
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: address_line1
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: address_line2
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
- column_name: contact_last_name
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
- column_name: contact_first_name
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
- column_name: quantity_ordered
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: BIGINT
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
- column_name: year
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: BIGINT
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
- column_name: postal_code
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
- column_name: customer_name
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
- column_name: deal_size
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
- column_name: state
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
- column_name: status
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: order_line_number
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: BIGINT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: quantity_ordered
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: BIGINT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: order_number
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: BIGINT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
- column_name: month
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: BIGINT
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: quarter
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: BIGINT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: year
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: BIGINT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: month
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: BIGINT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: msrp
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: BIGINT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: contact_last_name
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: contact_first_name
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: postal_code
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: customer_name
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: deal_size
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: product_code
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: product_line
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: state
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: status
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: city
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: country
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: phone
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
- column_name: territory
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: TEXT
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: null
|
||||
version: 1.0.0
|
||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||
data: https://raw.githubusercontent.com/apache-superset/examples-data/lowercase_columns_examples/datasets/examples/sales.csv
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Add folder table
|
||||
"""Add folders column to datasets
|
||||
|
||||
Revision ID: 94e7a3499973
|
||||
Revises: 74ad1125881c
|
||||
@@ -15,4 +15,4 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
Babel==2.9.1
|
||||
jinja2==3.1.5
|
||||
jinja2==3.1.6
|
||||
|
||||
@@ -1103,6 +1103,11 @@ class TestDatabaseApi(SupersetTestCase):
|
||||
assert rv.status_code == 201
|
||||
assert "sqlalchemy_form" in response["result"]["configuration_method"]
|
||||
|
||||
# Cleanup
|
||||
model = db.session.query(Database).get(response.get("id"))
|
||||
db.session.delete(model)
|
||||
db.session.commit()
|
||||
|
||||
def test_create_database_server_cert_validate(self):
|
||||
"""
|
||||
Database API: Test create server cert validation
|
||||
@@ -3153,6 +3158,59 @@ class TestDatabaseApi(SupersetTestCase):
|
||||
]
|
||||
}
|
||||
|
||||
@mock.patch("superset.commands.database.importers.v1.utils.add_permissions")
|
||||
def test_import_database_row_expansion_enabled(self, mock_add_permissions):
|
||||
"""
|
||||
Database API: Test import database with row expansion enabled.
|
||||
"""
|
||||
self.login(ADMIN_USERNAME)
|
||||
uri = "api/v1/database/import/"
|
||||
|
||||
db_config = {
|
||||
"database_name": "DB with expand rows enabled",
|
||||
"allow_csv_upload": True,
|
||||
"allow_ctas": True,
|
||||
"allow_cvas": True,
|
||||
"allow_dml": True,
|
||||
"allow_run_async": False,
|
||||
"cache_timeout": None,
|
||||
"expose_in_sqllab": True,
|
||||
"extra": {
|
||||
"schema_options": {"expand_rows": True},
|
||||
},
|
||||
"sqlalchemy_uri": "postgresql://user:pass@host1",
|
||||
"uuid": "b8a1ccd3-779d-4ab7-8ad8-9ab119d7ff90",
|
||||
"version": "1.0.0",
|
||||
}
|
||||
|
||||
buf = BytesIO()
|
||||
with ZipFile(buf, "w") as bundle:
|
||||
with bundle.open("database_export/metadata.yaml", "w") as fp:
|
||||
fp.write(yaml.safe_dump(database_metadata_config).encode())
|
||||
with bundle.open(
|
||||
"database_export/databases/DB_with_expand_rows_enabled.yaml", "w"
|
||||
) as fp:
|
||||
fp.write(yaml.safe_dump(db_config).encode())
|
||||
buf.seek(0)
|
||||
|
||||
form_data = {
|
||||
"formData": (buf, "database_export.zip"),
|
||||
"passwords": json.dumps(
|
||||
{"databases/DB_with_expand_rows_enabled.yaml": "SECRET"}
|
||||
),
|
||||
}
|
||||
rv = self.client.post(uri, data=form_data, content_type="multipart/form-data")
|
||||
response = json.loads(rv.data.decode("utf-8"))
|
||||
|
||||
assert rv.status_code == 200
|
||||
assert response == {"message": "OK"}
|
||||
|
||||
database = db.session.query(Database).filter_by(uuid=db_config["uuid"]).one()
|
||||
assert database.extra == json.dumps({"schema_options": {"expand_rows": True}})
|
||||
|
||||
db.session.delete(database)
|
||||
db.session.commit()
|
||||
|
||||
@mock.patch(
|
||||
"superset.db_engine_specs.base.BaseEngineSpec.get_function_names",
|
||||
)
|
||||
|
||||
@@ -255,7 +255,7 @@ class TestDatasetApi(SupersetTestCase):
|
||||
"table_name",
|
||||
"uuid",
|
||||
]
|
||||
assert sorted(response["result"][0]) == expected_columns
|
||||
assert sorted(list(response["result"][0].keys())) == expected_columns # noqa: C414
|
||||
|
||||
def test_get_dataset_list_gamma(self):
|
||||
"""
|
||||
@@ -1563,92 +1563,6 @@ class TestDatasetApi(SupersetTestCase):
|
||||
db.session.delete(dataset)
|
||||
db.session.commit()
|
||||
|
||||
@with_feature_flags(DATASET_FOLDERS=True)
|
||||
def test_update_dataset_add_folders(self):
|
||||
"""
|
||||
Dataset API: Test adding folders to dataset
|
||||
"""
|
||||
self.login(username="admin")
|
||||
|
||||
dataset = self.insert_default_dataset()
|
||||
dataset_data = {
|
||||
"folders": [
|
||||
{
|
||||
"type": "folder",
|
||||
"uuid": "b49ac3dd-c79b-42a4-9082-39ee74f3b369",
|
||||
"name": "My metrics",
|
||||
"children": [
|
||||
{
|
||||
"type": "metric",
|
||||
"uuid": dataset.metrics[0].uuid,
|
||||
"name": dataset.metrics[0].metric_name,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"uuid": "f5db85fa-75d6-45e5-bdce-c6194db80642",
|
||||
"name": "My columns",
|
||||
"children": [
|
||||
{
|
||||
"type": "folder",
|
||||
"uuid": "b5330233-e323-4157-b767-98b16f00ca93",
|
||||
"name": "Dimensions",
|
||||
"children": [
|
||||
{
|
||||
"type": "column",
|
||||
"uuid": dataset.columns[1].uuid,
|
||||
"name": dataset.columns[1].column_name,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
uri = f"api/v1/dataset/{dataset.id}"
|
||||
rv = self.put_assert_metric(uri, dataset_data, "put")
|
||||
assert rv.status_code == 200
|
||||
|
||||
model = db.session.query(SqlaTable).get(dataset.id)
|
||||
assert model.folders == [
|
||||
{
|
||||
"uuid": "b49ac3dd-c79b-42a4-9082-39ee74f3b369",
|
||||
"type": "folder",
|
||||
"name": "My metrics",
|
||||
"children": [
|
||||
{
|
||||
"uuid": str(dataset.metrics[0].uuid),
|
||||
"type": "metric",
|
||||
"name": "count",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"uuid": "f5db85fa-75d6-45e5-bdce-c6194db80642",
|
||||
"type": "folder",
|
||||
"name": "My columns",
|
||||
"children": [
|
||||
{
|
||||
"uuid": "b5330233-e323-4157-b767-98b16f00ca93",
|
||||
"type": "folder",
|
||||
"name": "Dimensions",
|
||||
"children": [
|
||||
{
|
||||
"uuid": str(dataset.columns[1].uuid),
|
||||
"type": "column",
|
||||
"name": "name",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
db.session.delete(dataset)
|
||||
db.session.commit()
|
||||
|
||||
def test_delete_dataset_item(self):
|
||||
"""
|
||||
Dataset API: Test delete dataset item
|
||||
|
||||
@@ -335,6 +335,54 @@ def test_rename_without_catalog(
|
||||
assert schema2_pvm.view_menu.name == f"[{database_without_catalog.name}].[schema2]"
|
||||
|
||||
|
||||
def test_rename_without_catalog_with_assets(
|
||||
mocker: MockerFixture,
|
||||
database_without_catalog: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
Test that permissions are renamed correctly when the DB connection does not support
|
||||
catalogs, and it has assets associated with it.
|
||||
"""
|
||||
database_dao = mocker.patch("superset.commands.database.update.DatabaseDAO")
|
||||
original_database = mocker.MagicMock()
|
||||
original_database.database_name = "my_db"
|
||||
database_without_catalog.database_name = "my_other_db"
|
||||
database_without_catalog.get_all_schema_names.return_value = ["schema1"]
|
||||
database_dao.update.return_value = database_without_catalog
|
||||
database_dao.find_by_id.return_value = original_database
|
||||
sync_db_perms_dao = mocker.patch(
|
||||
"superset.commands.database.sync_permissions.DatabaseDAO"
|
||||
)
|
||||
sync_db_perms_dao.find_by_id.return_value = database_without_catalog
|
||||
mocker.patch("superset.commands.database.update.get_username")
|
||||
mocker.patch("superset.security_manager.get_user_by_username")
|
||||
|
||||
dataset = mocker.MagicMock()
|
||||
chart = mocker.MagicMock()
|
||||
sync_db_perms_dao.get_datasets.return_value = [dataset]
|
||||
dataset_dao = mocker.patch("superset.commands.database.sync_permissions.DatasetDAO")
|
||||
dataset_dao.get_related_objects.return_value = {"charts": [chart]}
|
||||
|
||||
find_permission_view_menu = mocker.patch.object(
|
||||
security_manager,
|
||||
"find_permission_view_menu",
|
||||
)
|
||||
schema_pvm = mocker.MagicMock()
|
||||
schema_pvm.view_menu.name = "[my_db].[schema1]"
|
||||
find_permission_view_menu.side_effect = [
|
||||
"[my_db].[schema1]",
|
||||
schema_pvm,
|
||||
]
|
||||
|
||||
UpdateDatabaseCommand(1, {}).run()
|
||||
|
||||
assert schema_pvm.view_menu.name == f"[{database_without_catalog.name}].[schema1]"
|
||||
assert dataset.schema_perm == f"[{database_without_catalog.name}].[schema1]"
|
||||
assert dataset.catalog_perm is None
|
||||
assert chart.catalog_perm is None
|
||||
assert chart.schema_perm == f"[{database_without_catalog.name}].[schema1]"
|
||||
|
||||
|
||||
def test_update_with_oauth2(
|
||||
mocker: MockerFixture,
|
||||
database_needs_oauth2: MockerFixture,
|
||||
|
||||
@@ -14,21 +14,16 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from marshmallow import ValidationError
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset import db
|
||||
from superset.commands.dataset.exceptions import DatasetInvalidError
|
||||
from superset.commands.dataset.update import UpdateDatasetCommand, validate_folders
|
||||
from superset.commands.dataset.update import UpdateDatasetCommand
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.datasets.schemas import FolderSchema
|
||||
from superset.models.core import Database
|
||||
from tests.unit_tests.conftest import with_feature_flags
|
||||
|
||||
|
||||
@pytest.mark.usefixture("session")
|
||||
@@ -63,350 +58,3 @@ def test_update_uniqueness_error(mocker: MockerFixture) -> None:
|
||||
"schema": "qux",
|
||||
},
|
||||
).run()
|
||||
|
||||
|
||||
@with_feature_flags(DATASET_FOLDERS=True)
|
||||
def test_validate_folders(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test the folder validation.
|
||||
"""
|
||||
metrics = [mocker.MagicMock(metric_name="metric1", uuid="uuid1")]
|
||||
columns = [
|
||||
mocker.MagicMock(column_name="column1", uuid="uuid2"),
|
||||
mocker.MagicMock(column_name="column2", uuid="uuid3"),
|
||||
]
|
||||
|
||||
validate_folders(folders=[], metrics=metrics, columns=columns)
|
||||
|
||||
folders = cast(
|
||||
list[FolderSchema],
|
||||
[
|
||||
{
|
||||
"uuid": "uuid4",
|
||||
"type": "folder",
|
||||
"name": "My folder",
|
||||
"children": [
|
||||
{
|
||||
"uuid": "uuid1",
|
||||
"type": "metric",
|
||||
"name": "metric1",
|
||||
},
|
||||
{
|
||||
"uuid": "uuid2",
|
||||
"type": "column",
|
||||
"name": "column1",
|
||||
},
|
||||
{
|
||||
"uuid": "uuid3",
|
||||
"type": "column",
|
||||
"name": "column2",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
validate_folders(folders=folders, metrics=metrics, columns=columns)
|
||||
|
||||
|
||||
@with_feature_flags(DATASET_FOLDERS=True)
|
||||
def test_validate_folders_cycle(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test that we can detect cycles in the folder structure.
|
||||
"""
|
||||
folders = cast(
|
||||
list[FolderSchema],
|
||||
[
|
||||
{
|
||||
"uuid": "uuid1",
|
||||
"type": "folder",
|
||||
"name": "My folder",
|
||||
"children": [
|
||||
{
|
||||
"uuid": "uuid2",
|
||||
"type": "folder",
|
||||
"name": "My other folder",
|
||||
"children": [
|
||||
{
|
||||
"uuid": "uuid1",
|
||||
"type": "folder",
|
||||
"name": "My folder",
|
||||
"children": [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
validate_folders(folders=folders, metrics=[], columns=[])
|
||||
assert str(excinfo.value) == "Cycle detected: uuid1 appears in its ancestry"
|
||||
|
||||
|
||||
@with_feature_flags(DATASET_FOLDERS=True)
|
||||
def test_validate_folders_inter_cycle(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test that we can detect cycles between folders.
|
||||
"""
|
||||
folders = cast(
|
||||
list[FolderSchema],
|
||||
[
|
||||
{
|
||||
"uuid": "uuid1",
|
||||
"type": "folder",
|
||||
"name": "My folder",
|
||||
"children": [
|
||||
{
|
||||
"uuid": "uuid2",
|
||||
"type": "folder",
|
||||
"name": "My other folder",
|
||||
"children": [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"uuid": "uuid2",
|
||||
"type": "folder",
|
||||
"name": "My other folder",
|
||||
"children": [
|
||||
{
|
||||
"uuid": "uuid1",
|
||||
"type": "folder",
|
||||
"name": "My folder",
|
||||
"children": [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
validate_folders(folders=folders, metrics=[], columns=[])
|
||||
assert str(excinfo.value) == "Duplicate UUID in folder structure: uuid2"
|
||||
|
||||
|
||||
@with_feature_flags(DATASET_FOLDERS=True)
|
||||
def test_validate_folders_duplicates(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test that metrics and columns belong to a single folder.
|
||||
"""
|
||||
metrics = [mocker.MagicMock(metric_name="count", uuid="uuid2")]
|
||||
folders = cast(
|
||||
list[FolderSchema],
|
||||
[
|
||||
{
|
||||
"uuid": "uuid1",
|
||||
"type": "folder",
|
||||
"name": "My folder",
|
||||
"children": [
|
||||
{
|
||||
"uuid": "uuid2",
|
||||
"type": "metric",
|
||||
"name": "count",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"uuid": "uuid2",
|
||||
"type": "folder",
|
||||
"name": "My other folder",
|
||||
"children": [
|
||||
{
|
||||
"uuid": "uuid2",
|
||||
"type": "metric",
|
||||
"name": "count",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
validate_folders(folders=folders, metrics=metrics, columns=[])
|
||||
assert str(excinfo.value) == "Duplicate UUID in folder structure: uuid2"
|
||||
|
||||
|
||||
@with_feature_flags(DATASET_FOLDERS=True)
|
||||
def test_validate_folders_duplicate_name_not_siblings(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Duplicate folder names are allowed if folders are not siblings.
|
||||
"""
|
||||
folders = cast(
|
||||
list[FolderSchema],
|
||||
[
|
||||
{
|
||||
"uuid": "uuid1",
|
||||
"type": "folder",
|
||||
"name": "Sales",
|
||||
"children": [
|
||||
{
|
||||
"uuid": "uuid2",
|
||||
"type": "folder",
|
||||
"name": "Core",
|
||||
"children": [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"uuid": "uuid3",
|
||||
"type": "folder",
|
||||
"name": "Engineering",
|
||||
"children": [
|
||||
{
|
||||
"uuid": "uuid4",
|
||||
"type": "folder",
|
||||
"name": "Core",
|
||||
"children": [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
validate_folders(folders=folders, metrics=[], columns=[])
|
||||
|
||||
|
||||
@with_feature_flags(DATASET_FOLDERS=True)
|
||||
def test_validate_folders_duplicate_name_siblings(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Duplicate folder names are not allowed if folders are siblings.
|
||||
"""
|
||||
folders = cast(
|
||||
list[FolderSchema],
|
||||
[
|
||||
{
|
||||
"uuid": "uuid1",
|
||||
"type": "folder",
|
||||
"name": "Sales",
|
||||
"children": [
|
||||
{
|
||||
"uuid": "uuid2",
|
||||
"type": "folder",
|
||||
"name": "Core",
|
||||
"children": [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"uuid": "uuid3",
|
||||
"type": "folder",
|
||||
"name": "Sales",
|
||||
"children": [
|
||||
{
|
||||
"uuid": "uuid4",
|
||||
"type": "folder",
|
||||
"name": "Other",
|
||||
"children": [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
validate_folders(folders=folders, metrics=[], columns=[])
|
||||
assert str(excinfo.value) == "Duplicate folder name: Sales"
|
||||
|
||||
|
||||
@with_feature_flags(DATASET_FOLDERS=True)
|
||||
def test_validate_folders_invalid_names(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test that we can detect reserved folder names.
|
||||
"""
|
||||
folders_with_metrics = cast(
|
||||
list[FolderSchema],
|
||||
[
|
||||
{
|
||||
"uuid": "uuid1",
|
||||
"type": "folder",
|
||||
"name": "Metrics",
|
||||
"children": [],
|
||||
},
|
||||
],
|
||||
)
|
||||
folders_with_columns = cast(
|
||||
list[FolderSchema],
|
||||
[
|
||||
{
|
||||
"uuid": "uuid1",
|
||||
"type": "folder",
|
||||
"name": "Columns",
|
||||
"children": [],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
validate_folders(folders=folders_with_metrics, metrics=[], columns=[])
|
||||
assert str(excinfo.value) == "Folder cannot have name 'Metrics'"
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
validate_folders(folders=folders_with_columns, metrics=[], columns=[])
|
||||
assert str(excinfo.value) == "Folder cannot have name 'Columns'"
|
||||
|
||||
|
||||
@with_feature_flags(DATASET_FOLDERS=True)
|
||||
def test_validate_folders_invalid_uuid(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test that we can detect invalid UUIDs.
|
||||
"""
|
||||
metrics = [mocker.MagicMock(metric_name="metric1", uuid="uuid1")]
|
||||
columns = [
|
||||
mocker.MagicMock(column_name="column1", uuid="uuid2"),
|
||||
mocker.MagicMock(column_name="column2", uuid="uuid3"),
|
||||
]
|
||||
folders = cast(
|
||||
list[FolderSchema],
|
||||
[
|
||||
{
|
||||
"uuid": "uuid4",
|
||||
"type": "folder",
|
||||
"name": "My folder",
|
||||
"children": [
|
||||
{
|
||||
"uuid": "uuid2",
|
||||
"type": "metric",
|
||||
"name": "metric1",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
validate_folders(folders=folders, metrics=metrics, columns=columns)
|
||||
assert str(excinfo.value) == "Invalid UUID for metric 'metric1': uuid2"
|
||||
|
||||
|
||||
@with_feature_flags(DATASET_FOLDERS=True)
|
||||
def test_validate_folders_mismatched_name(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test that we can detect mismatched names.
|
||||
"""
|
||||
metrics = [mocker.MagicMock(metric_name="metric1", uuid="uuid1")]
|
||||
columns = [
|
||||
mocker.MagicMock(column_name="column1", uuid="uuid2"),
|
||||
mocker.MagicMock(column_name="column2", uuid="uuid3"),
|
||||
]
|
||||
folders = cast(
|
||||
list[FolderSchema],
|
||||
[
|
||||
{
|
||||
"uuid": "uuid4",
|
||||
"type": "folder",
|
||||
"name": "My folder",
|
||||
"children": [
|
||||
{
|
||||
"uuid": "uuid1",
|
||||
"type": "metric",
|
||||
"name": "metric2",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
validate_folders(folders=folders, metrics=metrics, columns=columns)
|
||||
assert str(excinfo.value) == "Mismatched name 'metric2' for UUID 'uuid1'"
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
# pylint: disable=import-outside-toplevel, invalid-name, line-too-long
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any, TYPE_CHECKING
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pandas as pd
|
||||
@@ -697,3 +697,23 @@ def test_get_oauth2_fresh_token(
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
|
||||
def test_update_params_from_encrypted_extra(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test `update_params_from_encrypted_extra`.
|
||||
"""
|
||||
from superset.db_engine_specs.gsheets import GSheetsEngineSpec
|
||||
|
||||
database = mocker.MagicMock(
|
||||
encrypted_extra=json.dumps(
|
||||
{
|
||||
"oauth2_client_info": "SECRET",
|
||||
"foo": "bar",
|
||||
}
|
||||
)
|
||||
)
|
||||
params: dict[str, Any] = {}
|
||||
|
||||
GSheetsEngineSpec.update_params_from_encrypted_extra(database, params)
|
||||
assert params == {"foo": "bar"}
|
||||
|
||||
Reference in New Issue
Block a user