Compare commits

...

3 Commits

Author SHA1 Message Date
Ville Brofeldt
2c76dd4338 fix(native-filters): show human readable time grain label in indicator (#15411)
* fix(native-filters): show human readable time grain label in indicator

* lint

* simplify

(cherry picked from commit ddcf461749)
2021-06-28 11:01:32 -07:00
Michael S. Molina
23e212a558 fix: Enlarged select filter value (#15373)
* fix: Enlarged select filter value

* fix: Makes the label take the whole width

* fix: Moves the required icon before the cascade icon

* fix: Fixes the cascading icon overlap with big texts

(cherry picked from commit 819118be13)
2021-06-28 11:01:32 -07:00
Elizabeth Thompson
e5233f5d30 fix: return query if it already exists (#15207)
* check if query exists before saving a new one

* fix test

(cherry picked from commit 58cc78d2c1)
2021-06-22 17:12:52 -07:00
11 changed files with 160 additions and 112 deletions

View File

@@ -18,11 +18,24 @@
*/ */
import React from 'react'; import React from 'react';
import { styled } from '@superset-ui/core'; import { styled } from '@superset-ui/core';
import { Form, FormItem } from 'src/components/Form';
import FilterValue from './FilterValue'; import FilterValue from './FilterValue';
import { FilterProps } from './types'; import { FilterProps } from './types';
import { checkIsMissingRequiredValue } from '../utils';
const StyledFormItem = styled(FormItem)`
& label {
width: 100%;
padding-right: ${({ theme }) => theme.gridUnit * 11}px;
}
`;
const StyledIcon = styled.div`
position: absolute;
right: 0;
`;
const StyledFilterControlTitle = styled.h4` const StyledFilterControlTitle = styled.h4`
width: 100%;
font-size: ${({ theme }) => theme.typography.sizes.s}px; font-size: ${({ theme }) => theme.typography.sizes.s}px;
color: ${({ theme }) => theme.colors.grayscale.dark1}; color: ${({ theme }) => theme.colors.grayscale.dark1};
margin: 0; margin: 0;
@@ -37,7 +50,7 @@ const StyledFilterControlTitleBox = styled.div`
margin-bottom: ${({ theme }) => theme.gridUnit}px; margin-bottom: ${({ theme }) => theme.gridUnit}px;
`; `;
const StyledFilterControlContainer = styled.div` const StyledFilterControlContainer = styled(Form)`
width: 100%; width: 100%;
`; `;
@@ -50,21 +63,34 @@ const FilterControl: React.FC<FilterProps> = ({
inView, inView,
}) => { }) => {
const { name = '<undefined>' } = filter; const { name = '<undefined>' } = filter;
const isMissingRequiredValue = checkIsMissingRequiredValue(
filter,
filter.dataMask?.filterState,
);
return ( return (
<StyledFilterControlContainer> <StyledFilterControlContainer layout="vertical">
<StyledFilterControlTitleBox> <StyledFormItem
<StyledFilterControlTitle data-test="filter-control-name"> label={
{name} <StyledFilterControlTitleBox>
</StyledFilterControlTitle> <StyledFilterControlTitle data-test="filter-control-name">
<div data-test="filter-icon">{icon}</div> {name}
</StyledFilterControlTitleBox> </StyledFilterControlTitle>
<FilterValue <StyledIcon data-test="filter-icon">{icon}</StyledIcon>
dataMaskSelected={dataMaskSelected} </StyledFilterControlTitleBox>
filter={filter} }
directPathToChild={directPathToChild} required={filter?.controlValues?.enableEmptyFilter}
onFilterSelectionChange={onFilterSelectionChange} validateStatus={isMissingRequiredValue ? 'error' : undefined}
inView={inView} >
/> <FilterValue
dataMaskSelected={dataMaskSelected}
filter={filter}
directPathToChild={directPathToChild}
onFilterSelectionChange={onFilterSelectionChange}
inView={inView}
/>
</StyledFormItem>
</StyledFilterControlContainer> </StyledFilterControlContainer>
); );
}; };

View File

@@ -19,10 +19,10 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { import {
QueryFormData, QueryFormData,
styled,
SuperChart, SuperChart,
DataMask, DataMask,
t, t,
styled,
Behavior, Behavior,
ChartDataResponseResult, ChartDataResponseResult,
JsonObject, JsonObject,
@@ -43,13 +43,14 @@ import { ClientErrorObject } from 'src/utils/getClientErrorObject';
import { FilterProps } from './types'; import { FilterProps } from './types';
import { getFormData } from '../../utils'; import { getFormData } from '../../utils';
import { useCascadingFilters } from './state'; import { useCascadingFilters } from './state';
import { checkIsMissingRequiredValue } from '../utils';
const FilterItem = styled.div` const HEIGHT = 32;
min-height: ${({ theme }) => theme.gridUnit * 11}px;
padding-bottom: ${({ theme }) => theme.gridUnit * 3}px; // Overrides superset-ui height with min-height
& > div > div { const StyledDiv = styled.div`
height: auto; & > div {
height: auto !important;
min-height: ${HEIGHT}px;
} }
`; `;
@@ -184,35 +185,27 @@ const FilterValue: React.FC<FilterProps> = ({
); );
} }
const isMissingRequiredValue = checkIsMissingRequiredValue(
filter,
filter.dataMask?.filterState,
);
return ( return (
<FilterItem data-test="form-item-value"> <StyledDiv data-test="form-item-value">
{isLoading ? ( {isLoading ? (
<Loading position="inline-centered" /> <Loading position="inline-centered" />
) : ( ) : (
<SuperChart <SuperChart
height={50} height={HEIGHT}
width="100%" width="100%"
formData={formData} formData={formData}
// For charts that don't have datasource we need workaround for empty placeholder // For charts that don't have datasource we need workaround for empty placeholder
queriesData={hasDataSource ? state : [{ data: [{}] }]} queriesData={hasDataSource ? state : [{ data: [{}] }]}
chartType={filterType} chartType={filterType}
behaviors={[Behavior.NATIVE_FILTER]} behaviors={[Behavior.NATIVE_FILTER]}
filterState={{ filterState={{ ...filter.dataMask?.filterState }}
...filter.dataMask?.filterState,
validateMessage: isMissingRequiredValue && t('Value is required'),
}}
ownState={filter.dataMask?.ownState} ownState={filter.dataMask?.ownState}
enableNoResults={metadata?.enableNoResults} enableNoResults={metadata?.enableNoResults}
isRefreshing={isRefreshing} isRefreshing={isRefreshing}
hooks={{ setDataMask, setFocusedFilter, unsetFocusedFilter }} hooks={{ setDataMask, setFocusedFilter, unsetFocusedFilter }}
/> />
)} )}
</FilterItem> </StyledDiv>
); );
}; };

View File

@@ -22,7 +22,6 @@ import {
SetDataMaskHook, SetDataMaskHook,
SuperChart, SuperChart,
AppSection, AppSection,
t,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { FormInstance } from 'antd/lib/form'; import { FormInstance } from 'antd/lib/form';
import Loading from 'src/components/Loading'; import Loading from 'src/components/Loading';
@@ -57,10 +56,6 @@ const DefaultValue: FC<DefaultValueProps> = ({
setLoading(true); setLoading(true);
} }
}, [hasDataset, queriesData]); }, [hasDataset, queriesData]);
const value = formFilter.defaultDataMask?.filterState.value;
const isMissingRequiredValue =
(value === null || value === undefined) &&
formFilter?.controlValues?.enableEmptyFilter;
return loading ? ( return loading ? (
<Loading position="inline-centered" /> <Loading position="inline-centered" />
) : ( ) : (
@@ -79,7 +74,6 @@ const DefaultValue: FC<DefaultValueProps> = ({
enableNoResults={enableNoResults} enableNoResults={enableNoResults}
filterState={{ filterState={{
...formFilter.defaultDataMask?.filterState, ...formFilter.defaultDataMask?.filterState,
validateMessage: isMissingRequiredValue && t('Value is required'),
}} }}
/> />
); );

View File

@@ -130,14 +130,24 @@ export const StyledRowFormItem = styled(FormItem)`
`; `;
export const StyledRowSubFormItem = styled(FormItem)` export const StyledRowSubFormItem = styled(FormItem)`
min-width: 50%;
& .ant-form-item-label { & .ant-form-item-label {
padding-bottom: 0; padding-bottom: 0;
} }
.ant-form-item {
margin-bottom: 0;
}
.ant-form-item-control-input-content > div > div { .ant-form-item-control-input-content > div > div {
height: auto; height: auto;
} }
.ant-form-item-extra {
display: none;
}
& .ant-form-item-control-input { & .ant-form-item-control-input {
height: auto; height: auto;
} }
@@ -749,7 +759,9 @@ const FiltersConfigForm = (
if (hasValue) { if (hasValue) {
return Promise.resolve(); return Promise.resolve();
} }
return Promise.reject(); return Promise.reject(
new Error(t('Default value is required')),
);
}, },
}, },
]} ]}
@@ -958,7 +970,7 @@ const FiltersConfigForm = (
onChange={checked => onSortChanged(checked || undefined)} onChange={checked => onSortChanged(checked || undefined)}
initialValue={hasSorting} initialValue={hasSorting}
> >
<StyledFormItem <StyledRowFormItem
name={[ name={[
'filters', 'filters',
filterId, filterId,
@@ -986,7 +998,7 @@ const FiltersConfigForm = (
onSortChanged(value) onSortChanged(value)
} }
/> />
</StyledFormItem> </StyledRowFormItem>
{hasMetrics && ( {hasMetrics && (
<StyledRowSubFormItem <StyledRowSubFormItem
name={['filters', filterId, 'sortMetric']} name={['filters', filterId, 'sortMetric']}

View File

@@ -54,8 +54,7 @@ export const useDefaultValue = (
filterToEdit?: Filter, filterToEdit?: Filter,
) => { ) => {
const [hasDefaultValue, setHasPartialDefaultValue] = useState( const [hasDefaultValue, setHasPartialDefaultValue] = useState(
!!filterToEdit?.defaultDataMask?.filterState?.value || !!filterToEdit?.defaultDataMask?.filterState?.value,
formFilter?.controlValues?.enableEmptyFilter,
); );
const [isRequired, setisRequired] = useState( const [isRequired, setisRequired] = useState(
formFilter?.controlValues?.enableEmptyFilter, formFilter?.controlValues?.enableEmptyFilter,

View File

@@ -26,11 +26,9 @@ import {
GenericDataType, GenericDataType,
JsonObject, JsonObject,
smartDateDetailedFormatter, smartDateDetailedFormatter,
styled,
t, t,
tn, tn,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { FormItem } from 'src/components/Form';
import React, { import React, {
RefObject, RefObject,
ReactElement, ReactElement,
@@ -82,10 +80,6 @@ function reducer(
} }
} }
const Error = styled.div`
color: ${({ theme }) => theme.colors.error.base};
`;
export default function PluginFilterSelect(props: PluginFilterSelectProps) { export default function PluginFilterSelect(props: PluginFilterSelectProps) {
const { const {
coltypeMap, coltypeMap,
@@ -279,57 +273,52 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
return ( return (
<Styles height={height} width={width}> <Styles height={height} width={width}>
<FormItem <StyledSelect
validateStatus={filterState.validateMessage && 'error'} allowClear
extra={<Error>{filterState.validateMessage}</Error>} // @ts-ignore
value={filterState.value || []}
disabled={isDisabled}
showSearch={showSearch}
mode={multiSelect ? 'multiple' : undefined}
placeholder={placeholderText}
onSearch={searchWrapper}
onSelect={clearSuggestionSearch}
onBlur={handleBlur}
onDropdownVisibleChange={setIsDropdownVisible}
dropdownRender={(
originNode: ReactElement & { ref?: RefObject<HTMLElement> },
) => {
if (isDropdownVisible && !wasDropdownVisible) {
originNode.ref?.current?.scrollTo({ top: 0 });
}
return originNode;
}}
onFocus={setFocusedFilter}
// @ts-ignore
onChange={handleChange}
ref={inputRef}
loading={isRefreshing}
maxTagCount={5}
menuItemSelectedIcon={<Icon iconSize="m" />}
> >
<StyledSelect {sortedData.map(row => {
allowClear const [value] = groupby.map(col => row[col]);
// @ts-ignore return (
value={filterState.value || []} // @ts-ignore
disabled={isDisabled} <Option key={`${value}`} value={value}>
showSearch={showSearch} {labelFormatter(value, datatype)}
mode={multiSelect ? 'multiple' : undefined} </Option>
placeholder={placeholderText} );
onSearch={searchWrapper} })}
onSelect={clearSuggestionSearch} {currentSuggestionSearch &&
onBlur={handleBlur} !ensureIsArray(filterState.value).some(
onDropdownVisibleChange={setIsDropdownVisible} suggestion => suggestion === currentSuggestionSearch,
dropdownRender={( ) && (
originNode: ReactElement & { ref?: RefObject<HTMLElement> }, <Option value={currentSuggestionSearch}>
) => { {`${t('Create "%s"', currentSuggestionSearch)}`}
if (isDropdownVisible && !wasDropdownVisible) { </Option>
originNode.ref?.current?.scrollTo({ top: 0 }); )}
} </StyledSelect>
return originNode;
}}
onFocus={setFocusedFilter}
// @ts-ignore
onChange={handleChange}
ref={inputRef}
loading={isRefreshing}
maxTagCount={5}
menuItemSelectedIcon={<Icon iconSize="m" />}
>
{sortedData.map(row => {
const [value] = groupby.map(col => row[col]);
return (
// @ts-ignore
<Option key={`${value}`} value={value}>
{labelFormatter(value, datatype)}
</Option>
);
})}
{currentSuggestionSearch &&
!ensureIsArray(filterState.value).some(
suggestion => suggestion === currentSuggestionSearch,
) && (
<Option value={currentSuggestionSearch}>
{`${t('Create "%s"', currentSuggestionSearch)}`}
</Option>
)}
</StyledSelect>
</FormItem>
</Styles> </Styles>
); );
} }

View File

@@ -24,7 +24,7 @@ import {
TimeGranularity, TimeGranularity,
tn, tn,
} from '@superset-ui/core'; } from '@superset-ui/core';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Select } from 'src/common/components'; import { Select } from 'src/common/components';
import { Styles, StyledSelect } from '../common'; import { Styles, StyledSelect } from '../common';
import { PluginFilterTimeGrainProps } from './types'; import { PluginFilterTimeGrainProps } from './types';
@@ -52,10 +52,22 @@ export default function PluginFilterTimegrain(
const { defaultValue, inputRef } = formData; const { defaultValue, inputRef } = formData;
const [value, setValue] = useState<string[]>(defaultValue ?? []); const [value, setValue] = useState<string[]>(defaultValue ?? []);
const durationMap = useMemo(
() =>
data.reduce(
(agg, { duration, name }: { duration: string; name: string }) => ({
...agg,
[duration]: name,
}),
{} as { [key in string]: string },
),
[JSON.stringify(data)],
);
const handleChange = (values: string[] | string | undefined | null) => { const handleChange = (values: string[] | string | undefined | null) => {
const resultValue: string[] = ensureIsArray<string>(values); const resultValue: string[] = ensureIsArray<string>(values);
const [timeGrain] = resultValue; const [timeGrain] = resultValue;
const label = timeGrain ? durationMap[timeGrain] : undefined;
const extraFormData: ExtraFormData = {}; const extraFormData: ExtraFormData = {};
if (timeGrain) { if (timeGrain) {
@@ -65,6 +77,7 @@ export default function PluginFilterTimegrain(
setDataMask({ setDataMask({
extraFormData, extraFormData,
filterState: { filterState: {
label,
value: resultValue.length ? resultValue : null, value: resultValue.length ? resultValue : null,
}, },
}); });

View File

@@ -21,7 +21,7 @@ import { Select } from 'src/common/components';
import { PluginFilterStylesProps } from './types'; import { PluginFilterStylesProps } from './types';
export const Styles = styled.div<PluginFilterStylesProps>` export const Styles = styled.div<PluginFilterStylesProps>`
height: ${({ height }) => height}px; min-height: ${({ height }) => height}px;
width: ${({ width }) => width}px; width: ${({ width }) => width}px;
`; `;

View File

@@ -2477,14 +2477,34 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
CtasMethod, query_params.get("ctas_method", CtasMethod.TABLE) CtasMethod, query_params.get("ctas_method", CtasMethod.TABLE)
) )
tmp_table_name: str = cast(str, query_params.get("tmp_table_name")) tmp_table_name: str = cast(str, query_params.get("tmp_table_name"))
client_id: str = cast( client_id: str = cast(str, query_params.get("client_id"))
str, query_params.get("client_id") or utils.shortid()[:10] client_id_or_short_id: str = cast(str, client_id or utils.shortid()[:10])
)
sql_editor_id: str = cast(str, query_params.get("sql_editor_id")) sql_editor_id: str = cast(str, query_params.get("sql_editor_id"))
tab_name: str = cast(str, query_params.get("tab")) tab_name: str = cast(str, query_params.get("tab"))
status: str = QueryStatus.PENDING if async_flag else QueryStatus.RUNNING status: str = QueryStatus.PENDING if async_flag else QueryStatus.RUNNING
user_id: int = g.user.get_id() if g.user else None
session = db.session() session = db.session()
# check to see if this query is already running
query = (
session.query(Query)
.filter_by(
client_id=client_id, user_id=user_id, sql_editor_id=sql_editor_id
)
.one_or_none()
)
if query is not None and query.status in [
QueryStatus.RUNNING,
QueryStatus.PENDING,
QueryStatus.TIMED_OUT,
]:
# return the existing query
payload = json.dumps(
{"query": query.to_dict()}, default=utils.json_int_dttm_ser
)
return json_success(payload)
mydb = session.query(Database).get(database_id) mydb = session.query(Database).get(database_id)
if not mydb: if not mydb:
return json_error_response("Database with id %i is missing.", database_id) return json_error_response("Database with id %i is missing.", database_id)
@@ -2512,8 +2532,8 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
sql_editor_id=sql_editor_id, sql_editor_id=sql_editor_id,
tmp_table_name=tmp_table_name, tmp_table_name=tmp_table_name,
tmp_schema_name=tmp_schema_name, tmp_schema_name=tmp_schema_name,
user_id=g.user.get_id() if g.user else None, user_id=user_id,
client_id=client_id, client_id=client_id_or_short_id,
) )
try: try:
session.add(query) session.add(query)

View File

@@ -228,10 +228,12 @@ class TabStateView(BaseSupersetView):
@has_access_api @has_access_api
@expose("<int:tab_state_id>/query/<client_id>", methods=["DELETE"]) @expose("<int:tab_state_id>/query/<client_id>", methods=["DELETE"])
def delete_query( # pylint: disable=no-self-use def delete_query( # pylint: disable=no-self-use
self, tab_state_id: str, client_id: str self, tab_state_id: int, client_id: str
) -> FlaskResponse: ) -> FlaskResponse:
db.session.query(Query).filter_by( db.session.query(Query).filter_by(
client_id=client_id, user_id=g.user.get_id(), sql_editor_id=tab_state_id client_id=client_id,
user_id=g.user.get_id(),
sql_editor_id=str(tab_state_id),
).delete(synchronize_session=False) ).delete(synchronize_session=False)
db.session.commit() db.session.commit()
return json_success(json.dumps("OK")) return json_success(json.dumps("OK"))

View File

@@ -1412,7 +1412,7 @@ class TestCore(SupersetTestCase):
"client_id_1", "client_id_1",
user_name=username, user_name=username,
raise_on_error=True, raise_on_error=True,
sql_editor_id=tab_state_id, sql_editor_id=str(tab_state_id),
) )
# run an orphan query (no tab) # run an orphan query (no tab)
self.run_sql( self.run_sql(