mirror of
https://github.com/apache/superset.git
synced 2026-07-28 17:42:40 +00:00
Compare commits
10 Commits
query-iden
...
no_unused_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9771ad601a | ||
|
|
6e5360b7ab | ||
|
|
2db8f809ba | ||
|
|
5912fad745 | ||
|
|
dc41c45bec | ||
|
|
88ee90c579 | ||
|
|
bbb2279644 | ||
|
|
1958df6b83 | ||
|
|
58bd3bfcf0 | ||
|
|
d6eb6e08d0 |
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -42,7 +42,7 @@ body:
|
||||
options:
|
||||
- master / latest-dev
|
||||
- "5.0.0"
|
||||
- "4.1.2"
|
||||
- "4.1.3"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
58
CHANGELOG/4.1.3.md
Normal file
58
CHANGELOG/4.1.3.md
Normal file
@@ -0,0 +1,58 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
## Change Log
|
||||
|
||||
### 4.1.3 (Thu May 29 02:31:07 2025 -0500)
|
||||
|
||||
**Database Migrations**
|
||||
|
||||
**Features**
|
||||
|
||||
**Fixes**
|
||||
|
||||
- [#33522](https://github.com/apache/superset/pull/33522) fix(Sqllab): Autocomplete got stuck in UI when open it too fast (@rebenitez1802)
|
||||
- [#33425](https://github.com/apache/superset/pull/33425) fix(table-chart): time shift is not working (@justinpark)
|
||||
- [#32414](https://github.com/apache/superset/pull/32414) fix(api): Added uuid to list api calls (@withnale)
|
||||
- [#33354](https://github.com/apache/superset/pull/33354) fix: loading examples from raw.githubusercontent.com fails with 429 errors (@mistercrunch)
|
||||
- [#32382](https://github.com/apache/superset/pull/32382) fix(pinot): revert join and subquery flags (@yuribogomolov)
|
||||
- [#32473](https://github.com/apache/superset/pull/32473) fix(plugin-chart-echarts): remove erroneous upper bound value (@villebro)
|
||||
- [#33048](https://github.com/apache/superset/pull/33048) fix: improve error type on parse error (@justinpark)
|
||||
- [#32968](https://github.com/apache/superset/pull/32968) fix(pivot-table): Revert "fix(Pivot Table): Fix column width to respect currency config (#31414)" (@justinpark)
|
||||
- [#32795](https://github.com/apache/superset/pull/32795) fix(log): store navigation path to get correct logging path (@justinpark)
|
||||
- [#33216](https://github.com/apache/superset/pull/33216) fix: Downgrade to marshmallow<4 (@amotl)
|
||||
- [#32866](https://github.com/apache/superset/pull/32866) fix: make packages PEP 625 compliant (@sadpandajoe)
|
||||
- [#32035](https://github.com/apache/superset/pull/32035) fix(fe/dashboard-list): display modifier info for `Last modified` data (@hainenber)
|
||||
- [#32708](https://github.com/apache/superset/pull/32708) fix(logging): missing path in event data (@justinpark)
|
||||
- [#32699](https://github.com/apache/superset/pull/32699) fix: Signature of Celery pruner jobs (@michael-s-molina)
|
||||
- [#32681](https://github.com/apache/superset/pull/32681) fix(log): Update recent_activity by event name (@justinpark)
|
||||
- [#32608](https://github.com/apache/superset/pull/32608) fix(welcome): perf on distinct recent activities (@justinpark)
|
||||
- [#32572](https://github.com/apache/superset/pull/32572) fix: Log table retention policy (@michael-s-molina)
|
||||
- [#32406](https://github.com/apache/superset/pull/32406) fix(model/helper): represent RLS filter clause in proper textual SQL string (@hainenber)
|
||||
- [#32240](https://github.com/apache/superset/pull/32240) fix: upgrade to 3.11.11-slim-bookworm to address critical vulnerabilities (@gpchandran)
|
||||
- [#30858](https://github.com/apache/superset/pull/30858) fix(chart data): removing query from /chart/data payload when accessing as guest user (@fisjac)
|
||||
|
||||
**Others**
|
||||
|
||||
- [#33612](https://github.com/apache/superset/pull/33612) chore: update Dockerfile - Upgrade to 3.11.12 (@gpchandran)
|
||||
- [#33435](https://github.com/apache/superset/pull/33435) docs: CVEs fixed on 4.1.2 (@sha174n)
|
||||
- [#33339](https://github.com/apache/superset/pull/33339) chore(🦾): bump python h11 0.14.0 -> 0.16.0 (@github-actions[bot])
|
||||
- [#32745](https://github.com/apache/superset/pull/32745) chore(🦾): bump python sqlglot 26.1.3 -> 26.11.1 (@github-actions[bot])
|
||||
- [#32782](https://github.com/apache/superset/pull/32782) chore: Revert "chore: bump base image in Dockerfile with `ARG PY_VER=3.11.11-slim-bookworm`" (@sadpandajoe)
|
||||
- [#32780](https://github.com/apache/superset/pull/32780) chore: bump base image in Dockerfile with `ARG PY_VER=3.11.11-slim-bookworm` (@gpchandran)
|
||||
@@ -29,7 +29,7 @@ maintainers:
|
||||
- name: craig-rueda
|
||||
email: craig@craigrueda.com
|
||||
url: https://github.com/craig-rueda
|
||||
version: 0.14.2
|
||||
version: 0.14.3
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 13.4.4
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
|
||||
|
||||
@@ -28,9 +28,9 @@ metadata:
|
||||
chart: {{ template "superset.chart" . }}
|
||||
release: {{ .Release.Name }}
|
||||
heritage: {{ .Release.Service }}
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- if .Values.init.jobAnnotations }}
|
||||
annotations: {{- toYaml .Values.init.jobAnnotations | nindent 4 }}
|
||||
{{- end }}
|
||||
@@ -44,10 +44,10 @@ spec:
|
||||
{{- if or .Values.extraLabels .Values.init.podLabels }}
|
||||
labels:
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 8 }}
|
||||
{{- toYaml .Values.extraLabels | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .Values.init.podLabels }}
|
||||
{{- toYaml .Values.init.podLabels | nindent 8 }}
|
||||
{{- toYaml .Values.init.podLabels | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
{{- with .Values.supersetCeleryBeat.podDisruptionBudget }}
|
||||
{{- if .enabled -}}
|
||||
{{- if and .minAvailable .maxUnavailable }}
|
||||
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
|
||||
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
|
||||
{{- end}}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
@@ -35,12 +35,12 @@ metadata:
|
||||
{{- toYaml $.Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .minAvailable }}
|
||||
{{- if .minAvailable }}
|
||||
minAvailable: {{ .minAvailable }}
|
||||
{{- end }}
|
||||
{{- if .maxUnavailable }}
|
||||
{{- end }}
|
||||
{{- if .maxUnavailable }}
|
||||
maxUnavailable: {{ .maxUnavailable }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "supersetCeleryBeat.selectorLabels" $ | nindent 6 }}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
{{- with .Values.supersetCeleryFlower.podDisruptionBudget }}
|
||||
{{- if .enabled -}}
|
||||
{{- if and .minAvailable .maxUnavailable }}
|
||||
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
|
||||
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
|
||||
{{- end}}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
@@ -35,12 +35,12 @@ metadata:
|
||||
{{- toYaml $.Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .minAvailable }}
|
||||
{{- if .minAvailable }}
|
||||
minAvailable: {{ .minAvailable }}
|
||||
{{- end }}
|
||||
{{- if .maxUnavailable }}
|
||||
{{- end }}
|
||||
{{- if .maxUnavailable }}
|
||||
maxUnavailable: {{ .maxUnavailable }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "supersetCeleryFlower.selectorLabels" $ | nindent 6 }}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
{{- with .Values.supersetWorker.podDisruptionBudget }}
|
||||
{{- if .enabled -}}
|
||||
{{- if and .minAvailable .maxUnavailable }}
|
||||
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
|
||||
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
|
||||
{{- end}}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
@@ -35,12 +35,12 @@ metadata:
|
||||
{{- toYaml $.Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .minAvailable }}
|
||||
{{- if .minAvailable }}
|
||||
minAvailable: {{ .minAvailable }}
|
||||
{{- end }}
|
||||
{{- if .maxUnavailable }}
|
||||
{{- end }}
|
||||
{{- if .maxUnavailable }}
|
||||
maxUnavailable: {{ .maxUnavailable }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "supersetWorker.selectorLabels" $ | nindent 6 }}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
{{- with .Values.supersetWebsockets.podDisruptionBudget }}
|
||||
{{- if .enabled -}}
|
||||
{{- if and .minAvailable .maxUnavailable }}
|
||||
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
|
||||
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
|
||||
{{- end}}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
@@ -35,12 +35,12 @@ metadata:
|
||||
{{- toYaml $.Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .minAvailable }}
|
||||
{{- if .minAvailable }}
|
||||
minAvailable: {{ .minAvailable }}
|
||||
{{- end }}
|
||||
{{- if .maxUnavailable }}
|
||||
{{- end }}
|
||||
{{- if .maxUnavailable }}
|
||||
maxUnavailable: {{ .maxUnavailable }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "supersetWebsockets.selectorLabels" $ | nindent 6 }}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
{{- with .Values.supersetNode.podDisruptionBudget }}
|
||||
{{- if .enabled -}}
|
||||
{{- if and .minAvailable .maxUnavailable }}
|
||||
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
|
||||
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
|
||||
{{- end}}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
@@ -35,12 +35,12 @@ metadata:
|
||||
{{- toYaml $.Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .minAvailable }}
|
||||
{{- if .minAvailable }}
|
||||
minAvailable: {{ .minAvailable }}
|
||||
{{- end }}
|
||||
{{- if .maxUnavailable }}
|
||||
{{- end }}
|
||||
{{- if .maxUnavailable }}
|
||||
maxUnavailable: {{ .maxUnavailable }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "supersetNode.selectorLabels" $ | nindent 6 }}
|
||||
|
||||
@@ -163,7 +163,7 @@ module.exports = {
|
||||
'@typescript-eslint/no-non-null-assertion': 0, // disabled temporarily
|
||||
'@typescript-eslint/explicit-function-return-type': 0,
|
||||
'@typescript-eslint/explicit-module-boundary-types': 0, // re-enable up for discussion
|
||||
'@typescript-eslint/no-unused-vars': 'warn', // downgrade to Warning severity for Jest v30 upgrade
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
camelcase: 0,
|
||||
'class-methods-use-this': 0,
|
||||
'func-names': 0,
|
||||
|
||||
13
superset-frontend/package-lock.json
generated
13
superset-frontend/package-lock.json
generated
@@ -135,6 +135,7 @@
|
||||
"use-event-callback": "^0.1.0",
|
||||
"use-immer": "^0.9.0",
|
||||
"use-query-params": "^1.1.9",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -56626,6 +56627,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.20.3",
|
||||
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
|
||||
@@ -104,12 +104,12 @@
|
||||
"@superset-ui/legacy-plugin-chart-world-map": "file:./plugins/legacy-plugin-chart-world-map",
|
||||
"@superset-ui/legacy-preset-chart-deckgl": "file:./plugins/legacy-preset-chart-deckgl",
|
||||
"@superset-ui/legacy-preset-chart-nvd3": "file:./plugins/legacy-preset-chart-nvd3",
|
||||
"@superset-ui/plugin-chart-ag-grid-table": "file:./plugins/plugin-chart-ag-grid-table",
|
||||
"@superset-ui/plugin-chart-cartodiagram": "file:./plugins/plugin-chart-cartodiagram",
|
||||
"@superset-ui/plugin-chart-echarts": "file:./plugins/plugin-chart-echarts",
|
||||
"@superset-ui/plugin-chart-handlebars": "file:./plugins/plugin-chart-handlebars",
|
||||
"@superset-ui/plugin-chart-pivot-table": "file:./plugins/plugin-chart-pivot-table",
|
||||
"@superset-ui/plugin-chart-table": "file:./plugins/plugin-chart-table",
|
||||
"@superset-ui/plugin-chart-ag-grid-table": "file:./plugins/plugin-chart-ag-grid-table",
|
||||
"@superset-ui/plugin-chart-word-cloud": "file:./plugins/plugin-chart-word-cloud",
|
||||
"@superset-ui/switchboard": "file:./packages/superset-ui-switchboard",
|
||||
"@types/d3-format": "^3.0.1",
|
||||
@@ -203,6 +203,7 @@
|
||||
"use-event-callback": "^0.1.0",
|
||||
"use-immer": "^0.9.0",
|
||||
"use-query-params": "^1.1.9",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -33,7 +33,6 @@ export function Label(props: LabelProps) {
|
||||
onClick,
|
||||
children,
|
||||
icon,
|
||||
id,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
|
||||
@@ -27,8 +27,6 @@ const DEFAULT_TIMEZONE = {
|
||||
value: 'Africa/Abidjan', // timezones are deduped by the first alphabetical value
|
||||
};
|
||||
|
||||
const MIN_SELECT_WIDTH = '400px';
|
||||
|
||||
const offsetsToName = {
|
||||
'-300-240': ['Eastern Standard Time', 'Eastern Daylight Time'],
|
||||
'-360-300': ['Central Standard Time', 'Central Daylight Time'],
|
||||
@@ -48,13 +46,11 @@ const offsetsToName = {
|
||||
export type TimezoneSelectorProps = {
|
||||
onTimezoneChange: (value: string) => void;
|
||||
timezone?: string | null;
|
||||
minWidth?: string;
|
||||
};
|
||||
|
||||
export default function TimezoneSelector({
|
||||
onTimezoneChange,
|
||||
timezone,
|
||||
minWidth = MIN_SELECT_WIDTH, // smallest size for current values
|
||||
...rest
|
||||
}: TimezoneSelectorProps) {
|
||||
const { TIMEZONE_OPTIONS, TIMEZONE_OPTIONS_SORT_COMPARATOR, validTimezone } =
|
||||
@@ -156,6 +152,7 @@ export default function TimezoneSelector({
|
||||
value={validTimezone}
|
||||
options={TIMEZONE_OPTIONS}
|
||||
sortComparator={TIMEZONE_OPTIONS_SORT_COMPARATOR}
|
||||
style={{ minWidth }}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -265,11 +265,7 @@ const DeckMulti = (props: DeckMultiProps) => {
|
||||
);
|
||||
|
||||
const loadLayers = useCallback(
|
||||
(
|
||||
formData: QueryFormData,
|
||||
payload: JsonObject,
|
||||
viewport?: Viewport,
|
||||
): void => {
|
||||
(formData: QueryFormData, payload: JsonObject): void => {
|
||||
setViewport(getAdjustedViewport());
|
||||
setSubSlicesLayers({});
|
||||
|
||||
|
||||
@@ -138,7 +138,6 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
defaultAlignPN: alignPositiveNegative,
|
||||
showCellBars,
|
||||
colorPositiveNegative,
|
||||
totals,
|
||||
columnColorFormatters,
|
||||
allowRearrangeColumns,
|
||||
basicColorFormatters,
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
import { ColDef } from 'ag-grid-community';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { DataRecord, GenericDataType } from '@superset-ui/core';
|
||||
import { GenericDataType } from '@superset-ui/core';
|
||||
import { ColorFormatters } from '@superset-ui/chart-controls';
|
||||
import { extent as d3Extent, max as d3Max } from 'd3-array';
|
||||
import {
|
||||
@@ -49,7 +49,6 @@ type UseColDefsProps = {
|
||||
defaultAlignPN: boolean;
|
||||
showCellBars: boolean;
|
||||
colorPositiveNegative: boolean;
|
||||
totals: DataRecord | undefined;
|
||||
columnColorFormatters: ColorFormatters;
|
||||
allowRearrangeColumns?: boolean;
|
||||
basicColorFormatters?: { [Key: string]: BasicColorFormatterType }[];
|
||||
@@ -136,7 +135,6 @@ export const useColDefs = ({
|
||||
defaultAlignPN,
|
||||
showCellBars,
|
||||
colorPositiveNegative,
|
||||
totals,
|
||||
columnColorFormatters,
|
||||
allowRearrangeColumns,
|
||||
basicColorFormatters,
|
||||
|
||||
@@ -336,13 +336,8 @@ const config: ControlPanelConfig = {
|
||||
description: t(
|
||||
'Stack in groups, where each group corresponds to a dimension',
|
||||
),
|
||||
shouldMapStateToProps: (
|
||||
prevState,
|
||||
state,
|
||||
controlState,
|
||||
chartState,
|
||||
) => true,
|
||||
mapStateToProps: (state, controlState, chartState) => {
|
||||
shouldMapStateToProps: () => true,
|
||||
mapStateToProps: state => {
|
||||
const value: JsonArray = state.controls.groupby
|
||||
.value as JsonArray;
|
||||
const valueAsStringArr: string[][] = value.map(v => {
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
LocalStorageKeys,
|
||||
setItem,
|
||||
} from 'src/utils/localStorageHelpers';
|
||||
import { noop } from 'lodash';
|
||||
import TableElement from '../TableElement';
|
||||
|
||||
export interface SqlEditorLeftBarProps {
|
||||
@@ -86,7 +85,7 @@ const SqlEditorLeftBar = ({
|
||||
'schema',
|
||||
]);
|
||||
|
||||
const [_emptyResultsWithSearch, setEmptyResultsWithSearch] = useState(false);
|
||||
const [, setEmptyResultsWithSearch] = useState(false);
|
||||
const [userSelectedDb, setUserSelected] = useState<DatabaseObject | null>(
|
||||
null,
|
||||
);
|
||||
@@ -99,8 +98,6 @@ const SqlEditorLeftBar = ({
|
||||
[allSelectedTables, dbId, schema],
|
||||
);
|
||||
|
||||
noop(_emptyResultsWithSearch); // This is to avoid unused variable warning, can be removed if not needed
|
||||
|
||||
useEffect(() => {
|
||||
const bool = new URLSearchParams(window.location.search).get('db');
|
||||
const userSelected = getItem(
|
||||
|
||||
@@ -23,7 +23,6 @@ import { ErrorAlert } from './ErrorAlert';
|
||||
|
||||
export function FrontendNetworkErrorMessage({
|
||||
error,
|
||||
subtitle,
|
||||
compact,
|
||||
}: ErrorMessageComponentProps) {
|
||||
const { level, message } = error;
|
||||
|
||||
@@ -59,6 +59,7 @@ type SliceHeaderProps = SliceHeaderControlsProps & {
|
||||
formData: object;
|
||||
width: number;
|
||||
height: number;
|
||||
exportPivotExcel?: (arg0: string) => void;
|
||||
};
|
||||
|
||||
const annotationsLoading = t('Annotation layers are still loading.');
|
||||
@@ -167,6 +168,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
formData,
|
||||
width,
|
||||
height,
|
||||
exportPivotExcel = () => ({}),
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -344,6 +346,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
formData={formData}
|
||||
exploreUrl={exploreUrl}
|
||||
crossFiltersEnabled={isCrossFiltersEnabled}
|
||||
exportPivotExcel={exportPivotExcel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -33,6 +33,7 @@ const createProps = (viz_type = VizType.Sunburst) =>
|
||||
exportFullCSV: jest.fn(),
|
||||
exportXLSX: jest.fn(),
|
||||
exportFullXLSX: jest.fn(),
|
||||
exportPivotExcel: jest.fn(),
|
||||
forceRefresh: jest.fn(),
|
||||
handleToggleFullSize: jest.fn(),
|
||||
toggleExpandSlice: jest.fn(),
|
||||
@@ -254,6 +255,20 @@ test('Should not show export full Excel if report is not table', async () => {
|
||||
expect(screen.queryByText('Export to full Excel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Should export to pivoted Excel if report is pivot table', async () => {
|
||||
const props = createProps(VizType.PivotTable);
|
||||
renderWrapper(props);
|
||||
openMenu();
|
||||
expect(props.exportPivotExcel).toHaveBeenCalledTimes(0);
|
||||
userEvent.hover(screen.getByText('Download'));
|
||||
userEvent.click(await screen.findByText('Export to Pivoted Excel'));
|
||||
expect(props.exportPivotExcel).toHaveBeenCalledTimes(1);
|
||||
expect(props.exportPivotExcel).toHaveBeenCalledWith(
|
||||
'.pvtTable',
|
||||
props.slice.slice_name,
|
||||
);
|
||||
});
|
||||
|
||||
test('Should "Show chart description"', () => {
|
||||
const props = createProps();
|
||||
renderWrapper(props);
|
||||
|
||||
@@ -128,6 +128,7 @@ export interface SliceHeaderControlsProps {
|
||||
exportXLSX?: (sliceId: number) => void;
|
||||
exportFullXLSX?: (sliceId: number) => void;
|
||||
handleToggleFullSize: () => void;
|
||||
exportPivotExcel?: (tableSelector: string, sliceName: string) => void;
|
||||
|
||||
addDangerToast: (message: string) => void;
|
||||
addSuccessToast: (message: string) => void;
|
||||
@@ -255,6 +256,10 @@ const SliceHeaderControls = (
|
||||
});
|
||||
break;
|
||||
}
|
||||
case MenuKeys.ExportPivotXlsx: {
|
||||
props.exportPivotExcel?.('.pvtTable', props.slice.slice_name);
|
||||
break;
|
||||
}
|
||||
case MenuKeys.CrossFilterScoping: {
|
||||
openScopingModal();
|
||||
break;
|
||||
@@ -468,6 +473,15 @@ const SliceHeaderControls = (
|
||||
{t('Export to Excel')}
|
||||
</Menu.Item>
|
||||
|
||||
{isPivotTable && (
|
||||
<Menu.Item
|
||||
key={MenuKeys.ExportPivotXlsx}
|
||||
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
|
||||
>
|
||||
{t('Export to Pivoted Excel')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{isFeatureEnabled(FeatureFlag.AllowFullCsvExport) &&
|
||||
props.supersetCanCSV &&
|
||||
isTable && (
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import { postFormData } from 'src/explore/exploreUtils/formData';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { enforceSharedLabelsColorsArray } from 'src/utils/colorScheme';
|
||||
import exportPivotExcel from 'src/utils/downloadAsPivotExcel';
|
||||
|
||||
import SliceHeader from '../SliceHeader';
|
||||
import MissingChart from '../MissingChart';
|
||||
@@ -471,6 +472,7 @@ const Chart = props => {
|
||||
formData={formData}
|
||||
width={width}
|
||||
height={getHeaderHeight()}
|
||||
exportPivotExcel={exportPivotExcel}
|
||||
/>
|
||||
|
||||
{/*
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function DownloadAsImage({
|
||||
text,
|
||||
logEvent,
|
||||
dashboardTitle,
|
||||
...props
|
||||
}: {
|
||||
text: string;
|
||||
dashboardTitle: string;
|
||||
@@ -50,6 +51,7 @@ export default function DownloadAsImage({
|
||||
onClick={e => {
|
||||
onDownloadImage(e.domEvent);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{text}
|
||||
</Menu.Item>
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function DownloadAsPdf({
|
||||
text,
|
||||
logEvent,
|
||||
dashboardTitle,
|
||||
...props
|
||||
}: {
|
||||
text: string;
|
||||
dashboardTitle: string;
|
||||
@@ -50,6 +51,7 @@ export default function DownloadAsPdf({
|
||||
onClick={e => {
|
||||
onDownloadPdf(e.domEvent);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{text}
|
||||
</Menu.Item>
|
||||
|
||||
@@ -35,7 +35,7 @@ interface TitleContainerProps {
|
||||
const FILTER_TYPE = 'FILTER';
|
||||
|
||||
const Container = styled.div<TitleContainerProps>`
|
||||
${({ isDragging, theme }) => `
|
||||
${({ isDragging }) => `
|
||||
opacity: ${isDragging ? 0.3 : 1};
|
||||
cursor: ${isDragging ? 'grabbing' : 'pointer'};
|
||||
width: 100%;
|
||||
|
||||
@@ -289,4 +289,5 @@ export enum MenuKeys {
|
||||
ToggleFullscreen = 'toggle_fullscreen',
|
||||
ManageEmbedded = 'manage_embedded',
|
||||
ManageEmailReports = 'manage_email_reports',
|
||||
ExportPivotXlsx = 'export_pivot_xlsx',
|
||||
}
|
||||
|
||||
@@ -138,12 +138,16 @@ export default function DataSourcePanel({
|
||||
width,
|
||||
}: Props) {
|
||||
const [dropzones] = useContext(DropzoneContext);
|
||||
const { columns: _columns, metrics, folders: _folders } = datasource;
|
||||
const {
|
||||
columns: datasourceColumns,
|
||||
metrics,
|
||||
folders: datasourceFolders,
|
||||
} = datasource;
|
||||
|
||||
const allowedColumns = useMemo(() => {
|
||||
const validators = Object.values(dropzones);
|
||||
if (!Array.isArray(_columns)) return [];
|
||||
return _columns.filter(column =>
|
||||
if (!Array.isArray(datasourceColumns)) return [];
|
||||
return datasourceColumns.filter(column =>
|
||||
validators.some(validator =>
|
||||
validator({
|
||||
value: column as DndItemValue,
|
||||
@@ -151,7 +155,7 @@ export default function DataSourcePanel({
|
||||
}),
|
||||
),
|
||||
);
|
||||
}, [dropzones, _columns]);
|
||||
}, [dropzones, datasourceColumns]);
|
||||
|
||||
const allowedMetrics = useMemo(() => {
|
||||
const validators = Object.values(dropzones);
|
||||
@@ -234,11 +238,11 @@ export default function DataSourcePanel({
|
||||
transformDatasourceWithFolders(
|
||||
filteredMetrics,
|
||||
sortedColumns,
|
||||
_folders,
|
||||
datasourceFolders,
|
||||
allowedMetrics,
|
||||
allowedColumns,
|
||||
),
|
||||
[_folders, filteredMetrics, sortedColumns],
|
||||
[datasourceFolders, filteredMetrics, sortedColumns],
|
||||
);
|
||||
|
||||
const showInfoboxCheck = () => {
|
||||
|
||||
@@ -47,6 +47,7 @@ export const ExploreAlert = forwardRef(
|
||||
ref: RefObject<HTMLDivElement>,
|
||||
) => (
|
||||
<ErrorAlert
|
||||
ref={ref}
|
||||
errorType={title}
|
||||
message={bodyText}
|
||||
type={type}
|
||||
|
||||
@@ -65,18 +65,19 @@ export default function ColumnConfigControl<T extends ColumnConfig>({
|
||||
height,
|
||||
...props
|
||||
}: ColumnConfigControlProps<T>) {
|
||||
const { colnames: _colnames, coltypes: _coltypes } = columnsPropsObject || {};
|
||||
const { colnames: sourceColnames, coltypes: sourceColtypes } =
|
||||
columnsPropsObject || {};
|
||||
let colnames: string[] = [];
|
||||
let coltypes: GenericDataType[] = [];
|
||||
if (appliedColumnNames.length === 0) {
|
||||
colnames = _colnames || [];
|
||||
coltypes = _coltypes || [];
|
||||
colnames = sourceColnames || [];
|
||||
coltypes = sourceColtypes || [];
|
||||
} else {
|
||||
const appliedCol = new Set(appliedColumnNames);
|
||||
_colnames?.forEach((col, idx) => {
|
||||
sourceColnames?.forEach((col, idx) => {
|
||||
if (appliedCol.has(col)) {
|
||||
colnames.push(col);
|
||||
coltypes.push(_coltypes?.[idx] as GenericDataType);
|
||||
coltypes.push(sourceColtypes?.[idx] as GenericDataType);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
LOG_ACTIONS_CHART_DOWNLOAD_AS_CSV_PIVOTED,
|
||||
LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS,
|
||||
} from 'src/logger/LogUtils';
|
||||
import exportPivotExcel from 'src/utils/downloadAsPivotExcel';
|
||||
import ViewQueryModal from '../controls/ViewQueryModal';
|
||||
import EmbedCodeContent from '../EmbedCodeContent';
|
||||
import DashboardsSubMenu from './DashboardsSubMenu';
|
||||
@@ -67,6 +68,7 @@ const MENU_KEYS = {
|
||||
DELETE_REPORT: 'delete_report',
|
||||
VIEW_QUERY: 'view_query',
|
||||
RUN_IN_SQL_LAB: 'run_in_sql_lab',
|
||||
EXPORT_TO_PIVOT_XLSX: 'export_to_pivot_xlsx',
|
||||
};
|
||||
|
||||
const VIZ_TYPES_PIVOTABLE = [VizType.PivotTable];
|
||||
@@ -248,6 +250,16 @@ export const useExploreAdditionalActionsMenu = (
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case MENU_KEYS.EXPORT_TO_PIVOT_XLSX:
|
||||
exportPivotExcel('.pvtTable', slice?.slice_name ?? t('pivoted_xlsx'));
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS, {
|
||||
chartId: slice?.slice_id,
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case MENU_KEYS.DOWNLOAD_AS_IMAGE:
|
||||
downloadAsImage(
|
||||
'.panel-body .chart-container',
|
||||
@@ -365,6 +377,13 @@ export const useExploreAdditionalActionsMenu = (
|
||||
>
|
||||
{t('Export to Excel')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key={MENU_KEYS.EXPORT_TO_PIVOT_XLSX}
|
||||
icon={<Icons.FileOutlined />}
|
||||
disabled={!canDownloadCSV}
|
||||
>
|
||||
{t('Export to Pivoted Excel')}
|
||||
</Menu.Item>
|
||||
</Menu.SubMenu>
|
||||
<Menu.SubMenu title={t('Share')} key={MENU_KEYS.SHARE_SUBMENU}>
|
||||
<Menu.Item key={MENU_KEYS.COPY_PERMALINK}>
|
||||
|
||||
@@ -307,7 +307,7 @@ export const NotificationMethod: FunctionComponent<NotificationMethodProps> = ({
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
.catch(() => {
|
||||
// Fallback to slack v1 if slack v2 is not compatible
|
||||
setUseSlackV1(true);
|
||||
})
|
||||
|
||||
@@ -58,7 +58,6 @@ interface AllEntitiesTableProps {
|
||||
}
|
||||
|
||||
export default function AllEntitiesTable({
|
||||
search = '',
|
||||
setShowTagModal,
|
||||
objects,
|
||||
canEditTag,
|
||||
|
||||
@@ -187,7 +187,7 @@ const AnnotationModal: FunctionComponent<AnnotationModalProps> = ({
|
||||
setCurrentAnnotation(data);
|
||||
};
|
||||
|
||||
const onDateChange = (dates: any, dateString: Array<string>) => {
|
||||
const onDateChange = (dates: any) => {
|
||||
if (!dates?.[0] || !dates?.[1]) {
|
||||
const data = {
|
||||
...currentAnnotation,
|
||||
|
||||
@@ -329,7 +329,9 @@ const UploadDataModal: FunctionComponent<UploadDataModalProps> = ({
|
||||
|
||||
const loadDatabaseOptions = useMemo(
|
||||
() =>
|
||||
(input = '', page: number, pageSize: number) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
(input: string, page: number, pageSize: number) => {
|
||||
// TODO: Implement search functionality using input parameter
|
||||
const query = rison.encode_uri({
|
||||
filters: [
|
||||
{
|
||||
@@ -357,21 +359,20 @@ const UploadDataModal: FunctionComponent<UploadDataModalProps> = ({
|
||||
);
|
||||
|
||||
const loadSchemaOptions = useMemo(
|
||||
() =>
|
||||
(input = '', page: number, pageSize: number) => {
|
||||
if (!currentDatabaseId) {
|
||||
return Promise.resolve({ data: [], totalCount: 0 });
|
||||
}
|
||||
return SupersetClient.get({
|
||||
endpoint: `/api/v1/database/${currentDatabaseId}/schemas/?q=(upload_allowed:!t)`,
|
||||
}).then(response => {
|
||||
const list = response.json.result.map((item: string) => ({
|
||||
value: item,
|
||||
label: item,
|
||||
}));
|
||||
return { data: list, totalCount: response.json.count };
|
||||
});
|
||||
},
|
||||
() => () => {
|
||||
if (!currentDatabaseId) {
|
||||
return Promise.resolve({ data: [], totalCount: 0 });
|
||||
}
|
||||
return SupersetClient.get({
|
||||
endpoint: `/api/v1/database/${currentDatabaseId}/schemas/?q=(upload_allowed:!t)`,
|
||||
}).then(response => {
|
||||
const list = response.json.result.map((item: string) => ({
|
||||
value: item,
|
||||
label: item,
|
||||
}));
|
||||
return { data: list, totalCount: response.json.count };
|
||||
});
|
||||
},
|
||||
[currentDatabaseId],
|
||||
);
|
||||
|
||||
@@ -546,7 +547,7 @@ const UploadDataModal: FunctionComponent<UploadDataModalProps> = ({
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
const validateUpload = (_: any, value: string) => {
|
||||
const validateUpload = () => {
|
||||
if (fileList.length === 0) {
|
||||
return Promise.reject(t('Uploading a file is required'));
|
||||
}
|
||||
@@ -561,7 +562,7 @@ const UploadDataModal: FunctionComponent<UploadDataModalProps> = ({
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const validateDatabase = (_: any, value: string) => {
|
||||
const validateDatabase = () => {
|
||||
if (!currentDatabaseId) {
|
||||
return Promise.reject(t('Selecting a database is required'));
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ const BulkTagModal: FC<BulkTagModalProps> = ({
|
||||
}
|
||||
addSuccessToast(t('Tagged %s %ss', tagged.length, resourceName));
|
||||
})
|
||||
.catch(err => {
|
||||
.catch(() => {
|
||||
addDangerToast(t('Failed to tag items'));
|
||||
});
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ const TagModal: FC<TagModalProps> = ({
|
||||
setChartsToTag(resourceMap[TaggableResources.Chart]);
|
||||
setSavedQueriesToTag(resourceMap[TaggableResources.SavedQuery]);
|
||||
},
|
||||
(error: Response) => {
|
||||
() => {
|
||||
addDangerToast('Error Fetching Tagged Objects');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -48,6 +48,8 @@ function UserInfoModal({
|
||||
const handleFormSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const { confirm_password, ...payload } = values;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-expressions
|
||||
confirm_password; // Destructured to exclude from payload
|
||||
await SupersetClient.put({
|
||||
endpoint: `/api/v1/me/`,
|
||||
jsonPayload: { ...payload },
|
||||
|
||||
@@ -22,6 +22,8 @@ import { FormValues } from './types';
|
||||
|
||||
export const createUser = async (values: FormValues) => {
|
||||
const { confirmPassword, ...payload } = values;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-expressions
|
||||
confirmPassword; // Destructured to exclude from payload
|
||||
if (payload.active == null) {
|
||||
payload.active = false;
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ function AllEntities() {
|
||||
setObjects(objects);
|
||||
setLoading(false);
|
||||
},
|
||||
(error: Response) => {
|
||||
() => {
|
||||
addDangerToast('Error Fetching Tagged Objects');
|
||||
setLoading(false);
|
||||
},
|
||||
@@ -168,7 +168,7 @@ function AllEntities() {
|
||||
setTag(tag);
|
||||
setLoading(false);
|
||||
},
|
||||
(error: Response) => {
|
||||
() => {
|
||||
addDangerToast(t('Error Fetching Tagged Objects'));
|
||||
setLoading(false);
|
||||
},
|
||||
|
||||
@@ -190,23 +190,21 @@ export default function ExplorePage() {
|
||||
})()
|
||||
: Promise.reject()
|
||||
)
|
||||
.then(
|
||||
({ result: { id, url, owners, form_data: _, ...data } }) => {
|
||||
const slice = {
|
||||
...data,
|
||||
datasource: err.extra?.datasource_name,
|
||||
slice_id: id,
|
||||
slice_url: url,
|
||||
owners: owners?.map(({ id }) => id),
|
||||
};
|
||||
dispatch(
|
||||
hydrateExplore({
|
||||
...exploreData,
|
||||
slice,
|
||||
}),
|
||||
);
|
||||
},
|
||||
)
|
||||
.then(({ result: { id, url, owners, ...data } }) => {
|
||||
const slice = {
|
||||
...data,
|
||||
datasource: err.extra?.datasource_name,
|
||||
slice_id: id,
|
||||
slice_url: url,
|
||||
owners: owners?.map(({ id }) => id),
|
||||
};
|
||||
dispatch(
|
||||
hydrateExplore({
|
||||
...exploreData,
|
||||
slice,
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch(hydrateExplore(exploreData));
|
||||
});
|
||||
|
||||
@@ -285,11 +285,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { kind },
|
||||
},
|
||||
}: any) => null,
|
||||
Cell: () => null,
|
||||
accessor: 'kind_icon',
|
||||
disableSortBy: true,
|
||||
size: 'xs',
|
||||
|
||||
@@ -147,7 +147,7 @@ function GroupsList({ user }: GroupsListProps) {
|
||||
.then(() => {
|
||||
deletedGroupsNames.push(group.name);
|
||||
})
|
||||
.catch(err => {
|
||||
.catch(() => {
|
||||
addDangerToast(t('Error deleting %s', group.name));
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -168,7 +168,7 @@ function UsersList({ user }: UsersListProps) {
|
||||
.then(() => {
|
||||
deletedUserNames.push(user.username);
|
||||
})
|
||||
.catch(err => {
|
||||
.catch(() => {
|
||||
addDangerToast(t('Error deleting %s', user.username));
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -101,13 +101,14 @@ export class ThemeController {
|
||||
const {
|
||||
storage = new LocalStorageAdapter(),
|
||||
modeStorageKey = STORAGE_KEYS.THEME_MODE,
|
||||
themeObject: fallbackThemeObject = supersetThemeObject,
|
||||
themeObject = supersetThemeObject,
|
||||
defaultTheme = (supersetThemeObject.theme as AnyThemeConfig) ?? {},
|
||||
onChange = null,
|
||||
} = options;
|
||||
|
||||
this.storage = storage;
|
||||
this.modeStorageKey = modeStorageKey;
|
||||
this.themeObject = themeObject;
|
||||
|
||||
// Initialize bootstrap data and themes
|
||||
const {
|
||||
@@ -139,16 +140,14 @@ export class ThemeController {
|
||||
|
||||
// Initialize theme and mode
|
||||
this.currentMode = this.determineInitialMode();
|
||||
const { theme, themeObject } =
|
||||
this.createInitialThemeObject(fallbackThemeObject);
|
||||
|
||||
this.themeObject = themeObject;
|
||||
const initialTheme =
|
||||
this.getThemeForMode(this.currentMode) || this.defaultTheme;
|
||||
|
||||
// Setup change callback
|
||||
if (onChange) this.onChangeCallbacks.add(onChange);
|
||||
|
||||
// Apply initial theme and persist mode
|
||||
this.applyTheme(theme);
|
||||
this.applyTheme(initialTheme);
|
||||
this.persistMode();
|
||||
}
|
||||
|
||||
@@ -158,7 +157,12 @@ export class ThemeController {
|
||||
* Cleans up listeners and references. Should be called when the controller is no longer needed.
|
||||
*/
|
||||
public destroy(): void {
|
||||
this.mediaQuery.removeEventListener('change', this.handleSystemThemeChange);
|
||||
if (this.mediaQuery)
|
||||
this.mediaQuery.removeEventListener(
|
||||
'change',
|
||||
this.handleSystemThemeChange,
|
||||
);
|
||||
|
||||
this.onChangeCallbacks.clear();
|
||||
}
|
||||
|
||||
@@ -329,8 +333,12 @@ export class ThemeController {
|
||||
* Initializes media query listeners if OS preference is allowed
|
||||
*/
|
||||
private initializeMediaQueryListener(): void {
|
||||
this.mediaQuery = window.matchMedia(MEDIA_QUERY_DARK_SCHEME);
|
||||
this.mediaQuery.addEventListener('change', this.handleSystemThemeChange);
|
||||
try {
|
||||
this.mediaQuery = window.matchMedia(MEDIA_QUERY_DARK_SCHEME);
|
||||
this.mediaQuery.addEventListener('change', this.handleSystemThemeChange);
|
||||
} catch (error) {
|
||||
console.warn('Failed to initialize media query listener:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -347,9 +355,9 @@ export class ThemeController {
|
||||
settings: themeSettings,
|
||||
} = theme;
|
||||
|
||||
const hasValidDefault: boolean = this.hasKeys(defaultTheme);
|
||||
const hasValidDark: boolean = this.hasKeys(darkTheme);
|
||||
const hasValidSettings: boolean = this.hasKeys(themeSettings);
|
||||
const hasValidDefault: boolean = this.isNonEmptyObject(defaultTheme);
|
||||
const hasValidDark: boolean = this.isNonEmptyObject(darkTheme);
|
||||
const hasValidSettings: boolean = this.isNonEmptyObject(themeSettings);
|
||||
|
||||
return {
|
||||
bootstrapDefaultTheme: hasValidDefault ? defaultTheme : null,
|
||||
@@ -360,9 +368,11 @@ export class ThemeController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an object has keys (not empty).
|
||||
* Checks if an object is non-empty (has at least one property).
|
||||
*/
|
||||
private hasKeys(obj: Record<string, any> | undefined | null): boolean {
|
||||
private isNonEmptyObject(
|
||||
obj: Record<string, any> | undefined | null,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
obj && typeof obj === 'object' && Object.keys(obj).length > 0,
|
||||
);
|
||||
@@ -417,28 +427,7 @@ export class ThemeController {
|
||||
? this.darkTheme || this.defaultTheme
|
||||
: this.defaultTheme;
|
||||
|
||||
return normalizeThemeConfig(selectedTheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the initial theme object.
|
||||
* This sets the theme based on the current mode and ensures it has the correct algorithm.
|
||||
* @param defaultThemeObject - The fallback theme object to use if no theme is set
|
||||
* @returns An object containing the theme and the themeObject
|
||||
*/
|
||||
private createInitialThemeObject(defaultThemeObject: Theme): {
|
||||
theme: AnyThemeConfig;
|
||||
themeObject: Theme;
|
||||
} {
|
||||
let theme: AnyThemeConfig | null = this.getThemeForMode(this.currentMode);
|
||||
theme = theme || (defaultThemeObject.theme as AnyThemeConfig);
|
||||
|
||||
const normalizedTheme = this.normalizeTheme(theme);
|
||||
|
||||
return {
|
||||
theme: normalizedTheme,
|
||||
themeObject: Theme.fromConfig(normalizedTheme),
|
||||
};
|
||||
return selectedTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -230,13 +230,13 @@ describe('ThemeController', () => {
|
||||
expect(controller.getTheme()).toBe(mockThemeObject);
|
||||
});
|
||||
|
||||
it('should use BootsrapData themes when available', () => {
|
||||
it('should use BootstrapData themes when available', () => {
|
||||
controller = new ThemeController({
|
||||
themeObject: mockThemeObject,
|
||||
});
|
||||
|
||||
expect(mockThemeFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockThemeFromConfig).toHaveBeenCalledWith(
|
||||
expect(mockSetConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: expect.objectContaining({
|
||||
colorBgBase: '#ededed',
|
||||
@@ -246,7 +246,7 @@ describe('ThemeController', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to Superset default theme when BootsrapData themes are empty', () => {
|
||||
it('should fallback to Superset default theme when BootstrapData themes are empty', () => {
|
||||
mockGetBootstrapData.mockReturnValue(
|
||||
createMockBootstrapData({
|
||||
default: {},
|
||||
@@ -267,8 +267,8 @@ describe('ThemeController', () => {
|
||||
defaultTheme: fallbackTheme,
|
||||
});
|
||||
|
||||
expect(mockThemeFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockThemeFromConfig).toHaveBeenCalledWith(
|
||||
expect(mockSetConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
...fallbackTheme,
|
||||
algorithm: antdThemeImport.defaultAlgorithm,
|
||||
|
||||
28
superset-frontend/src/utils/downloadAsPivotExcel.ts
Normal file
28
superset-frontend/src/utils/downloadAsPivotExcel.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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 { utils, writeFile } from 'xlsx';
|
||||
|
||||
export default function exportPivotExcel(
|
||||
tableSelector: string,
|
||||
fileName: string,
|
||||
) {
|
||||
const table = document.querySelector(tableSelector);
|
||||
const workbook = utils.table_to_book(table);
|
||||
writeFile(workbook, `${fileName}.xlsx`);
|
||||
}
|
||||
@@ -87,40 +87,40 @@ def cidr_translate_filter_func( # noqa: C901
|
||||
if operator in (FilterOperator.IN, FilterOperator.NOT_IN):
|
||||
dict_items = [val for val in values if isinstance(val, dict)]
|
||||
single_values = [val for val in values if not isinstance(val, dict)]
|
||||
if operator == FilterOperator.IN.value:
|
||||
if operator == FilterOperator.IN:
|
||||
cond = col.in_(single_values)
|
||||
for dictionary in dict_items:
|
||||
cond = cond | (col <= dictionary["end"]) & (col >= dictionary["start"])
|
||||
elif operator == FilterOperator.NOT_IN.value:
|
||||
elif operator == FilterOperator.NOT_IN:
|
||||
cond = ~(col.in_(single_values))
|
||||
for dictionary in dict_items:
|
||||
cond = cond & (col > dictionary["end"]) & (col < dictionary["start"])
|
||||
return_expression = cond
|
||||
if len(values) == 1:
|
||||
value = values[0]
|
||||
if operator == FilterOperator.EQUALS.value:
|
||||
if operator == FilterOperator.EQUALS:
|
||||
return_expression = (
|
||||
col == value
|
||||
if not isinstance(value, dict)
|
||||
else (col <= value["end"]) & (col >= value["start"])
|
||||
)
|
||||
if operator == FilterOperator.GREATER_THAN_OR_EQUALS.value:
|
||||
if operator == FilterOperator.GREATER_THAN_OR_EQUALS:
|
||||
return_expression = (
|
||||
col >= value if not isinstance(value, dict) else col >= value["end"]
|
||||
)
|
||||
if operator == FilterOperator.GREATER_THAN.value:
|
||||
if operator == FilterOperator.GREATER_THAN:
|
||||
return_expression = (
|
||||
col > value if not isinstance(value, dict) else col > value["end"]
|
||||
)
|
||||
if operator == FilterOperator.LESS_THAN.value:
|
||||
if operator == FilterOperator.LESS_THAN:
|
||||
return_expression = (
|
||||
col < value if not isinstance(value, dict) else col < value["start"]
|
||||
)
|
||||
if operator == FilterOperator.LESS_THAN_OR_EQUALS.value:
|
||||
if operator == FilterOperator.LESS_THAN_OR_EQUALS:
|
||||
return_expression = (
|
||||
col <= value if not isinstance(value, dict) else col <= value["start"]
|
||||
)
|
||||
if operator == FilterOperator.NOT_EQUALS.value:
|
||||
if operator == FilterOperator.NOT_EQUALS:
|
||||
return_expression = (
|
||||
col != value
|
||||
if not isinstance(value, dict)
|
||||
|
||||
@@ -114,25 +114,25 @@ def port_translate_filter_func( # noqa: C901
|
||||
return_expression: Any
|
||||
if operator in (FilterOperator.IN, FilterOperator.NOT_IN):
|
||||
vals_list = itertools.chain.from_iterable(values)
|
||||
if operator == FilterOperator.IN.value:
|
||||
if operator == FilterOperator.IN:
|
||||
cond = col.in_(vals_list)
|
||||
elif operator == FilterOperator.NOT_IN.value:
|
||||
elif operator == FilterOperator.NOT_IN:
|
||||
cond = ~(col.in_(vals_list))
|
||||
return_expression = cond
|
||||
if len(values) == 1:
|
||||
value = values[0]
|
||||
value.sort()
|
||||
if operator == FilterOperator.EQUALS.value:
|
||||
if operator == FilterOperator.EQUALS:
|
||||
return_expression = col.in_(value)
|
||||
if operator == FilterOperator.GREATER_THAN_OR_EQUALS.value:
|
||||
if operator == FilterOperator.GREATER_THAN_OR_EQUALS:
|
||||
return_expression = col >= value[0]
|
||||
if operator == FilterOperator.GREATER_THAN.value:
|
||||
if operator == FilterOperator.GREATER_THAN:
|
||||
return_expression = col > value[0]
|
||||
if operator == FilterOperator.LESS_THAN.value:
|
||||
if operator == FilterOperator.LESS_THAN:
|
||||
return_expression = col < value[-1]
|
||||
if operator == FilterOperator.LESS_THAN_OR_EQUALS.value:
|
||||
if operator == FilterOperator.LESS_THAN_OR_EQUALS:
|
||||
return_expression = col <= value[-1]
|
||||
if operator == FilterOperator.NOT_EQUALS.value:
|
||||
if operator == FilterOperator.NOT_EQUALS:
|
||||
return_expression = ~col.in_(value)
|
||||
return return_expression
|
||||
|
||||
|
||||
@@ -541,9 +541,7 @@ class QueryContextProcessor:
|
||||
# Lets find the first temporal filter in the filters array and change
|
||||
# its val to be the result of get_since_until with the offset
|
||||
for flt in query_object_clone.filter:
|
||||
if flt.get(
|
||||
"op"
|
||||
) == FilterOperator.TEMPORAL_RANGE.value and isinstance(
|
||||
if flt.get("op") == FilterOperator.TEMPORAL_RANGE and isinstance(
|
||||
flt.get("val"), str
|
||||
):
|
||||
time_range = cast(str, flt.get("val"))
|
||||
|
||||
@@ -66,7 +66,7 @@ def get_since_until_from_query_object(
|
||||
|
||||
time_range = None
|
||||
for flt in query_object.filter:
|
||||
if flt.get("op") == FilterOperator.TEMPORAL_RANGE.value and isinstance(
|
||||
if flt.get("op") == FilterOperator.TEMPORAL_RANGE and isinstance(
|
||||
flt.get("val"), str
|
||||
):
|
||||
time_range = cast(str, flt.get("val"))
|
||||
|
||||
@@ -54,7 +54,7 @@ from sqlalchemy.engine.reflection import Inspector
|
||||
from sqlalchemy.engine.url import URL
|
||||
from sqlalchemy.ext.compiler import compiles
|
||||
from sqlalchemy.sql import literal_column, quoted_name, text
|
||||
from sqlalchemy.sql.expression import ColumnClause, Select, TextClause
|
||||
from sqlalchemy.sql.expression import BinaryExpression, ColumnClause, Select, TextClause
|
||||
from sqlalchemy.types import TypeEngine
|
||||
|
||||
from superset import db
|
||||
@@ -377,6 +377,10 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
|
||||
disallow_uri_query_params: dict[str, set[str]] = {}
|
||||
# A Dict of query parameters that will always be used on every connection
|
||||
# by driver name
|
||||
|
||||
# Whether to use equality operators (= true/false) instead of IS operators
|
||||
# for boolean filters. Some databases like Snowflake don't support IS true/false
|
||||
use_equality_for_boolean_filters = False
|
||||
enforce_uri_query_params: dict[str, dict[str, Any]] = {}
|
||||
|
||||
force_column_alias_quotes = False
|
||||
@@ -1202,6 +1206,78 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
|
||||
"""
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def handle_boolean_filter(
|
||||
cls, sqla_col: Any, op: str, value: bool
|
||||
) -> BinaryExpression:
|
||||
"""
|
||||
Handle boolean filter operations with engine-specific logic.
|
||||
|
||||
By default, uses SQLAlchemy's IS operator (column IS true/false).
|
||||
Engines that don't support IS for boolean values can override
|
||||
use_equality_for_boolean_filters to use equality operators instead.
|
||||
|
||||
:param sqla_col: SQLAlchemy column element
|
||||
:param op: Filter operator (IS_TRUE or IS_FALSE)
|
||||
:param value: Boolean value (True or False)
|
||||
:return: SQLAlchemy expression for the boolean filter
|
||||
"""
|
||||
if cls.use_equality_for_boolean_filters:
|
||||
return sqla_col == value
|
||||
else:
|
||||
return sqla_col.is_(value)
|
||||
|
||||
@classmethod
|
||||
def handle_null_filter(
|
||||
cls,
|
||||
sqla_col: Any,
|
||||
op: utils.FilterOperator,
|
||||
) -> BinaryExpression:
|
||||
"""
|
||||
Handle null/not null filter operations.
|
||||
|
||||
:param sqla_col: SQLAlchemy column element
|
||||
:param op: Filter operator (IS_NULL or IS_NOT_NULL)
|
||||
:return: SQLAlchemy expression for the null filter
|
||||
"""
|
||||
from superset.utils import core as utils
|
||||
|
||||
if op == utils.FilterOperator.IS_NULL:
|
||||
return sqla_col.is_(None)
|
||||
elif op == utils.FilterOperator.IS_NOT_NULL:
|
||||
return sqla_col.isnot(None)
|
||||
else:
|
||||
raise ValueError(f"Invalid null filter operator: {op}")
|
||||
|
||||
@classmethod
|
||||
def handle_comparison_filter(
|
||||
cls, sqla_col: Any, op: utils.FilterOperator, value: Any
|
||||
) -> BinaryExpression:
|
||||
"""
|
||||
Handle comparison filter operations (=, !=, >, <, >=, <=).
|
||||
|
||||
:param sqla_col: SQLAlchemy column element
|
||||
:param op: Filter operator
|
||||
:param value: Filter value
|
||||
:return: SQLAlchemy expression for the comparison filter
|
||||
"""
|
||||
from superset.utils import core as utils
|
||||
|
||||
if op == utils.FilterOperator.EQUALS:
|
||||
return sqla_col == value
|
||||
elif op == utils.FilterOperator.NOT_EQUALS:
|
||||
return sqla_col != value
|
||||
elif op == utils.FilterOperator.GREATER_THAN:
|
||||
return sqla_col > value
|
||||
elif op == utils.FilterOperator.LESS_THAN:
|
||||
return sqla_col < value
|
||||
elif op == utils.FilterOperator.GREATER_THAN_OR_EQUALS:
|
||||
return sqla_col >= value
|
||||
elif op == utils.FilterOperator.LESS_THAN_OR_EQUALS:
|
||||
return sqla_col <= value
|
||||
else:
|
||||
raise ValueError(f"Invalid comparison filter operator: {op}")
|
||||
|
||||
@classmethod
|
||||
def handle_cursor(cls, cursor: Any, query: Query) -> None:
|
||||
"""Handle a live cursor between the execute and fetchall calls
|
||||
|
||||
@@ -83,6 +83,9 @@ class SnowflakeEngineSpec(PostgresBaseEngineSpec):
|
||||
force_column_alias_quotes = True
|
||||
max_column_name_length = 256
|
||||
|
||||
# Snowflake doesn't support IS true/false syntax, use = true/false instead
|
||||
use_equality_for_boolean_filters = True
|
||||
|
||||
parameters_schema = SnowflakeParametersSchema()
|
||||
default_driver = "snowflake"
|
||||
sqlalchemy_uri_placeholder = "snowflake://"
|
||||
|
||||
@@ -409,8 +409,8 @@ class ExtraCache:
|
||||
# IS_NULL and IS_NOT_NULL operators do not have a value
|
||||
or op
|
||||
in (
|
||||
FilterOperator.IS_NULL.value,
|
||||
FilterOperator.IS_NOT_NULL.value,
|
||||
FilterOperator.IS_NULL,
|
||||
FilterOperator.IS_NOT_NULL,
|
||||
)
|
||||
)
|
||||
):
|
||||
@@ -421,8 +421,8 @@ class ExtraCache:
|
||||
self.applied_filters.append(column)
|
||||
|
||||
if op in (
|
||||
FilterOperator.IN.value,
|
||||
FilterOperator.NOT_IN.value,
|
||||
FilterOperator.IN,
|
||||
FilterOperator.NOT_IN,
|
||||
) and not isinstance(val, list):
|
||||
val = [val]
|
||||
|
||||
|
||||
@@ -1756,7 +1756,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
flt_col = flt["col"]
|
||||
val = flt.get("val")
|
||||
flt_grain = flt.get("grain")
|
||||
op = flt["op"].upper()
|
||||
op = utils.FilterOperator(flt["op"].upper())
|
||||
col_obj: Optional["TableColumn"] = None
|
||||
sqla_col: Optional[Column] = None
|
||||
if flt_col == utils.DTTM_ALIAS and is_timeseries and dttm_col:
|
||||
@@ -1790,8 +1790,8 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
col_type = col_obj.type if col_obj else None
|
||||
col_spec = db_engine_spec.get_column_spec(native_type=col_type)
|
||||
is_list_target = op in (
|
||||
utils.FilterOperator.IN.value,
|
||||
utils.FilterOperator.NOT_IN.value,
|
||||
utils.FilterOperator.IN,
|
||||
utils.FilterOperator.NOT_IN,
|
||||
)
|
||||
|
||||
col_advanced_data_type = col_obj.advanced_data_type if col_obj else ""
|
||||
@@ -1850,23 +1850,30 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
cond = is_null_cond
|
||||
else:
|
||||
cond = sqla_col.in_(eq)
|
||||
if op == utils.FilterOperator.NOT_IN.value:
|
||||
if op == utils.FilterOperator.NOT_IN:
|
||||
cond = ~cond
|
||||
where_clause_and.append(cond)
|
||||
elif op == utils.FilterOperator.IS_NULL.value:
|
||||
where_clause_and.append(sqla_col.is_(None))
|
||||
elif op == utils.FilterOperator.IS_NOT_NULL.value:
|
||||
where_clause_and.append(sqla_col.isnot(None))
|
||||
elif op == utils.FilterOperator.IS_TRUE.value:
|
||||
where_clause_and.append(sqla_col.is_(True))
|
||||
elif op == utils.FilterOperator.IS_FALSE.value:
|
||||
where_clause_and.append(sqla_col.is_(False))
|
||||
elif op in {
|
||||
utils.FilterOperator.IS_NULL,
|
||||
utils.FilterOperator.IS_NOT_NULL,
|
||||
}:
|
||||
where_clause_and.append(
|
||||
db_engine_spec.handle_null_filter(sqla_col, op)
|
||||
)
|
||||
elif op == utils.FilterOperator.IS_TRUE:
|
||||
where_clause_and.append(
|
||||
db_engine_spec.handle_boolean_filter(sqla_col, op, True)
|
||||
)
|
||||
elif op == utils.FilterOperator.IS_FALSE:
|
||||
where_clause_and.append(
|
||||
db_engine_spec.handle_boolean_filter(sqla_col, op, False)
|
||||
)
|
||||
else:
|
||||
if (
|
||||
op
|
||||
not in {
|
||||
utils.FilterOperator.EQUALS.value,
|
||||
utils.FilterOperator.NOT_EQUALS.value,
|
||||
utils.FilterOperator.EQUALS,
|
||||
utils.FilterOperator.NOT_EQUALS,
|
||||
}
|
||||
and eq is None
|
||||
):
|
||||
@@ -1876,36 +1883,35 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
"with comparison operators"
|
||||
)
|
||||
)
|
||||
if op == utils.FilterOperator.EQUALS.value:
|
||||
where_clause_and.append(sqla_col == eq)
|
||||
elif op == utils.FilterOperator.NOT_EQUALS.value:
|
||||
where_clause_and.append(sqla_col != eq)
|
||||
elif op == utils.FilterOperator.GREATER_THAN.value:
|
||||
where_clause_and.append(sqla_col > eq)
|
||||
elif op == utils.FilterOperator.LESS_THAN.value:
|
||||
where_clause_and.append(sqla_col < eq)
|
||||
elif op == utils.FilterOperator.GREATER_THAN_OR_EQUALS.value:
|
||||
where_clause_and.append(sqla_col >= eq)
|
||||
elif op == utils.FilterOperator.LESS_THAN_OR_EQUALS.value:
|
||||
where_clause_and.append(sqla_col <= eq)
|
||||
if op in {
|
||||
utils.FilterOperator.EQUALS,
|
||||
utils.FilterOperator.NOT_EQUALS,
|
||||
utils.FilterOperator.GREATER_THAN,
|
||||
utils.FilterOperator.LESS_THAN,
|
||||
utils.FilterOperator.GREATER_THAN_OR_EQUALS,
|
||||
utils.FilterOperator.LESS_THAN_OR_EQUALS,
|
||||
}:
|
||||
where_clause_and.append(
|
||||
db_engine_spec.handle_comparison_filter(sqla_col, op, eq)
|
||||
)
|
||||
elif op in {
|
||||
utils.FilterOperator.ILIKE.value,
|
||||
utils.FilterOperator.LIKE.value,
|
||||
utils.FilterOperator.ILIKE,
|
||||
utils.FilterOperator.LIKE,
|
||||
}:
|
||||
if target_generic_type != GenericDataType.STRING:
|
||||
sqla_col = sa.cast(sqla_col, sa.String)
|
||||
|
||||
if op == utils.FilterOperator.LIKE.value:
|
||||
if op == utils.FilterOperator.LIKE:
|
||||
where_clause_and.append(sqla_col.like(eq))
|
||||
else:
|
||||
where_clause_and.append(sqla_col.ilike(eq))
|
||||
elif op in {utils.FilterOperator.NOT_LIKE.value}:
|
||||
elif op in {utils.FilterOperator.NOT_LIKE}:
|
||||
if target_generic_type != GenericDataType.STRING:
|
||||
sqla_col = sa.cast(sqla_col, sa.String)
|
||||
|
||||
where_clause_and.append(sqla_col.not_like(eq))
|
||||
elif (
|
||||
op == utils.FilterOperator.TEMPORAL_RANGE.value
|
||||
op == utils.FilterOperator.TEMPORAL_RANGE
|
||||
and isinstance(eq, str)
|
||||
and col_obj is not None
|
||||
):
|
||||
|
||||
@@ -55,6 +55,7 @@ from superset.views.filters import (
|
||||
BaseFilterRelatedRoles,
|
||||
BaseFilterRelatedUsers,
|
||||
FilterRelatedOwners,
|
||||
FilterRelatedTables,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -135,6 +136,7 @@ class RLSRestApi(BaseSupersetModelRestApi):
|
||||
|
||||
allowed_rel_fields = {"tables", "roles", "created_by", "changed_by"}
|
||||
related_field_filters = {
|
||||
"tables": RelatedFieldFilter("table_name", FilterRelatedTables),
|
||||
"changed_by": RelatedFieldFilter("first_name", FilterRelatedOwners),
|
||||
}
|
||||
base_related_field_filters = {
|
||||
|
||||
@@ -102,3 +102,22 @@ class BaseFilterRelatedRoles(BaseFilter): # pylint: disable=too-few-public-meth
|
||||
return extra_filters(query)
|
||||
|
||||
return query
|
||||
|
||||
|
||||
class FilterRelatedTables(BaseFilter): # pylint: disable=too-few-public-methods
|
||||
"""
|
||||
A filter to allow searching for related tables.
|
||||
Use in the api by adding something like:
|
||||
related_field_filters = {
|
||||
"tables": RelatedFieldFilter("table_name", FilterRelatedTables),
|
||||
}
|
||||
"""
|
||||
|
||||
name = lazy_gettext("Table")
|
||||
arg_name = "tables"
|
||||
|
||||
def apply(self, query: Query, value: Optional[Any]) -> Query:
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
|
||||
like_value = "%" + cast(str, value) + "%"
|
||||
return query.filter(SqlaTable.table_name.ilike(like_value))
|
||||
|
||||
@@ -555,6 +555,30 @@ class TestRowLevelSecurityWithRelatedAPI(SupersetTestCase):
|
||||
assert len(result) == len(db_tables)
|
||||
assert db_table_names == received_tables
|
||||
|
||||
def test_rls_tables_related_api_with_filter_matching_birth(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
# Test with filter that should match 'birth_names'
|
||||
params = prison.dumps({"filter": "birth", "page": 0, "page_size": 100})
|
||||
rv = self.client.get(f"/api/v1/rowlevelsecurity/related/tables?q={params}")
|
||||
assert rv.status_code == 200
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
result = data["result"]
|
||||
received_tables = {table["text"] for table in result}
|
||||
# Should only return tables with 'birth' in the name
|
||||
assert all("birth" in table_name.lower() for table_name in received_tables)
|
||||
assert len(result) >= 1 # At least birth_names should be returned
|
||||
|
||||
def test_rls_tables_related_api_with_filter_no_matches(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
# Test with filter that should match nothing
|
||||
params = prison.dumps({"filter": "nonexistent", "page": 0, "page_size": 100})
|
||||
rv = self.client.get(f"/api/v1/rowlevelsecurity/related/tables?q={params}")
|
||||
assert rv.status_code == 200
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
result = data["result"]
|
||||
assert len(result) == 0
|
||||
assert data["count"] == 0
|
||||
|
||||
def test_rls_roles_related_api(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
params = prison.dumps({"page": 0, "page_size": 100})
|
||||
|
||||
@@ -749,12 +749,12 @@ def test_should_generate_closed_and_open_time_filter_range(login_as_admin):
|
||||
def test_none_operand_in_filter(login_as_admin, physical_dataset):
|
||||
expected_results = [
|
||||
{
|
||||
"operator": FilterOperator.EQUALS.value,
|
||||
"operator": FilterOperator.EQUALS,
|
||||
"count": 10,
|
||||
"sql_should_contain": "COL4 IS NULL",
|
||||
},
|
||||
{
|
||||
"operator": FilterOperator.NOT_EQUALS.value,
|
||||
"operator": FilterOperator.NOT_EQUALS,
|
||||
"count": 0,
|
||||
"sql_should_contain": "COL4 IS NOT NULL",
|
||||
},
|
||||
@@ -1122,12 +1122,12 @@ def test__temporal_range_operator_in_adhoc_filter(physical_dataset):
|
||||
{
|
||||
"col": "col5",
|
||||
"val": "2000-01-05 : 2000-01-06",
|
||||
"op": FilterOperator.TEMPORAL_RANGE.value,
|
||||
"op": FilterOperator.TEMPORAL_RANGE,
|
||||
},
|
||||
{
|
||||
"col": "col6",
|
||||
"val": "2002-05-11 : 2002-05-12",
|
||||
"op": FilterOperator.TEMPORAL_RANGE.value,
|
||||
"op": FilterOperator.TEMPORAL_RANGE,
|
||||
},
|
||||
],
|
||||
"is_timeseries": False,
|
||||
|
||||
@@ -423,3 +423,119 @@ def test_impersonate_user_no_database(mocker: MockerFixture) -> None:
|
||||
"alice",
|
||||
"SECRET",
|
||||
)
|
||||
|
||||
|
||||
def test_handle_boolean_filter_default_behavior() -> None:
|
||||
"""
|
||||
Test that BaseEngineSpec uses IS operators for boolean filters by default.
|
||||
"""
|
||||
from sqlalchemy import Boolean, Column
|
||||
|
||||
from superset.db_engine_specs.base import BaseEngineSpec
|
||||
|
||||
# Create a mock SQLAlchemy column
|
||||
bool_col = Column("test_col", Boolean)
|
||||
|
||||
# Test IS_TRUE filter - should use IS operator by default
|
||||
result_true = BaseEngineSpec.handle_boolean_filter(bool_col, "IS TRUE", True)
|
||||
assert hasattr(result_true, "left") # IS comparison has left/right attributes
|
||||
assert hasattr(result_true, "right")
|
||||
|
||||
# Test IS_FALSE filter - should use IS operator by default
|
||||
result_false = BaseEngineSpec.handle_boolean_filter(bool_col, "IS FALSE", False)
|
||||
assert hasattr(result_false, "left")
|
||||
assert hasattr(result_false, "right")
|
||||
|
||||
|
||||
def test_handle_boolean_filter_with_equality() -> None:
|
||||
"""
|
||||
Test that BaseEngineSpec can use equality operators when configured.
|
||||
"""
|
||||
from sqlalchemy import Boolean, Column
|
||||
|
||||
from superset.db_engine_specs.base import BaseEngineSpec
|
||||
|
||||
# Create a test engine spec that uses equality
|
||||
class TestEngineSpec(BaseEngineSpec):
|
||||
use_equality_for_boolean_filters = True
|
||||
|
||||
bool_col = Column("test_col", Boolean)
|
||||
|
||||
# Test with equality enabled
|
||||
result_true = TestEngineSpec.handle_boolean_filter(bool_col, "IS TRUE", True)
|
||||
# Equality comparison should have different structure than IS comparison
|
||||
assert str(type(result_true)).endswith("BinaryExpression'>")
|
||||
|
||||
result_false = TestEngineSpec.handle_boolean_filter(bool_col, "IS FALSE", False)
|
||||
assert str(type(result_false)).endswith("BinaryExpression'>")
|
||||
|
||||
|
||||
def test_handle_null_filter() -> None:
|
||||
"""
|
||||
Test null/not null filter handling.
|
||||
"""
|
||||
from sqlalchemy import Boolean, Column
|
||||
|
||||
from superset.db_engine_specs.base import BaseEngineSpec
|
||||
|
||||
bool_col = Column("test_col", Boolean)
|
||||
|
||||
# Test IS_NULL - use actual FilterOperator values
|
||||
from superset.utils.core import FilterOperator
|
||||
|
||||
result_null = BaseEngineSpec.handle_null_filter(bool_col, FilterOperator.IS_NULL)
|
||||
assert hasattr(result_null, "left")
|
||||
assert hasattr(result_null, "right")
|
||||
|
||||
# Test IS_NOT_NULL
|
||||
result_not_null = BaseEngineSpec.handle_null_filter(
|
||||
bool_col, FilterOperator.IS_NOT_NULL
|
||||
)
|
||||
assert hasattr(result_not_null, "left")
|
||||
assert hasattr(result_not_null, "right")
|
||||
|
||||
# Test invalid operator
|
||||
with pytest.raises(ValueError, match="Invalid null filter operator"):
|
||||
BaseEngineSpec.handle_null_filter(bool_col, "INVALID") # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_handle_comparison_filter() -> None:
|
||||
"""
|
||||
Test comparison filter handling for all operators.
|
||||
"""
|
||||
from sqlalchemy import Column, Integer
|
||||
|
||||
from superset.db_engine_specs.base import BaseEngineSpec
|
||||
|
||||
int_col = Column("test_col", Integer)
|
||||
|
||||
# Test all comparison operators - use actual FilterOperator values
|
||||
from superset.utils.core import FilterOperator
|
||||
|
||||
operators_and_values = [
|
||||
(FilterOperator.EQUALS, 5),
|
||||
(FilterOperator.NOT_EQUALS, 5),
|
||||
(FilterOperator.GREATER_THAN, 5),
|
||||
(FilterOperator.LESS_THAN, 5),
|
||||
(FilterOperator.GREATER_THAN_OR_EQUALS, 5),
|
||||
(FilterOperator.LESS_THAN_OR_EQUALS, 5),
|
||||
]
|
||||
|
||||
for op, value in operators_and_values:
|
||||
result = BaseEngineSpec.handle_comparison_filter(int_col, op, value)
|
||||
# All comparison operators should return binary expressions
|
||||
assert str(type(result)).endswith("BinaryExpression'>")
|
||||
|
||||
# Test invalid operator
|
||||
with pytest.raises(ValueError, match="Invalid comparison filter operator"):
|
||||
BaseEngineSpec.handle_comparison_filter(int_col, "INVALID", 5) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_use_equality_for_boolean_filters_property() -> None:
|
||||
"""
|
||||
Test that BaseEngineSpec has the correct default value for boolean filter property.
|
||||
"""
|
||||
from superset.db_engine_specs.base import BaseEngineSpec
|
||||
|
||||
# Default should be False (use IS operators)
|
||||
assert BaseEngineSpec.use_equality_for_boolean_filters is False
|
||||
|
||||
@@ -352,6 +352,48 @@ def test_mask_encrypted_extra_no_fields() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_handle_boolean_filter() -> None:
|
||||
"""
|
||||
Test that Snowflake uses equality operators for boolean filters instead of IS.
|
||||
"""
|
||||
from sqlalchemy import Boolean, Column
|
||||
|
||||
from superset.db_engine_specs.snowflake import SnowflakeEngineSpec
|
||||
|
||||
# Create a mock SQLAlchemy column
|
||||
bool_col = Column("test_col", Boolean)
|
||||
|
||||
# Test IS_TRUE filter - use actual FilterOperator values
|
||||
from superset.utils.core import FilterOperator
|
||||
|
||||
result_true = SnowflakeEngineSpec.handle_boolean_filter(
|
||||
bool_col, FilterOperator.IS_TRUE, True
|
||||
)
|
||||
# The result should be a equality comparison, not an IS comparison
|
||||
assert (
|
||||
str(result_true.compile(compile_kwargs={"literal_binds": True}))
|
||||
== "test_col = true"
|
||||
)
|
||||
|
||||
# Test IS_FALSE filter
|
||||
result_false = SnowflakeEngineSpec.handle_boolean_filter(
|
||||
bool_col, FilterOperator.IS_FALSE, False
|
||||
)
|
||||
assert (
|
||||
str(result_false.compile(compile_kwargs={"literal_binds": True}))
|
||||
== "test_col = false"
|
||||
)
|
||||
|
||||
|
||||
def test_use_equality_for_boolean_filters_property() -> None:
|
||||
"""
|
||||
Test that Snowflake has the use_equality_for_boolean_filters property set to True.
|
||||
"""
|
||||
from superset.db_engine_specs.snowflake import SnowflakeEngineSpec
|
||||
|
||||
assert SnowflakeEngineSpec.use_equality_for_boolean_filters is True
|
||||
|
||||
|
||||
def test_unmask_encrypted_extra() -> None:
|
||||
"""
|
||||
Test that the private keys can be reused from the previous `encrypted_extra`.
|
||||
|
||||
Reference in New Issue
Block a user