From 16f193c813933aff89dada664b2485cbc3d99723 Mon Sep 17 00:00:00 2001 From: Geido <60598000+geido@users.noreply.github.com> Date: Fri, 8 Apr 2022 15:20:23 +0300 Subject: [PATCH 001/136] Catch colors when theme top level (#19571) --- .../tools/eslint-plugin-theme-colors/index.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/superset-frontend/tools/eslint-plugin-theme-colors/index.js b/superset-frontend/tools/eslint-plugin-theme-colors/index.js index ce0d492a1ce5f..ed1bd4d392e1c 100644 --- a/superset-frontend/tools/eslint-plugin-theme-colors/index.js +++ b/superset-frontend/tools/eslint-plugin-theme-colors/index.js @@ -72,14 +72,19 @@ module.exports = { return { TemplateElement(node) { const rawValue = node?.value?.raw; - const isParentProperty = + const isChildParentTagged = node?.parent?.parent?.type === 'TaggedTemplateExpression'; + const isChildParentArrow = + node?.parent?.parent?.type === 'ArrowFunctionExpression'; + const isParentTemplateLiteral = + node?.parent?.type === 'TemplateLiteral'; const loc = node?.parent?.parent?.loc; const locId = loc && JSON.stringify(loc); const hasWarned = warned.includes(locId); if ( !hasWarned && - isParentProperty && + (isChildParentTagged || + (isChildParentArrow && isParentTemplateLiteral)) && rawValue && (hasLiteralColor(rawValue) || hasHexColor(rawValue) || From 1ad82af058ec79a544f48df7a1aa9b0a165ecfb8 Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Fri, 8 Apr 2022 16:40:14 +0300 Subject: [PATCH 002/136] fix(select): render when empty multiselect (#19612) * fix(select): render when empty multiselect * disable flaky test --- .../src/components/Select/Select.tsx | 17 ++++++++--------- .../src/components/Select/utils.ts | 9 +++++++-- .../nativeFilters/FilterBar/FilterBar.test.tsx | 3 ++- .../index.tsx | 14 +++++--------- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/superset-frontend/src/components/Select/Select.tsx b/superset-frontend/src/components/Select/Select.tsx index 9c7b92c38cf88..20bab6bcfc416 100644 --- a/superset-frontend/src/components/Select/Select.tsx +++ b/superset-frontend/src/components/Select/Select.tsx @@ -42,7 +42,7 @@ import Icons from 'src/components/Icons'; import { getClientErrorObject } from 'src/utils/getClientErrorObject'; import { SLOW_DEBOUNCE } from 'src/constants'; import { rankedSearchCompare } from 'src/utils/rankedSearchCompare'; -import { getValue, hasOption, isObject } from './utils'; +import { getValue, hasOption, isLabeledValue } from './utils'; const { Option } = AntdSelect; @@ -376,7 +376,7 @@ const Select = ( const missingValues: OptionsType = ensureIsArray(selectValue) .filter(opt => !hasOption(getValue(opt), selectOptions)) .map(opt => - typeof opt === 'object' ? opt : { value: opt, label: String(opt) }, + isLabeledValue(opt) ? opt : { value: opt, label: String(opt) }, ); return missingValues.length > 0 ? missingValues.concat(selectOptions) @@ -393,12 +393,11 @@ const Select = ( } else { setSelectValue(previousState => { const array = ensureIsArray(previousState); - const isLabeledValue = isObject(selectedItem); - const value = isLabeledValue ? selectedItem.value : selectedItem; + const value = getValue(selectedItem); // Tokenized values can contain duplicated values if (!hasOption(value, array)) { const result = [...array, selectedItem]; - return isLabeledValue + return isLabeledValue(selectedItem) ? (result as AntdLabeledValue[]) : (result as (string | number)[]); } @@ -412,12 +411,12 @@ const Select = ( value: string | number | AntdLabeledValue | undefined, ) => { if (Array.isArray(selectValue)) { - if (typeof value === 'number' || typeof value === 'string' || !value) { - const array = selectValue as (string | number)[]; - setSelectValue(array.filter(element => element !== value)); - } else { + if (isLabeledValue(value)) { const array = selectValue as AntdLabeledValue[]; setSelectValue(array.filter(element => element.value !== value.value)); + } else { + const array = selectValue as (string | number)[]; + setSelectValue(array.filter(element => element !== value)); } } setInputValue(''); diff --git a/superset-frontend/src/components/Select/utils.ts b/superset-frontend/src/components/Select/utils.ts index 73c6dd3533242..9836b9ddd2eaa 100644 --- a/superset-frontend/src/components/Select/utils.ts +++ b/superset-frontend/src/components/Select/utils.ts @@ -24,6 +24,7 @@ import { OptionsType, GroupedOptionsType, } from 'react-select'; +import { LabeledValue as AntdLabeledValue } from 'antd/lib/select'; export function isObject(value: unknown): value is Record { return ( @@ -68,10 +69,14 @@ export function findValue( return (Array.isArray(value) ? value : [value]).map(find); } +export function isLabeledValue(value: unknown): value is AntdLabeledValue { + return isObject(value) && 'value' in value && 'label' in value; +} + export function getValue( - option: string | number | { value: string | number | null } | null, + option: string | number | AntdLabeledValue | null | undefined, ) { - return isObject(option) ? option.value : option; + return isLabeledValue(option) ? option.value : option; } type LabeledValue = { label?: ReactNode; value?: V }; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx index 632f8978efa2f..de7d6af99ca09 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx @@ -88,7 +88,8 @@ const addFilterFlow = async () => { userEvent.click(screen.getByText('Time range')); userEvent.type(screen.getByTestId(getModalTestId('name-input')), FILTER_NAME); userEvent.click(screen.getByText('Save')); - await screen.findByText('All filters (1)'); + // TODO: fix this flaky test + // await screen.findByText('All filters (1)'); }; const addFilterSetFlow = async () => { diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/index.tsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/index.tsx index 4c521d8aad451..58b1b25081f2a 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/index.tsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/index.tsx @@ -406,15 +406,11 @@ const AdhocFilterEditPopoverSimpleTabContent: React.FC = props => { {...operatorSelectProps} /> {MULTI_OPERATORS.has(operatorId) || suggestions.length > 0 ? ( - // We need to delay rendering the select because we can't pass a primitive value without options - // We can't pass value = [null] and options=[] - comparatorSelectProps.value && suggestions.length === 0 ? null : ( - - ) + ) : ( Date: Fri, 8 Apr 2022 12:03:40 -0500 Subject: [PATCH 003/136] feat: Move Database Import option into DB Connection modal (#19314) * rebase * more progress * Fix unintended changes * DB import goes to step 3 * debugging * DB list refreshing properly * import screens flowing properly * Code cleanup * Fixed back button on import flow * Remove import db tooltip test * Fix test * Password field resets properly * Changed import modal state dictators and removed unneeded comment * Removed unneeded param pass and corrected modal spelling * Fixed typos * Changed file to fileList * Clarified import footer comment * Cleaned passwordNeededField and confirmOverwriteField state checks * debugging * Import state flow fixed * Removed unneeded importModal check in unreachable area * Fixed import db footer behavior when pressing back on step 2 * Import db button now at 14px * Removed animation from import db button * Fixed doble-loading successToast * Fixed errored import behavior * Updated import password check info box text * Connect button disables while importing db is loading * Connect button disables while overwrite confirmation is still needed * Connect button disables while password confirmation is still needed * Removed gray line under upload filename * Hide trashcan icon on import filename * Modal scroll behavior fixed for importing filename * Changed errored to failed * RTL testing for db import --- .../src/components/Button/index.tsx | 8 + .../CRUD/data/database/DatabaseList.test.jsx | 56 ---- .../views/CRUD/data/database/DatabaseList.tsx | 57 ---- .../database/DatabaseModal/ModalHeader.tsx | 40 ++- .../database/DatabaseModal/index.test.jsx | 17 + .../data/database/DatabaseModal/index.tsx | 317 ++++++++++++++++-- .../data/database/DatabaseModal/styles.ts | 40 +++ superset-frontend/src/views/CRUD/hooks.ts | 15 +- 8 files changed, 393 insertions(+), 157 deletions(-) diff --git a/superset-frontend/src/components/Button/index.tsx b/superset-frontend/src/components/Button/index.tsx index ea8cd4cd3525c..30d4e3d9aca81 100644 --- a/superset-frontend/src/components/Button/index.tsx +++ b/superset-frontend/src/components/Button/index.tsx @@ -66,6 +66,14 @@ export interface ButtonProps { cta?: boolean; loading?: boolean | { delay?: number | undefined } | undefined; showMarginRight?: boolean; + type?: + | 'default' + | 'text' + | 'link' + | 'primary' + | 'dashed' + | 'ghost' + | undefined; } export default function Button(props: ButtonProps) { diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx index 12580d8ee73fa..fa8721e9e3dad 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx @@ -23,10 +23,6 @@ import configureStore from 'redux-mock-store'; import fetchMock from 'fetch-mock'; import { Provider } from 'react-redux'; import { styledMount as mount } from 'spec/helpers/theming'; -import { render, screen, cleanup } from 'spec/helpers/testing-library'; -import userEvent from '@testing-library/user-event'; -import { QueryParamProvider } from 'use-query-params'; -import * as featureFlags from 'src/featureFlags'; import DatabaseList from 'src/views/CRUD/data/database/DatabaseList'; import DatabaseModal from 'src/views/CRUD/data/database/DatabaseModal'; @@ -41,17 +37,6 @@ import { act } from 'react-dom/test-utils'; const mockStore = configureStore([thunk]); const store = mockStore({}); -const mockAppState = { - common: { - config: { - CSV_EXTENSIONS: ['csv'], - EXCEL_EXTENSIONS: ['xls', 'xlsx'], - COLUMNAR_EXTENSIONS: ['parquet', 'zip'], - ALLOWED_EXTENSIONS: ['parquet', 'zip', 'xls', 'xlsx', 'csv'], - }, - }, -}; - const databasesInfoEndpoint = 'glob:*/api/v1/database/_info*'; const databasesEndpoint = 'glob:*/api/v1/database/?*'; const databaseEndpoint = 'glob:*/api/v1/database/*'; @@ -208,44 +193,3 @@ describe('DatabaseList', () => { ); }); }); - -describe('RTL', () => { - async function renderAndWait() { - const mounted = act(async () => { - render( - - - , - { useRedux: true }, - mockAppState, - ); - }); - - return mounted; - } - - let isFeatureEnabledMock; - beforeEach(async () => { - isFeatureEnabledMock = jest - .spyOn(featureFlags, 'isFeatureEnabled') - .mockImplementation(() => true); - await renderAndWait(); - }); - - afterEach(() => { - cleanup(); - isFeatureEnabledMock.mockRestore(); - }); - - it('renders an "Import Database" tooltip under import button', async () => { - const importButton = await screen.findByTestId('import-button'); - userEvent.hover(importButton); - - await screen.findByRole('tooltip'); - const importTooltip = screen.getByRole('tooltip', { - name: 'Import databases', - }); - - expect(importTooltip).toBeInTheDocument(); - }); -}); diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx index 10149bc9e8a16..f980295cc2035 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx @@ -30,7 +30,6 @@ import { Tooltip } from 'src/components/Tooltip'; import Icons from 'src/components/Icons'; import ListView, { FilterOperator, Filters } from 'src/components/ListView'; import { commonMenuData } from 'src/views/CRUD/data/common'; -import ImportModelsModal from 'src/components/ImportModal/index'; import handleResourceExport from 'src/utils/export'; import { ExtentionConfigs } from 'src/views/components/types'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; @@ -39,17 +38,6 @@ import DatabaseModal from './DatabaseModal'; import { DatabaseObject } from './types'; const PAGE_SIZE = 25; -const PASSWORDS_NEEDED_MESSAGE = t( - 'The passwords for the databases below are needed in order to ' + - 'import them. Please note that the "Secure Extra" and "Certificate" ' + - 'sections of the database configuration are not present in export ' + - 'files, and should be added manually after the import if they are needed.', -); -const CONFIRM_OVERWRITE_MESSAGE = t( - 'You are importing one or more databases that already exist. ' + - 'Overwriting might cause you to lose some of your work. Are you ' + - 'sure you want to overwrite?', -); interface DatabaseDeleteObject extends DatabaseObject { chart_count: number; @@ -103,8 +91,6 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { const [currentDatabase, setCurrentDatabase] = useState( null, ); - const [importingDatabase, showImportModal] = useState(false); - const [passwordFields, setPasswordFields] = useState([]); const [preparingExport, setPreparingExport] = useState(false); const { roles } = useSelector( state => state.user, @@ -116,20 +102,6 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { ALLOWED_EXTENSIONS, } = useSelector(state => state.common.conf); - const openDatabaseImportModal = () => { - showImportModal(true); - }; - - const closeDatabaseImportModal = () => { - showImportModal(false); - }; - - const handleDatabaseImport = () => { - showImportModal(false); - refreshData(); - addSuccessToast(t('Database imported')); - }; - const openDatabaseDeleteModal = (database: DatabaseObject) => SupersetClient.get({ endpoint: `/api/v1/database/${database.id}/related_objects/`, @@ -245,22 +217,6 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { }, }, ]; - - if (isFeatureEnabled(FeatureFlag.VERSIONED_EXPORT)) { - menuData.buttons.push({ - name: ( - - - - ), - buttonStyle: 'link', - onClick: openDatabaseImportModal, - }); - } } function handleDatabaseExport(database: DatabaseObject) { @@ -526,19 +482,6 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { pageSize={PAGE_SIZE} /> - {preparingExport && } ); diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/ModalHeader.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/ModalHeader.tsx index 992aa76e36060..7cdcbaba281eb 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/ModalHeader.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/ModalHeader.tsx @@ -19,6 +19,7 @@ import React from 'react'; import { getDatabaseDocumentationLinks } from 'src/views/CRUD/hooks'; +import { UploadFile } from 'antd/lib/upload/interface'; import { EditHeaderTitle, EditHeaderSubtitle, @@ -52,6 +53,7 @@ const documentationLink = (engine: string | undefined) => { } return irregularDocumentationLinks[engine]; }; + const ModalHeader = ({ isLoading, isEditMode, @@ -61,6 +63,7 @@ const ModalHeader = ({ dbName, dbModel, editNewDb, + fileList, }: { isLoading: boolean; isEditMode: boolean; @@ -70,13 +73,19 @@ const ModalHeader = ({ dbName: string; dbModel: DatabaseForm; editNewDb?: boolean; + fileList?: UploadFile[]; + passwordFields?: string[]; + needsOverwriteConfirm?: boolean; }) => { + const fileCheck = fileList && fileList?.length > 0; + const isEditHeader = ( {db?.backend} {dbName} ); + const useSqlAlchemyFormHeader = (

STEP 2 OF 2

@@ -94,6 +103,7 @@ const ModalHeader = ({

); + const hasConnectedDbHeader = ( @@ -115,6 +125,7 @@ const ModalHeader = ({ ); + const hasDbHeader = ( @@ -133,6 +144,7 @@ const ModalHeader = ({ ); + const noDbHeader = (
@@ -142,19 +154,23 @@ const ModalHeader = ({ ); + const importDbHeader = ( + + +

STEP 2 OF 2

+

Enter the required {dbModel.name} credentials

+

{fileCheck ? fileList[0].name : ''}

+
+
+ ); + + if (fileCheck) return importDbHeader; if (isLoading) return <>; - if (isEditMode) { - return isEditHeader; - } - if (useSqlAlchemyForm) { - return useSqlAlchemyFormHeader; - } - if (hasConnectedDb && !editNewDb) { - return hasConnectedDbHeader; - } - if (db || editNewDb) { - return hasDbHeader; - } + if (isEditMode) return isEditHeader; + if (useSqlAlchemyForm) return useSqlAlchemyFormHeader; + if (hasConnectedDb && !editNewDb) return hasConnectedDbHeader; + if (db || editNewDb) return hasDbHeader; + return noDbHeader; }; diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx index 9db2333573dfa..79a11b0b13438 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx @@ -1028,7 +1028,24 @@ describe('DatabaseModal', () => { */ }); }); + + describe('Import database flow', () => { + it('imports a file', () => { + const importDbButton = screen.getByTestId('import-database-btn'); + expect(importDbButton).toBeVisible(); + + const testFile = new File([new ArrayBuffer(1)], 'model_export.zip'); + + userEvent.click(importDbButton); + userEvent.upload(importDbButton, testFile); + + expect(importDbButton.files[0]).toStrictEqual(testFile); + expect(importDbButton.files.item(0)).toStrictEqual(testFile); + expect(importDbButton.files).toHaveLength(1); + }); + }); }); + describe('DatabaseModal w/ Deeplinking Engine', () => { const renderAndWait = async () => { const mounted = act(async () => { diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx index c39feaee18471..583b540579e99 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx @@ -25,18 +25,21 @@ import { import React, { FunctionComponent, useEffect, + useRef, useState, useReducer, Reducer, } from 'react'; +import { UploadChangeParam, UploadFile } from 'antd/lib/upload/interface'; import Tabs from 'src/components/Tabs'; -import { AntdSelect } from 'src/components'; +import { AntdSelect, Upload } from 'src/components'; import Alert from 'src/components/Alert'; import Modal from 'src/components/Modal'; import Button from 'src/components/Button'; import IconButton from 'src/components/IconButton'; import InfoTooltip from 'src/components/InfoTooltip'; import withToasts from 'src/components/MessageToasts/withToasts'; +import ValidatedInput from 'src/components/Form/LabeledErrorBoundInput'; import { testDatabaseConnection, useSingleViewResource, @@ -44,6 +47,7 @@ import { useDatabaseValidation, getDatabaseImages, getConnectionAlert, + useImportResource, } from 'src/views/CRUD/hooks'; import { useCommonConf } from 'src/views/CRUD/data/database/state'; import { @@ -59,11 +63,13 @@ import DatabaseConnectionForm from './DatabaseConnectionForm'; import { antDErrorAlertStyles, antDAlertStyles, + antdWarningAlertStyles, StyledAlertMargin, antDModalNoPaddingStyles, antDModalStyles, antDTabsStyles, buttonLinkStyles, + importDbButtonLinkStyles, alchemyButtonLinkStyles, TabHeader, formHelperStyles, @@ -73,6 +79,8 @@ import { infoTooltip, StyledFooterButton, StyledStickyHeader, + formScrollableStyles, + StyledUploadWrapper, } from './styles'; import ModalHeader, { DOCUMENTATION_LINK } from './ModalHeader'; @@ -402,10 +410,12 @@ function dbReducer( return { ...action.payload, }; + case ActionType.configMethodChange: return { ...action.payload, }; + case ActionType.reset: default: return null; @@ -436,6 +446,19 @@ const DatabaseModal: FunctionComponent = ({ const [db, setDB] = useReducer< Reducer | null, DBReducerActionType> >(dbReducer, null); + // Database fetch logic + const { + state: { loading: dbLoading, resource: dbFetched, error: dbErrors }, + fetchResource, + createResource, + updateResource, + clearError, + } = useSingleViewResource( + 'database', + t('database'), + addDangerToast, + ); + const [tabKey, setTabKey] = useState(DEFAULT_TAB_KEY); const [availableDbs, getAvailableDbs] = useAvailableDatabases(); const [validationErrors, getValidation, setValidationErrors] = @@ -445,6 +468,11 @@ const DatabaseModal: FunctionComponent = ({ const [editNewDb, setEditNewDb] = useState(false); const [isLoading, setLoading] = useState(false); const [testInProgress, setTestInProgress] = useState(false); + const [passwords, setPasswords] = useState>({}); + const [confirmedOverwrite, setConfirmedOverwrite] = useState(false); + const [fileList, setFileList] = useState([]); + const [importingModal, setImportingModal] = useState(false); + const conf = useCommonConf(); const dbImages = getDatabaseImages(); const connectionAlert = getConnectionAlert(); @@ -457,18 +485,6 @@ const DatabaseModal: FunctionComponent = ({ const useSqlAlchemyForm = db?.configuration_method === CONFIGURATION_METHOD.SQLALCHEMY_URI; const useTabLayout = isEditMode || useSqlAlchemyForm; - // Database fetch logic - const { - state: { loading: dbLoading, resource: dbFetched, error: dbErrors }, - fetchResource, - createResource, - updateResource, - clearError, - } = useSingleViewResource( - 'database', - t('database'), - addDangerToast, - ); const isDynamic = (engine: string | undefined) => availableDbs?.databases?.find( (DB: DatabaseObject) => DB.backend === engine || DB.engine === engine, @@ -513,14 +529,43 @@ const DatabaseModal: FunctionComponent = ({ ); }; + const removeFile = (removedFile: UploadFile) => { + setFileList(fileList.filter(file => file.uid !== removedFile.uid)); + return false; + }; + const onClose = () => { setDB({ type: ActionType.reset }); setHasConnectedDb(false); setValidationErrors(null); // reset validation errors on close clearError(); setEditNewDb(false); + setFileList([]); + setImportingModal(false); + setPasswords({}); + setConfirmedOverwrite(false); + if (onDatabaseAdd) onDatabaseAdd(); onHide(); }; + + // Database import logic + const { + state: { + alreadyExists, + passwordsNeeded, + loading: importLoading, + failed: importErrored, + }, + importResource, + } = useImportResource('database', t('database'), msg => { + addDangerToast(msg); + onClose(); + }); + + const onChange = (type: any, payload: any) => { + setDB({ type, payload } as DBReducerActionType); + }; + const onSave = async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { id, ...update } = db || {}; @@ -596,9 +641,7 @@ const DatabaseModal: FunctionComponent = ({ dbToUpdate.configuration_method === CONFIGURATION_METHOD.DYNAMIC_FORM, // onShow toast on SQLA Forms ); if (result) { - if (onDatabaseAdd) { - onDatabaseAdd(); - } + if (onDatabaseAdd) onDatabaseAdd(); if (!editNewDb) { onClose(); addSuccessToast(t('Database settings updated')); @@ -613,9 +656,7 @@ const DatabaseModal: FunctionComponent = ({ ); if (dbId) { setHasConnectedDb(true); - if (onDatabaseAdd) { - onDatabaseAdd(); - } + if (onDatabaseAdd) onDatabaseAdd(); if (useTabLayout) { // tab layout only has one step // so it should close immediately on save @@ -624,14 +665,29 @@ const DatabaseModal: FunctionComponent = ({ } } } + + // Import - doesn't use db state + if (!db) { + setLoading(true); + setImportingModal(true); + + if (!(fileList[0].originFileObj instanceof File)) return; + const dbId = await importResource( + fileList[0].originFileObj, + passwords, + confirmedOverwrite, + ); + + if (dbId) { + onClose(); + addSuccessToast(t('Database connected')); + } + } + setEditNewDb(false); setLoading(false); }; - const onChange = (type: any, payload: any) => { - setDB({ type, payload } as DBReducerActionType); - }; - // Initialize const fetchDB = () => { if (isEditMode && databaseId) { @@ -773,10 +829,20 @@ const DatabaseModal: FunctionComponent = ({ }; const handleBackButtonOnConnect = () => { - if (editNewDb) { - setHasConnectedDb(false); - } + if (editNewDb) setHasConnectedDb(false); + if (importingModal) setImportingModal(false); setDB({ type: ActionType.reset }); + setFileList([]); + }; + + const handleDisableOnImport = () => { + if ( + importLoading || + (alreadyExists.length && !confirmedOverwrite) || + (passwordsNeeded.length && JSON.stringify(passwords) === '{}') + ) + return true; + return false; }; const renderModalFooter = () => { @@ -815,6 +881,26 @@ const DatabaseModal: FunctionComponent = ({ ); } + + // Import doesn't use db state, so footer will not render in the if statement above + if (importingModal) { + return ( + <> + + {t('Back')} + + + {t('Connect')} + + + ); + } + return []; }; @@ -840,6 +926,28 @@ const DatabaseModal: FunctionComponent = ({ ); + + const firstUpdate = useRef(true); // Captures first render + // Only runs when importing files don't need user input + useEffect(() => { + // Will not run on first render + if (firstUpdate.current) { + firstUpdate.current = false; + return; + } + + if ( + !importLoading && + !alreadyExists.length && + !passwordsNeeded.length && + !isLoading && // This prevents a double toast for non-related imports + !importErrored // This prevents a success toast on error + ) { + onClose(); + addSuccessToast(t('Database connected')); + } + }, [alreadyExists, passwordsNeeded, importLoading, importErrored]); + useEffect(() => { if (show) { setTabKey(DEFAULT_TAB_KEY); @@ -874,19 +982,111 @@ const DatabaseModal: FunctionComponent = ({ } }, [availableDbs]); - const tabChange = (key: string) => { - setTabKey(key); + // This forces the modal to scroll until the importing filename is in view + useEffect(() => { + if (importingModal) { + document + .getElementsByClassName('ant-upload-list-item-name')[0] + .scrollIntoView(); + } + }, [importingModal]); + + const onDbImport = async (info: UploadChangeParam) => { + setImportingModal(true); + setFileList([ + { + ...info.file, + status: 'done', + }, + ]); + + if (!(info.file.originFileObj instanceof File)) return; + await importResource( + info.file.originFileObj, + passwords, + confirmedOverwrite, + ); + }; + + const passwordNeededField = () => { + if (!passwordsNeeded.length) return null; + + return passwordsNeeded.map(database => ( + <> + + antDAlertStyles(theme)} + type="info" + showIcon + message="Database passwords" + description={t( + `The passwords for the databases below are needed in order to import them. Please note that the "Secure Extra" and "Certificate" sections of the database configuration are not present in explore files and should be added manually after the import if they are needed.`, + )} + /> + + ) => + setPasswords({ ...passwords, [database]: event.target.value }) + } + validationMethods={{ onBlur: () => {} }} + errorMessage={validationErrors?.password_needed} + label={t(`${database.slice(10)} PASSWORD`)} + css={formScrollableStyles} + /> + + )); + }; + + const confirmOverwrite = (event: React.ChangeEvent) => { + const targetValue = (event.currentTarget?.value as string) ?? ''; + setConfirmedOverwrite(targetValue.toUpperCase() === t('OVERWRITE')); }; + const confirmOverwriteField = () => { + if (!alreadyExists.length) return null; + + return ( + <> + + antdWarningAlertStyles(theme)} + type="warning" + showIcon + message="" + description={t( + 'You are importing one or more databases that already exist. Overwriting might cause you to lose some of your work. Are you sure you want to overwrite?', + )} + /> + + {} }} + errorMessage={validationErrors?.confirm_overwrite} + label={t(`TYPE "OVERWRITE" TO CONFIRM`)} + onChange={confirmOverwrite} + css={formScrollableStyles} + /> + + ); + }; + + const tabChange = (key: string) => setTabKey(key); + const renderStepTwoAlert = () => { const { hostname } = window.location; let ipAlert = connectionAlert?.REGIONAL_IPS?.default || ''; const regionalIPs = connectionAlert?.REGIONAL_IPS || {}; Object.entries(regionalIPs).forEach(([ipRegion, ipRange]) => { const regex = new RegExp(ipRegion); - if (hostname.match(regex)) { - ipAlert = ipRange; - } + if (hostname.match(regex)) ipAlert = ipRange; }); return ( db?.engine && ( @@ -1027,6 +1227,41 @@ const DatabaseModal: FunctionComponent = ({ ); }; + if (fileList.length > 0 && (alreadyExists.length || passwordsNeeded.length)) { + return ( + [ + antDModalNoPaddingStyles, + antDModalStyles(theme), + formHelperStyles(theme), + formStyles(theme), + ]} + name="database" + onHandledPrimaryAction={onSave} + onHide={onClose} + primaryButtonName={t('Connect')} + width="500px" + centered + show={show} + title={

{t('Connect a database')}

} + footer={renderModalFooter()} + > + + {passwordNeededField()} + {confirmOverwriteField()} +
+ ); + } + return useTabLayout ? ( [ @@ -1266,6 +1501,26 @@ const DatabaseModal: FunctionComponent = ({ /> {renderPreferredSelector()} {renderAvailableSelector()} + + {}} + onChange={onDbImport} + onRemove={removeFile} + > + + + ) : ( <> diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/styles.ts b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/styles.ts index c0e65b97774cc..39302168b2f07 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/styles.ts +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/styles.ts @@ -218,6 +218,29 @@ export const antDErrorAlertStyles = (theme: SupersetTheme) => css` } `; +export const antdWarningAlertStyles = (theme: SupersetTheme) => css` + border: 1px solid ${theme.colors.warning.light1}; + padding: ${theme.gridUnit * 4}px; + margin: ${theme.gridUnit * 4}px 0; + color: ${theme.colors.warning.dark2}; + + .ant-alert-message { + margin: 0; + } + + .ant-alert-description { + font-size: ${theme.typography.sizes.s + 1}px; + line-height: ${theme.gridUnit * 4}px; + + .ant-alert-icon { + margin-right: ${theme.gridUnit * 2.5}px; + font-size: ${theme.typography.sizes.l + 1}px; + position: relative; + top: ${theme.gridUnit / 4}px; + } + } +`; + export const formHelperStyles = (theme: SupersetTheme) => css` .required { margin-left: ${theme.gridUnit / 2}px; @@ -399,6 +422,13 @@ export const buttonLinkStyles = (theme: SupersetTheme) => css` padding-right: ${theme.gridUnit * 2}px; `; +export const importDbButtonLinkStyles = (theme: SupersetTheme) => css` + font-size: ${theme.gridUnit * 3.5}px; + font-weight: ${theme.typography.weights.normal}; + text-transform: initial; + padding-right: ${theme.gridUnit * 2}px; +`; + export const alchemyButtonLinkStyles = (theme: SupersetTheme) => css` font-weight: ${theme.typography.weights.normal}; text-transform: initial; @@ -583,3 +613,13 @@ export const StyledCatalogTable = styled.div` width: 95%; } `; + +export const StyledUploadWrapper = styled.div` + .ant-progress-inner { + display: none; + } + + .ant-upload-list-item-card-actions { + display: none; + } +`; diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts index a3de433247fcb..5a0e26131efc0 100644 --- a/superset-frontend/src/views/CRUD/hooks.ts +++ b/superset-frontend/src/views/CRUD/hooks.ts @@ -381,6 +381,7 @@ interface ImportResourceState { loading: boolean; passwordsNeeded: string[]; alreadyExists: string[]; + failed: boolean; } export function useImportResource( @@ -392,6 +393,7 @@ export function useImportResource( loading: false, passwordsNeeded: [], alreadyExists: [], + failed: false, }); function updateState(update: Partial) { @@ -407,6 +409,7 @@ export function useImportResource( // Set loading state updateState({ loading: true, + failed: false, }); const formData = new FormData(); @@ -430,9 +433,19 @@ export function useImportResource( body: formData, headers: { Accept: 'application/json' }, }) - .then(() => true) + .then(() => { + updateState({ + passwordsNeeded: [], + alreadyExists: [], + failed: false, + }); + return true; + }) .catch(response => getClientErrorObject(response).then(error => { + updateState({ + failed: true, + }); if (!error.errors) { handleErrorMsg( t( From 9a9e3b6e3bafd9a0d58b2f49404c19b31d2bc48a Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Fri, 8 Apr 2022 20:32:38 +0300 Subject: [PATCH 004/136] test(jinja): refactor to functional tests (#19606) --- .../integration_tests/jinja_context_tests.py | 422 ------------------ tests/integration_tests/test_jinja_context.py | 190 ++++++++ tests/unit_tests/test_jinja_context.py | 268 +++++++++++ 3 files changed, 458 insertions(+), 422 deletions(-) delete mode 100644 tests/integration_tests/jinja_context_tests.py create mode 100644 tests/integration_tests/test_jinja_context.py create mode 100644 tests/unit_tests/test_jinja_context.py diff --git a/tests/integration_tests/jinja_context_tests.py b/tests/integration_tests/jinja_context_tests.py deleted file mode 100644 index 924e93e17e25c..0000000000000 --- a/tests/integration_tests/jinja_context_tests.py +++ /dev/null @@ -1,422 +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 json -from datetime import datetime -from typing import Any -from unittest import mock - -import pytest -from sqlalchemy.dialects.postgresql import dialect - -import superset.utils.database -import tests.integration_tests.test_app -from superset import app -from superset.exceptions import SupersetTemplateException -from superset.jinja_context import ExtraCache, get_template_processor, safe_proxy -from superset.utils import core as utils -from tests.integration_tests.base_tests import SupersetTestCase - - -class TestJinja2Context(SupersetTestCase): - def test_filter_values_default(self) -> None: - with app.test_request_context(): - cache = ExtraCache() - self.assertEqual(cache.filter_values("name", "foo"), ["foo"]) - self.assertEqual(cache.removed_filters, list()) - - def test_filter_values_remove_not_present(self) -> None: - with app.test_request_context(): - cache = ExtraCache() - self.assertEqual(cache.filter_values("name", remove_filter=True), []) - self.assertEqual(cache.removed_filters, list()) - - def test_get_filters_remove_not_present(self) -> None: - with app.test_request_context(): - cache = ExtraCache() - self.assertEqual(cache.get_filters("name", remove_filter=True), []) - self.assertEqual(cache.removed_filters, list()) - - def test_filter_values_no_default(self) -> None: - with app.test_request_context(): - cache = ExtraCache() - self.assertEqual(cache.filter_values("name"), []) - - def test_filter_values_adhoc_filters(self) -> None: - with app.test_request_context( - data={ - "form_data": json.dumps( - { - "adhoc_filters": [ - { - "clause": "WHERE", - "comparator": "foo", - "expressionType": "SIMPLE", - "operator": "in", - "subject": "name", - } - ], - } - ) - } - ): - cache = ExtraCache() - self.assertEqual(cache.filter_values("name"), ["foo"]) - self.assertEqual(cache.applied_filters, ["name"]) - - with app.test_request_context( - data={ - "form_data": json.dumps( - { - "adhoc_filters": [ - { - "clause": "WHERE", - "comparator": ["foo", "bar"], - "expressionType": "SIMPLE", - "operator": "in", - "subject": "name", - } - ], - } - ) - } - ): - cache = ExtraCache() - self.assertEqual(cache.filter_values("name"), ["foo", "bar"]) - self.assertEqual(cache.applied_filters, ["name"]) - - def test_get_filters_adhoc_filters(self) -> None: - with app.test_request_context( - data={ - "form_data": json.dumps( - { - "adhoc_filters": [ - { - "clause": "WHERE", - "comparator": "foo", - "expressionType": "SIMPLE", - "operator": "in", - "subject": "name", - } - ], - } - ) - } - ): - cache = ExtraCache() - self.assertEqual( - cache.get_filters("name"), [{"op": "IN", "col": "name", "val": ["foo"]}] - ) - self.assertEqual(cache.removed_filters, list()) - self.assertEqual(cache.applied_filters, ["name"]) - - with app.test_request_context( - data={ - "form_data": json.dumps( - { - "adhoc_filters": [ - { - "clause": "WHERE", - "comparator": ["foo", "bar"], - "expressionType": "SIMPLE", - "operator": "in", - "subject": "name", - } - ], - } - ) - } - ): - cache = ExtraCache() - self.assertEqual( - cache.get_filters("name"), - [{"op": "IN", "col": "name", "val": ["foo", "bar"]}], - ) - self.assertEqual(cache.removed_filters, list()) - - with app.test_request_context( - data={ - "form_data": json.dumps( - { - "adhoc_filters": [ - { - "clause": "WHERE", - "comparator": ["foo", "bar"], - "expressionType": "SIMPLE", - "operator": "in", - "subject": "name", - } - ], - } - ) - } - ): - cache = ExtraCache() - self.assertEqual( - cache.get_filters("name", remove_filter=True), - [{"op": "IN", "col": "name", "val": ["foo", "bar"]}], - ) - self.assertEqual(cache.removed_filters, ["name"]) - self.assertEqual(cache.applied_filters, ["name"]) - - def test_filter_values_extra_filters(self) -> None: - with app.test_request_context( - data={ - "form_data": json.dumps( - {"extra_filters": [{"col": "name", "op": "in", "val": "foo"}]} - ) - } - ): - cache = ExtraCache() - self.assertEqual(cache.filter_values("name"), ["foo"]) - self.assertEqual(cache.applied_filters, ["name"]) - - def test_url_param_default(self) -> None: - with app.test_request_context(): - cache = ExtraCache() - self.assertEqual(cache.url_param("foo", "bar"), "bar") - - def test_url_param_no_default(self) -> None: - with app.test_request_context(): - cache = ExtraCache() - self.assertEqual(cache.url_param("foo"), None) - - def test_url_param_query(self) -> None: - with app.test_request_context(query_string={"foo": "bar"}): - cache = ExtraCache() - self.assertEqual(cache.url_param("foo"), "bar") - - def test_url_param_form_data(self) -> None: - with app.test_request_context( - query_string={"form_data": json.dumps({"url_params": {"foo": "bar"}})} - ): - cache = ExtraCache() - self.assertEqual(cache.url_param("foo"), "bar") - - def test_url_param_escaped_form_data(self) -> None: - with app.test_request_context( - query_string={"form_data": json.dumps({"url_params": {"foo": "O'Brien"}})} - ): - cache = ExtraCache(dialect=dialect()) - self.assertEqual(cache.url_param("foo"), "O''Brien") - - def test_url_param_escaped_default_form_data(self) -> None: - with app.test_request_context( - query_string={"form_data": json.dumps({"url_params": {"foo": "O'Brien"}})} - ): - cache = ExtraCache(dialect=dialect()) - self.assertEqual(cache.url_param("bar", "O'Malley"), "O''Malley") - - def test_url_param_unescaped_form_data(self) -> None: - with app.test_request_context( - query_string={"form_data": json.dumps({"url_params": {"foo": "O'Brien"}})} - ): - cache = ExtraCache(dialect=dialect()) - self.assertEqual(cache.url_param("foo", escape_result=False), "O'Brien") - - def test_url_param_unescaped_default_form_data(self) -> None: - with app.test_request_context( - query_string={"form_data": json.dumps({"url_params": {"foo": "O'Brien"}})} - ): - cache = ExtraCache(dialect=dialect()) - self.assertEqual( - cache.url_param("bar", "O'Malley", escape_result=False), "O'Malley" - ) - - def test_safe_proxy_primitive(self) -> None: - def func(input: Any) -> Any: - return input - - return_value = safe_proxy(func, "foo") - self.assertEqual("foo", return_value) - - def test_safe_proxy_dict(self) -> None: - def func(input: Any) -> Any: - return input - - return_value = safe_proxy(func, {"foo": "bar"}) - self.assertEqual({"foo": "bar"}, return_value) - - def test_safe_proxy_lambda(self) -> None: - def func(input: Any) -> Any: - return input - - with pytest.raises(SupersetTemplateException): - safe_proxy(func, lambda: "bar") - - def test_safe_proxy_nested_lambda(self) -> None: - def func(input: Any) -> Any: - return input - - with pytest.raises(SupersetTemplateException): - safe_proxy(func, {"foo": lambda: "bar"}) - - def test_process_template(self) -> None: - maindb = superset.utils.database.get_example_database() - sql = "SELECT '{{ 1+1 }}'" - tp = get_template_processor(database=maindb) - rendered = tp.process_template(sql) - self.assertEqual("SELECT '2'", rendered) - - def test_get_template_kwarg(self) -> None: - maindb = superset.utils.database.get_example_database() - s = "{{ foo }}" - tp = get_template_processor(database=maindb, foo="bar") - rendered = tp.process_template(s) - self.assertEqual("bar", rendered) - - def test_template_kwarg(self) -> None: - maindb = superset.utils.database.get_example_database() - s = "{{ foo }}" - tp = get_template_processor(database=maindb) - rendered = tp.process_template(s, foo="bar") - self.assertEqual("bar", rendered) - - def test_get_template_kwarg_dict(self) -> None: - maindb = superset.utils.database.get_example_database() - s = "{{ foo.bar }}" - tp = get_template_processor(database=maindb, foo={"bar": "baz"}) - rendered = tp.process_template(s) - self.assertEqual("baz", rendered) - - def test_template_kwarg_dict(self) -> None: - maindb = superset.utils.database.get_example_database() - s = "{{ foo.bar }}" - tp = get_template_processor(database=maindb) - rendered = tp.process_template(s, foo={"bar": "baz"}) - self.assertEqual("baz", rendered) - - def test_get_template_kwarg_lambda(self) -> None: - maindb = superset.utils.database.get_example_database() - s = "{{ foo() }}" - tp = get_template_processor(database=maindb, foo=lambda: "bar") - with pytest.raises(SupersetTemplateException): - tp.process_template(s) - - def test_template_kwarg_lambda(self) -> None: - maindb = superset.utils.database.get_example_database() - s = "{{ foo() }}" - tp = get_template_processor(database=maindb) - with pytest.raises(SupersetTemplateException): - tp.process_template(s, foo=lambda: "bar") - - def test_get_template_kwarg_module(self) -> None: - maindb = superset.utils.database.get_example_database() - s = "{{ dt(2017, 1, 1).isoformat() }}" - tp = get_template_processor(database=maindb, dt=datetime) - with pytest.raises(SupersetTemplateException): - tp.process_template(s) - - def test_template_kwarg_module(self) -> None: - maindb = superset.utils.database.get_example_database() - s = "{{ dt(2017, 1, 1).isoformat() }}" - tp = get_template_processor(database=maindb) - with pytest.raises(SupersetTemplateException): - tp.process_template(s, dt=datetime) - - def test_get_template_kwarg_nested_module(self) -> None: - maindb = superset.utils.database.get_example_database() - s = "{{ foo.dt }}" - tp = get_template_processor(database=maindb, foo={"dt": datetime}) - with pytest.raises(SupersetTemplateException): - tp.process_template(s) - - def test_template_kwarg_nested_module(self) -> None: - maindb = superset.utils.database.get_example_database() - s = "{{ foo.dt }}" - tp = get_template_processor(database=maindb) - with pytest.raises(SupersetTemplateException): - tp.process_template(s, foo={"bar": datetime}) - - @mock.patch("superset.jinja_context.HiveTemplateProcessor.latest_partition") - def test_template_hive(self, lp_mock) -> None: - lp_mock.return_value = "the_latest" - db = mock.Mock() - db.backend = "hive" - s = "{{ hive.latest_partition('my_table') }}" - tp = get_template_processor(database=db) - rendered = tp.process_template(s) - self.assertEqual("the_latest", rendered) - - @mock.patch("superset.jinja_context.context_addons") - def test_template_context_addons(self, addons_mock) -> None: - addons_mock.return_value = {"datetime": datetime} - maindb = superset.utils.database.get_example_database() - s = "SELECT '{{ datetime(2017, 1, 1).isoformat() }}'" - tp = get_template_processor(database=maindb) - rendered = tp.process_template(s) - self.assertEqual("SELECT '2017-01-01T00:00:00'", rendered) - - @mock.patch( - "tests.integration_tests.superset_test_custom_template_processors.datetime" - ) - def test_custom_process_template(self, mock_dt) -> None: - """Test macro defined in custom template processor works.""" - mock_dt.utcnow = mock.Mock(return_value=datetime(1970, 1, 1)) - db = mock.Mock() - db.backend = "db_for_macros_testing" - tp = get_template_processor(database=db) - - sql = "SELECT '$DATE()'" - rendered = tp.process_template(sql) - self.assertEqual("SELECT '{}'".format("1970-01-01"), rendered) - - sql = "SELECT '$DATE(1, 2)'" - rendered = tp.process_template(sql) - self.assertEqual("SELECT '{}'".format("1970-01-02"), rendered) - - def test_custom_get_template_kwarg(self) -> None: - """Test macro passed as kwargs when getting template processor - works in custom template processor.""" - db = mock.Mock() - db.backend = "db_for_macros_testing" - s = "$foo()" - tp = get_template_processor(database=db, foo=lambda: "bar") - rendered = tp.process_template(s) - self.assertEqual("bar", rendered) - - def test_custom_template_kwarg(self) -> None: - """Test macro passed as kwargs when processing template - works in custom template processor.""" - db = mock.Mock() - db.backend = "db_for_macros_testing" - s = "$foo()" - tp = get_template_processor(database=db) - rendered = tp.process_template(s, foo=lambda: "bar") - self.assertEqual("bar", rendered) - - def test_custom_template_processors_overwrite(self) -> None: - """Test template processor for presto gets overwritten by custom one.""" - db = mock.Mock() - db.backend = "db_for_macros_testing" - tp = get_template_processor(database=db) - - sql = "SELECT '{{ datetime(2017, 1, 1).isoformat() }}'" - rendered = tp.process_template(sql) - self.assertEqual(sql, rendered) - - sql = "SELECT '{{ DATE(1, 2) }}'" - rendered = tp.process_template(sql) - self.assertEqual(sql, rendered) - - def test_custom_template_processors_ignored(self) -> None: - """Test custom template processor is ignored for a difference backend - database.""" - maindb = superset.utils.database.get_example_database() - sql = "SELECT '$DATE()'" - tp = get_template_processor(database=maindb) - rendered = tp.process_template(sql) - assert sql == rendered diff --git a/tests/integration_tests/test_jinja_context.py b/tests/integration_tests/test_jinja_context.py new file mode 100644 index 0000000000000..879881a2996ae --- /dev/null +++ b/tests/integration_tests/test_jinja_context.py @@ -0,0 +1,190 @@ +# 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. +from datetime import datetime +from unittest import mock + +import pytest +from flask.ctx import AppContext +from pytest_mock import MockFixture + +import superset.utils.database +from superset.exceptions import SupersetTemplateException +from superset.jinja_context import get_template_processor + + +def test_process_template(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "SELECT '{{ 1+1 }}'" + tp = get_template_processor(database=maindb) + assert tp.process_template(template) == "SELECT '2'" + + +def test_get_template_kwarg(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "{{ foo }}" + tp = get_template_processor(database=maindb, foo="bar") + assert tp.process_template(template) == "bar" + + +def test_template_kwarg(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "{{ foo }}" + tp = get_template_processor(database=maindb) + assert tp.process_template(template, foo="bar") == "bar" + + +def test_get_template_kwarg_dict(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "{{ foo.bar }}" + tp = get_template_processor(database=maindb, foo={"bar": "baz"}) + assert tp.process_template(template) == "baz" + + +def test_template_kwarg_dict(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "{{ foo.bar }}" + tp = get_template_processor(database=maindb) + assert tp.process_template(template, foo={"bar": "baz"}) == "baz" + + +def test_get_template_kwarg_lambda(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "{{ foo() }}" + tp = get_template_processor(database=maindb, foo=lambda: "bar") + with pytest.raises(SupersetTemplateException): + tp.process_template(template) + + +def test_template_kwarg_lambda(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "{{ foo() }}" + tp = get_template_processor(database=maindb) + with pytest.raises(SupersetTemplateException): + tp.process_template(template, foo=lambda: "bar") + + +def test_get_template_kwarg_module(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "{{ dt(2017, 1, 1).isoformat() }}" + tp = get_template_processor(database=maindb, dt=datetime) + with pytest.raises(SupersetTemplateException): + tp.process_template(template) + + +def test_template_kwarg_module(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "{{ dt(2017, 1, 1).isoformat() }}" + tp = get_template_processor(database=maindb) + with pytest.raises(SupersetTemplateException): + tp.process_template(template, dt=datetime) + + +def test_get_template_kwarg_nested_module(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "{{ foo.dt }}" + tp = get_template_processor(database=maindb, foo={"dt": datetime}) + with pytest.raises(SupersetTemplateException): + tp.process_template(template) + + +def test_template_kwarg_nested_module(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "{{ foo.dt }}" + tp = get_template_processor(database=maindb) + with pytest.raises(SupersetTemplateException): + tp.process_template(template, foo={"bar": datetime}) + + +def test_template_hive(app_context: AppContext, mocker: MockFixture) -> None: + lp_mock = mocker.patch( + "superset.jinja_context.HiveTemplateProcessor.latest_partition" + ) + lp_mock.return_value = "the_latest" + db = mock.Mock() + db.backend = "hive" + template = "{{ hive.latest_partition('my_table') }}" + tp = get_template_processor(database=db) + assert tp.process_template(template) == "the_latest" + + +def test_template_context_addons(app_context: AppContext, mocker: MockFixture) -> None: + addons_mock = mocker.patch("superset.jinja_context.context_addons") + addons_mock.return_value = {"datetime": datetime} + maindb = superset.utils.database.get_example_database() + template = "SELECT '{{ datetime(2017, 1, 1).isoformat() }}'" + tp = get_template_processor(database=maindb) + assert tp.process_template(template) == "SELECT '2017-01-01T00:00:00'" + + +def test_custom_process_template(app_context: AppContext, mocker: MockFixture) -> None: + """Test macro defined in custom template processor works.""" + + mock_dt = mocker.patch( + "tests.integration_tests.superset_test_custom_template_processors.datetime" + ) + mock_dt.utcnow = mock.Mock(return_value=datetime(1970, 1, 1)) + db = mock.Mock() + db.backend = "db_for_macros_testing" + tp = get_template_processor(database=db) + + template = "SELECT '$DATE()'" + assert tp.process_template(template) == f"SELECT '1970-01-01'" + + template = "SELECT '$DATE(1, 2)'" + assert tp.process_template(template) == "SELECT '1970-01-02'" + + +def test_custom_get_template_kwarg(app_context: AppContext) -> None: + """Test macro passed as kwargs when getting template processor + works in custom template processor.""" + db = mock.Mock() + db.backend = "db_for_macros_testing" + template = "$foo()" + tp = get_template_processor(database=db, foo=lambda: "bar") + assert tp.process_template(template) == "bar" + + +def test_custom_template_kwarg(app_context: AppContext) -> None: + """Test macro passed as kwargs when processing template + works in custom template processor.""" + db = mock.Mock() + db.backend = "db_for_macros_testing" + template = "$foo()" + tp = get_template_processor(database=db) + assert tp.process_template(template, foo=lambda: "bar") == "bar" + + +def test_custom_template_processors_overwrite(app_context: AppContext) -> None: + """Test template processor for presto gets overwritten by custom one.""" + db = mock.Mock() + db.backend = "db_for_macros_testing" + tp = get_template_processor(database=db) + + template = "SELECT '{{ datetime(2017, 1, 1).isoformat() }}'" + assert tp.process_template(template) == template + + template = "SELECT '{{ DATE(1, 2) }}'" + assert tp.process_template(template) == template + + +def test_custom_template_processors_ignored(app_context: AppContext) -> None: + """Test custom template processor is ignored for a difference backend + database.""" + maindb = superset.utils.database.get_example_database() + template = "SELECT '$DATE()'" + tp = get_template_processor(database=maindb) + assert tp.process_template(template) == template diff --git a/tests/unit_tests/test_jinja_context.py b/tests/unit_tests/test_jinja_context.py new file mode 100644 index 0000000000000..7c301c88ea3e5 --- /dev/null +++ b/tests/unit_tests/test_jinja_context.py @@ -0,0 +1,268 @@ +# 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 json +from typing import Any + +import pytest +from flask.ctx import AppContext +from sqlalchemy.dialects.postgresql import dialect + +from superset import app +from superset.exceptions import SupersetTemplateException +from superset.jinja_context import ExtraCache, safe_proxy + + +def test_filter_values_default(app_context: AppContext) -> None: + cache = ExtraCache() + assert cache.filter_values("name", "foo") == ["foo"] + assert cache.removed_filters == [] + + +def test_filter_values_remove_not_present(app_context: AppContext) -> None: + cache = ExtraCache() + assert cache.filter_values("name", remove_filter=True) == [] + assert cache.removed_filters == [] + + +def test_get_filters_remove_not_present(app_context: AppContext) -> None: + cache = ExtraCache() + assert cache.get_filters("name", remove_filter=True) == [] + assert cache.removed_filters == [] + + +def test_filter_values_no_default(app_context: AppContext) -> None: + cache = ExtraCache() + assert cache.filter_values("name") == [] + + +def test_filter_values_adhoc_filters(app_context: AppContext) -> None: + with app.test_request_context( + data={ + "form_data": json.dumps( + { + "adhoc_filters": [ + { + "clause": "WHERE", + "comparator": "foo", + "expressionType": "SIMPLE", + "operator": "in", + "subject": "name", + } + ], + } + ) + } + ): + cache = ExtraCache() + assert cache.filter_values("name") == ["foo"] + assert cache.applied_filters == ["name"] + + with app.test_request_context( + data={ + "form_data": json.dumps( + { + "adhoc_filters": [ + { + "clause": "WHERE", + "comparator": ["foo", "bar"], + "expressionType": "SIMPLE", + "operator": "in", + "subject": "name", + } + ], + } + ) + } + ): + cache = ExtraCache() + assert cache.filter_values("name") == ["foo", "bar"] + assert cache.applied_filters == ["name"] + + +def test_get_filters_adhoc_filters(app_context: AppContext) -> None: + with app.test_request_context( + data={ + "form_data": json.dumps( + { + "adhoc_filters": [ + { + "clause": "WHERE", + "comparator": "foo", + "expressionType": "SIMPLE", + "operator": "in", + "subject": "name", + } + ], + } + ) + } + ): + cache = ExtraCache() + assert cache.get_filters("name") == [ + {"op": "IN", "col": "name", "val": ["foo"]} + ] + + assert cache.removed_filters == [] + assert cache.applied_filters == ["name"] + + with app.test_request_context( + data={ + "form_data": json.dumps( + { + "adhoc_filters": [ + { + "clause": "WHERE", + "comparator": ["foo", "bar"], + "expressionType": "SIMPLE", + "operator": "in", + "subject": "name", + } + ], + } + ) + } + ): + cache = ExtraCache() + assert cache.get_filters("name") == [ + {"op": "IN", "col": "name", "val": ["foo", "bar"]} + ] + assert cache.removed_filters == [] + + with app.test_request_context( + data={ + "form_data": json.dumps( + { + "adhoc_filters": [ + { + "clause": "WHERE", + "comparator": ["foo", "bar"], + "expressionType": "SIMPLE", + "operator": "in", + "subject": "name", + } + ], + } + ) + } + ): + cache = ExtraCache() + assert cache.get_filters("name", remove_filter=True) == [ + {"op": "IN", "col": "name", "val": ["foo", "bar"]} + ] + assert cache.removed_filters == ["name"] + assert cache.applied_filters == ["name"] + + +def test_filter_values_extra_filters(app_context: AppContext) -> None: + with app.test_request_context( + data={ + "form_data": json.dumps( + {"extra_filters": [{"col": "name", "op": "in", "val": "foo"}]} + ) + } + ): + cache = ExtraCache() + assert cache.filter_values("name") == ["foo"] + assert cache.applied_filters == ["name"] + + +def test_url_param_default(app_context: AppContext) -> None: + with app.test_request_context(): + cache = ExtraCache() + assert cache.url_param("foo", "bar") == "bar" + + +def test_url_param_no_default(app_context: AppContext) -> None: + with app.test_request_context(): + cache = ExtraCache() + assert cache.url_param("foo") is None + + +def test_url_param_query(app_context: AppContext) -> None: + with app.test_request_context(query_string={"foo": "bar"}): + cache = ExtraCache() + assert cache.url_param("foo") == "bar" + + +def test_url_param_form_data(app_context: AppContext) -> None: + with app.test_request_context( + query_string={"form_data": json.dumps({"url_params": {"foo": "bar"}})} + ): + cache = ExtraCache() + assert cache.url_param("foo") == "bar" + + +def test_url_param_escaped_form_data(app_context: AppContext) -> None: + with app.test_request_context( + query_string={"form_data": json.dumps({"url_params": {"foo": "O'Brien"}})} + ): + cache = ExtraCache(dialect=dialect()) + assert cache.url_param("foo") == "O''Brien" + + +def test_url_param_escaped_default_form_data(app_context: AppContext) -> None: + with app.test_request_context( + query_string={"form_data": json.dumps({"url_params": {"foo": "O'Brien"}})} + ): + cache = ExtraCache(dialect=dialect()) + assert cache.url_param("bar", "O'Malley") == "O''Malley" + + +def test_url_param_unescaped_form_data(app_context: AppContext) -> None: + with app.test_request_context( + query_string={"form_data": json.dumps({"url_params": {"foo": "O'Brien"}})} + ): + cache = ExtraCache(dialect=dialect()) + assert cache.url_param("foo", escape_result=False) == "O'Brien" + + +def test_url_param_unescaped_default_form_data(app_context: AppContext) -> None: + with app.test_request_context( + query_string={"form_data": json.dumps({"url_params": {"foo": "O'Brien"}})} + ): + cache = ExtraCache(dialect=dialect()) + assert cache.url_param("bar", "O'Malley", escape_result=False) == "O'Malley" + + +def test_safe_proxy_primitive(app_context: AppContext) -> None: + def func(input_: Any) -> Any: + return input_ + + assert safe_proxy(func, "foo") == "foo" + + +def test_safe_proxy_dict(app_context: AppContext) -> None: + def func(input_: Any) -> Any: + return input_ + + assert safe_proxy(func, {"foo": "bar"}) == {"foo": "bar"} + + +def test_safe_proxy_lambda(app_context: AppContext) -> None: + def func(input_: Any) -> Any: + return input_ + + with pytest.raises(SupersetTemplateException): + safe_proxy(func, lambda: "bar") + + +def test_safe_proxy_nested_lambda(app_context: AppContext) -> None: + def func(input_: Any) -> Any: + return input_ + + with pytest.raises(SupersetTemplateException): + safe_proxy(func, {"foo": lambda: "bar"}) From 738bd04b4fde728474233e562a97a6c84efc8049 Mon Sep 17 00:00:00 2001 From: Jesse Yang Date: Sat, 9 Apr 2022 07:48:37 -0700 Subject: [PATCH 005/136] fix(test): make test_clean_requests_after_schema_grant more idempotent (#19625) --- tests/integration_tests/access_tests.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/tests/integration_tests/access_tests.py b/tests/integration_tests/access_tests.py index 13febbd413c9d..abefc58c9bc65 100644 --- a/tests/integration_tests/access_tests.py +++ b/tests/integration_tests/access_tests.py @@ -374,6 +374,7 @@ def test_clean_requests_after_schema_grant(self): .filter_by(table_name="wb_health_population") .first() ) + original_schema = ds.schema ds.schema = "temp_schema" security_manager.add_permission_view_menu("schema_access", ds.schema_perm) @@ -394,13 +395,7 @@ def test_clean_requests_after_schema_grant(self): gamma_user = security_manager.find_user(username="gamma") gamma_user.roles.remove(security_manager.find_role(SCHEMA_ACCESS_ROLE)) - ds = ( - session.query(SqlaTable) - .filter_by(table_name="wb_health_population") - .first() - ) - ds.schema = None - + ds.schema = original_schema session.commit() @mock.patch("superset.utils.core.send_mime_email") From a975af3e9e43f4524d71a8a9fe89e4cb448fbca3 Mon Sep 17 00:00:00 2001 From: Jesse Yang Date: Sat, 9 Apr 2022 07:48:59 -0700 Subject: [PATCH 006/136] chore: clean up unused imports in db migration scripts (#19630) --- .../07071313dd52_change_fetch_values_predicate_to_text.py | 4 +--- .../181091c0ef16_add_extra_column_to_columns_model.py | 3 --- .../19e978e1b9c3_add_report_format_to_report_schedule_.py | 1 - .../3ba29ecbaac5_change_datatype_of_type_in_basecolumn.py | 1 - .../versions/6d20ba9ecb33_add_last_saved_at_to_slice_model.py | 1 - .../versions/73fd22e742ab_add_dynamic_plugins_py.py | 1 - 6 files changed, 1 insertion(+), 10 deletions(-) diff --git a/superset/migrations/versions/07071313dd52_change_fetch_values_predicate_to_text.py b/superset/migrations/versions/07071313dd52_change_fetch_values_predicate_to_text.py index 320fb55a35243..ce90e37c8bd2e 100644 --- a/superset/migrations/versions/07071313dd52_change_fetch_values_predicate_to_text.py +++ b/superset/migrations/versions/07071313dd52_change_fetch_values_predicate_to_text.py @@ -30,9 +30,7 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy import and_, func, or_ -from sqlalchemy.dialects import postgresql -from sqlalchemy.sql.schema import Table +from sqlalchemy import func from superset import db from superset.connectors.sqla.models import SqlaTable diff --git a/superset/migrations/versions/181091c0ef16_add_extra_column_to_columns_model.py b/superset/migrations/versions/181091c0ef16_add_extra_column_to_columns_model.py index 6adeccf1c011c..8ed0f00598173 100644 --- a/superset/migrations/versions/181091c0ef16_add_extra_column_to_columns_model.py +++ b/superset/migrations/versions/181091c0ef16_add_extra_column_to_columns_model.py @@ -28,9 +28,6 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects import postgresql - -from superset.utils.core import generic_find_constraint_name def upgrade(): diff --git a/superset/migrations/versions/19e978e1b9c3_add_report_format_to_report_schedule_.py b/superset/migrations/versions/19e978e1b9c3_add_report_format_to_report_schedule_.py index ab25b88c5e4d2..ff191d0e3b29f 100644 --- a/superset/migrations/versions/19e978e1b9c3_add_report_format_to_report_schedule_.py +++ b/superset/migrations/versions/19e978e1b9c3_add_report_format_to_report_schedule_.py @@ -28,7 +28,6 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects import postgresql def upgrade(): diff --git a/superset/migrations/versions/3ba29ecbaac5_change_datatype_of_type_in_basecolumn.py b/superset/migrations/versions/3ba29ecbaac5_change_datatype_of_type_in_basecolumn.py index 15f81488a310c..4f94a4bb9beac 100644 --- a/superset/migrations/versions/3ba29ecbaac5_change_datatype_of_type_in_basecolumn.py +++ b/superset/migrations/versions/3ba29ecbaac5_change_datatype_of_type_in_basecolumn.py @@ -28,7 +28,6 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects import postgresql def upgrade(): diff --git a/superset/migrations/versions/6d20ba9ecb33_add_last_saved_at_to_slice_model.py b/superset/migrations/versions/6d20ba9ecb33_add_last_saved_at_to_slice_model.py index 408da53118415..c149adbc518d2 100644 --- a/superset/migrations/versions/6d20ba9ecb33_add_last_saved_at_to_slice_model.py +++ b/superset/migrations/versions/6d20ba9ecb33_add_last_saved_at_to_slice_model.py @@ -28,7 +28,6 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects import postgresql def upgrade(): diff --git a/superset/migrations/versions/73fd22e742ab_add_dynamic_plugins_py.py b/superset/migrations/versions/73fd22e742ab_add_dynamic_plugins_py.py index e4c2d0bc519ff..e2bbedcd347cf 100644 --- a/superset/migrations/versions/73fd22e742ab_add_dynamic_plugins_py.py +++ b/superset/migrations/versions/73fd22e742ab_add_dynamic_plugins_py.py @@ -28,7 +28,6 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects import postgresql def upgrade(): From ce2bd984423f2b7e606f97ac25f25e015c3f4a37 Mon Sep 17 00:00:00 2001 From: Jesse Yang Date: Sat, 9 Apr 2022 07:49:39 -0700 Subject: [PATCH 007/136] test: freeze time for dashboard export test (#19634) --- tests/integration_tests/dashboards/api_tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration_tests/dashboards/api_tests.py b/tests/integration_tests/dashboards/api_tests.py index 66b498eb35136..afeab6e7db8e9 100644 --- a/tests/integration_tests/dashboards/api_tests.py +++ b/tests/integration_tests/dashboards/api_tests.py @@ -1425,6 +1425,7 @@ def test_update_dashboard_not_owned(self): "load_world_bank_dashboard_with_slices", "load_birth_names_dashboard_with_slices", ) + @freeze_time("2022-01-01") def test_export(self): """ Dashboard API: Test dashboard export From b45f89b9540442fe398831e1f29dfd60a153bab6 Mon Sep 17 00:00:00 2001 From: Jesse Yang Date: Sat, 9 Apr 2022 07:50:52 -0700 Subject: [PATCH 008/136] refactor: consistent migration tests organization (#19635) --- ...te_native_filters_to_new_schema__tests.py} | 0 .../fb13d49b72f9_better_filters__tests.py} | 27 +++++++++---------- ...grate_filter_sets_to_new_format__tests.py} | 0 3 files changed, 12 insertions(+), 15 deletions(-) rename tests/integration_tests/migrations/{f1410ed7ec95_tests.py => f1410ed7ec95_migrate_native_filters_to_new_schema__tests.py} (100%) rename tests/integration_tests/{migration_tests.py => migrations/fb13d49b72f9_better_filters__tests.py} (63%) rename tests/integration_tests/migrations/{fc3a3a8ff221_tests.py => fc3a3a8ff221_migrate_filter_sets_to_new_format__tests.py} (100%) diff --git a/tests/integration_tests/migrations/f1410ed7ec95_tests.py b/tests/integration_tests/migrations/f1410ed7ec95_migrate_native_filters_to_new_schema__tests.py similarity index 100% rename from tests/integration_tests/migrations/f1410ed7ec95_tests.py rename to tests/integration_tests/migrations/f1410ed7ec95_migrate_native_filters_to_new_schema__tests.py diff --git a/tests/integration_tests/migration_tests.py b/tests/integration_tests/migrations/fb13d49b72f9_better_filters__tests.py similarity index 63% rename from tests/integration_tests/migration_tests.py rename to tests/integration_tests/migrations/fb13d49b72f9_better_filters__tests.py index 444aefbc36253..f1fb9d737664d 100644 --- a/tests/integration_tests/migration_tests.py +++ b/tests/integration_tests/migrations/fb13d49b72f9_better_filters__tests.py @@ -21,20 +21,17 @@ upgrade_slice, ) -from .base_tests import SupersetTestCase +def test_upgrade_slice(): + slc = Slice( + slice_name="FOO", + viz_type="filter_box", + params=json.dumps(dict(metric="foo", groupby=["bar"])), + ) + upgrade_slice(slc) + params = json.loads(slc.params) + assert "metric" not in params + assert "filter_configs" in params -class TestMigration(SupersetTestCase): - def test_upgrade_slice(self): - slc = Slice( - slice_name="FOO", - viz_type="filter_box", - params=json.dumps(dict(metric="foo", groupby=["bar"])), - ) - upgrade_slice(slc) - params = json.loads(slc.params) - self.assertNotIn("metric", params) - self.assertIn("filter_configs", params) - - cfg = params["filter_configs"][0] - self.assertEqual(cfg.get("metric"), "foo") + cfg = params["filter_configs"][0] + assert cfg.get("metric") == "foo" diff --git a/tests/integration_tests/migrations/fc3a3a8ff221_tests.py b/tests/integration_tests/migrations/fc3a3a8ff221_migrate_filter_sets_to_new_format__tests.py similarity index 100% rename from tests/integration_tests/migrations/fc3a3a8ff221_tests.py rename to tests/integration_tests/migrations/fc3a3a8ff221_migrate_filter_sets_to_new_format__tests.py From a6bf041eddcde0247461f35c806414df00ef105e Mon Sep 17 00:00:00 2001 From: Stephen Liu <750188453@qq.com> Date: Mon, 11 Apr 2022 13:56:45 +0800 Subject: [PATCH 009/136] feat(plugin-chart-echarts): add aggregate total for the Pie/Donuct chart (#19622) --- .../src/Pie/controlPanel.tsx | 12 +++ .../src/Pie/transformProps.ts | 79 ++++++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/controlPanel.tsx index aab4af54585b4..c195c5e2214d9 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/controlPanel.tsx @@ -183,6 +183,18 @@ const config: ControlPanelConfig = { }, }, ], + [ + { + name: 'show_total', + config: { + type: 'CheckboxControl', + label: t('Show Total'), + default: false, + renderTrigger: true, + description: t('Whether to display the aggregate count'), + }, + }, + ], // eslint-disable-next-line react/jsx-key [

{t('Pie shape')}

], [ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts index 237f4ae001f70..aeeec088b05c4 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts @@ -25,6 +25,7 @@ import { getTimeFormatter, NumberFormats, NumberFormatter, + t, } from '@superset-ui/core'; import { CallbackDataParams } from 'echarts/types/src/util/types'; import { EChartsCoreOption, PieSeriesOption } from 'echarts'; @@ -45,6 +46,7 @@ import { } from '../utils/series'; import { defaultGrid, defaultTooltip } from '../defaults'; import { OpacityEnum } from '../constants'; +import { convertInteger } from '../utils/convertInteger'; const percentFormatter = getNumberFormatter(NumberFormats.PERCENT_2_POINT); @@ -82,6 +84,54 @@ export function formatPieLabel({ } } +function getTotalValuePadding({ + chartPadding, + donut, + width, + height, +}: { + chartPadding: { + bottom: number; + left: number; + right: number; + top: number; + }; + donut: boolean; + width: number; + height: number; +}) { + const padding: { + left?: string; + top?: string; + } = { + top: donut ? 'middle' : '0', + left: 'center', + }; + const LEGEND_HEIGHT = 15; + const LEGEND_WIDTH = 215; + if (chartPadding.top) { + padding.top = donut + ? `${50 + ((chartPadding.top - LEGEND_HEIGHT) / height / 2) * 100}%` + : `${((chartPadding.top + LEGEND_HEIGHT) / height) * 100}%`; + } + if (chartPadding.bottom) { + padding.top = donut + ? `${50 - ((chartPadding.bottom + LEGEND_HEIGHT) / height / 2) * 100}%` + : '0'; + } + if (chartPadding.left) { + padding.left = `${ + 50 + ((chartPadding.left - LEGEND_WIDTH) / width / 2) * 100 + }%`; + } + if (chartPadding.right) { + padding.left = `${ + 50 - ((chartPadding.right + LEGEND_WIDTH) / width / 2) * 100 + }%`; + } + return padding; +} + export default function transformProps( chartProps: EchartsPieChartProps, ): PieChartTransformedProps { @@ -110,6 +160,7 @@ export default function transformProps( showLabelsThreshold, emitFilter, sliceId, + showTotal, }: EchartsPieFormData = { ...DEFAULT_LEGEND_FORM_DATA, ...DEFAULT_PIE_FORM_DATA, @@ -147,6 +198,7 @@ export default function transformProps( const colorFn = CategoricalColorNamespace.getScale(colorScheme as string); const numberFormatter = getNumberFormatter(numberFormat); + let totalValue = 0; const transformedData: PieSeriesOption[] = data.map(datum => { const name = extractGroupbyLabel({ @@ -158,9 +210,14 @@ export default function transformProps( const isFiltered = filterState.selectedValues && !filterState.selectedValues.includes(name); + const value = datum[metricLabel]; + + if (typeof value === 'number' || typeof value === 'string') { + totalValue += convertInteger(value); + } return { - value: datum[metricLabel], + value, name, itemStyle: { color: colorFn(name, sliceId), @@ -197,10 +254,16 @@ export default function transformProps( color: '#000000', }; + const chartPadding = getChartPadding( + showLegend, + legendOrientation, + legendMargin, + ); + const series: PieSeriesOption[] = [ { type: 'pie', - ...getChartPadding(showLegend, legendOrientation, legendMargin), + ...chartPadding, animation: false, radius: [`${donut ? innerRadius : 0}%`, `${outerRadius}%`], center: ['50%', '50%'], @@ -248,6 +311,18 @@ export default function transformProps( ...getLegendProps(legendType, legendOrientation, showLegend), data: keys, }, + graphic: showTotal + ? { + type: 'text', + ...getTotalValuePadding({ chartPadding, donut, width, height }), + style: { + text: t(`Total: ${numberFormatter(totalValue)}`), + fontSize: 16, + fontWeight: 'bold', + }, + z: 10, + } + : null, series, }; From f21ba68a304787a196eb03a31ccd64405e375c72 Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Mon, 11 Apr 2022 13:02:20 +0300 Subject: [PATCH 010/136] chore: clean up dynamic translation strings (#19641) --- .../plugins/plugin-chart-echarts/src/Pie/transformProps.ts | 2 +- superset-frontend/src/dashboard/actions/dashboardLayout.js | 2 +- .../nativeFilters/FiltersConfigModal/Footer/Footer.tsx | 2 +- superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx | 4 ++-- .../src/views/CRUD/annotation/AnnotationList.tsx | 2 +- .../src/views/CRUD/data/database/DatabaseModal/index.tsx | 2 +- superset-frontend/src/views/CRUD/welcome/EmptyState.tsx | 3 +-- 7 files changed, 8 insertions(+), 9 deletions(-) diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts index aeeec088b05c4..cf40ce7e1be94 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts @@ -316,7 +316,7 @@ export default function transformProps( type: 'text', ...getTotalValuePadding({ chartPadding, donut, width, height }), style: { - text: t(`Total: ${numberFormatter(totalValue)}`), + text: t('Total: %s', numberFormatter(totalValue)), fontSize: 16, fontWeight: 'bold', }, diff --git a/superset-frontend/src/dashboard/actions/dashboardLayout.js b/superset-frontend/src/dashboard/actions/dashboardLayout.js index e0cbe7aa00c77..8d4b3fa56c944 100644 --- a/superset-frontend/src/dashboard/actions/dashboardLayout.js +++ b/superset-frontend/src/dashboard/actions/dashboardLayout.js @@ -210,7 +210,7 @@ export function handleComponentDrop(dropResult) { destination.id !== rootChildId ) { return dispatch( - addWarningToast(t(`Can not move top level tab into nested tabs`)), + addWarningToast(t('Can not move top level tab into nested tabs')), ); } else if ( destination && diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/Footer/Footer.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/Footer/Footer.tsx index 4c2f774a62758..aed85af3a2500 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/Footer/Footer.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/Footer/Footer.tsx @@ -46,7 +46,7 @@ const Footer: FC = ({ onConfirm={onConfirmCancel} onDismiss={onDismiss} > - {t(`Are you sure you want to cancel?`)} + {t('Are you sure you want to cancel?')} ); } diff --git a/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx b/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx index 7a68b2663fe0e..66b4b390062bb 100644 --- a/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx +++ b/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx @@ -557,7 +557,7 @@ const AlertReportModal: FunctionComponent = ({ return; } - addSuccessToast(t(`${data.type} updated`)); + addSuccessToast(t('%s updated', data.type)); if (onAdd) { onAdd(); @@ -573,7 +573,7 @@ const AlertReportModal: FunctionComponent = ({ return; } - addSuccessToast(t(`${data.type} updated`)); + addSuccessToast(t('%s updated', data.type)); if (onAdd) { onAdd(response); diff --git a/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx b/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx index 413373aefce68..c91099a6d59cd 100644 --- a/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx +++ b/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx @@ -262,7 +262,7 @@ function AnnotationList({ - {t(`Annotation Layer ${annotationLayerName}`)} + {t('Annotation Layer %s', annotationLayerName)} {hasHistory ? ( Back to all diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx index 583b540579e99..dee6f4dd3f9dc 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx @@ -1035,7 +1035,7 @@ const DatabaseModal: FunctionComponent = ({ } validationMethods={{ onBlur: () => {} }} errorMessage={validationErrors?.password_needed} - label={t(`${database.slice(10)} PASSWORD`)} + label={t('%s PASSWORD', database.slice(10))} css={formScrollableStyles} /> diff --git a/superset-frontend/src/views/CRUD/welcome/EmptyState.tsx b/superset-frontend/src/views/CRUD/welcome/EmptyState.tsx index 379fbe3995a1d..525c9ef62e803 100644 --- a/superset-frontend/src/views/CRUD/welcome/EmptyState.tsx +++ b/superset-frontend/src/views/CRUD/welcome/EmptyState.tsx @@ -122,11 +122,10 @@ export default function EmptyState({ tableName, tab }: EmptyStateProps) { {tableName === 'SAVED_QUERIES' ? t('SQL query') - : t(`${tableName + : tableName .split('') .slice(0, tableName.length - 1) .join('')} - `)} )} From d49fd01ff3e3ee153e5e50352ec2151f028a5456 Mon Sep 17 00:00:00 2001 From: Stephen Liu <750188453@qq.com> Date: Mon, 11 Apr 2022 18:04:45 +0800 Subject: [PATCH 011/136] feat(CRUD): add new empty state (#19310) * feat(CRUD): add new empty state * fix ci * add svg license --- .../src/assets/images/filter-results.svg | 34 +++++++++++++++ .../src/components/Button/index.tsx | 1 + .../src/components/EmptyState/index.tsx | 19 ++++++-- .../src/components/ListView/Filters/Base.ts | 4 ++ .../components/ListView/Filters/DateRange.tsx | 27 +++++++++--- .../components/ListView/Filters/Search.tsx | 23 ++++++---- .../components/ListView/Filters/Select.tsx | 36 +++++++++++----- .../src/components/ListView/Filters/index.tsx | 36 +++++++++++++--- .../src/components/ListView/ListView.tsx | 43 +++++++++++++------ .../src/components/ListView/utils.ts | 1 + .../src/views/CRUD/alert/AlertList.tsx | 17 ++++---- .../views/CRUD/annotation/AnnotationList.tsx | 22 ++++------ .../annotationlayers/AnnotationLayersList.tsx | 20 +++------ 13 files changed, 196 insertions(+), 87 deletions(-) create mode 100644 superset-frontend/src/assets/images/filter-results.svg diff --git a/superset-frontend/src/assets/images/filter-results.svg b/superset-frontend/src/assets/images/filter-results.svg new file mode 100644 index 0000000000000..770a54b34f37f --- /dev/null +++ b/superset-frontend/src/assets/images/filter-results.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + diff --git a/superset-frontend/src/components/Button/index.tsx b/superset-frontend/src/components/Button/index.tsx index 30d4e3d9aca81..b8e428d6ca3a6 100644 --- a/superset-frontend/src/components/Button/index.tsx +++ b/superset-frontend/src/components/Button/index.tsx @@ -56,6 +56,7 @@ export interface ButtonProps { | 'rightTop' | 'rightBottom'; onClick?: OnClickHandler; + onMouseDown?: OnClickHandler; disabled?: boolean; buttonStyle?: ButtonStyle; buttonSize?: 'default' | 'small' | 'xsmall'; diff --git a/superset-frontend/src/components/EmptyState/index.tsx b/superset-frontend/src/components/EmptyState/index.tsx index 02c1d7c4a2959..7ba54567e438f 100644 --- a/superset-frontend/src/components/EmptyState/index.tsx +++ b/superset-frontend/src/components/EmptyState/index.tsx @@ -17,7 +17,7 @@ * under the License. */ -import React, { ReactNode } from 'react'; +import React, { ReactNode, SyntheticEvent } from 'react'; import { styled, css, SupersetTheme } from '@superset-ui/core'; import { Empty } from 'src/components'; import Button from 'src/components/Button'; @@ -140,6 +140,11 @@ const ImageContainer = ({ image, size }: ImageContainerProps) => ( /> ); +const handleMouseDown = (e: SyntheticEvent) => { + e.preventDefault(); + e.stopPropagation(); +}; + export const EmptyStateBig = ({ title, image, @@ -159,7 +164,11 @@ export const EmptyStateBig = ({ {title} {description && {description}} {buttonAction && buttonText && ( - + {buttonText} )} @@ -186,7 +195,11 @@ export const EmptyStateMedium = ({ {title} {description && {description}} {buttonText && buttonAction && ( - + {buttonText} )} diff --git a/superset-frontend/src/components/ListView/Filters/Base.ts b/superset-frontend/src/components/ListView/Filters/Base.ts index 03d805a751986..6baca649ffa16 100644 --- a/superset-frontend/src/components/ListView/Filters/Base.ts +++ b/superset-frontend/src/components/ListView/Filters/Base.ts @@ -31,3 +31,7 @@ export const FilterContainer = styled.div` align-items: center; width: ${SELECT_WIDTH}px; `; + +export type FilterHandler = { + clearFilter: () => void; +}; diff --git a/superset-frontend/src/components/ListView/Filters/DateRange.tsx b/superset-frontend/src/components/ListView/Filters/DateRange.tsx index c391d6ff67479..4dfaf11f79fdf 100644 --- a/superset-frontend/src/components/ListView/Filters/DateRange.tsx +++ b/superset-frontend/src/components/ListView/Filters/DateRange.tsx @@ -16,12 +16,17 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useState, useMemo } from 'react'; +import React, { + useState, + useMemo, + forwardRef, + useImperativeHandle, +} from 'react'; import moment, { Moment } from 'moment'; import { styled } from '@superset-ui/core'; import { RangePicker } from 'src/components/DatePicker'; import { FormLabel } from 'src/components/Form'; -import { BaseFilter } from './Base'; +import { BaseFilter, FilterHandler } from './Base'; interface DateRangeFilterProps extends BaseFilter { onSubmit: (val: number[]) => void; @@ -38,17 +43,23 @@ const RangeFilterContainer = styled.div` width: 360px; `; -export default function DateRangeFilter({ - Header, - initialValue, - onSubmit, -}: DateRangeFilterProps) { +function DateRangeFilter( + { Header, initialValue, onSubmit }: DateRangeFilterProps, + ref: React.RefObject, +) { const [value, setValue] = useState(initialValue ?? null); const momentValue = useMemo((): [Moment, Moment] | null => { if (!value || (Array.isArray(value) && !value.length)) return null; return [moment(value[0]), moment(value[1])]; }, [value]); + useImperativeHandle(ref, () => ({ + clearFilter: () => { + setValue(null); + onSubmit([]); + }, + })); + return ( {Header} @@ -72,3 +83,5 @@ export default function DateRangeFilter({ ); } + +export default forwardRef(DateRangeFilter); diff --git a/superset-frontend/src/components/ListView/Filters/Search.tsx b/superset-frontend/src/components/ListView/Filters/Search.tsx index f327ac4b39ebb..60cfe41bac070 100644 --- a/superset-frontend/src/components/ListView/Filters/Search.tsx +++ b/superset-frontend/src/components/ListView/Filters/Search.tsx @@ -16,13 +16,13 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useState } from 'react'; +import React, { forwardRef, useImperativeHandle, useState } from 'react'; import { t, styled } from '@superset-ui/core'; import Icons from 'src/components/Icons'; import { AntdInput } from 'src/components'; import { SELECT_WIDTH } from 'src/components/ListView/utils'; import { FormLabel } from 'src/components/Form'; -import { BaseFilter } from './Base'; +import { BaseFilter, FilterHandler } from './Base'; interface SearchHeaderProps extends BaseFilter { Header: string; @@ -42,12 +42,10 @@ const StyledInput = styled(AntdInput)` border-radius: ${({ theme }) => theme.gridUnit}px; `; -export default function SearchFilter({ - Header, - name, - initialValue, - onSubmit, -}: SearchHeaderProps) { +function SearchFilter( + { Header, name, initialValue, onSubmit }: SearchHeaderProps, + ref: React.RefObject, +) { const [value, setValue] = useState(initialValue || ''); const handleSubmit = () => { if (value) { @@ -61,6 +59,13 @@ export default function SearchFilter({ } }; + useImperativeHandle(ref, () => ({ + clearFilter: () => { + setValue(''); + onSubmit(''); + }, + })); + return ( {Header} @@ -78,3 +83,5 @@ export default function SearchFilter({ ); } + +export default forwardRef(SearchFilter); diff --git a/superset-frontend/src/components/ListView/Filters/Select.tsx b/superset-frontend/src/components/ListView/Filters/Select.tsx index b2e5e639d4307..525061fd27411 100644 --- a/superset-frontend/src/components/ListView/Filters/Select.tsx +++ b/superset-frontend/src/components/ListView/Filters/Select.tsx @@ -16,12 +16,17 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useState, useMemo } from 'react'; +import React, { + useState, + useMemo, + forwardRef, + useImperativeHandle, +} from 'react'; import { t } from '@superset-ui/core'; import { Select } from 'src/components'; import { Filter, SelectOption } from 'src/components/ListView/types'; import { FormLabel } from 'src/components/Form'; -import { FilterContainer, BaseFilter } from './Base'; +import { FilterContainer, BaseFilter, FilterHandler } from './Base'; interface SelectFilterProps extends BaseFilter { fetchSelects?: Filter['fetchSelects']; @@ -31,14 +36,17 @@ interface SelectFilterProps extends BaseFilter { selects: Filter['selects']; } -function SelectFilter({ - Header, - name, - fetchSelects, - initialValue, - onSelect, - selects = [], -}: SelectFilterProps) { +function SelectFilter( + { + Header, + name, + fetchSelects, + initialValue, + onSelect, + selects = [], + }: SelectFilterProps, + ref: React.RefObject, +) { const [selectedOption, setSelectedOption] = useState(initialValue); const onChange = (selected: SelectOption) => { @@ -53,6 +61,12 @@ function SelectFilter({ setSelectedOption(undefined); }; + useImperativeHandle(ref, () => ({ + clearFilter: () => { + onClear(); + }, + })); + const fetchAndFormatSelects = useMemo( () => async (inputValue: string, page: number, pageSize: number) => { if (fetchSelects) { @@ -88,4 +102,4 @@ function SelectFilter({ ); } -export default SelectFilter; +export default forwardRef(SelectFilter); diff --git a/superset-frontend/src/components/ListView/Filters/index.tsx b/superset-frontend/src/components/ListView/Filters/index.tsx index 5b630ebe9c271..348ed3850dd26 100644 --- a/superset-frontend/src/components/ListView/Filters/index.tsx +++ b/superset-frontend/src/components/ListView/Filters/index.tsx @@ -16,7 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; +import React, { + createRef, + forwardRef, + useImperativeHandle, + useMemo, +} from 'react'; import { withTheme } from '@superset-ui/core'; import { @@ -28,6 +33,7 @@ import { import SearchFilter from './Search'; import SelectFilter from './Select'; import DateRangeFilter from './DateRange'; +import { FilterHandler } from './Base'; interface UIFiltersProps { filters: Filters; @@ -35,11 +41,24 @@ interface UIFiltersProps { updateFilterValue: (id: number, value: FilterValue['value']) => void; } -function UIFilters({ - filters, - internalFilters = [], - updateFilterValue, -}: UIFiltersProps) { +function UIFilters( + { filters, internalFilters = [], updateFilterValue }: UIFiltersProps, + ref: React.RefObject<{ clearFilters: () => void }>, +) { + const filterRefs = useMemo( + () => + Array.from({ length: filters.length }, () => createRef()), + [filters.length], + ); + + useImperativeHandle(ref, () => ({ + clearFilters: () => { + filterRefs.forEach((filter: any) => { + filter.current?.clearFilter?.(); + }); + }, + })); + return ( <> {filters.map( @@ -49,6 +68,7 @@ function UIFilters({ if (input === 'select') { return ( { defaultViewMode?: ViewModeType; highlightRowId?: number; showThumbnails?: boolean; - emptyState?: { - message?: string; - slot?: React.ReactNode; - }; + emptyState?: EmptyStateProps; } function ListView({ @@ -248,7 +244,7 @@ function ListView({ cardSortSelectOptions, defaultViewMode = 'card', highlightRowId, - emptyState = {}, + emptyState, }: ListViewProps) { const { getTableProps, @@ -263,6 +259,7 @@ function ListView({ toggleAllRowsSelected, setViewMode, state: { pageIndex, pageSize, internalFilters, viewMode }, + query, } = useListViewState({ bulkSelectColumnConfig, bulkSelectMode: bulkSelectEnabled && Boolean(bulkActions.length), @@ -291,6 +288,14 @@ function ListView({ }); } + const filterControlsRef = useRef<{ clearFilters: () => void }>(null); + + const handleClearFilterControls = useCallback(() => { + if (query.filters) { + filterControlsRef.current?.clearFilters(); + } + }, [query.filters]); + const cardViewEnabled = Boolean(renderCard); useEffect(() => { @@ -308,6 +313,7 @@ function ListView({
{filterable && ( ({ )} {!loading && rows.length === 0 && ( - } - description={emptyState.message || t('No Data')} - > - {emptyState.slot || null} - + {query.filters ? ( + handleClearFilterControls()} + buttonText={t('clear all filters')} + /> + ) : ( + + )} )}
diff --git a/superset-frontend/src/components/ListView/utils.ts b/superset-frontend/src/components/ListView/utils.ts index 346bde0982fc3..78873f51f14a0 100644 --- a/superset-frontend/src/components/ListView/utils.ts +++ b/superset-frontend/src/components/ListView/utils.ts @@ -378,6 +378,7 @@ export function useListViewState({ toggleAllRowsSelected, applyFilterValue, setViewMode, + query, }; } diff --git a/superset-frontend/src/views/CRUD/alert/AlertList.tsx b/superset-frontend/src/views/CRUD/alert/AlertList.tsx index 2d84cb0b976df..f0f9d7423b24b 100644 --- a/superset-frontend/src/views/CRUD/alert/AlertList.tsx +++ b/superset-frontend/src/views/CRUD/alert/AlertList.tsx @@ -22,7 +22,6 @@ import { useHistory } from 'react-router-dom'; import { t, SupersetClient, makeApi, styled } from '@superset-ui/core'; import moment from 'moment'; import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar'; -import Button from 'src/components/Button'; import FacePile from 'src/components/FacePile'; import { Tooltip } from 'src/components/Tooltip'; import ListView, { @@ -366,15 +365,15 @@ function AlertList({ }); } - const EmptyStateButton = ( - - ); - const emptyState = { - message: t('No %s yet', titlePlural), - slot: canCreate ? EmptyStateButton : null, + title: t('No %s yet', titlePlural), + image: 'filter-results.svg', + buttonAction: () => handleAlertEdit(null), + buttonText: canCreate ? ( + <> + {title}{' '} + + ) : null, }; const filters: Filters = useMemo( diff --git a/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx b/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx index c91099a6d59cd..a4599b9ff5dfb 100644 --- a/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx +++ b/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx @@ -24,7 +24,6 @@ import moment from 'moment'; import rison from 'rison'; import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar'; -import Button from 'src/components/Button'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import DeleteModal from 'src/components/DeleteModal'; import ListView, { ListViewProps } from 'src/components/ListView'; @@ -239,22 +238,17 @@ function AnnotationList({ hasHistory = false; } - const EmptyStateButton = ( - - ); - - const emptyState = { - message: t('No annotation yet'), - slot: EmptyStateButton, + ), }; return ( diff --git a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx index b93e31d38017b..0265682dc7e6c 100644 --- a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx +++ b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx @@ -32,7 +32,6 @@ import ListView, { Filters, FilterOperator, } from 'src/components/ListView'; -import Button from 'src/components/Button'; import DeleteModal from 'src/components/DeleteModal'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import AnnotationLayerModal from './AnnotationLayerModal'; @@ -311,22 +310,15 @@ function AnnotationLayersList({ [], ); - const EmptyStateButton = ( - - ); - - const emptyState = { - message: t('No annotation layers yet'), - slot: EmptyStateButton, + ), }; const onLayerAdd = (id?: number) => { From 03a80d5d2f4b4f2a066721f3aec1207c652c6186 Mon Sep 17 00:00:00 2001 From: Geido <60598000+geido@users.noreply.github.com> Date: Mon, 11 Apr 2022 16:45:09 +0300 Subject: [PATCH 012/136] chore: Update font-sizes in QueryPreviewModal (#19620) * Remove hacky font-sizes * Remove hacky font-size SavedQueryPreviewModal --- .../src/views/CRUD/data/query/QueryPreviewModal.tsx | 4 ++-- .../src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/superset-frontend/src/views/CRUD/data/query/QueryPreviewModal.tsx b/superset-frontend/src/views/CRUD/data/query/QueryPreviewModal.tsx index 397970e1b2941..694b490557001 100644 --- a/superset-frontend/src/views/CRUD/data/query/QueryPreviewModal.tsx +++ b/superset-frontend/src/views/CRUD/data/query/QueryPreviewModal.tsx @@ -30,14 +30,14 @@ import { QueryObject } from 'src/views/CRUD/types'; const QueryTitle = styled.div` color: ${({ theme }) => theme.colors.secondary.light2}; - font-size: ${({ theme }) => theme.typography.sizes.s - 1}px; + font-size: ${({ theme }) => theme.typography.sizes.s}px; margin-bottom: 0; text-transform: uppercase; `; const QueryLabel = styled.div` color: ${({ theme }) => theme.colors.grayscale.dark2}; - font-size: ${({ theme }) => theme.typography.sizes.m - 1}px; + font-size: ${({ theme }) => theme.typography.sizes.m}px; padding: 4px 0 24px 0; `; diff --git a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx index 883ce3b695005..e8250d0fb7f80 100644 --- a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx +++ b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx @@ -28,14 +28,14 @@ import { useQueryPreviewState } from 'src/views/CRUD/data/hooks'; const QueryTitle = styled.div` color: ${({ theme }) => theme.colors.secondary.light2}; - font-size: ${({ theme }) => theme.typography.sizes.s - 1}px; + font-size: ${({ theme }) => theme.typography.sizes.s}px; margin-bottom: 0; text-transform: uppercase; `; const QueryLabel = styled.div` color: ${({ theme }) => theme.colors.grayscale.dark2}; - font-size: ${({ theme }) => theme.typography.sizes.m - 1}px; + font-size: ${({ theme }) => theme.typography.sizes.m}px; padding: 4px 0 16px 0; `; From d1e17646e2fa90362cd10dd92a4b99e6be325572 Mon Sep 17 00:00:00 2001 From: Geido <60598000+geido@users.noreply.github.com> Date: Mon, 11 Apr 2022 16:45:25 +0300 Subject: [PATCH 013/136] Remove hacky usage of font-size (#19615) --- superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx b/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx index 66b4b390062bb..6d87acfa484aa 100644 --- a/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx +++ b/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx @@ -261,7 +261,7 @@ export const StyledInputContainer = styled.div` .helper { display: block; color: ${({ theme }) => theme.colors.grayscale.base}; - font-size: ${({ theme }) => theme.typography.sizes.s - 1}px; + font-size: ${({ theme }) => theme.typography.sizes.s}px; padding: ${({ theme }) => theme.gridUnit}px 0; text-align: left; } From 5d418b21a30090b0802288add1ae8825a9cacdc2 Mon Sep 17 00:00:00 2001 From: Geido <60598000+geido@users.noreply.github.com> Date: Mon, 11 Apr 2022 16:45:52 +0300 Subject: [PATCH 014/136] Remove font-size hacky usage (#19611) --- superset-frontend/src/components/ImportModal/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-frontend/src/components/ImportModal/index.tsx b/superset-frontend/src/components/ImportModal/index.tsx index da32756df1458..e8c29b94e9561 100644 --- a/superset-frontend/src/components/ImportModal/index.tsx +++ b/superset-frontend/src/components/ImportModal/index.tsx @@ -29,7 +29,7 @@ import { ImportResourceName } from 'src/views/CRUD/types'; const HelperMessage = styled.div` display: block; color: ${({ theme }) => theme.colors.grayscale.base}; - font-size: ${({ theme }) => theme.typography.sizes.s - 1}px; + font-size: ${({ theme }) => theme.typography.sizes.s}px; `; const StyledInputContainer = styled.div` From d693f4e9700e932a29cb51583b26e10793aeab17 Mon Sep 17 00:00:00 2001 From: Geido <60598000+geido@users.noreply.github.com> Date: Mon, 11 Apr 2022 16:46:06 +0300 Subject: [PATCH 015/136] Update font-sizes (#19593) --- .../src/components/ReportModal/styles.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/superset-frontend/src/components/ReportModal/styles.tsx b/superset-frontend/src/components/ReportModal/styles.tsx index b37939b4feca6..f9edf3736d0ec 100644 --- a/superset-frontend/src/components/ReportModal/styles.tsx +++ b/superset-frontend/src/components/ReportModal/styles.tsx @@ -33,7 +33,7 @@ export const StyledTopSection = styled.div` padding: ${({ theme }) => `${theme.gridUnit * 3}px ${theme.gridUnit * 4}px ${theme.gridUnit * 2}px`}; label { - font-size: ${({ theme }) => theme.typography.sizes.s - 1}px; + font-size: ${({ theme }) => theme.typography.sizes.s}px; color: ${({ theme }) => theme.colors.grayscale.light1}; } `; @@ -46,7 +46,7 @@ export const StyledBottomSection = styled.div` width: 100%; } .control-label { - font-size: ${({ theme }) => theme.typography.sizes.s - 1}px; + font-size: ${({ theme }) => theme.typography.sizes.s}px; color: ${({ theme }) => theme.colors.grayscale.light1}; } `; @@ -113,15 +113,15 @@ export const antDErrorAlertStyles = (theme: SupersetTheme) => css` margin: ${theme.gridUnit * 8}px ${theme.gridUnit * 4}px; color: ${theme.colors.error.dark2}; .ant-alert-message { - font-size: ${theme.typography.sizes.s + 1}px; + font-size: ${theme.typography.sizes.m}px; font-weight: bold; } .ant-alert-description { - font-size: ${theme.typography.sizes.s + 1}px; + font-size: ${theme.typography.sizes.m}px; line-height: ${theme.gridUnit * 4}px; .ant-alert-icon { margin-right: ${theme.gridUnit * 2.5}px; - font-size: ${theme.typography.sizes.l + 1}px; + font-size: ${theme.typography.sizes.l}px; position: relative; top: ${theme.gridUnit / 4}px; } From 4bf4d58423e39c3cf3b592adece41049984ffced Mon Sep 17 00:00:00 2001 From: AAfghahi <48933336+AAfghahi@users.noreply.github.com> Date: Mon, 11 Apr 2022 16:50:59 -0400 Subject: [PATCH 016/136] fix: update Permissions for right nav (#19051) * draft pr * finished styling * add filter * added testing * added tests * added permissions tests * Empty-Commit * new test * Update superset-frontend/src/views/components/MenuRight.tsx Co-authored-by: Elizabeth Thompson * revisions * added to CRUD view Co-authored-by: Elizabeth Thompson --- .../src/dashboard/util/findPermission.ts | 2 +- .../CRUD/data/database/DatabaseList.test.jsx | 36 +- .../views/CRUD/data/database/DatabaseList.tsx | 42 +- .../src/views/components/Menu.test.tsx | 6 + .../src/views/components/Menu.tsx | 1 + .../src/views/components/MenuRight.tsx | 94 ++++- .../src/views/components/SubMenu.tsx | 25 +- superset/databases/api.py | 10 +- superset/databases/filters.py | 73 +++- .../integration_tests/databases/api_tests.py | 358 ++++++++++++++++++ tests/integration_tests/security_tests.py | 3 +- 11 files changed, 602 insertions(+), 48 deletions(-) diff --git a/superset-frontend/src/dashboard/util/findPermission.ts b/superset-frontend/src/dashboard/util/findPermission.ts index 8f28a03c99337..d3a8b61eca94a 100644 --- a/superset-frontend/src/dashboard/util/findPermission.ts +++ b/superset-frontend/src/dashboard/util/findPermission.ts @@ -36,7 +36,7 @@ export default findPermission; // but is hardcoded in backend logic already, so... const ADMIN_ROLE_NAME = 'admin'; -const isUserAdmin = (user: UserWithPermissionsAndRoles) => +export const isUserAdmin = (user: UserWithPermissionsAndRoles) => Object.keys(user.roles).some(role => role.toLowerCase() === ADMIN_ROLE_NAME); const isUserDashboardOwner = ( diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx index fa8721e9e3dad..5fe6ead7fdf08 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx @@ -18,7 +18,7 @@ */ import React from 'react'; import thunk from 'redux-thunk'; -import * as redux from 'react-redux'; +import * as reactRedux from 'react-redux'; import configureStore from 'redux-mock-store'; import fetchMock from 'fetch-mock'; import { Provider } from 'react-redux'; @@ -34,6 +34,7 @@ import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; import { act } from 'react-dom/test-utils'; // store needed for withToasts(DatabaseList) + const mockStore = configureStore([thunk]); const store = mockStore({}); @@ -63,10 +64,6 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); -const mockUser = { - userId: 1, -}; - fetchMock.get(databasesInfoEndpoint, { permissions: ['can_write'], }); @@ -91,7 +88,13 @@ fetchMock.get(databaseRelatedEndpoint, { }, }); -const useSelectorMock = jest.spyOn(redux, 'useSelector'); +fetchMock.get( + 'glob:*api/v1/database/?q=(filters:!((col:allow_file_upload,opr:upload_is_enabled,value:!t)))', + {}, +); + +const useSelectorMock = jest.spyOn(reactRedux, 'useSelector'); +const userSelectorMock = jest.spyOn(reactRedux, 'useSelector'); describe('DatabaseList', () => { useSelectorMock.mockReturnValue({ @@ -100,10 +103,27 @@ describe('DatabaseList', () => { COLUMNAR_EXTENSIONS: ['parquet', 'zip'], ALLOWED_EXTENSIONS: ['parquet', 'zip', 'xls', 'xlsx', 'csv'], }); + userSelectorMock.mockReturnValue({ + createdOn: '2021-04-27T18:12:38.952304', + email: 'admin', + firstName: 'admin', + isActive: true, + lastName: 'admin', + permissions: {}, + roles: { + Admin: [ + ['can_sqllab', 'Superset'], + ['can_write', 'Dashboard'], + ['can_write', 'Chart'], + ], + }, + userId: 1, + username: 'admin', + }); const wrapper = mount( - + , ); @@ -129,7 +149,7 @@ describe('DatabaseList', () => { it('fetches Databases', () => { const callsD = fetchMock.calls(/database\/\?q/); - expect(callsD).toHaveLength(1); + expect(callsD).toHaveLength(2); expect(callsD[0][0]).toMatchInlineSnapshot( `"http://localhost/api/v1/database/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25)"`, ); diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx index f980295cc2035..df4ef3cf02a40 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx @@ -17,7 +17,8 @@ * under the License. */ import { SupersetClient, t, styled } from '@superset-ui/core'; -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; +import rison from 'rison'; import { useSelector } from 'react-redux'; import Loading from 'src/components/Loading'; import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; @@ -28,6 +29,7 @@ import SubMenu, { SubMenuProps } from 'src/views/components/SubMenu'; import DeleteModal from 'src/components/DeleteModal'; import { Tooltip } from 'src/components/Tooltip'; import Icons from 'src/components/Icons'; +import { isUserAdmin } from 'src/dashboard/util/findPermission'; import ListView, { FilterOperator, Filters } from 'src/components/ListView'; import { commonMenuData } from 'src/views/CRUD/data/common'; import handleResourceExport from 'src/utils/export'; @@ -85,16 +87,22 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { t('database'), addDangerToast, ); + const user = useSelector( + state => state.user, + ); + const [databaseModalOpen, setDatabaseModalOpen] = useState(false); const [databaseCurrentlyDeleting, setDatabaseCurrentlyDeleting] = useState(null); const [currentDatabase, setCurrentDatabase] = useState( null, ); + const [allowUploads, setAllowUploads] = useState(false); + const isAdmin = isUserAdmin(user); + const showUploads = allowUploads || isAdmin; + const [preparingExport, setPreparingExport] = useState(false); - const { roles } = useSelector( - state => state.user, - ); + const { roles } = user; const { CSV_EXTENSIONS, COLUMNAR_EXTENSIONS, @@ -163,6 +171,8 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { ALLOWED_EXTENSIONS, ); + const isDisabled = isAdmin && !allowUploads; + const uploadDropdownMenu = [ { label: t('Upload file to database'), @@ -171,24 +181,42 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { label: t('Upload CSV'), name: 'Upload CSV file', url: '/csvtodatabaseview/form', - perm: canUploadCSV, + perm: canUploadCSV && showUploads, + disable: isDisabled, }, { label: t('Upload columnar file'), name: 'Upload columnar file', url: '/columnartodatabaseview/form', - perm: canUploadColumnar, + perm: canUploadColumnar && showUploads, + disable: isDisabled, }, { label: t('Upload Excel file'), name: 'Upload Excel file', url: '/exceltodatabaseview/form', - perm: canUploadExcel, + perm: canUploadExcel && showUploads, + disable: isDisabled, }, ], }, ]; + const hasFileUploadEnabled = () => { + const payload = { + filters: [ + { col: 'allow_file_upload', opr: 'upload_is_enabled', value: true }, + ], + }; + SupersetClient.get({ + endpoint: `/api/v1/database/?q=${rison.encode(payload)}`, + }).then(({ json }: Record) => { + setAllowUploads(json.count >= 1); + }); + }; + + useEffect(() => hasFileUploadEnabled(), [databaseModalOpen]); + const filteredDropDown = uploadDropdownMenu.map(link => { // eslint-disable-next-line no-param-reassign link.childs = link.childs.filter(item => item.perm); diff --git a/superset-frontend/src/views/components/Menu.test.tsx b/superset-frontend/src/views/components/Menu.test.tsx index d13275fbc0b57..a80a43a22f02c 100644 --- a/superset-frontend/src/views/components/Menu.test.tsx +++ b/superset-frontend/src/views/components/Menu.test.tsx @@ -18,6 +18,7 @@ */ import React from 'react'; import * as reactRedux from 'react-redux'; +import fetchMock from 'fetch-mock'; import { render, screen } from 'spec/helpers/testing-library'; import userEvent from '@testing-library/user-event'; import { Menu } from './Menu'; @@ -235,6 +236,11 @@ const notanonProps = { const useSelectorMock = jest.spyOn(reactRedux, 'useSelector'); +fetchMock.get( + 'glob:*api/v1/database/?q=(filters:!((col:allow_file_upload,opr:upload_is_enabled,value:!t)))', + {}, +); + beforeEach(() => { // setup a DOM element as a render target useSelectorMock.mockClear(); diff --git a/superset-frontend/src/views/components/Menu.tsx b/superset-frontend/src/views/components/Menu.tsx index 55d929e7b7bc4..d26742096acc7 100644 --- a/superset-frontend/src/views/components/Menu.tsx +++ b/superset-frontend/src/views/components/Menu.tsx @@ -75,6 +75,7 @@ export interface MenuObjectChildProps { isFrontendRoute?: boolean; perm?: string | boolean; view?: string; + disable?: boolean; } export interface MenuObjectProps extends MenuObjectChildProps { diff --git a/superset-frontend/src/views/components/MenuRight.tsx b/superset-frontend/src/views/components/MenuRight.tsx index 4628a47e2402b..9d657aa9b7df2 100644 --- a/superset-frontend/src/views/components/MenuRight.tsx +++ b/superset-frontend/src/views/components/MenuRight.tsx @@ -16,12 +16,20 @@ * specific language governing permissions and limitations * under the License. */ -import React, { Fragment, useState } from 'react'; +import React, { Fragment, useState, useEffect } from 'react'; +import rison from 'rison'; import { MainNav as Menu } from 'src/components/Menu'; -import { t, styled, css, SupersetTheme } from '@superset-ui/core'; +import { + t, + styled, + css, + SupersetTheme, + SupersetClient, +} from '@superset-ui/core'; +import { Tooltip } from 'src/components/Tooltip'; import { Link } from 'react-router-dom'; import Icons from 'src/components/Icons'; -import findPermission from 'src/dashboard/util/findPermission'; +import findPermission, { isUserAdmin } from 'src/dashboard/util/findPermission'; import { useSelector } from 'react-redux'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import LanguagePicker from './LanguagePicker'; @@ -45,6 +53,15 @@ const StyledI = styled.div` color: ${({ theme }) => theme.colors.primary.dark1}; `; +const styledDisabled = (theme: SupersetTheme) => css` + color: ${theme.colors.grayscale.base}; + backgroundColor: ${theme.colors.grayscale.light2}}; + .ant-menu-item:hover { + color: ${theme.colors.grayscale.base}; + cursor: default; + } +`; + const StyledDiv = styled.div<{ align: string }>` display: flex; flex-direction: row; @@ -69,9 +86,11 @@ const RightMenu = ({ navbarRight, isFrontendRoute, }: RightMenuProps) => { - const { roles } = useSelector( + const user = useSelector( state => state.user, ); + + const { roles } = user; const { CSV_EXTENSIONS, COLUMNAR_EXTENSIONS, @@ -96,6 +115,9 @@ const RightMenu = ({ const canUpload = canUploadCSV || canUploadColumnar || canUploadExcel; const showActionDropdown = canSql || canChart || canDashboard; + const [allowUploads, setAllowUploads] = useState(false); + const isAdmin = isUserAdmin(user); + const showUploads = allowUploads || isAdmin; const dropdownItems: MenuObjectProps[] = [ { label: t('Data'), @@ -115,19 +137,19 @@ const RightMenu = ({ label: t('Upload CSV to database'), name: 'Upload a CSV', url: '/csvtodatabaseview/form', - perm: canUploadCSV, + perm: CSV_EXTENSIONS && showUploads, }, { label: t('Upload columnar file to database'), name: 'Upload a Columnar file', url: '/columnartodatabaseview/form', - perm: canUploadColumnar, + perm: COLUMNAR_EXTENSIONS && showUploads, }, { label: t('Upload Excel file to database'), name: 'Upload Excel', url: '/exceltodatabaseview/form', - perm: canUploadExcel, + perm: EXCEL_EXTENSIONS && showUploads, }, ], }, @@ -154,6 +176,21 @@ const RightMenu = ({ }, ]; + const hasFileUploadEnabled = () => { + const payload = { + filters: [ + { col: 'allow_file_upload', opr: 'upload_is_enabled', value: true }, + ], + }; + SupersetClient.get({ + endpoint: `/api/v1/database/?q=${rison.encode(payload)}`, + }).then(({ json }: Record) => { + setAllowUploads(json.count >= 1); + }); + }; + + useEffect(() => hasFileUploadEnabled(), []); + const menuIconAndLabel = (menu: MenuObjectProps) => ( <> @@ -175,6 +212,34 @@ const RightMenu = ({ setShowModal(false); }; + const isDisabled = isAdmin && !allowUploads; + + const tooltipText = t( + "Enable 'Allow data upload' in any database's settings", + ); + + const buildMenuItem = (item: Record) => { + const disabledText = isDisabled && item.url; + return disabledText ? ( + + + {item.label} + + + ) : ( + + {item.url ? {item.label} : item.label} + + ); + }; + + const onMenuOpen = (openKeys: string[]) => { + if (openKeys.length) { + return hasFileUploadEnabled(); + } + return null; + }; + return ( - + {!navbarRight.user_is_anonymous && showActionDropdown && ( {idx === 2 && } - - {item.url ? ( - {item.label} - ) : ( - item.label - )} - + {buildMenuItem(item)} ) : null, )} diff --git a/superset-frontend/src/views/components/SubMenu.tsx b/superset-frontend/src/views/components/SubMenu.tsx index 4ad3cfe42ede5..9dc5b41cdcfb2 100644 --- a/superset-frontend/src/views/components/SubMenu.tsx +++ b/superset-frontend/src/views/components/SubMenu.tsx @@ -18,8 +18,9 @@ */ import React, { ReactNode, useState, useEffect } from 'react'; import { Link, useHistory } from 'react-router-dom'; -import { styled } from '@superset-ui/core'; +import { styled, SupersetTheme, css, t } from '@superset-ui/core'; import cx from 'classnames'; +import { Tooltip } from 'src/components/Tooltip'; import { debounce } from 'lodash'; import { Row } from 'src/components'; import { Menu, MenuMode, MainNav as DropdownMenu } from 'src/components/Menu'; @@ -144,6 +145,15 @@ const StyledHeader = styled.div` } `; +const styledDisabled = (theme: SupersetTheme) => css` + color: ${theme.colors.grayscale.base}; + backgroundColor: ${theme.colors.grayscale.light2}}; + .ant-menu-item:hover { + color: ${theme.colors.grayscale.base}; + cursor: default; + } +`; + type MenuChild = { label: string; name: string; @@ -271,7 +281,18 @@ const SubMenuComponent: React.FunctionComponent = props => { > {link.childs?.map(item => { if (typeof item === 'object') { - return ( + return item.disable ? ( + + + {item.label} + + + ) : ( {item.label} diff --git a/superset/databases/api.py b/superset/databases/api.py index 0de8bcf83e9ba..63fcecedc4865 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -51,7 +51,7 @@ from superset.databases.commands.validate import ValidateDatabaseParametersCommand from superset.databases.dao import DatabaseDAO from superset.databases.decorators import check_datasource_access -from superset.databases.filters import DatabaseFilter +from superset.databases.filters import DatabaseFilter, DatabaseUploadEnabledFilter from superset.databases.schemas import ( database_schemas_query_schema, DatabaseFunctionNamesResponse, @@ -166,8 +166,16 @@ class DatabaseRestApi(BaseSupersetModelRestApi): "encrypted_extra", "server_cert", ] + edit_columns = add_columns + search_columns = ["allow_file_upload", "expose_in_sqllab"] + + search_filters = { + "allow_file_upload": [DatabaseUploadEnabledFilter], + "expose_in_sqllab": [DatabaseFilter], + } + list_select_columns = list_columns + ["extra", "sqlalchemy_uri", "password"] order_columns = [ "allow_file_upload", diff --git a/superset/databases/filters.py b/superset/databases/filters.py index bd0729767ee4e..228abbc3bfa81 100644 --- a/superset/databases/filters.py +++ b/superset/databases/filters.py @@ -16,32 +16,37 @@ # under the License. from typing import Any, Set +from flask import g +from flask_babel import lazy_gettext as _ from sqlalchemy import or_ from sqlalchemy.orm import Query +from sqlalchemy.sql.expression import cast +from sqlalchemy.sql.sqltypes import JSON -from superset import security_manager +from superset import app, security_manager +from superset.models.core import Database from superset.views.base import BaseFilter -class DatabaseFilter(BaseFilter): - # TODO(bogdan): consider caching. +def can_access_databases( + view_menu_name: str, +) -> Set[str]: + return { + security_manager.unpack_database_and_schema(vm).database + for vm in security_manager.user_view_menu_names(view_menu_name) + } + - def can_access_databases( # noqa pylint: disable=no-self-use - self, - view_menu_name: str, - ) -> Set[str]: - return { - security_manager.unpack_database_and_schema(vm).database - for vm in security_manager.user_view_menu_names(view_menu_name) - } +class DatabaseFilter(BaseFilter): # pylint: disable=too-few-public-methods + # TODO(bogdan): consider caching. def apply(self, query: Query, value: Any) -> Query: if security_manager.can_access_all_databases(): return query database_perms = security_manager.user_view_menu_names("database_access") - schema_access_databases = self.can_access_databases("schema_access") + schema_access_databases = can_access_databases("schema_access") - datasource_access_databases = self.can_access_databases("datasource_access") + datasource_access_databases = can_access_databases("datasource_access") return query.filter( or_( @@ -51,3 +56,45 @@ def apply(self, query: Query, value: Any) -> Query: ), ) ) + + +class DatabaseUploadEnabledFilter(BaseFilter): # pylint: disable=too-few-public-methods + """ + Custom filter for the GET list that filters all databases based on allow_file_upload + """ + + name = _("Upload Enabled") + arg_name = "upload_is_enabled" + + def apply(self, query: Query, value: Any) -> Query: + filtered_query = query.filter(Database.allow_file_upload) + + database_perms = security_manager.user_view_menu_names("database_access") + schema_access_databases = can_access_databases("schema_access") + datasource_access_databases = can_access_databases("datasource_access") + + if hasattr(g, "user"): + allowed_schemas = [ + app.config["ALLOWED_USER_CSV_SCHEMA_FUNC"](db, g.user) + for db in datasource_access_databases + ] + + if len(allowed_schemas): + return filtered_query + + filtered_query = filtered_query.filter( + or_( + cast(Database.extra, JSON)["schemas_allowed_for_file_upload"] + is not None, + cast(Database.extra, JSON)["schemas_allowed_for_file_upload"] != [], + ) + ) + + return filtered_query.filter( + or_( + self.model.perm.in_(database_perms), + self.model.database_name.in_( + [*schema_access_databases, *datasource_access_databases] + ), + ) + ) diff --git a/tests/integration_tests/databases/api_tests.py b/tests/integration_tests/databases/api_tests.py index 4f29600bdabb7..0c1dc27538d10 100644 --- a/tests/integration_tests/databases/api_tests.py +++ b/tests/integration_tests/databases/api_tests.py @@ -80,6 +80,7 @@ def insert_database( encrypted_extra: str = "", server_cert: str = "", expose_in_sqllab: bool = False, + allow_file_upload: bool = False, ) -> Database: database = Database( database_name=database_name, @@ -88,6 +89,7 @@ def insert_database( encrypted_extra=encrypted_extra, server_cert=server_cert, expose_in_sqllab=expose_in_sqllab, + allow_file_upload=allow_file_upload, ) db.session.add(database) db.session.commit() @@ -864,6 +866,362 @@ def test_get_select_star_not_found_table(self): # TODO(bkyryliuk): investigate why presto returns 500 self.assertEqual(rv.status_code, 404 if example_db.backend != "presto" else 500) + def test_get_allow_file_upload_filter(self): + """ + Database API: Test filter for allow file upload checks for schemas + """ + with self.create_app().app_context(): + example_db = get_example_database() + + extra = { + "metadata_params": {}, + "engine_params": {}, + "metadata_cache_timeout": {}, + "schemas_allowed_for_file_upload": ["public"], + } + self.login(username="admin") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + extra=json.dumps(extra), + allow_file_upload=True, + ) + db.session.commit() + yield database + + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 1 + db.session.delete(database) + db.session.commit() + + def test_get_allow_file_upload_filter_no_schema(self): + """ + Database API: Test filter for allow file upload checks for schemas. + This test has allow_file_upload but no schemas. + """ + with self.create_app().app_context(): + example_db = get_example_database() + + extra = { + "metadata_params": {}, + "engine_params": {}, + "metadata_cache_timeout": {}, + "schemas_allowed_for_file_upload": [], + } + self.login(username="admin") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + extra=json.dumps(extra), + allow_file_upload=True, + ) + db.session.commit() + yield database + + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 0 + db.session.delete(database) + db.session.commit() + + def test_get_allow_file_upload_filter_allow_file_false(self): + """ + Database API: Test filter for allow file upload checks for schemas. + This has a schema but does not allow_file_upload + """ + with self.create_app().app_context(): + example_db = get_example_database() + + extra = { + "metadata_params": {}, + "engine_params": {}, + "metadata_cache_timeout": {}, + "schemas_allowed_for_file_upload": ["public"], + } + self.login(username="admin") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + extra=json.dumps(extra), + allow_file_upload=False, + ) + db.session.commit() + yield database + + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 0 + db.session.delete(database) + db.session.commit() + + def test_get_allow_file_upload_false(self): + """ + Database API: Test filter for allow file upload checks for schemas. + Both databases have false allow_file_upload + """ + with self.create_app().app_context(): + example_db = get_example_database() + + extra = { + "metadata_params": {}, + "engine_params": {}, + "metadata_cache_timeout": {}, + "schemas_allowed_for_file_upload": [], + } + self.login(username="admin") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + extra=json.dumps(extra), + allow_file_upload=False, + ) + db.session.commit() + yield database + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 0 + db.session.delete(database) + db.session.commit() + + def test_get_allow_file_upload_false_no_extra(self): + """ + Database API: Test filter for allow file upload checks for schemas. + Both databases have false allow_file_upload + """ + with self.create_app().app_context(): + example_db = get_example_database() + + self.login(username="admin") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + allow_file_upload=False, + ) + db.session.commit() + yield database + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 0 + db.session.delete(database) + db.session.commit() + + def mock_csv_function(d, user): + return d.get_all_schema_names() + + @mock.patch( + "superset.views.core.app.config", + {**app.config, "ALLOWED_USER_CSV_SCHEMA_FUNC": mock_csv_function}, + ) + def test_get_allow_file_upload_true_csv(self): + """ + Database API: Test filter for allow file upload checks for schemas. + Both databases have false allow_file_upload + """ + with self.create_app().app_context(): + example_db = get_example_database() + + extra = { + "metadata_params": {}, + "engine_params": {}, + "metadata_cache_timeout": {}, + "schemas_allowed_for_file_upload": [], + } + self.login(username="admin") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + extra=json.dumps(extra), + allow_file_upload=True, + ) + db.session.commit() + yield database + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 1 + db.session.delete(database) + db.session.commit() + + def mock_empty_csv_function(d, user): + return [] + + @mock.patch( + "superset.views.core.app.config", + {**app.config, "ALLOWED_USER_CSV_SCHEMA_FUNC": mock_empty_csv_function}, + ) + def test_get_allow_file_upload_false_csv(self): + """ + Database API: Test filter for allow file upload checks for schemas. + Both databases have false allow_file_upload + """ + with self.create_app().app_context(): + self.login(username="admin") + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 0 + + def test_get_allow_file_upload_filter_no_permission(self): + """ + Database API: Test filter for allow file upload checks for schemas + """ + with self.create_app().app_context(): + example_db = get_example_database() + + extra = { + "metadata_params": {}, + "engine_params": {}, + "metadata_cache_timeout": {}, + "schemas_allowed_for_file_upload": ["public"], + } + self.login(username="gamma") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + extra=json.dumps(extra), + allow_file_upload=True, + ) + db.session.commit() + yield database + + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 0 + db.session.delete(database) + db.session.commit() + + def test_get_allow_file_upload_filter_with_permission(self): + """ + Database API: Test filter for allow file upload checks for schemas + """ + with self.create_app().app_context(): + main_db = get_main_database() + main_db.allow_file_upload = True + session = db.session + table = SqlaTable( + schema="public", + table_name="ab_permission", + database=get_main_database(), + ) + + session.add(table) + session.commit() + tmp_table_perm = security_manager.find_permission_view_menu( + "datasource_access", table.get_perm() + ) + gamma_role = security_manager.find_role("Gamma") + security_manager.add_permission_role(gamma_role, tmp_table_perm) + + self.login(username="gamma") + + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 1 + + # rollback changes + security_manager.del_permission_role(gamma_role, tmp_table_perm) + db.session.delete(table) + db.session.delete(main_db) + db.session.commit() + def test_database_schemas(self): """ Database API: Test database schemas diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py index 0b3a8b1d82d88..82b4d8717d14d 100644 --- a/tests/integration_tests/security_tests.py +++ b/tests/integration_tests/security_tests.py @@ -595,15 +595,16 @@ def test_public_sync_role_builtin_perms(self): for pvm in current_app.config["FAB_ROLES"]["TestRole"]: assert pvm in public_role_resource_names + @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") def test_sqllab_gamma_user_schema_access_to_sqllab(self): session = db.session - example_db = session.query(Database).filter_by(database_name="examples").one() example_db.expose_in_sqllab = True session.commit() arguments = { "keys": ["none"], + "columns": ["expose_in_sqllab"], "filters": [{"col": "expose_in_sqllab", "opr": "eq", "value": True}], "order_columns": "database_name", "order_direction": "asc", From d8b9e72682bdd92243d149b4e256ede30cba1bc9 Mon Sep 17 00:00:00 2001 From: smileydev <47900232+prosdev0107@users.noreply.github.com> Date: Mon, 11 Apr 2022 17:20:20 -0400 Subject: [PATCH 017/136] make to change the getBreakPoints of polygon chart (#19573) --- .../plugins/legacy-preset-chart-deckgl/src/utils.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utils.js b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utils.js index 5b3bc9dfdf123..4de17a9309b43 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utils.js +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utils.js @@ -48,10 +48,12 @@ export function getBreakPoints( const precision = delta === 0 ? 0 : Math.max(0, Math.ceil(Math.log10(1 / delta))); const extraBucket = maxValue > maxValue.toFixed(precision) ? 1 : 0; + const startValue = + minValue < minValue.toFixed(precision) ? minValue - 1 : minValue; return new Array(numBuckets + 1 + extraBucket) .fill() - .map((_, i) => (minValue + i * delta).toFixed(precision)); + .map((_, i) => (startValue + i * delta).toFixed(precision)); } return formDataBreakPoints.sort((a, b) => parseFloat(a) - parseFloat(b)); From 955413539b3edd892efd6bc069240efb5f5a29ac Mon Sep 17 00:00:00 2001 From: Diego Medina Date: Mon, 11 Apr 2022 17:20:41 -0400 Subject: [PATCH 018/136] fix: Table Autosizing Has Unnecessary Scroll Bars (#19628) --- .../plugin-chart-table/src/DataTable/hooks/useSticky.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx b/superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx index 240073210ea3b..9a98fee431817 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx @@ -183,7 +183,9 @@ function StickyWrap({ .clientHeight; const ths = bodyThead.childNodes[0] .childNodes as NodeListOf; - const widths = Array.from(ths).map(th => th.clientWidth); + const widths = Array.from(ths).map( + th => th.getBoundingClientRect()?.width || th.clientWidth, + ); const [hasVerticalScroll, hasHorizontalScroll] = needScrollBar({ width: maxWidth, height: maxHeight - theadHeight - tfootHeight, From 5c63df522a6df73e58142a1b9db62155c6ec5cd4 Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Mon, 11 Apr 2022 22:29:06 -0700 Subject: [PATCH 019/136] fix: allow_browser_login in import/export API (#19656) --- superset/importexport/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/superset/importexport/api.py b/superset/importexport/api.py index 156b4c21bd77f..c0021a8f88cd7 100644 --- a/superset/importexport/api.py +++ b/superset/importexport/api.py @@ -40,6 +40,7 @@ class ImportExportRestApi(BaseApi): resource_name = "assets" openapi_spec_tag = "Import/export" + allow_browser_login = True @expose("/export/", methods=["GET"]) @protect() From d7dd4119d4277dcd4682631de154b6aae27cbe69 Mon Sep 17 00:00:00 2001 From: Yongjie Zhao Date: Tue, 12 Apr 2022 18:19:22 +0800 Subject: [PATCH 020/136] fix: time comparision (#19659) --- .../utils/pandas_postprocessing/compare.py | 7 ++-- .../pandas_postprocessing/test_compare.py | 40 +++++++++---------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/superset/utils/pandas_postprocessing/compare.py b/superset/utils/pandas_postprocessing/compare.py index 18a66cec13456..2394aaa026e81 100644 --- a/superset/utils/pandas_postprocessing/compare.py +++ b/superset/utils/pandas_postprocessing/compare.py @@ -65,13 +65,12 @@ def compare( # pylint: disable=too-many-arguments c_df = df.loc[:, [c_col]] c_df.rename(columns={c_col: "__intermediate"}, inplace=True) if compare_type == PandasPostprocessingCompare.DIFF: - diff_df = c_df - s_df + diff_df = s_df - c_df elif compare_type == PandasPostprocessingCompare.PCT: - # https://en.wikipedia.org/wiki/Relative_change_and_difference#Percentage_change - diff_df = ((c_df - s_df) / s_df).astype(float).round(precision) + diff_df = ((s_df - c_df) / c_df).astype(float).round(precision) else: # compare_type == "ratio" - diff_df = (c_df / s_df).astype(float).round(precision) + diff_df = (s_df / c_df).astype(float).round(precision) diff_df.rename( columns={ diff --git a/tests/unit_tests/pandas_postprocessing/test_compare.py b/tests/unit_tests/pandas_postprocessing/test_compare.py index 970fa42f965e9..4f742bae16139 100644 --- a/tests/unit_tests/pandas_postprocessing/test_compare.py +++ b/tests/unit_tests/pandas_postprocessing/test_compare.py @@ -44,9 +44,9 @@ def test_compare_diff(): """ label y z difference__y__z 2019-01-01 x 2.0 2.0 0.0 - 2019-01-02 y 2.0 4.0 2.0 - 2019-01-05 z 2.0 10.0 8.0 - 2019-01-07 q 2.0 8.0 6.0 + 2019-01-02 y 2.0 4.0 -2.0 + 2019-01-05 z 2.0 10.0 -8.0 + 2019-01-07 q 2.0 8.0 -6.0 """ assert post_df.equals( pd.DataFrame( @@ -55,7 +55,7 @@ def test_compare_diff(): "label": ["x", "y", "z", "q"], "y": [2.0, 2.0, 2.0, 2.0], "z": [2.0, 4.0, 10.0, 8.0], - "difference__y__z": [0.0, 2.0, 8.0, 6.0], + "difference__y__z": [0.0, -2.0, -8.0, -6.0], }, ) ) @@ -73,7 +73,7 @@ def test_compare_diff(): index=timeseries_df2.index, data={ "label": ["x", "y", "z", "q"], - "difference__y__z": [0.0, 2.0, 8.0, 6.0], + "difference__y__z": [0.0, -2.0, -8.0, -6.0], }, ) ) @@ -90,9 +90,9 @@ def test_compare_percentage(): """ label y z percentage__y__z 2019-01-01 x 2.0 2.0 0.0 - 2019-01-02 y 2.0 4.0 1.0 - 2019-01-05 z 2.0 10.0 4.0 - 2019-01-07 q 2.0 8.0 3.0 + 2019-01-02 y 2.0 4.0 -0.50 + 2019-01-05 z 2.0 10.0 -0.80 + 2019-01-07 q 2.0 8.0 -0.75 """ assert post_df.equals( pd.DataFrame( @@ -101,7 +101,7 @@ def test_compare_percentage(): "label": ["x", "y", "z", "q"], "y": [2.0, 2.0, 2.0, 2.0], "z": [2.0, 4.0, 10.0, 8.0], - "percentage__y__z": [0.0, 1.0, 4.0, 3.0], + "percentage__y__z": [0.0, -0.50, -0.80, -0.75], }, ) ) @@ -117,10 +117,10 @@ def test_compare_ratio(): ) """ label y z ratio__y__z - 2019-01-01 x 2.0 2.0 1.0 - 2019-01-02 y 2.0 4.0 2.0 - 2019-01-05 z 2.0 10.0 5.0 - 2019-01-07 q 2.0 8.0 4.0 + 2019-01-01 x 2.0 2.0 1.00 + 2019-01-02 y 2.0 4.0 0.50 + 2019-01-05 z 2.0 10.0 0.20 + 2019-01-07 q 2.0 8.0 0.25 """ assert post_df.equals( pd.DataFrame( @@ -129,7 +129,7 @@ def test_compare_ratio(): "label": ["x", "y", "z", "q"], "y": [2.0, 2.0, 2.0, 2.0], "z": [2.0, 4.0, 10.0, 8.0], - "ratio__y__z": [1.0, 2.0, 5.0, 4.0], + "ratio__y__z": [1.00, 0.50, 0.20, 0.25], }, ) ) @@ -209,14 +209,14 @@ def test_compare_after_pivot(): difference__count_metric__sum_metric country UK US dttm - 2019-01-01 4 4 - 2019-01-02 4 4 + 2019-01-01 -4 -4 + 2019-01-02 -4 -4 """ flat_df = pp.flatten(compared_df) """ dttm difference__count_metric__sum_metric, UK difference__count_metric__sum_metric, US - 0 2019-01-01 4 4 - 1 2019-01-02 4 4 + 0 2019-01-01 -4 -4 + 1 2019-01-02 -4 -4 """ assert flat_df.equals( pd.DataFrame( @@ -224,10 +224,10 @@ def test_compare_after_pivot(): "dttm": pd.to_datetime(["2019-01-01", "2019-01-02"]), FLAT_COLUMN_SEPARATOR.join( ["difference__count_metric__sum_metric", "UK"] - ): [4, 4], + ): [-4, -4], FLAT_COLUMN_SEPARATOR.join( ["difference__count_metric__sum_metric", "US"] - ): [4, 4], + ): [-4, -4], } ) ) From 3a231f6b871cdab00b9dfb6192af76cf4cf9832a Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Tue, 12 Apr 2022 13:50:57 +0300 Subject: [PATCH 021/136] fix(database-api): allow search for all columns (#19662) --- superset/databases/api.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/superset/databases/api.py b/superset/databases/api.py index 63fcecedc4865..e5817afb5d13b 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -169,8 +169,6 @@ class DatabaseRestApi(BaseSupersetModelRestApi): edit_columns = add_columns - search_columns = ["allow_file_upload", "expose_in_sqllab"] - search_filters = { "allow_file_upload": [DatabaseUploadEnabledFilter], "expose_in_sqllab": [DatabaseFilter], From 87d47987b7800a183f3eebf2cfa7781d450e6e37 Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Tue, 12 Apr 2022 15:13:03 +0300 Subject: [PATCH 022/136] fix(sql-lab): do not replace undefined schema with empty object (#19664) --- superset-frontend/src/SqlLab/actions/sqlLab.js | 2 +- superset-frontend/src/SqlLab/types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.js b/superset-frontend/src/SqlLab/actions/sqlLab.js index e602b796bd368..3d1298e6c3b73 100644 --- a/superset-frontend/src/SqlLab/actions/sqlLab.js +++ b/superset-frontend/src/SqlLab/actions/sqlLab.js @@ -791,7 +791,7 @@ export function queryEditorSetSchema(queryEditor, schema) { dispatch({ type: QUERY_EDITOR_SET_SCHEMA, queryEditor: queryEditor || {}, - schema: schema || {}, + schema, }), ) .catch(() => diff --git a/superset-frontend/src/SqlLab/types.ts b/superset-frontend/src/SqlLab/types.ts index d5dfddbe2bbd5..6693089574602 100644 --- a/superset-frontend/src/SqlLab/types.ts +++ b/superset-frontend/src/SqlLab/types.ts @@ -61,7 +61,7 @@ export type Query = { query: { limit: number }; }; resultsKey: string | null; - schema: string; + schema?: string; sql: string; sqlEditorId: string; state: QueryState; From 7b0b029318a4cd6da7bbc54a79390c61322ded95 Mon Sep 17 00:00:00 2001 From: Geido <60598000+geido@users.noreply.github.com> Date: Tue, 12 Apr 2022 16:10:58 +0300 Subject: [PATCH 023/136] chore: Remove wrong usage of font-size in ExploreViewContainer (#19614) * Remove hacky usage of font-size * Update font --- .../src/explore/components/ExploreViewContainer/index.jsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx index 57437ea99f889..d789b350aab54 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx @@ -151,9 +151,7 @@ const ExplorePanelContainer = styled.div` padding: 0 ${theme.gridUnit * 4}px; justify-content: space-between; .horizontal-text { - text-transform: uppercase; - color: ${theme.colors.grayscale.light1}; - font-size: ${theme.typography.sizes.s * 4}; + font-size: ${theme.typography.sizes.s}px; } } .no-show { @@ -613,7 +611,7 @@ function ExploreViewContainer(props) { } >
- {t('Dataset')} + {t('Dataset')} Date: Tue, 12 Apr 2022 17:33:12 +0300 Subject: [PATCH 024/136] Remove TwoTone icons (#19666) --- superset-frontend/src/components/Icons/AntdEnhanced.tsx | 1 + superset-frontend/src/components/Icons/IconType.ts | 1 - superset-frontend/src/components/Icons/Icons.stories.tsx | 5 ----- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/superset-frontend/src/components/Icons/AntdEnhanced.tsx b/superset-frontend/src/components/Icons/AntdEnhanced.tsx index 6d72e002510c2..484e8ce6e37af 100644 --- a/superset-frontend/src/components/Icons/AntdEnhanced.tsx +++ b/superset-frontend/src/components/Icons/AntdEnhanced.tsx @@ -23,6 +23,7 @@ import { StyledIcon } from './Icon'; import IconType from './IconType'; const AntdEnhancedIcons = Object.keys(AntdIcons) + .filter(k => !k.includes('TwoTone')) .map(k => ({ [k]: (props: IconType) => ( diff --git a/superset-frontend/src/components/Icons/IconType.ts b/superset-frontend/src/components/Icons/IconType.ts index 7371007bf2bc3..41a4089e126af 100644 --- a/superset-frontend/src/components/Icons/IconType.ts +++ b/superset-frontend/src/components/Icons/IconType.ts @@ -21,7 +21,6 @@ import { IconComponentProps } from '@ant-design/icons/lib/components/Icon'; type AntdIconType = IconComponentProps; type IconType = AntdIconType & { iconColor?: string; - twoToneColor?: string; iconSize?: 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl'; }; diff --git a/superset-frontend/src/components/Icons/Icons.stories.tsx b/superset-frontend/src/components/Icons/Icons.stories.tsx index e2a3944eb4f1d..2012bd9b50037 100644 --- a/superset-frontend/src/components/Icons/Icons.stories.tsx +++ b/superset-frontend/src/components/Icons/Icons.stories.tsx @@ -78,11 +78,6 @@ InteractiveIcons.argTypes = { defaultValue: null, control: { type: 'select', options: palette }, }, - // @TODO twoToneColor is being ignored - twoToneColor: { - defaultValue: null, - control: { type: 'select', options: palette }, - }, theme: { table: { disable: true, From 59dda1fa05488c921cacc8791d761cd9f9b86e9c Mon Sep 17 00:00:00 2001 From: Geido <60598000+geido@users.noreply.github.com> Date: Tue, 12 Apr 2022 18:41:00 +0300 Subject: [PATCH 025/136] fix: Navbar styles and Welcome page text (#19586) * Enhance navbar styles * Clean up style --- .../src/assets/stylesheets/superset.less | 9 +- .../src/views/CRUD/welcome/Welcome.tsx | 36 ++-- .../src/views/components/Menu.tsx | 183 +++++++++--------- 3 files changed, 115 insertions(+), 113 deletions(-) diff --git a/superset-frontend/src/assets/stylesheets/superset.less b/superset-frontend/src/assets/stylesheets/superset.less index 97db49ec4463a..0cf419b30d190 100644 --- a/superset-frontend/src/assets/stylesheets/superset.less +++ b/superset-frontend/src/assets/stylesheets/superset.less @@ -539,12 +539,11 @@ td.filtered { width: 100% !important; } -// Remove this when the jinja menu/navbar is replaced with react. -// This style already exists in that view +/* +Hides the logo while loading the page. +Emotion styles will take care of the correct styling +*/ .navbar-brand { - display: flex; - flex-direction: column; - justify-content: center; display: none; } diff --git a/superset-frontend/src/views/CRUD/welcome/Welcome.tsx b/superset-frontend/src/views/CRUD/welcome/Welcome.tsx index 056e02e8f6226..2d564bc66fe9f 100644 --- a/superset-frontend/src/views/CRUD/welcome/Welcome.tsx +++ b/superset-frontend/src/views/CRUD/welcome/Welcome.tsx @@ -113,23 +113,27 @@ const WelcomeContainer = styled.div` `; const WelcomeNav = styled.div` - height: 50px; - background-color: white; - .navbar-brand { - margin-left: ${({ theme }) => theme.gridUnit * 2}px; - font-weight: ${({ theme }) => theme.typography.weights.bold}; - } - .switch { - float: right; - margin: ${({ theme }) => theme.gridUnit * 5}px; + ${({ theme }) => ` display: flex; - flex-direction: row; - span { - display: block; - margin: ${({ theme }) => theme.gridUnit * 1}px; - line-height: 1; + justify-content: space-between; + height: 50px; + background-color: ${theme.colors.grayscale.light5}; + .welcome-header { + font-size: ${theme.typography.sizes.l}px; + padding: ${theme.gridUnit * 4}px ${theme.gridUnit * 2 + 2}px; + margin: 0 ${theme.gridUnit * 2}px; } - } + .switch { + display: flex; + flex-direction: row; + margin: ${theme.gridUnit * 4}px; + span { + display: block; + margin: ${theme.gridUnit * 1}px; + line-height: 1; + } + } + `} `; export const LoadingCards = ({ cover }: LoadingProps) => ( @@ -275,7 +279,7 @@ function Welcome({ user, addDangerToast }: WelcomeProps) { return ( - Home +

Home

{isFeatureEnabled(FeatureFlag.THUMBNAILS) ? (
diff --git a/superset-frontend/src/views/components/Menu.tsx b/superset-frontend/src/views/components/Menu.tsx index d26742096acc7..3cc61f5243057 100644 --- a/superset-frontend/src/views/components/Menu.tsx +++ b/superset-frontend/src/views/components/Menu.tsx @@ -84,101 +84,100 @@ export interface MenuObjectProps extends MenuObjectChildProps { } const StyledHeader = styled.header` - background-color: white; - margin-bottom: 2px; - &:nth-last-of-type(2) nav { - margin-bottom: 2px; - } - - .caret { - display: none; - } - .navbar-brand { - display: flex; - flex-direction: column; - justify-content: center; - /* must be exactly the height of the Antd navbar */ - min-height: 50px; - padding: ${({ theme }) => - `${theme.gridUnit}px ${theme.gridUnit * 2}px ${theme.gridUnit}px ${ - theme.gridUnit * 4 - }px`}; - max-width: ${({ theme }) => `${theme.gridUnit * 37}px`}; - img { - height: 100%; - object-fit: contain; - } - } - .navbar-brand-text { - border-left: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; - border-right: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; - height: 100%; - color: ${({ theme }) => theme.colors.grayscale.dark1}; - padding-left: ${({ theme }) => theme.gridUnit * 4}px; - padding-right: ${({ theme }) => theme.gridUnit * 4}px; - margin-right: ${({ theme }) => theme.gridUnit * 6}px; - font-size: ${({ theme }) => theme.gridUnit * 4}px; - float: left; - display: flex; - flex-direction: column; - justify-content: center; - - span { - max-width: ${({ theme }) => theme.gridUnit * 58}px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - @media (max-width: 1127px) { - display: none; - } - } - .main-nav .ant-menu-submenu-title > svg { - top: ${({ theme }) => theme.gridUnit * 5.25}px; - } - @media (max-width: 767px) { - .navbar-brand { - float: none; - } - } - .ant-menu-horizontal .ant-menu-item { - height: 100%; - line-height: inherit; - } - .ant-menu > .ant-menu-item > a { - padding: ${({ theme }) => theme.gridUnit * 4}px; - } - @media (max-width: 767px) { - .ant-menu-item { - padding: 0 ${({ theme }) => theme.gridUnit * 6}px 0 - ${({ theme }) => theme.gridUnit * 3}px !important; - } - .ant-menu > .ant-menu-item > a { - padding: 0px; - } - .main-nav .ant-menu-submenu-title > svg:nth-child(1) { - display: none; - } - .ant-menu-item-active > a { - &:hover { - color: ${({ theme }) => theme.colors.primary.base} !important; - background-color: transparent !important; + ${({ theme }) => ` + background-color: ${theme.colors.grayscale.light5}; + margin-bottom: 2px; + &:nth-last-of-type(2) nav { + margin-bottom: 2px; } - } - } + .caret { + display: none; + } + .navbar-brand { + display: flex; + flex-direction: column; + justify-content: center; + /* must be exactly the height of the Antd navbar */ + min-height: 50px; + padding: ${theme.gridUnit}px ${theme.gridUnit * 2}px ${ + theme.gridUnit + }px ${theme.gridUnit * 4}px; + max-width: ${theme.gridUnit * 37}px; + img { + height: 100%; + object-fit: contain; + } + } + .navbar-brand-text { + border-left: 1px solid ${theme.colors.grayscale.light2}; + border-right: 1px solid ${theme.colors.grayscale.light2}; + height: 100%; + color: ${theme.colors.grayscale.dark1}; + padding-left: ${theme.gridUnit * 4}px; + padding-right: ${theme.gridUnit * 4}px; + margin-right: ${theme.gridUnit * 6}px; + font-size: ${theme.gridUnit * 4}px; + float: left; + display: flex; + flex-direction: column; + justify-content: center; - .ant-menu-item a { - &:hover { - color: ${({ theme }) => theme.colors.grayscale.dark1}; - background-color: ${({ theme }) => theme.colors.primary.light5}; - border-bottom: none; - margin: 0; - &:after { - opacity: 1; - width: 100%; + span { + max-width: ${theme.gridUnit * 58}px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + @media (max-width: 1127px) { + display: none; + } } - } - } + .main-nav .ant-menu-submenu-title > svg { + top: ${theme.gridUnit * 5.25}px; + } + @media (max-width: 767px) { + .navbar-brand { + float: none; + } + } + .ant-menu-horizontal .ant-menu-item { + height: 100%; + line-height: inherit; + } + .ant-menu > .ant-menu-item > a { + padding: ${theme.gridUnit * 4}px; + } + @media (max-width: 767px) { + .ant-menu-item { + padding: 0 ${theme.gridUnit * 6}px 0 + ${theme.gridUnit * 3}px !important; + } + .ant-menu > .ant-menu-item > a { + padding: 0px; + } + .main-nav .ant-menu-submenu-title > svg:nth-child(1) { + display: none; + } + .ant-menu-item-active > a { + &:hover { + color: ${theme.colors.primary.base} !important; + background-color: transparent !important; + } + } + } + .ant-menu-item a { + &:hover { + color: ${theme.colors.grayscale.dark1}; + background-color: ${theme.colors.primary.light5}; + border-bottom: none; + margin: 0; + &:after { + opacity: 1; + width: 100%; + } + } + } + `} `; const globalStyles = (theme: SupersetTheme) => css` .ant-menu-submenu.ant-menu-submenu-popup.ant-menu.ant-menu-light.ant-menu-submenu-placement-bottomLeft { From 224769bd452b831ae4ab4d7fc658b61805970b62 Mon Sep 17 00:00:00 2001 From: Lily Kuang Date: Tue, 12 Apr 2022 15:14:08 -0700 Subject: [PATCH 026/136] feat(embedded): API get embedded dashboard config by uuid (#19650) * feat(embedded): get embedded dashboard config by uuid * add tests and validation * remove accidentally commit * fix tests --- superset/embedded/api.py | 105 ++++++++++++++++++ .../embedded_dashboard/commands/exceptions.py | 34 ++++++ superset/initialization/__init__.py | 2 + superset/security/api.py | 8 +- superset/security/manager.py | 18 +++ tests/integration_tests/embedded/api_tests.py | 53 +++++++++ tests/integration_tests/security/api_tests.py | 29 ++++- 7 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 superset/embedded/api.py create mode 100644 superset/embedded_dashboard/commands/exceptions.py create mode 100644 tests/integration_tests/embedded/api_tests.py diff --git a/superset/embedded/api.py b/superset/embedded/api.py new file mode 100644 index 0000000000000..f7278d910a079 --- /dev/null +++ b/superset/embedded/api.py @@ -0,0 +1,105 @@ +# 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 logging +from typing import Optional + +from flask import Response +from flask_appbuilder.api import expose, protect, safe +from flask_appbuilder.hooks import before_request +from flask_appbuilder.models.sqla.interface import SQLAInterface + +from superset import is_feature_enabled +from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod +from superset.dashboards.schemas import EmbeddedDashboardResponseSchema +from superset.embedded.dao import EmbeddedDAO +from superset.embedded_dashboard.commands.exceptions import ( + EmbeddedDashboardNotFoundError, +) +from superset.extensions import event_logger +from superset.models.embedded_dashboard import EmbeddedDashboard +from superset.reports.logs.schemas import openapi_spec_methods_override +from superset.views.base_api import BaseSupersetModelRestApi, statsd_metrics + +logger = logging.getLogger(__name__) + + +class EmbeddedDashboardRestApi(BaseSupersetModelRestApi): + datamodel = SQLAInterface(EmbeddedDashboard) + + @before_request + def ensure_embedded_enabled(self) -> Optional[Response]: + if not is_feature_enabled("EMBEDDED_SUPERSET"): + return self.response_404() + return None + + include_route_methods = RouteMethod.GET + class_permission_name = "EmbeddedDashboard" + method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP + + resource_name = "embedded_dashboard" + allow_browser_login = True + + openapi_spec_tag = "Embedded Dashboard" + openapi_spec_methods = openapi_spec_methods_override + + embedded_response_schema = EmbeddedDashboardResponseSchema() + + @expose("/", methods=["GET"]) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_embedded", + log_to_statsd=False, + ) + # pylint: disable=arguments-differ, arguments-renamed) + def get(self, uuid: str) -> Response: + """Response + Returns the dashboard's embedded configuration + --- + get: + description: >- + Returns the dashboard's embedded configuration + parameters: + - in: path + schema: + type: string + name: uuid + description: The embedded configuration uuid + responses: + 200: + description: Result contains the embedded dashboard configuration + content: + application/json: + schema: + type: object + properties: + result: + $ref: '#/components/schemas/EmbeddedDashboardResponseSchema' + 401: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + try: + embedded = EmbeddedDAO.find_by_id(uuid) + if not embedded: + raise EmbeddedDashboardNotFoundError() + result = self.embedded_response_schema.dump(embedded) + return self.response(200, result=result) + except EmbeddedDashboardNotFoundError: + return self.response_404() diff --git a/superset/embedded_dashboard/commands/exceptions.py b/superset/embedded_dashboard/commands/exceptions.py new file mode 100644 index 0000000000000..e99dfa807cf49 --- /dev/null +++ b/superset/embedded_dashboard/commands/exceptions.py @@ -0,0 +1,34 @@ +# 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. +from typing import Optional + +from flask_babel import lazy_gettext as _ + +from superset.commands.exceptions import ForbiddenError, ObjectNotFoundError + + +class EmbeddedDashboardNotFoundError(ObjectNotFoundError): + def __init__( + self, + embedded_dashboard_uuid: Optional[str] = None, + exception: Optional[Exception] = None, + ) -> None: + super().__init__("EmbeddedDashboard", embedded_dashboard_uuid, exception) + + +class EmbeddedDashboardAccessDeniedError(ForbiddenError): + message = _("You don't have access to this embedded dashboard config.") diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 74b05e168804e..1bc6b0b82417d 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -141,6 +141,7 @@ def init_views(self) -> None: from superset.datasets.api import DatasetRestApi from superset.datasets.columns.api import DatasetColumnsRestApi from superset.datasets.metrics.api import DatasetMetricRestApi + from superset.embedded.api import EmbeddedDashboardRestApi from superset.embedded.view import EmbeddedView from superset.explore.form_data.api import ExploreFormDataRestApi from superset.explore.permalink.api import ExplorePermalinkRestApi @@ -208,6 +209,7 @@ def init_views(self) -> None: appbuilder.add_api(DatasetRestApi) appbuilder.add_api(DatasetColumnsRestApi) appbuilder.add_api(DatasetMetricRestApi) + appbuilder.add_api(EmbeddedDashboardRestApi) appbuilder.add_api(ExploreFormDataRestApi) appbuilder.add_api(ExplorePermalinkRestApi) appbuilder.add_api(FilterSetRestApi) diff --git a/superset/security/api.py b/superset/security/api.py index b919e29f78ddd..6411ccf7be56b 100644 --- a/superset/security/api.py +++ b/superset/security/api.py @@ -25,6 +25,9 @@ from marshmallow import EXCLUDE, fields, post_load, Schema, ValidationError from marshmallow_enum import EnumField +from superset.embedded_dashboard.commands.exceptions import ( + EmbeddedDashboardNotFoundError, +) from superset.extensions import event_logger from superset.security.guest_token import GuestTokenResourceType @@ -142,13 +145,16 @@ def guest_token(self) -> Response: """ try: body = guest_token_create_schema.load(request.json) + self.appbuilder.sm.validate_guest_token_resources(body["resources"]) + # todo validate stuff: - # make sure the resource ids are valid # make sure username doesn't reference an existing user # check rls rules for validity? token = self.appbuilder.sm.create_guest_access_token( body["user"], body["resources"], body["rls"] ) return self.response(200, token=token) + except EmbeddedDashboardNotFoundError as error: + return self.response_400(message=error.message) except ValidationError as error: return self.response_400(message=error.messages) diff --git a/superset/security/manager.py b/superset/security/manager.py index f57f1166ce394..48d43d01d0f76 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -1313,6 +1313,24 @@ def _get_guest_token_jwt_audience() -> str: audience = audience() return audience + @staticmethod + def validate_guest_token_resources(resources: GuestTokenResources) -> None: + # pylint: disable=import-outside-toplevel + from superset.embedded.dao import EmbeddedDAO + from superset.embedded_dashboard.commands.exceptions import ( + EmbeddedDashboardNotFoundError, + ) + from superset.models.dashboard import Dashboard + + for resource in resources: + if resource["type"] == GuestTokenResourceType.DASHBOARD.value: + # TODO (embedded): remove this check once uuids are rolled out + dashboard = Dashboard.get(str(resource["id"])) + if not dashboard: + embedded = EmbeddedDAO.find_by_id(str(resource["id"])) + if not embedded: + raise EmbeddedDashboardNotFoundError() + def create_guest_access_token( self, user: GuestTokenUser, diff --git a/tests/integration_tests/embedded/api_tests.py b/tests/integration_tests/embedded/api_tests.py new file mode 100644 index 0000000000000..8f3950fcf5462 --- /dev/null +++ b/tests/integration_tests/embedded/api_tests.py @@ -0,0 +1,53 @@ +# 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. +# isort:skip_file +"""Tests for security api methods""" +from unittest import mock + +import pytest + +from superset import db +from superset.embedded.dao import EmbeddedDAO +from superset.models.dashboard import Dashboard +from tests.integration_tests.base_tests import SupersetTestCase +from tests.integration_tests.fixtures.birth_names_dashboard import ( + load_birth_names_dashboard_with_slices, + load_birth_names_data, +) + + +class TestEmbeddedDashboardApi(SupersetTestCase): + resource_name = "embedded_dashboard" + + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") + @mock.patch.dict( + "superset.extensions.feature_flag_manager._feature_flags", + EMBEDDED_SUPERSET=True, + ) + def test_get_embedded_dashboard(self): + self.login("admin") + self.dash = db.session.query(Dashboard).filter_by(slug="births").first() + self.embedded = EmbeddedDAO.upsert(self.dash, []) + uri = f"api/v1/{self.resource_name}/{self.embedded.uuid}" + response = self.client.get(uri) + self.assert200(response) + + def test_get_embedded_dashboard_non_found(self): + self.login("admin") + uri = f"api/v1/{self.resource_name}/bad-uuid" + response = self.client.get(uri) + self.assert404(response) diff --git a/tests/integration_tests/security/api_tests.py b/tests/integration_tests/security/api_tests.py index f936219971517..9a5a085c81c34 100644 --- a/tests/integration_tests/security/api_tests.py +++ b/tests/integration_tests/security/api_tests.py @@ -19,10 +19,18 @@ import json import jwt +import pytest -from tests.integration_tests.base_tests import SupersetTestCase from flask_wtf.csrf import generate_csrf +from superset import db +from superset.embedded.dao import EmbeddedDAO +from superset.models.dashboard import Dashboard from superset.utils.urls import get_url_host +from tests.integration_tests.base_tests import SupersetTestCase +from tests.integration_tests.fixtures.birth_names_dashboard import ( + load_birth_names_dashboard_with_slices, + load_birth_names_data, +) class TestSecurityCsrfApi(SupersetTestCase): @@ -78,10 +86,13 @@ def test_post_guest_token_unauthorized(self): response = self.client.post(self.uri) self.assert403(response) + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") def test_post_guest_token_authorized(self): + self.dash = db.session.query(Dashboard).filter_by(slug="births").first() + self.embedded = EmbeddedDAO.upsert(self.dash, []) self.login(username="admin") user = {"username": "bob", "first_name": "Bob", "last_name": "Also Bob"} - resource = {"type": "dashboard", "id": "blah"} + resource = {"type": "dashboard", "id": str(self.embedded.uuid)} rls_rule = {"dataset": 1, "clause": "1=1"} params = {"user": user, "resources": [resource], "rls": [rls_rule]} @@ -99,3 +110,17 @@ def test_post_guest_token_authorized(self): ) self.assertEqual(user, decoded_token["user"]) self.assertEqual(resource, decoded_token["resources"][0]) + + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") + def test_post_guest_token_bad_resources(self): + self.login(username="admin") + user = {"username": "bob", "first_name": "Bob", "last_name": "Also Bob"} + resource = {"type": "dashboard", "id": "bad-id"} + rls_rule = {"dataset": 1, "clause": "1=1"} + params = {"user": user, "resources": [resource], "rls": [rls_rule]} + + response = self.client.post( + self.uri, data=json.dumps(params), content_type="application/json" + ) + + self.assert400(response) From 2f41ed0940fce0768ff6da465fb3d4d996fd2b5e Mon Sep 17 00:00:00 2001 From: Srini Kadamati Date: Tue, 12 Apr 2022 19:31:35 -0400 Subject: [PATCH 027/136] 1. Removed duplicate security vulnerability issue template. 2. Modified feature request template to encourage people to post in Discussions instead (#19617) --- .github/ISSUE_TEMPLATE/feature_request.md | 12 ++++-------- .github/ISSUE_TEMPLATE/security_vulnerability.md | 12 ------------ 2 files changed, 4 insertions(+), 20 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/security_vulnerability.md diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index cb66edb2bcc76..8e6e0da9c9597 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -5,14 +5,10 @@ labels: "#enhancement" --- -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] +Github Discussions is our new home for discussing features and improvements! -**Describe the solution you'd like** -A clear and concise description of what you want to happen. +https://github.com/apache/superset/discussions/categories/ideas -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. +We'd like to keep Github Issues focuses on bugs and SIP's (Superset Improvement Proposals)! -**Additional context** -Add any other context or screenshots about the feature request here. +Please note that feature requests opened as Github Issues will be moved to Discussions. diff --git a/.github/ISSUE_TEMPLATE/security_vulnerability.md b/.github/ISSUE_TEMPLATE/security_vulnerability.md deleted file mode 100644 index 9cdad9b4bd7da..0000000000000 --- a/.github/ISSUE_TEMPLATE/security_vulnerability.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: Security vulnerability -about: Report a security vulnerability or issue -labels: "#security" - ---- - -## DO NOT REPORT SECURITY VULNERABILITIES HERE - -Please report security vulnerabilities to private@superset.apache.org. - -In the event a community member discovers a security flaw in Superset, it is important to follow the [Apache Security Guidelines](https://www.apache.org/security/committers.html) and release a fix as quickly as possible before public disclosure. Reporting security vulnerabilities through the usual GitHub Issues channel is not ideal as it will publicize the flaw before a fix can be applied. From 01cb6c684b39fc6cdd6956bb28aff52722df1c31 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Apr 2022 23:23:31 -0600 Subject: [PATCH 028/136] chore(deps): bump moment from 2.29.1 to 2.29.2 in /docs (#19638) Bumps [moment](https://github.com/moment/moment) from 2.29.1 to 2.29.2. - [Release notes](https://github.com/moment/moment/releases) - [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md) - [Commits](https://github.com/moment/moment/compare/2.29.1...2.29.2) --- updated-dependencies: - dependency-name: moment dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/yarn.lock b/docs/yarn.lock index 1354b26ac7432..f58b8a5078d35 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -7441,9 +7441,9 @@ mkdirp@^0.5.5, mkdirp@~0.5.1: minimist "^1.2.5" moment@^2.24.0, moment@^2.25.3: - version "2.29.1" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" - integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== + version "2.29.2" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.2.tgz#00910c60b20843bcba52d37d58c628b47b1f20e4" + integrity sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg== ms@2.0.0: version "2.0.0" From 6e8e29ce53faffbedfd8e9657d894d87d70eff54 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Apr 2022 23:23:58 -0600 Subject: [PATCH 029/136] chore(deps): bump urijs from 1.19.8 to 1.19.11 in /superset-frontend (#19679) Bumps [urijs](https://github.com/medialize/URI.js) from 1.19.8 to 1.19.11. - [Release notes](https://github.com/medialize/URI.js/releases) - [Changelog](https://github.com/medialize/URI.js/blob/gh-pages/CHANGELOG.md) - [Commits](https://github.com/medialize/URI.js/compare/v1.19.8...v1.19.11) --- updated-dependencies: - dependency-name: urijs dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 34 +++++++++++++---------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 2e845ffab4f53..4cf5cdba13950 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -26,7 +26,6 @@ "@superset-ui/legacy-plugin-chart-chord": "file:./plugins/legacy-plugin-chart-chord", "@superset-ui/legacy-plugin-chart-country-map": "file:./plugins/legacy-plugin-chart-country-map", "@superset-ui/legacy-plugin-chart-event-flow": "file:./plugins/legacy-plugin-chart-event-flow", - "@superset-ui/legacy-plugin-chart-force-directed": "file:./plugins/legacy-plugin-chart-force-directed", "@superset-ui/legacy-plugin-chart-heatmap": "file:./plugins/legacy-plugin-chart-heatmap", "@superset-ui/legacy-plugin-chart-histogram": "file:./plugins/legacy-plugin-chart-histogram", "@superset-ui/legacy-plugin-chart-horizon": "file:./plugins/legacy-plugin-chart-horizon", @@ -54499,9 +54498,9 @@ } }, "node_modules/urijs": { - "version": "1.19.8", - "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.8.tgz", - "integrity": "sha512-iIXHrjomQ0ZCuDRy44wRbyTZVnfVNLVo3Ksz1yxNyE5wV1IDZW2S5Jszy45DTlw/UdsnRT7DyDhIz7Gy+vJumw==" + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==" }, "node_modules/urix": { "version": "0.1.0", @@ -58656,7 +58655,6 @@ "@superset-ui/legacy-plugin-chart-chord": "*", "@superset-ui/legacy-plugin-chart-country-map": "*", "@superset-ui/legacy-plugin-chart-event-flow": "*", - "@superset-ui/legacy-plugin-chart-force-directed": "*", "@superset-ui/legacy-plugin-chart-heatmap": "*", "@superset-ui/legacy-plugin-chart-histogram": "*", "@superset-ui/legacy-plugin-chart-horizon": "*", @@ -59071,7 +59069,8 @@ }, "peerDependencies": { "@superset-ui/chart-controls": "*", - "@superset-ui/core": "*" + "@superset-ui/core": "*", + "react": "^16.13.1" } }, "plugins/legacy-plugin-chart-country-map/node_modules/d3-array": { @@ -59098,16 +59097,10 @@ }, "plugins/legacy-plugin-chart-force-directed": { "name": "@superset-ui/legacy-plugin-chart-force-directed", - "version": "0.18.25", - "license": "Apache-2.0", + "version": "0.0.1", "dependencies": { "d3": "^3.5.17", "prop-types": "^15.7.2" - }, - "peerDependencies": { - "@superset-ui/chart-controls": "*", - "@superset-ui/core": "*", - "react": "^16.13.1" } }, "plugins/legacy-plugin-chart-heatmap": { @@ -59360,7 +59353,8 @@ }, "peerDependencies": { "@superset-ui/chart-controls": "*", - "@superset-ui/core": "*" + "@superset-ui/core": "*", + "react": "^16.13.1" } }, "plugins/legacy-plugin-chart-sunburst": { @@ -59373,7 +59367,8 @@ }, "peerDependencies": { "@superset-ui/chart-controls": "*", - "@superset-ui/core": "*" + "@superset-ui/core": "*", + "react": "^16.13.1" } }, "plugins/legacy-plugin-chart-time-table": { @@ -59403,7 +59398,8 @@ }, "peerDependencies": { "@superset-ui/chart-controls": "*", - "@superset-ui/core": "*" + "@superset-ui/core": "*", + "react": "^16.13.1" } }, "plugins/legacy-plugin-chart-world-map": { @@ -102581,9 +102577,9 @@ } }, "urijs": { - "version": "1.19.8", - "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.8.tgz", - "integrity": "sha512-iIXHrjomQ0ZCuDRy44wRbyTZVnfVNLVo3Ksz1yxNyE5wV1IDZW2S5Jszy45DTlw/UdsnRT7DyDhIz7Gy+vJumw==" + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==" }, "urix": { "version": "0.1.0", From 4a5dddf52d8191b002fa11add6baaee26bc3b1a7 Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Wed, 13 Apr 2022 13:19:02 +0200 Subject: [PATCH 030/136] fix(explore): Change copy of cross filters checkbox (#19646) --- .../src/shared-controls/emitFilterControl.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/emitFilterControl.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/emitFilterControl.tsx index 5088ad155567e..a4c3f4a86d8af 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/emitFilterControl.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/emitFilterControl.tsx @@ -27,10 +27,10 @@ export const emitFilterControl = enableCrossFilter name: 'emit_filter', config: { type: 'CheckboxControl', - label: t('Emit dashboard cross filters'), + label: t('Enable dashboard cross filters'), default: false, renderTrigger: true, - description: t('Emit dashboard cross filters.'), + description: t('Enable dashboard cross filters'), }, }, ] From ee85466f2ed45d3f51a7609ef4e30cf087c033e4 Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Wed, 13 Apr 2022 14:22:46 +0200 Subject: [PATCH 031/136] fix(dashboard): Fix BigNumber causing dashboard to crash when overflowing (#19688) * fix(dashboard): fix(plugin-chart-echarts): Fix BigNumber causing dashboard to crash when overflowing * Add tooltips for truncated titles * Fix type --- .../src/components/EditableTitle/index.tsx | 9 ++-- .../SliceHeader/SliceHeader.test.tsx | 2 + .../components/SliceHeader/index.tsx | 49 +++++++++++++------ .../components/gridComponents/Chart.jsx | 17 +++++-- .../src/dashboard/stylesheets/dashboard.less | 8 +++ 5 files changed, 63 insertions(+), 22 deletions(-) diff --git a/superset-frontend/src/components/EditableTitle/index.tsx b/superset-frontend/src/components/EditableTitle/index.tsx index 6839b45c7d902..ddd85875568af 100644 --- a/superset-frontend/src/components/EditableTitle/index.tsx +++ b/superset-frontend/src/components/EditableTitle/index.tsx @@ -57,6 +57,8 @@ export default function EditableTitle({ placeholder = '', certifiedBy, certificationDetails, + // rest is related to title tooltip + ...rest }: EditableTitleProps) { const [isEditing, setIsEditing] = useState(editing); const [currentTitle, setCurrentTitle] = useState(title); @@ -214,11 +216,7 @@ export default function EditableTitle({ } if (!canEdit) { // don't actually want an input in this case - titleComponent = ( - - {value} - - ); + titleComponent = {value}; } return ( {certifiedBy && ( <> diff --git a/superset-frontend/src/dashboard/components/SliceHeader/SliceHeader.test.tsx b/superset-frontend/src/dashboard/components/SliceHeader/SliceHeader.test.tsx index b1a2efc7b87ad..fd5892f427635 100644 --- a/superset-frontend/src/dashboard/components/SliceHeader/SliceHeader.test.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeader/SliceHeader.test.tsx @@ -157,6 +157,8 @@ const createProps = () => ({ exportCSV: jest.fn(), onExploreChart: jest.fn(), formData: { slice_id: 1, datasource: '58__table' }, + width: 100, + height: 100, }); test('Should render', () => { diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx index 88e9a4c69f300..44ddf7b5adef8 100644 --- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import React, { FC, useMemo } from 'react'; +import React, { FC, useEffect, useMemo, useRef, useState } from 'react'; import { styled, t } from '@superset-ui/core'; import { useUiConfig } from 'src/components/UiConfigContext'; import { Tooltip } from 'src/components/Tooltip'; @@ -41,6 +41,8 @@ type SliceHeaderProps = SliceHeaderControlsProps & { filters: object; handleToggleFullSize: () => void; formData: object; + width: number; + height: number; }; const annotationsLoading = t('Annotation layers are still loading.'); @@ -82,9 +84,13 @@ const SliceHeader: FC = ({ isFullSize, chartStatus, formData, + width, + height, }) => { const dispatch = useDispatch(); const uiConfig = useUiConfig(); + const [headerTooltip, setHeaderTooltip] = useState(null); + const headerRef = useRef(null); // TODO: change to indicator field after it will be implemented const crossFilterValue = useSelector( state => state.dataMask[slice?.slice_id]?.filterState?.value, @@ -98,21 +104,36 @@ const SliceHeader: FC = ({ [crossFilterValue], ); + useEffect(() => { + const headerElement = headerRef.current; + if ( + headerElement && + (headerElement.scrollWidth > headerElement.offsetWidth || + headerElement.scrollHeight > headerElement.offsetHeight) + ) { + setHeaderTooltip(sliceName ?? null); + } else { + setHeaderTooltip(null); + } + }, [sliceName, width, height]); + return (
-
- +
+ + + {!!Object.values(annotationQuery).length && ( {/* @@ -468,7 +479,7 @@ export default class Chart extends React.Component { datasetsStatus={datasetsStatus} />
-
+ ); } } diff --git a/superset-frontend/src/dashboard/stylesheets/dashboard.less b/superset-frontend/src/dashboard/stylesheets/dashboard.less index a3409c4d48bd8..b9b2b0aab92f8 100644 --- a/superset-frontend/src/dashboard/stylesheets/dashboard.less +++ b/superset-frontend/src/dashboard/stylesheets/dashboard.less @@ -63,12 +63,20 @@ body { display: flex; max-width: 100%; align-items: flex-start; + min-height: 0; & > .header-title { overflow: hidden; text-overflow: ellipsis; max-width: 100%; flex-grow: 1; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + + & > span.ant-tooltip-open { + display: inline; + } } & > .header-controls { From 059cb4ec25855b844a9c35be9b6c462595e90a5c Mon Sep 17 00:00:00 2001 From: Stephen Liu <750188453@qq.com> Date: Wed, 13 Apr 2022 20:48:13 +0800 Subject: [PATCH 032/136] fix(plugin-chart-echarts): xAxis scale is not correct when setting quarter time grain (#19686) --- .../src/Timeseries/transformProps.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts index 1a2200db22097..b8585c6e68ed8 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -28,6 +28,7 @@ import { isFormulaAnnotationLayer, isIntervalAnnotationLayer, isTimeseriesAnnotationLayer, + TimeGranularity, TimeseriesChartDataResponseResult, } from '@superset-ui/core'; import { EChartsCoreOption, SeriesOption } from 'echarts'; @@ -69,6 +70,14 @@ import { } from './transformers'; import { TIMESERIES_CONSTANTS } from '../constants'; +const TimeGrainToTimestamp = { + [TimeGranularity.HOUR]: 3600 * 1000, + [TimeGranularity.DAY]: 3600 * 1000 * 24, + [TimeGranularity.MONTH]: 3600 * 1000 * 24 * 31, + [TimeGranularity.QUARTER]: 3600 * 1000 * 24 * 31 * 3, + [TimeGranularity.YEAR]: 3600 * 1000 * 24 * 31 * 12, +}; + export default function transformProps( chartProps: EchartsTimeseriesChartProps, ): TimeseriesChartTransformedProps { @@ -126,6 +135,7 @@ export default function transformProps( yAxisTitleMargin, yAxisTitlePosition, sliceId, + timeGrainSqla, }: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData }; const colorScale = CategoricalColorNamespace.getScale(colorScheme as string); @@ -324,6 +334,10 @@ export default function transformProps( formatter: xAxisFormatter, rotate: xAxisLabelRotation, }, + minInterval: + xAxisType === 'time' && timeGrainSqla + ? TimeGrainToTimestamp[timeGrainSqla] + : 0, }, yAxis: { ...defaultYAxis, From 2ba484fe43880ee09d6e61d778ad467ab7b0e459 Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Wed, 13 Apr 2022 16:04:04 +0300 Subject: [PATCH 033/136] fix: login button does not render (#19685) * fix: login button does not render * add type guard --- .../src/dashboard/util/findPermission.test.ts | 100 +++++++++++------- .../src/dashboard/util/findPermission.ts | 25 +++-- superset-frontend/src/types/bootstrapTypes.ts | 13 +++ 3 files changed, 90 insertions(+), 48 deletions(-) diff --git a/superset-frontend/src/dashboard/util/findPermission.test.ts b/superset-frontend/src/dashboard/util/findPermission.test.ts index 8930549f4a7e9..1c80770f50014 100644 --- a/superset-frontend/src/dashboard/util/findPermission.test.ts +++ b/superset-frontend/src/dashboard/util/findPermission.test.ts @@ -16,10 +16,54 @@ * specific language governing permissions and limitations * under the License. */ -import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; +import { + UndefinedUser, + UserWithPermissionsAndRoles, +} from 'src/types/bootstrapTypes'; import Dashboard from 'src/types/Dashboard'; import Owner from 'src/types/Owner'; -import findPermission, { canUserEditDashboard } from './findPermission'; +import findPermission, { + canUserEditDashboard, + isUserAdmin, +} from './findPermission'; + +const ownerUser: UserWithPermissionsAndRoles = { + createdOn: '2021-05-12T16:56:22.116839', + email: 'user@example.com', + firstName: 'Test', + isActive: true, + isAnonymous: false, + lastName: 'User', + userId: 1, + username: 'owner', + permissions: {}, + roles: { Alpha: [['can_write', 'Dashboard']] }, +}; + +const adminUser: UserWithPermissionsAndRoles = { + ...ownerUser, + roles: { + ...(ownerUser?.roles || {}), + Admin: [['can_write', 'Dashboard']], + }, + userId: 2, + username: 'admin', +}; + +const outsiderUser: UserWithPermissionsAndRoles = { + ...ownerUser, + userId: 3, + username: 'outsider', +}; + +const owner: Owner = { + first_name: 'Test', + id: ownerUser.userId, + last_name: 'User', + username: ownerUser.username, +}; + +const undefinedUser: UndefinedUser = {}; describe('findPermission', () => { it('findPermission for single role', () => { @@ -70,42 +114,6 @@ describe('findPermission', () => { }); describe('canUserEditDashboard', () => { - const ownerUser: UserWithPermissionsAndRoles = { - createdOn: '2021-05-12T16:56:22.116839', - email: 'user@example.com', - firstName: 'Test', - isActive: true, - isAnonymous: false, - lastName: 'User', - userId: 1, - username: 'owner', - permissions: {}, - roles: { Alpha: [['can_write', 'Dashboard']] }, - }; - - const adminUser: UserWithPermissionsAndRoles = { - ...ownerUser, - roles: { - ...ownerUser.roles, - Admin: [['can_write', 'Dashboard']], - }, - userId: 2, - username: 'admin', - }; - - const outsiderUser: UserWithPermissionsAndRoles = { - ...ownerUser, - userId: 3, - username: 'outsider', - }; - - const owner: Owner = { - first_name: 'Test', - id: ownerUser.userId, - last_name: 'User', - username: ownerUser.username, - }; - const dashboard: Dashboard = { id: 1, dashboard_title: 'Test Dash', @@ -136,9 +144,7 @@ describe('canUserEditDashboard', () => { it('rejects missing roles', () => { // in redux, when there is no user, the user is actually set to an empty object, // so we need to handle missing roles as well as a missing user.s - expect( - canUserEditDashboard(dashboard, {} as UserWithPermissionsAndRoles), - ).toEqual(false); + expect(canUserEditDashboard(dashboard, {})).toEqual(false); }); it('rejects "admins" if the admin role does not have edit rights for some reason', () => { expect( @@ -149,3 +155,15 @@ describe('canUserEditDashboard', () => { ).toEqual(false); }); }); + +test('isUserAdmin returns true for admin user', () => { + expect(isUserAdmin(adminUser)).toEqual(true); +}); + +test('isUserAdmin returns false for undefined user', () => { + expect(isUserAdmin(undefinedUser)).toEqual(false); +}); + +test('isUserAdmin returns false for non-admin user', () => { + expect(isUserAdmin(ownerUser)).toEqual(false); +}); diff --git a/superset-frontend/src/dashboard/util/findPermission.ts b/superset-frontend/src/dashboard/util/findPermission.ts index d3a8b61eca94a..496f993bdf80d 100644 --- a/superset-frontend/src/dashboard/util/findPermission.ts +++ b/superset-frontend/src/dashboard/util/findPermission.ts @@ -17,7 +17,11 @@ * under the License. */ import memoizeOne from 'memoize-one'; -import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; +import { + isUserWithPermissionsAndRoles, + UndefinedUser, + UserWithPermissionsAndRoles, +} from 'src/types/bootstrapTypes'; import Dashboard from 'src/types/Dashboard'; type UserRoles = Record; @@ -36,18 +40,25 @@ export default findPermission; // but is hardcoded in backend logic already, so... const ADMIN_ROLE_NAME = 'admin'; -export const isUserAdmin = (user: UserWithPermissionsAndRoles) => - Object.keys(user.roles).some(role => role.toLowerCase() === ADMIN_ROLE_NAME); +export const isUserAdmin = ( + user: UserWithPermissionsAndRoles | UndefinedUser, +) => + isUserWithPermissionsAndRoles(user) && + Object.keys(user.roles || {}).some( + role => role.toLowerCase() === ADMIN_ROLE_NAME, + ); const isUserDashboardOwner = ( dashboard: Dashboard, - user: UserWithPermissionsAndRoles, -) => dashboard.owners.some(owner => owner.username === user.username); + user: UserWithPermissionsAndRoles | UndefinedUser, +) => + isUserWithPermissionsAndRoles(user) && + dashboard.owners.some(owner => owner.username === user.username); export const canUserEditDashboard = ( dashboard: Dashboard, - user?: UserWithPermissionsAndRoles | null, + user?: UserWithPermissionsAndRoles | UndefinedUser | null, ) => - !!user?.roles && + isUserWithPermissionsAndRoles(user) && (isUserAdmin(user) || isUserDashboardOwner(dashboard, user)) && findPermission('can_write', 'Dashboard', user.roles); diff --git a/superset-frontend/src/types/bootstrapTypes.ts b/superset-frontend/src/types/bootstrapTypes.ts index 33314e7e46906..8918ea8489046 100644 --- a/superset-frontend/src/types/bootstrapTypes.ts +++ b/superset-frontend/src/types/bootstrapTypes.ts @@ -1,4 +1,5 @@ import { JsonObject, Locale } from '@superset-ui/core'; +import { isPlainObject } from 'lodash'; /** * Licensed to the Apache Software Foundation (ASF) under one @@ -37,6 +38,8 @@ export interface UserWithPermissionsAndRoles extends User { roles: Record; } +export type UndefinedUser = {}; + export type Dashboard = { dttm: number; id: number; @@ -62,3 +65,13 @@ export interface CommonBootstrapData { locale: Locale; feature_flags: Record; } + +export function isUser(user: any): user is User { + return isPlainObject(user) && 'username' in user; +} + +export function isUserWithPermissionsAndRoles( + user: any, +): user is UserWithPermissionsAndRoles { + return isUser(user) && 'permissions' in user && 'roles' in user; +} From de9fb2109d2e64cb926d04733dc6a9855816f4de Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Wed, 13 Apr 2022 15:16:03 +0200 Subject: [PATCH 034/136] chore(explore): Change labels "Group by"/"Series" to "Dimensions" (#19647) --- .../src/shared-controls/dndControls.tsx | 4 ++-- .../superset-ui-chart-controls/src/shared-controls/index.tsx | 4 ++-- .../legacy-preset-chart-nvd3/src/DistBar/controlPanel.ts | 2 +- .../plugins/plugin-chart-echarts/src/BoxPlot/controlPanel.ts | 2 +- .../plugins/plugin-chart-echarts/src/Gauge/controlPanel.tsx | 2 +- superset-frontend/src/explore/controls.jsx | 4 ++-- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/dndControls.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/dndControls.tsx index 44b4bcc186dda..44e0d2fb6381c 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/dndControls.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/dndControls.tsx @@ -28,7 +28,7 @@ import { TIME_COLUMN_OPTION, TIME_FILTER_LABELS } from '../constants'; export const dndGroupByControl: SharedControlConfig<'DndColumnSelect'> = { type: 'DndColumnSelect', - label: t('Group by'), + label: t('Dimensions'), default: [], description: t( 'One or many columns to group by. High cardinality groupings should include a series limit ' + @@ -58,7 +58,7 @@ export const dndColumnsControl: typeof dndGroupByControl = { export const dndSeries: typeof dndGroupByControl = { ...dndGroupByControl, - label: t('Series'), + label: t('Dimensions'), multi: false, default: null, description: t( diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/index.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/index.tsx index ec50568315319..382fcb5bb3316 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/index.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/index.tsx @@ -103,7 +103,7 @@ type SelectDefaultOption = { const groupByControl: SharedControlConfig<'SelectControl', ColumnMeta> = { type: 'SelectControl', - label: t('Group by'), + label: t('Dimensions'), multi: true, freeForm: true, clearable: true, @@ -403,7 +403,7 @@ const sort_by: SharedControlConfig<'MetricsControl'> = { const series: typeof groupByControl = { ...groupByControl, - label: t('Series'), + label: t('Dimensions'), multi: false, default: null, description: t( diff --git a/superset-frontend/plugins/legacy-preset-chart-nvd3/src/DistBar/controlPanel.ts b/superset-frontend/plugins/legacy-preset-chart-nvd3/src/DistBar/controlPanel.ts index 3df9e00057b1a..278743d472749 100644 --- a/superset-frontend/plugins/legacy-preset-chart-nvd3/src/DistBar/controlPanel.ts +++ b/superset-frontend/plugins/legacy-preset-chart-nvd3/src/DistBar/controlPanel.ts @@ -106,7 +106,7 @@ const config: ControlPanelConfig = { ], controlOverrides: { groupby: { - label: t('Series'), + label: t('Dimensions'), validators: [validateNonEmpty], mapStateToProps: (state, controlState) => { const groupbyProps = diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/controlPanel.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/controlPanel.ts index a9456dce3852b..f8e5cbb62950f 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/controlPanel.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/controlPanel.ts @@ -125,7 +125,7 @@ const config: ControlPanelConfig = { ], controlOverrides: { groupby: { - label: t('Series'), + label: t('Dimensions'), description: t('Categories to group by on the x-axis.'), }, columns: { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/controlPanel.tsx index 81af7d3963e49..581d98c6b99f7 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/controlPanel.tsx @@ -39,7 +39,7 @@ const config: ControlPanelConfig = { name: 'groupby', config: { ...sharedControls.groupby, - label: t('Group by'), + label: t('Dimensions'), description: t('Columns to group by'), }, }, diff --git a/superset-frontend/src/explore/controls.jsx b/superset-frontend/src/explore/controls.jsx index 974a79b9af7d8..daba78ca6d243 100644 --- a/superset-frontend/src/explore/controls.jsx +++ b/superset-frontend/src/explore/controls.jsx @@ -120,7 +120,7 @@ const groupByControl = { type: 'SelectControl', multi: true, freeForm: true, - label: t('Group by'), + label: t('Dimensions'), default: [], includeTime: false, description: t( @@ -393,7 +393,7 @@ export const controls = { series: { ...groupByControl, - label: t('Series'), + label: t('Dimensions'), multi: false, default: null, description: t( From 26a0f0575931850f81a593785d29232f40fd7d71 Mon Sep 17 00:00:00 2001 From: Diego Medina Date: Wed, 13 Apr 2022 10:42:12 -0400 Subject: [PATCH 035/136] fix(sql lab): table selector should display all the selected tables (#19257) * fix: table Selector should clear once selected * Multi select * Add tests * refactor * PR comments --- .../components/SqlEditorLeftBar/index.tsx | 37 +++++-- .../Datasource/DatasourceEditor.jsx | 4 +- .../TableSelector/TableSelector.test.tsx | 101 +++++++++++++++++- .../src/components/TableSelector/index.tsx | 80 +++++++++----- .../CRUD/data/dataset/AddDatasetModal.tsx | 4 +- 5 files changed, 189 insertions(+), 37 deletions(-) diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx index f9e8c2da9f98f..a50e3a3f62437 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx @@ -16,12 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useEffect, useRef, useCallback } from 'react'; +import React, { useEffect, useRef, useCallback, useMemo } from 'react'; import Button from 'src/components/Button'; import { t, styled, css, SupersetTheme } from '@superset-ui/core'; import Collapse from 'src/components/Collapse'; import Icons from 'src/components/Icons'; -import TableSelector from 'src/components/TableSelector'; +import { TableSelectorMultiple } from 'src/components/TableSelector'; import { IconTooltip } from 'src/components/IconTooltip'; import { QueryEditor } from 'src/SqlLab/types'; import { DatabaseObject } from 'src/components/DatabaseSelector'; @@ -101,10 +101,32 @@ export default function SqlEditorLeftBar({ actions.queryEditorSetFunctionNames(queryEditor, dbId); }; - const onTableChange = (tableName: string, schemaName: string) => { - if (tableName && schemaName) { - actions.addTable(queryEditor, database, tableName, schemaName); + const selectedTableNames = useMemo( + () => tables?.map(table => table.name) || [], + [tables], + ); + + const onTablesChange = (tableNames: string[], schemaName: string) => { + if (!schemaName) { + return; } + + const currentTables = [...tables]; + const tablesToAdd = tableNames.filter(name => { + const index = currentTables.findIndex(table => table.name === name); + if (index >= 0) { + currentTables.splice(index, 1); + return false; + } + + return true; + }); + + tablesToAdd.forEach(tableName => + actions.addTable(queryEditor, database, tableName, schemaName), + ); + + currentTables.forEach(table => actions.removeTable(table)); }; const onToggleTable = (updatedTables: string[]) => { @@ -162,16 +184,17 @@ export default function SqlEditorLeftBar({ return (
-
diff --git a/superset-frontend/src/components/Datasource/DatasourceEditor.jsx b/superset-frontend/src/components/Datasource/DatasourceEditor.jsx index 15f9afa44729f..8ba4652980a1f 100644 --- a/superset-frontend/src/components/Datasource/DatasourceEditor.jsx +++ b/superset-frontend/src/components/Datasource/DatasourceEditor.jsx @@ -1008,7 +1008,7 @@ class DatasourceEditor extends React.PureComponent { handleError={this.props.addDangerToast} schema={datasource.schema} sqlLabMode={false} - tableName={datasource.table_name} + tableValue={datasource.table_name} onSchemaChange={ this.state.isEditMode ? schema => @@ -1024,7 +1024,7 @@ class DatasourceEditor extends React.PureComponent { ) : undefined } - onTableChange={ + onTableSelectChange={ this.state.isEditMode ? table => this.onDatasourcePropChange('table_name', table) diff --git a/superset-frontend/src/components/TableSelector/TableSelector.test.tsx b/superset-frontend/src/components/TableSelector/TableSelector.test.tsx index 013e937edeb41..32d84c008605c 100644 --- a/superset-frontend/src/components/TableSelector/TableSelector.test.tsx +++ b/superset-frontend/src/components/TableSelector/TableSelector.test.tsx @@ -18,11 +18,11 @@ */ import React from 'react'; -import { render, screen, waitFor } from 'spec/helpers/testing-library'; +import { render, screen, waitFor, within } from 'spec/helpers/testing-library'; import { SupersetClient } from '@superset-ui/core'; import { act } from 'react-dom/test-utils'; import userEvent from '@testing-library/user-event'; -import TableSelector from '.'; +import TableSelector, { TableSelectorMultiple } from '.'; const SupersetClientGet = jest.spyOn(SupersetClient, 'get'); @@ -55,10 +55,17 @@ const getTableMockFunction = async () => options: [ { label: 'table_a', value: 'table_a' }, { label: 'table_b', value: 'table_b' }, + { label: 'table_c', value: 'table_c' }, + { label: 'table_d', value: 'table_d' }, ], }, } as any); +const getSelectItemContainer = (select: HTMLElement) => + select.parentElement?.parentElement?.getElementsByClassName( + 'ant-select-selection-item', + ); + test('renders with default props', async () => { SupersetClientGet.mockImplementation(getTableMockFunction); @@ -145,6 +152,96 @@ test('table options are notified after schema selection', async () => { expect(callback).toHaveBeenCalledWith([ { label: 'table_a', value: 'table_a' }, { label: 'table_b', value: 'table_b' }, + { label: 'table_c', value: 'table_c' }, + { label: 'table_d', value: 'table_d' }, ]); }); }); + +test('table select retain value if not in SQL Lab mode', async () => { + SupersetClientGet.mockImplementation(getTableMockFunction); + + const callback = jest.fn(); + const props = createProps({ + onTableSelectChange: callback, + sqlLabMode: false, + }); + + render(, { useRedux: true }); + + const tableSelect = screen.getByRole('combobox', { + name: 'Select table or type table name', + }); + + expect(screen.queryByText('table_a')).not.toBeInTheDocument(); + expect(getSelectItemContainer(tableSelect)).toHaveLength(0); + + userEvent.click(tableSelect); + + expect( + await screen.findByRole('option', { name: 'table_a' }), + ).toBeInTheDocument(); + + act(() => { + userEvent.click(screen.getAllByText('table_a')[1]); + }); + + expect(callback).toHaveBeenCalled(); + + const selectedValueContainer = getSelectItemContainer(tableSelect); + + expect(selectedValueContainer).toHaveLength(1); + expect( + await within(selectedValueContainer?.[0] as HTMLElement).findByText( + 'table_a', + ), + ).toBeInTheDocument(); +}); + +test('table multi select retain all the values selected', async () => { + SupersetClientGet.mockImplementation(getTableMockFunction); + + const callback = jest.fn(); + const props = createProps({ + onTableSelectChange: callback, + }); + + render(, { useRedux: true }); + + const tableSelect = screen.getByRole('combobox', { + name: 'Select table or type table name', + }); + + expect(screen.queryByText('table_a')).not.toBeInTheDocument(); + expect(getSelectItemContainer(tableSelect)).toHaveLength(0); + + userEvent.click(tableSelect); + + expect( + await screen.findByRole('option', { name: 'table_a' }), + ).toBeInTheDocument(); + + act(() => { + const item = screen.getAllByText('table_a'); + userEvent.click(item[item.length - 1]); + }); + + act(() => { + const item = screen.getAllByText('table_c'); + userEvent.click(item[item.length - 1]); + }); + + const selectedValueContainer = getSelectItemContainer(tableSelect); + + expect(selectedValueContainer).toHaveLength(2); + expect( + await within(selectedValueContainer?.[0] as HTMLElement).findByText( + 'table_a', + ), + ).toBeInTheDocument(); + expect( + await within(selectedValueContainer?.[1] as HTMLElement).findByText( + 'table_c', + ), + ).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/components/TableSelector/index.tsx b/superset-frontend/src/components/TableSelector/index.tsx index 50804f7d920ce..84696f93916f8 100644 --- a/superset-frontend/src/components/TableSelector/index.tsx +++ b/superset-frontend/src/components/TableSelector/index.tsx @@ -23,6 +23,8 @@ import React, { useMemo, useEffect, } from 'react'; +import { SelectValue } from 'antd/lib/select'; + import { styled, SupersetClient, t } from '@superset-ui/core'; import { Select } from 'src/components'; import { FormLabel } from 'src/components/Form'; @@ -87,12 +89,13 @@ interface TableSelectorProps { onDbChange?: (db: DatabaseObject) => void; onSchemaChange?: (schema?: string) => void; onSchemasLoad?: () => void; - onTableChange?: (tableName?: string, schema?: string) => void; onTablesLoad?: (options: Array) => void; readOnly?: boolean; schema?: string; sqlLabMode?: boolean; - tableName?: string; + tableValue?: string | string[]; + onTableSelectChange?: (value?: string | string[], schema?: string) => void; + tableSelectMode?: 'single' | 'multiple'; } interface Table { @@ -150,12 +153,13 @@ const TableSelector: FunctionComponent = ({ onDbChange, onSchemaChange, onSchemasLoad, - onTableChange, onTablesLoad, readOnly = false, schema, sqlLabMode = true, - tableName, + tableSelectMode = 'single', + tableValue = undefined, + onTableSelectChange, }) => { const [currentDatabase, setCurrentDatabase] = useState< DatabaseObject | undefined @@ -163,11 +167,14 @@ const TableSelector: FunctionComponent = ({ const [currentSchema, setCurrentSchema] = useState( schema, ); - const [currentTable, setCurrentTable] = useState(); + + const [tableOptions, setTableOptions] = useState([]); + const [tableSelectValue, setTableSelectValue] = useState< + SelectValue | undefined + >(undefined); const [refresh, setRefresh] = useState(0); const [previousRefresh, setPreviousRefresh] = useState(0); const [loadingTables, setLoadingTables] = useState(false); - const [tableOptions, setTableOptions] = useState([]); const { addSuccessToast } = useToasts(); useEffect(() => { @@ -175,9 +182,23 @@ const TableSelector: FunctionComponent = ({ if (database === undefined) { setCurrentDatabase(undefined); setCurrentSchema(undefined); - setCurrentTable(undefined); + setTableSelectValue(undefined); } - }, [database]); + }, [database, tableSelectMode]); + + useEffect(() => { + if (tableSelectMode === 'single') { + setTableSelectValue( + tableOptions.find(option => option.value === tableValue), + ); + } else { + setTableSelectValue( + tableOptions?.filter( + option => option && tableValue?.includes(option.value), + ) || [], + ); + } + }, [tableOptions, tableValue, tableSelectMode]); useEffect(() => { if (currentDatabase && currentSchema) { @@ -195,23 +216,18 @@ const TableSelector: FunctionComponent = ({ SupersetClient.get({ endpoint }) .then(({ json }) => { - const options: TableOption[] = []; - let currentTable; - json.options.forEach((table: Table) => { - const option = { + const options: TableOption[] = json.options.map((table: Table) => { + const option: TableOption = { value: table.value, label: , text: table.label, }; - options.push(option); - if (table.label === tableName) { - currentTable = option; - } + + return option; }); onTablesLoad?.(json.options); setTableOptions(options); - setCurrentTable(currentTable); setLoadingTables(false); if (forceRefresh) addSuccessToast('List updated'); }) @@ -223,7 +239,7 @@ const TableSelector: FunctionComponent = ({ // We are using the refresh state to re-trigger the query // previousRefresh should be out of dependencies array // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentDatabase, currentSchema, onTablesLoad, refresh]); + }, [currentDatabase, currentSchema, onTablesLoad, setTableOptions, refresh]); function renderSelectRow(select: ReactNode, refreshBtn: ReactNode) { return ( @@ -234,10 +250,18 @@ const TableSelector: FunctionComponent = ({ ); } - const internalTableChange = (table?: TableOption) => { - setCurrentTable(table); - if (onTableChange && currentSchema) { - onTableChange(table?.value, currentSchema); + const internalTableChange = ( + selectedOptions: TableOption | TableOption[] | undefined, + ) => { + if (currentSchema) { + onTableSelectChange?.( + Array.isArray(selectedOptions) + ? selectedOptions.map(option => option?.value) + : selectedOptions?.value, + currentSchema, + ); + } else { + setTableSelectValue(selectedOptions); } }; @@ -253,6 +277,7 @@ const TableSelector: FunctionComponent = ({ if (onSchemaChange) { onSchemaChange(schema); } + internalTableChange(undefined); }; @@ -305,11 +330,15 @@ const TableSelector: FunctionComponent = ({ lazyLoading={false} loading={loadingTables} name="select-table" - onChange={(table: TableOption) => internalTableChange(table)} + onChange={(options: TableOption | TableOption[]) => + internalTableChange(options) + } options={tableOptions} placeholder={t('Select table or type table name')} showSearch - value={currentTable} + mode={tableSelectMode} + value={tableSelectValue} + allowClear={tableSelectMode === 'multiple'} /> ); @@ -332,4 +361,7 @@ const TableSelector: FunctionComponent = ({ ); }; +export const TableSelectorMultiple: FunctionComponent = + props => ; + export default TableSelector; diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx b/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx index f3ad4e488c2c4..7e7e7429bddd3 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx +++ b/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx @@ -126,10 +126,10 @@ const DatasetModal: FunctionComponent = ({ formMode database={currentDatabase} schema={currentSchema} - tableName={currentTableName} + tableValue={currentTableName} onDbChange={onDbChange} onSchemaChange={onSchemaChange} - onTableChange={onTableChange} + onTableSelectChange={onTableChange} handleError={addDangerToast} /> From 32239b04aa84657f0485925749f4d65999f68477 Mon Sep 17 00:00:00 2001 From: Diego Medina Date: Wed, 13 Apr 2022 10:45:38 -0400 Subject: [PATCH 036/136] fix: improve the alerts & reports modal layout on small screens (#19294) --- superset-frontend/src/components/TimezoneSelector/index.tsx | 4 +++- superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/superset-frontend/src/components/TimezoneSelector/index.tsx b/superset-frontend/src/components/TimezoneSelector/index.tsx index 119cb50fbce37..b33981e721006 100644 --- a/superset-frontend/src/components/TimezoneSelector/index.tsx +++ b/superset-frontend/src/components/TimezoneSelector/index.tsx @@ -104,11 +104,13 @@ const matchTimezoneToOptions = (timezone: string) => 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 }: TimezoneSelectorProps) { const validTimezone = useMemo( () => matchTimezoneToOptions(timezone || moment.tz.guess()), @@ -125,7 +127,7 @@ export default function TimezoneSelector({ return ( {t('Schema')}} labelInValue lazyLoading={false} diff --git a/superset-frontend/src/components/Select/Select.tsx b/superset-frontend/src/components/Select/Select.tsx index 20bab6bcfc416..3624f4a2b1574 100644 --- a/superset-frontend/src/components/Select/Select.tsx +++ b/superset-frontend/src/components/Select/Select.tsx @@ -35,9 +35,9 @@ import AntdSelect, { LabeledValue as AntdLabeledValue, } from 'antd/lib/select'; import { DownOutlined, SearchOutlined } from '@ant-design/icons'; +import { Spin } from 'antd'; import debounce from 'lodash/debounce'; import { isEqual } from 'lodash'; -import { Spin } from 'antd'; import Icons from 'src/components/Icons'; import { getClientErrorObject } from 'src/utils/getClientErrorObject'; import { SLOW_DEBOUNCE } from 'src/constants'; diff --git a/superset-frontend/src/components/TableSelector/index.tsx b/superset-frontend/src/components/TableSelector/index.tsx index 84696f93916f8..fcc5dbe10d214 100644 --- a/superset-frontend/src/components/TableSelector/index.tsx +++ b/superset-frontend/src/components/TableSelector/index.tsx @@ -82,6 +82,7 @@ const TableLabel = styled.span` interface TableSelectorProps { clearable?: boolean; database?: DatabaseObject; + emptyState?: ReactNode; formMode?: boolean; getDbList?: (arg0: any) => {}; handleError: (msg: string) => void; @@ -92,6 +93,7 @@ interface TableSelectorProps { onTablesLoad?: (options: Array) => void; readOnly?: boolean; schema?: string; + onEmptyResults?: (searchText?: string) => void; sqlLabMode?: boolean; tableValue?: string | string[]; onTableSelectChange?: (value?: string | string[], schema?: string) => void; @@ -146,6 +148,7 @@ const TableOption = ({ table }: { table: Table }) => { const TableSelector: FunctionComponent = ({ database, + emptyState, formMode = false, getDbList, handleError, @@ -155,6 +158,7 @@ const TableSelector: FunctionComponent = ({ onSchemasLoad, onTablesLoad, readOnly = false, + onEmptyResults, schema, sqlLabMode = true, tableSelectMode = 'single', @@ -286,10 +290,12 @@ const TableSelector: FunctionComponent = ({ Date: Fri, 15 Apr 2022 16:15:27 -0700 Subject: [PATCH 066/136] fix: deactivate embedding on a dashboard (#19626) * fix tests * commit it properly * unnecessary commit, correct type in docstring * unused import --- superset/dao/base.py | 2 +- superset/dashboards/api.py | 3 ++- tests/integration_tests/dashboards/api_tests.py | 10 +++++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/superset/dao/base.py b/superset/dao/base.py index 607967e3041e2..0090c4e535e23 100644 --- a/superset/dao/base.py +++ b/superset/dao/base.py @@ -175,7 +175,7 @@ def update( def delete(cls, model: Model, commit: bool = True) -> Model: """ Generic delete a model - :raises: DAOCreateFailedError + :raises: DAODeleteFailedError """ try: db.session.delete(model) diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index fb9c36ca033b8..277e0d10c34a6 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -1191,5 +1191,6 @@ def delete_embedded(self, dashboard: Dashboard) -> Response: 500: $ref: '#/components/responses/500' """ - dashboard.embedded = [] + for embedded in dashboard.embedded: + DashboardDAO.delete(embedded) return self.response(200, message="OK") diff --git a/tests/integration_tests/dashboards/api_tests.py b/tests/integration_tests/dashboards/api_tests.py index afeab6e7db8e9..a027dcffae604 100644 --- a/tests/integration_tests/dashboards/api_tests.py +++ b/tests/integration_tests/dashboards/api_tests.py @@ -1796,6 +1796,8 @@ def test_embedded_dashboards(self): self.assertNotEqual(result["uuid"], "") self.assertEqual(result["allowed_domains"], allowed_domains) + db.session.expire_all() + # get returns value resp = self.get_assert_metric(uri, "get_embedded") self.assertEqual(resp.status_code, 200) @@ -1810,9 +1812,13 @@ def test_embedded_dashboards(self): # put succeeds and returns value resp = self.post_assert_metric(uri, {"allowed_domains": []}, "set_embedded") self.assertEqual(resp.status_code, 200) + result = json.loads(resp.data.decode("utf-8"))["result"] + self.assertEqual(resp.status_code, 200) self.assertIsNotNone(result["uuid"]) self.assertNotEqual(result["uuid"], "") - self.assertEqual(result["allowed_domains"], allowed_domains) + self.assertEqual(result["allowed_domains"], []) + + db.session.expire_all() # get returns changed value resp = self.get_assert_metric(uri, "get_embedded") @@ -1825,6 +1831,8 @@ def test_embedded_dashboards(self): resp = self.delete_assert_metric(uri, "delete_embedded") self.assertEqual(resp.status_code, 200) + db.session.expire_all() + # get returns 404 resp = self.get_assert_metric(uri, "get_embedded") self.assertEqual(resp.status_code, 404) From 57157c8b1580545b5ef4d25a4d9039006bc27548 Mon Sep 17 00:00:00 2001 From: AAfghahi <48933336+AAfghahi@users.noreply.github.com> Date: Sun, 17 Apr 2022 01:02:23 -0400 Subject: [PATCH 067/136] fix: remove expose (#19700) * bumping shillelagh * remove expose --- superset/databases/api.py | 5 +---- superset/databases/filters.py | 13 +------------ tests/integration_tests/databases/api_tests.py | 2 +- 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/superset/databases/api.py b/superset/databases/api.py index e5817afb5d13b..ac497bf67dbde 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -169,10 +169,7 @@ class DatabaseRestApi(BaseSupersetModelRestApi): edit_columns = add_columns - search_filters = { - "allow_file_upload": [DatabaseUploadEnabledFilter], - "expose_in_sqllab": [DatabaseFilter], - } + search_filters = {"allow_file_upload": [DatabaseUploadEnabledFilter]} list_select_columns = list_columns + ["extra", "sqlalchemy_uri", "password"] order_columns = [ diff --git a/superset/databases/filters.py b/superset/databases/filters.py index 228abbc3bfa81..86564e8f15a7e 100644 --- a/superset/databases/filters.py +++ b/superset/databases/filters.py @@ -69,8 +69,6 @@ class DatabaseUploadEnabledFilter(BaseFilter): # pylint: disable=too-few-public def apply(self, query: Query, value: Any) -> Query: filtered_query = query.filter(Database.allow_file_upload) - database_perms = security_manager.user_view_menu_names("database_access") - schema_access_databases = can_access_databases("schema_access") datasource_access_databases = can_access_databases("datasource_access") if hasattr(g, "user"): @@ -82,19 +80,10 @@ def apply(self, query: Query, value: Any) -> Query: if len(allowed_schemas): return filtered_query - filtered_query = filtered_query.filter( + return filtered_query.filter( or_( cast(Database.extra, JSON)["schemas_allowed_for_file_upload"] is not None, cast(Database.extra, JSON)["schemas_allowed_for_file_upload"] != [], ) ) - - return filtered_query.filter( - or_( - self.model.perm.in_(database_perms), - self.model.database_name.in_( - [*schema_access_databases, *datasource_access_databases] - ), - ) - ) diff --git a/tests/integration_tests/databases/api_tests.py b/tests/integration_tests/databases/api_tests.py index 0c1dc27538d10..70640728ac352 100644 --- a/tests/integration_tests/databases/api_tests.py +++ b/tests/integration_tests/databases/api_tests.py @@ -1135,7 +1135,7 @@ def test_get_allow_file_upload_false_csv(self): uri = f"api/v1/database/?q={prison.dumps(arguments)}" rv = self.client.get(uri) data = json.loads(rv.data.decode("utf-8")) - assert data["count"] == 0 + assert data["count"] == 1 def test_get_allow_file_upload_filter_no_permission(self): """ From cf5145918ba6da3b8b803bed86ad7ca22d50494a Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Mon, 18 Apr 2022 14:26:21 +0300 Subject: [PATCH 068/136] fix(permalink): remove memoize on get salt func (#19749) --- superset/key_value/shared_entries.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/superset/key_value/shared_entries.py b/superset/key_value/shared_entries.py index 5dda89a7b3163..5f4ded949808c 100644 --- a/superset/key_value/shared_entries.py +++ b/superset/key_value/shared_entries.py @@ -20,7 +20,6 @@ from superset.key_value.types import KeyValueResource, SharedKey from superset.key_value.utils import get_uuid_namespace, random_key -from superset.utils.memoized import memoized RESOURCE = KeyValueResource.APP NAMESPACE = get_uuid_namespace("") @@ -42,7 +41,6 @@ def set_shared_value(key: SharedKey, value: Any) -> None: CreateKeyValueCommand(resource=RESOURCE, value=value, key=uuid_key).run() -@memoized def get_permalink_salt(key: SharedKey) -> str: salt = get_shared_value(key) if salt is None: From a05ff5e5983632809518995b7b50b985845fba88 Mon Sep 17 00:00:00 2001 From: Diego Medina Date: Mon, 18 Apr 2022 19:41:07 -0400 Subject: [PATCH 069/136] fix: alert/report created by filter inconsistency with table display (#19518) * fix: alert/report created by filter inconsistency with table display * Match column order to dashboard list --- .../src/views/CRUD/alert/AlertList.tsx | 30 ++++++++++++++++--- superset/reports/api.py | 2 ++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/superset-frontend/src/views/CRUD/alert/AlertList.tsx b/superset-frontend/src/views/CRUD/alert/AlertList.tsx index f0f9d7423b24b..66fc0109238a5 100644 --- a/superset-frontend/src/views/CRUD/alert/AlertList.tsx +++ b/superset-frontend/src/views/CRUD/alert/AlertList.tsx @@ -89,7 +89,7 @@ function AlertList({ const title = isReportEnabled ? t('report') : t('alert'); const titlePlural = isReportEnabled ? t('reports') : t('alerts'); const pathName = isReportEnabled ? 'Reports' : 'Alerts'; - const initalFilters = useMemo( + const initialFilters = useMemo( () => [ { id: 'type', @@ -117,7 +117,7 @@ function AlertList({ addDangerToast, true, undefined, - initalFilters, + initialFilters, ); const { updateResource } = useSingleViewResource>( @@ -261,9 +261,15 @@ function AlertList({ size: 'xl', }, { - accessor: 'created_by', + Cell: ({ + row: { + original: { created_by }, + }, + }: any) => + created_by ? `${created_by.first_name} ${created_by.last_name}` : '', + Header: t('Created by'), + id: 'created_by', disableSortBy: true, - hidden: true, size: 'xl', }, { @@ -378,6 +384,22 @@ function AlertList({ const filters: Filters = useMemo( () => [ + { + Header: t('Owner'), + id: 'owners', + input: 'select', + operator: FilterOperator.relationManyMany, + unfilteredLabel: 'All', + fetchSelects: createFetchRelated( + 'report', + 'owners', + createErrorHandler(errMsg => + t('An error occurred while fetching owners values: %s', errMsg), + ), + user, + ), + paginate: true, + }, { Header: t('Created by'), id: 'created_by', diff --git a/superset/reports/api.py b/superset/reports/api.py index e0d2598249d66..2871125c9a322 100644 --- a/superset/reports/api.py +++ b/superset/reports/api.py @@ -189,6 +189,7 @@ def ensure_alert_reports_enabled(self) -> Optional[Response]: "name", "active", "created_by", + "owners", "type", "last_state", "creation_method", @@ -212,6 +213,7 @@ def ensure_alert_reports_enabled(self) -> Optional[Response]: "chart": "slice_name", "database": "database_name", "created_by": RelatedFieldFilter("first_name", FilterRelatedOwners), + "owners": RelatedFieldFilter("first_name", FilterRelatedOwners), } apispec_parameter_schemas = { From a2d34ec4b8a89723e7468f194a98386699af0bd7 Mon Sep 17 00:00:00 2001 From: smileydev <47900232+prosdev0107@users.noreply.github.com> Date: Mon, 18 Apr 2022 19:43:24 -0400 Subject: [PATCH 070/136] fix(import): Add the error alert on failed database import (#19673) * fix(import): make to add the error alert * fix(import): make to add licence * fix(import): make to create ErrorAlert component and use errorMessage spelling --- .../src/components/ImportModal/ErrorAlert.tsx | 63 +++++++++++++++++++ .../src/components/ImportModal/index.tsx | 9 ++- .../src/components/ImportModal/styles.ts | 43 +++++++++++++ 3 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 superset-frontend/src/components/ImportModal/ErrorAlert.tsx create mode 100644 superset-frontend/src/components/ImportModal/styles.ts diff --git a/superset-frontend/src/components/ImportModal/ErrorAlert.tsx b/superset-frontend/src/components/ImportModal/ErrorAlert.tsx new file mode 100644 index 0000000000000..91ee6467f4f9a --- /dev/null +++ b/superset-frontend/src/components/ImportModal/ErrorAlert.tsx @@ -0,0 +1,63 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { FunctionComponent } from 'react'; +import { t, SupersetTheme } from '@superset-ui/core'; + +import { getDatabaseDocumentationLinks } from 'src/views/CRUD/hooks'; +import Alert from 'src/components/Alert'; +import { antdWarningAlertStyles } from './styles'; + +const supersetTextDocs = getDatabaseDocumentationLinks(); +export const DOCUMENTATION_LINK = supersetTextDocs + ? supersetTextDocs.support + : 'https://superset.apache.org/docs/databases/installing-database-drivers'; + +export interface IProps { + errorMessage: string; +} + +const ErrorAlert: FunctionComponent = ({ errorMessage }) => ( + antdWarningAlertStyles(theme)} + type="error" + showIcon + message={errorMessage} + description={ + <> +
+ {t( + 'Database driver for importing maybe not installed. Visit the Superset documentation page for installation instructions:', + )} + + {t('here')} + + . + + } + /> +); + +export default ErrorAlert; diff --git a/superset-frontend/src/components/ImportModal/index.tsx b/superset-frontend/src/components/ImportModal/index.tsx index e8c29b94e9561..c13546f7d3bff 100644 --- a/superset-frontend/src/components/ImportModal/index.tsx +++ b/superset-frontend/src/components/ImportModal/index.tsx @@ -25,6 +25,7 @@ import Modal from 'src/components/Modal'; import { Upload } from 'src/components'; import { useImportResource } from 'src/views/CRUD/hooks'; import { ImportResourceName } from 'src/views/CRUD/types'; +import ErrorAlert from './ErrorAlert'; const HelperMessage = styled.div` display: block; @@ -116,7 +117,6 @@ const ImportModelsModal: FunctionComponent = ({ resourceLabel, passwordsNeededMessage, confirmOverwriteMessage, - addDangerToast, onModelImport, show, onHide, @@ -130,6 +130,7 @@ const ImportModelsModal: FunctionComponent = ({ const [confirmedOverwrite, setConfirmedOverwrite] = useState(false); const [fileList, setFileList] = useState([]); const [importingModel, setImportingModel] = useState(false); + const [errorMessage, setErrorMessage] = useState(); const clearModal = () => { setFileList([]); @@ -138,11 +139,11 @@ const ImportModelsModal: FunctionComponent = ({ setNeedsOverwriteConfirm(false); setConfirmedOverwrite(false); setImportingModel(false); + setErrorMessage(''); }; const handleErrorMsg = (msg: string) => { - clearModal(); - addDangerToast(msg); + setErrorMessage(msg); }; const { @@ -294,10 +295,12 @@ const ImportModelsModal: FunctionComponent = ({ onRemove={removeFile} // upload is handled by hook customRequest={() => {}} + disabled={importingModel} > + {errorMessage && } {renderPasswordFields()} {renderOverwriteConfirmation()} diff --git a/superset-frontend/src/components/ImportModal/styles.ts b/superset-frontend/src/components/ImportModal/styles.ts new file mode 100644 index 0000000000000..c73dc7c1ab277 --- /dev/null +++ b/superset-frontend/src/components/ImportModal/styles.ts @@ -0,0 +1,43 @@ +/** + * 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 { css, SupersetTheme } from '@superset-ui/core'; + +export const antdWarningAlertStyles = (theme: SupersetTheme) => css` + border: 1px solid ${theme.colors.warning.light1}; + padding: ${theme.gridUnit * 4}px; + margin: ${theme.gridUnit * 4}px 0; + color: ${theme.colors.warning.dark2}; + + .ant-alert-message { + margin: 0; + } + + .ant-alert-description { + font-size: ${theme.typography.sizes.s + 1}px; + line-height: ${theme.gridUnit * 4}px; + + .ant-alert-icon { + margin-right: ${theme.gridUnit * 2.5}px; + font-size: ${theme.typography.sizes.l + 1}px; + position: relative; + top: ${theme.gridUnit / 4}px; + } + } +`; From 34323f9b5fcb1768f172d634e166230b6689f0da Mon Sep 17 00:00:00 2001 From: smileydev <47900232+prosdev0107@users.noreply.github.com> Date: Mon, 18 Apr 2022 21:26:15 -0400 Subject: [PATCH 071/136] fix(explore): make to show the null value as N/A in view result (#19603) * fix(explore): make to show the null value as N/A in view result * fix(explore): make to remove console * fix(explore): make to remove console in Cell * fix(explore): make to translate N/A --- .../src/explore/components/DataTableControl/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/superset-frontend/src/explore/components/DataTableControl/index.tsx b/superset-frontend/src/explore/components/DataTableControl/index.tsx index c94c07cb74fd9..2ae35211822b4 100644 --- a/superset-frontend/src/explore/components/DataTableControl/index.tsx +++ b/superset-frontend/src/explore/components/DataTableControl/index.tsx @@ -250,7 +250,9 @@ export const useFilteredTableData = ( const rowsAsStrings = useMemo( () => data?.map((row: Record) => - Object.values(row).map(value => value?.toString().toLowerCase()), + Object.values(row).map(value => + value ? value.toString().toLowerCase() : t('N/A'), + ), ) ?? [], [data], ); From 594523e895a8fa455ba6db5d6cc4df80d20179a1 Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Tue, 19 Apr 2022 10:10:40 +0200 Subject: [PATCH 072/136] feat(explore): Implement data panel redesign (#19751) * feat(explore): Redesign of data panel * Auto calculate chart panel height and width * Add tests * Fix e2e tests * Increase collapsed data panel height --- .../integration/explore/control.test.ts | 6 +- .../components/DataTableControl/index.tsx | 45 ++- .../DataTablesPane/DataTablesPane.test.tsx | 220 ++++++++---- .../components/DataTablesPane/index.tsx | 325 +++++++++++------- .../explore/components/ExploreChartPanel.jsx | 229 ++++++------ .../components/ExploreViewContainer/index.jsx | 28 +- 6 files changed, 494 insertions(+), 359 deletions(-) diff --git a/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts b/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts index 271f86e9ed3b8..97dfd2945aaf5 100644 --- a/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts @@ -121,14 +121,12 @@ describe('Test datatable', () => { cy.visitChartByName('Daily Totals'); }); it('Data Pane opens and loads results', () => { - cy.get('[data-test="data-tab"]').click(); + cy.contains('Results').click(); cy.get('[data-test="row-count-label"]').contains('26 rows retrieved'); - cy.contains('View results'); cy.get('.ant-empty-description').should('not.exist'); }); it('Datapane loads view samples', () => { - cy.get('[data-test="data-tab"]').click(); - cy.contains('View samples').click(); + cy.contains('Samples').click(); cy.get('[data-test="row-count-label"]').contains('1k rows retrieved'); cy.get('.ant-empty-description').should('not.exist'); }); diff --git a/superset-frontend/src/explore/components/DataTableControl/index.tsx b/superset-frontend/src/explore/components/DataTableControl/index.tsx index 2ae35211822b4..7a25c374bd8ac 100644 --- a/superset-frontend/src/explore/components/DataTableControl/index.tsx +++ b/superset-frontend/src/explore/components/DataTableControl/index.tsx @@ -67,41 +67,56 @@ export const CopyButton = styled(Button)` } `; -const CopyNode = ( - - - -); - export const CopyToClipboardButton = ({ data, columns, }: { data?: Record; columns?: string[]; -}) => ( - -); +}) => { + const theme = useTheme(); + return ( + * { + line-height: 0; + } + `} + /> + } + /> + ); +}; export const FilterInput = ({ onChangeHandler, }: { onChangeHandler(filterText: string): void; }) => { + const theme = useTheme(); const debouncedChangeHandler = debounce(onChangeHandler, SLOW_DEBOUNCE); return ( } placeholder={t('Search')} onChange={(event: any) => { const filterText = event.target.value; debouncedChangeHandler(filterText); }} + css={css` + width: 200px; + margin-right: ${theme.gridUnit * 2}px; + `} /> ); }; diff --git a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx index 9905d8f5c6d3c..786150449ee20 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx @@ -21,7 +21,11 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; import fetchMock from 'fetch-mock'; import * as copyUtils from 'src/utils/copy'; -import { render, screen } from 'spec/helpers/testing-library'; +import { + render, + screen, + waitForElementToBeRemoved, +} from 'spec/helpers/testing-library'; import { DataTablesPane } from '.'; const createProps = () => ({ @@ -50,7 +54,6 @@ const createProps = () => ({ sort_y_axis: 'alpha_asc', extra_form_data: {}, }, - tableSectionHeight: 156.9, chartStatus: 'rendered', onCollapseChange: jest.fn(), queriesResponse: [ @@ -60,91 +63,162 @@ const createProps = () => ({ ], }); -test('Rendering DataTablesPane correctly', () => { - const props = createProps(); - render(, { useRedux: true }); - expect(screen.getByTestId('some-purposeful-instance')).toBeVisible(); - expect(screen.getByRole('tablist')).toBeVisible(); - expect(screen.getByRole('tab', { name: 'right Data' })).toBeVisible(); - expect(screen.getByRole('img', { name: 'right' })).toBeVisible(); -}); +describe('DataTablesPane', () => { + // Collapsed/expanded state depends on local storage + // We need to clear it manually - otherwise initial state would depend on the order of tests + beforeEach(() => { + localStorage.clear(); + }); -test('Should show tabs', async () => { - const props = createProps(); - render(, { useRedux: true }); - expect(screen.queryByText('View results')).not.toBeInTheDocument(); - expect(screen.queryByText('View samples')).not.toBeInTheDocument(); - userEvent.click(await screen.findByText('Data')); - expect(await screen.findByText('View results')).toBeVisible(); - expect(screen.getByText('View samples')).toBeVisible(); -}); + afterAll(() => { + localStorage.clear(); + }); -test('Should show tabs: View results', async () => { - const props = createProps(); - render(, { - useRedux: true, + test('Rendering DataTablesPane correctly', () => { + const props = createProps(); + render(, { useRedux: true }); + expect(screen.getByText('Results')).toBeVisible(); + expect(screen.getByText('Samples')).toBeVisible(); + expect(screen.getByLabelText('Expand data panel')).toBeVisible(); }); - userEvent.click(await screen.findByText('Data')); - userEvent.click(await screen.findByText('View results')); - expect(screen.getByText('0 rows retrieved')).toBeVisible(); -}); -test('Should show tabs: View samples', async () => { - const props = createProps(); - render(, { - useRedux: true, + test('Collapse/Expand buttons', async () => { + const props = createProps(); + render(, { + useRedux: true, + }); + expect( + screen.queryByLabelText('Collapse data panel'), + ).not.toBeInTheDocument(); + userEvent.click(screen.getByLabelText('Expand data panel')); + expect(await screen.findByLabelText('Collapse data panel')).toBeVisible(); + expect( + screen.queryByLabelText('Expand data panel'), + ).not.toBeInTheDocument(); }); - userEvent.click(await screen.findByText('Data')); - expect(screen.queryByText('0 rows retrieved')).not.toBeInTheDocument(); - userEvent.click(await screen.findByText('View samples')); - expect(await screen.findByText('0 rows retrieved')).toBeVisible(); -}); -test('Should copy data table content correctly', async () => { - fetchMock.post( - 'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D', - { - result: [ - { - data: [{ __timestamp: 1230768000000, genre: 'Action' }], - colnames: ['__timestamp', 'genre'], - coltypes: [2, 1], + test('Should show tabs: View results', async () => { + const props = createProps(); + render(, { + useRedux: true, + }); + userEvent.click(screen.getByText('Results')); + expect(await screen.findByText('0 rows retrieved')).toBeVisible(); + expect(await screen.findByLabelText('Collapse data panel')).toBeVisible(); + localStorage.clear(); + }); + + test('Should show tabs: View samples', async () => { + const props = createProps(); + render(, { + useRedux: true, + }); + userEvent.click(screen.getByText('Samples')); + expect(await screen.findByText('0 rows retrieved')).toBeVisible(); + expect(await screen.findByLabelText('Collapse data panel')).toBeVisible(); + }); + + test('Should copy data table content correctly', async () => { + fetchMock.post( + 'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D', + { + result: [ + { + data: [{ __timestamp: 1230768000000, genre: 'Action' }], + colnames: ['__timestamp', 'genre'], + coltypes: [2, 1], + }, + ], + }, + ); + const copyToClipboardSpy = jest.spyOn(copyUtils, 'default'); + const props = createProps(); + render( + , + { + useRedux: true, + initialState: { + explore: { + timeFormattedColumns: { + '34__table': ['__timestamp'], + }, + }, }, - ], - }, - ); - const copyToClipboardSpy = jest.spyOn(copyUtils, 'default'); - const props = createProps(); - render( - { + fetchMock.post( + 'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D', + { + result: [ { + data: [ + { __timestamp: 1230768000000, genre: 'Action' }, + { __timestamp: 1230768000010, genre: 'Horror' }, + ], colnames: ['__timestamp', 'genre'], coltypes: [2, 1], }, ], - }} - />, - { - useRedux: true, - initialState: { - explore: { - timeFormattedColumns: { - '34__table': ['__timestamp'], + }, + ); + const props = createProps(); + render( + , + { + useRedux: true, + initialState: { + explore: { + timeFormattedColumns: { + '34__table': ['__timestamp'], + }, }, }, }, - }, - ); - userEvent.click(await screen.findByText('Data')); - expect(await screen.findByText('1 rows retrieved')).toBeVisible(); + ); + userEvent.click(screen.getByText('Results')); + expect(await screen.findByText('2 rows retrieved')).toBeVisible(); + expect(screen.getByText('Action')).toBeVisible(); + expect(screen.getByText('Horror')).toBeVisible(); - userEvent.click(screen.getByRole('button', { name: 'Copy' })); - expect(copyToClipboardSpy).toHaveBeenCalledWith( - '2009-01-01 00:00:00\tAction\n', - ); - fetchMock.done(); + userEvent.type(screen.getByPlaceholderText('Search'), 'hor'); + + await waitForElementToBeRemoved(() => screen.queryByText('Action')); + expect(screen.getByText('Horror')).toBeVisible(); + expect(screen.queryByText('Action')).not.toBeInTheDocument(); + fetchMock.restore(); + }); }); diff --git a/superset-frontend/src/explore/components/DataTablesPane/index.tsx b/superset-frontend/src/explore/components/DataTablesPane/index.tsx index 5d935caa63ddd..a41af3626f1e4 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/index.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/index.tsx @@ -16,15 +16,23 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useState, + MouseEvent, +} from 'react'; import { + css, ensureIsArray, GenericDataType, JsonObject, styled, t, + useTheme, } from '@superset-ui/core'; -import Collapse from 'src/components/Collapse'; +import Icons from 'src/components/Icons'; import Tabs from 'src/components/Tabs'; import Loading from 'src/components/Loading'; import { EmptyStateMedium } from 'src/components/EmptyState'; @@ -58,53 +66,58 @@ const getDefaultDataTablesState = (value: any) => ({ const DATA_TABLE_PAGE_SIZE = 50; -const DATAPANEL_KEY = 'data'; - const TableControlsWrapper = styled.div` - display: flex; - align-items: center; - - span { - flex-shrink: 0; - } + ${({ theme }) => ` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: ${theme.gridUnit * 2}px; + + span { + flex-shrink: 0; + } + `} `; const SouthPane = styled.div` - position: relative; - background-color: ${({ theme }) => theme.colors.grayscale.light5}; - z-index: 5; - overflow: hidden; -`; - -const TabsWrapper = styled.div<{ contentHeight: number }>` - height: ${({ contentHeight }) => contentHeight}px; - overflow: hidden; + ${({ theme }) => ` + position: relative; + background-color: ${theme.colors.grayscale.light5}; + z-index: 5; + overflow: hidden; - .table-condensed { - height: 100%; - overflow: auto; - } -`; + .ant-tabs { + height: 100%; + } -const CollapseWrapper = styled.div` - height: 100%; + .ant-tabs-content-holder { + height: 100%; + } - .collapse-inner { - height: 100%; + .ant-tabs-content { + height: 100%; + } - .ant-collapse-item { + .ant-tabs-tabpane { + display: flex; + flex-direction: column; height: 100%; - .ant-collapse-content { - height: calc(100% - ${({ theme }) => theme.gridUnit * 8}px); + .table-condensed { + height: 100%; + overflow: auto; + margin-bottom: ${theme.gridUnit * 4}px; - .ant-collapse-content-box { - padding-top: 0; - height: 100%; + .table { + margin-bottom: ${theme.gridUnit * 2}px; } } + + .pagination-container > ul[role='navigation'] { + margin-top: 0; + } } - } + `} `; const Error = styled.pre` @@ -117,7 +130,6 @@ interface DataTableProps { datasource: string | undefined; filterText: string; data: object[] | undefined; - timeFormattedColumns: string[] | undefined; isLoading: boolean; error: string | undefined; errorMessage: React.ReactElement | undefined; @@ -130,12 +142,12 @@ const DataTable = ({ datasource, filterText, data, - timeFormattedColumns, isLoading, error, errorMessage, type, }: DataTableProps) => { + const timeFormattedColumns = useTimeFormattedColumns(datasource); // this is to preserve the order of the columns, even if there are integer values, // while also only grabbing the first column's keys const columns = useTableColumns( @@ -185,9 +197,42 @@ const DataTable = ({ return null; }; +const TableControls = ({ + data, + datasourceId, + onInputChange, + columnNames, + isLoading, +}: { + data: Record[]; + datasourceId?: string; + onInputChange: (input: string) => void; + columnNames: string[]; + isLoading: boolean; +}) => { + const timeFormattedColumns = useTimeFormattedColumns(datasourceId); + const formattedData = useMemo( + () => applyFormattingToTabularData(data, timeFormattedColumns), + [data, timeFormattedColumns], + ); + return ( + + +
+ + +
+
+ ); +}; + export const DataTablesPane = ({ queryFormData, - tableSectionHeight, onCollapseChange, chartStatus, ownState, @@ -195,19 +240,19 @@ export const DataTablesPane = ({ queriesResponse, }: { queryFormData: Record; - tableSectionHeight: number; chartStatus: string; ownState?: JsonObject; - onCollapseChange: (openPanelName: string) => void; + onCollapseChange: (isOpen: boolean) => void; errorMessage?: JSX.Element; queriesResponse: Record; }) => { + const theme = useTheme(); const [data, setData] = useState(getDefaultDataTablesState(undefined)); const [isLoading, setIsLoading] = useState(getDefaultDataTablesState(true)); const [columnNames, setColumnNames] = useState(getDefaultDataTablesState([])); const [columnTypes, setColumnTypes] = useState(getDefaultDataTablesState([])); const [error, setError] = useState(getDefaultDataTablesState('')); - const [filterText, setFilterText] = useState(''); + const [filterText, setFilterText] = useState(getDefaultDataTablesState('')); const [activeTabKey, setActiveTabKey] = useState( RESULT_TYPES.results, ); @@ -218,24 +263,6 @@ export const DataTablesPane = ({ getItem(LocalStorageKeys.is_datapanel_open, false), ); - const timeFormattedColumns = useTimeFormattedColumns( - queryFormData?.datasource, - ); - - const formattedData = useMemo( - () => ({ - [RESULT_TYPES.results]: applyFormattingToTabularData( - data[RESULT_TYPES.results], - timeFormattedColumns, - ), - [RESULT_TYPES.samples]: applyFormattingToTabularData( - data[RESULT_TYPES.samples], - timeFormattedColumns, - ), - }), - [data, timeFormattedColumns], - ); - const getData = useCallback( (resultType: 'samples' | 'results') => { setIsLoading(prevIsLoading => ({ @@ -381,81 +408,121 @@ export const DataTablesPane = ({ errorMessage, ]); - const TableControls = ( - - - - - + const handleCollapseChange = useCallback( + (isOpen: boolean) => { + onCollapseChange(isOpen); + setPanelOpen(isOpen); + }, + [onCollapseChange], ); - const handleCollapseChange = (openPanelName: string) => { - onCollapseChange(openPanelName); - setPanelOpen(!!openPanelName); - }; + const handleTabClick = useCallback( + (tabKey: string, e: MouseEvent) => { + if (!panelOpen) { + handleCollapseChange(true); + } else if (tabKey === activeTabKey) { + e.preventDefault(); + handleCollapseChange(false); + } + setActiveTabKey(tabKey); + }, + [activeTabKey, handleCollapseChange, panelOpen], + ); + + const CollapseButton = useMemo(() => { + const caretIcon = panelOpen ? ( + + ) : ( + + ); + return ( + + {panelOpen ? ( + handleCollapseChange(false)} + > + {caretIcon} + + ) : ( + handleCollapseChange(true)} + > + {caretIcon} + + )} + + ); + }, [handleCollapseChange, panelOpen, theme.colors.grayscale.base]); return ( - - - - - - - - - - - - - - - - + + + + setFilterText(prevState => ({ + ...prevState, + [RESULT_TYPES.results]: input, + })) + } + isLoading={isLoading[RESULT_TYPES.results]} + /> + + + + + setFilterText(prevState => ({ + ...prevState, + [RESULT_TYPES.samples]: input, + })) + } + isLoading={isLoading[RESULT_TYPES.samples]} + /> + + + ); }; diff --git a/superset-frontend/src/explore/components/ExploreChartPanel.jsx b/superset-frontend/src/explore/components/ExploreChartPanel.jsx index 8fb1c3ef073d9..37523713a8e71 100644 --- a/superset-frontend/src/explore/components/ExploreChartPanel.jsx +++ b/superset-frontend/src/explore/components/ExploreChartPanel.jsx @@ -19,7 +19,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import PropTypes from 'prop-types'; import Split from 'react-split'; -import { styled, SupersetClient, useTheme } from '@superset-ui/core'; +import { css, styled, SupersetClient, useTheme } from '@superset-ui/core'; import { useResizeDetector } from 'react-resize-detector'; import { chartPropShape } from 'src/dashboard/util/propShapes'; import ChartContainer from 'src/components/Chart/ChartContainer'; @@ -41,8 +41,6 @@ const propTypes = { dashboardId: PropTypes.number, column_formats: PropTypes.object, containerId: PropTypes.string.isRequired, - height: PropTypes.string.isRequired, - width: PropTypes.string.isRequired, isStarred: PropTypes.bool.isRequired, slice: PropTypes.object, sliceName: PropTypes.string, @@ -61,11 +59,8 @@ const propTypes = { const GUTTER_SIZE_FACTOR = 1.25; -const CHART_PANEL_PADDING_HORIZ = 30; -const CHART_PANEL_PADDING_VERTICAL = 15; - -const INITIAL_SIZES = [90, 10]; -const MIN_SIZES = [300, 50]; +const INITIAL_SIZES = [100, 0]; +const MIN_SIZES = [300, 65]; const DEFAULT_SOUTH_PANE_HEIGHT_PERCENT = 40; const Styles = styled.div` @@ -109,28 +104,42 @@ const Styles = styled.div` } `; -const ExploreChartPanel = props => { +const ExploreChartPanel = ({ + chart, + slice, + vizType, + ownState, + triggerRender, + force, + datasource, + errorMessage, + form_data: formData, + onQuery, + refreshOverlayVisible, + actions, + timeout, + standalone, +}) => { const theme = useTheme(); const gutterMargin = theme.gridUnit * GUTTER_SIZE_FACTOR; const gutterHeight = theme.gridUnit * GUTTER_SIZE_FACTOR; - const { width: chartPanelWidth, ref: chartPanelRef } = useResizeDetector({ + const { + width: chartPanelWidth, + height: chartPanelHeight, + ref: chartPanelRef, + } = useResizeDetector({ refreshMode: 'debounce', refreshRate: 300, }); - const { height: pillsHeight, ref: pillsRef } = useResizeDetector({ - refreshMode: 'debounce', - refreshRate: 1000, - }); const [splitSizes, setSplitSizes] = useState( getItem(LocalStorageKeys.chart_split_sizes, INITIAL_SIZES), ); - const { slice } = props; const updateQueryContext = useCallback( async function fetchChartData() { if (slice && slice.query_context === null) { const queryContext = buildV1ChartDataPayload({ formData: slice.form_data, - force: props.force, + force, resultFormat: 'json', resultType: 'full', setDataMask: null, @@ -154,34 +163,6 @@ const ExploreChartPanel = props => { updateQueryContext(); }, [updateQueryContext]); - const calcSectionHeight = useCallback( - percent => { - let containerHeight = parseInt(props.height, 10); - if (pillsHeight) { - containerHeight -= pillsHeight; - } - return ( - (containerHeight * percent) / 100 - (gutterHeight / 2 + gutterMargin) - ); - }, - [gutterHeight, gutterMargin, pillsHeight, props.height, props.standalone], - ); - - const [tableSectionHeight, setTableSectionHeight] = useState( - calcSectionHeight(INITIAL_SIZES[1]), - ); - - const recalcPanelSizes = useCallback( - ([, southPercent]) => { - setTableSectionHeight(calcSectionHeight(southPercent)); - }, - [calcSectionHeight], - ); - - useEffect(() => { - recalcPanelSizes(splitSizes); - }, [recalcPanelSizes, splitSizes]); - useEffect(() => { setItem(LocalStorageKeys.chart_split_sizes, splitSizes); }, [splitSizes]); @@ -191,19 +172,19 @@ const ExploreChartPanel = props => { }; const refreshCachedQuery = () => { - props.actions.postChartFormData( - props.form_data, + actions.postChartFormData( + formData, true, - props.timeout, - props.chart.id, + timeout, + chart.id, undefined, - props.ownState, + ownState, ); }; - const onCollapseChange = openPanelName => { + const onCollapseChange = useCallback(isOpen => { let splitSizes; - if (!openPanelName) { + if (!isOpen) { splitSizes = INITIAL_SIZES; } else { splitSizes = [ @@ -212,53 +193,84 @@ const ExploreChartPanel = props => { ]; } setSplitSizes(splitSizes); - }; - const renderChart = useCallback(() => { - const { chart, vizType } = props; - const newHeight = - vizType === 'filter_box' - ? calcSectionHeight(100) - CHART_PANEL_PADDING_VERTICAL - : calcSectionHeight(splitSizes[0]) - CHART_PANEL_PADDING_VERTICAL; - const chartWidth = chartPanelWidth - CHART_PANEL_PADDING_HORIZ; - return ( - chartWidth > 0 && ( - - ) - ); - }, [calcSectionHeight, chartPanelWidth, props, splitSizes]); + }, []); + + const renderChart = useCallback( + () => ( +
+ {chartPanelWidth && chartPanelHeight && ( + + )} +
+ ), + [ + actions.setControlValue, + chart.annotationData, + chart.chartAlert, + chart.chartStackTrace, + chart.chartStatus, + chart.id, + chart.queriesResponse, + chart.triggerQuery, + chartPanelHeight, + chartPanelRef, + chartPanelWidth, + datasource, + errorMessage, + force, + formData, + onQuery, + ownState, + refreshOverlayVisible, + timeout, + triggerRender, + vizType, + ], + ); const panelBody = useMemo( () => ( -
+
{renderChart()}
@@ -266,14 +278,9 @@ const ExploreChartPanel = props => { [chartPanelRef, renderChart], ); - const standaloneChartBody = useMemo( - () =>
{renderChart()}
, - [chartPanelRef, renderChart], - ); + const standaloneChartBody = useMemo(() => renderChart(), [renderChart]); - const [queryFormData, setQueryFormData] = useState( - props.chart.latestQueryFormData, - ); + const [queryFormData, setQueryFormData] = useState(chart.latestQueryFormData); useEffect(() => { // only update when `latestQueryFormData` changes AND `triggerRender` @@ -281,13 +288,13 @@ const ExploreChartPanel = props => { // as this can trigger a query downstream based on incomplete form data. // (`latestQueryFormData` is only updated when a a valid request has been // triggered). - if (!props.triggerRender) { - setQueryFormData(props.chart.latestQueryFormData); + if (!triggerRender) { + setQueryFormData(chart.latestQueryFormData); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.chart.latestQueryFormData]); + }, [chart.latestQueryFormData]); - if (props.standalone) { + if (standalone) { // dom manipulation hack to get rid of the boostrap theme's body background const standaloneClass = 'background-transparent'; const bodyClasses = document.body.className.split(' '); @@ -302,8 +309,8 @@ const ExploreChartPanel = props => { }); return ( - - {props.vizType === 'filter_box' ? ( + + {vizType === 'filter_box' ? ( panelBody ) : ( { gutterSize={gutterHeight} onDragEnd={onDragEnd} elementStyle={elementStyle} + expandToMin > {panelBody} )} diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx index b856ba706cf88..7299adf251085 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx @@ -63,8 +63,6 @@ import ConnectedExploreChartHeader from '../ExploreChartHeader'; const propTypes = { ...ExploreChartPanel.propTypes, - height: PropTypes.string, - width: PropTypes.string, actions: PropTypes.object.isRequired, datasource_type: PropTypes.string.isRequired, dashboardId: PropTypes.number, @@ -135,6 +133,7 @@ const ExplorePanelContainer = styled.div` flex: 1; min-width: ${theme.gridUnit * 128}px; border-left: 1px solid ${theme.colors.grayscale.light2}; + padding: 0 ${theme.gridUnit * 4}px; .panel { margin-bottom: 0; } @@ -172,23 +171,6 @@ const ExplorePanelContainer = styled.div` `}; `; -const getWindowSize = () => ({ - height: window.innerHeight, - width: window.innerWidth, -}); - -function useWindowSize({ delayMs = 250 } = {}) { - const [size, setSize] = useState(getWindowSize()); - - useEffect(() => { - const onWindowResize = debounce(() => setSize(getWindowSize()), delayMs); - window.addEventListener('resize', onWindowResize); - return () => window.removeEventListener('resize', onWindowResize); - }, []); - - return size; -} - const updateHistory = debounce( async (formData, datasetId, isReplace, standalone, force, title, tabId) => { const payload = { ...formData }; @@ -246,7 +228,6 @@ function ExploreViewContainer(props) { const [lastQueriedControls, setLastQueriedControls] = useState( props.controls, ); - const windowSize = useWindowSize(); const [showingModal, setShowingModal] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false); @@ -254,11 +235,6 @@ function ExploreViewContainer(props) { const tabId = useTabId(); const theme = useTheme(); - const width = `${windowSize.width}px`; - const navHeight = props.standalone ? 0 : 120; - const height = props.forcedHeight - ? `${props.forcedHeight}px` - : `${windowSize.height - navHeight}px`; const defaultSidebarsWidth = { controls_width: 320, @@ -515,8 +491,6 @@ function ExploreViewContainer(props) { function renderChartContainer() { return ( Date: Tue, 19 Apr 2022 14:57:06 +0200 Subject: [PATCH 073/136] feat(explore): Replace overlay with alert banner when chart controls change (#19696) * Rename explore alert * Rename refreshOverlayVisible to chartIsStale * Implement banners * Add tests * Add clickable text to empty state * Fix viz type switching * styling changes * Fixes after rebasing * Code review fixes * Fix bug * Fix redundant refreshing --- .../src/components/Chart/Chart.jsx | 64 +++------ .../src/components/Chart/ChartRenderer.jsx | 28 ++-- .../components/Chart/ChartRenderer.test.jsx | 19 +-- .../explore/components/ControlPanelAlert.tsx | 98 -------------- .../components/ControlPanelsContainer.tsx | 7 +- .../src/explore/components/ExploreAlert.tsx | 127 ++++++++++++++++++ .../explore/components/ExploreChartPanel.jsx | 89 ++++++++++-- .../components/ExploreChartPanel.test.jsx | 75 +++++++++-- .../ExploreViewContainer.test.tsx | 5 +- .../components/ExploreViewContainer/index.jsx | 41 ++++-- .../controlUtils/getFormDataFromControls.ts | 7 +- .../getChartRequiredFieldsMissingMessage.ts | 26 ++++ 12 files changed, 372 insertions(+), 214 deletions(-) delete mode 100644 superset-frontend/src/explore/components/ControlPanelAlert.tsx create mode 100644 superset-frontend/src/explore/components/ExploreAlert.tsx create mode 100644 superset-frontend/src/utils/getChartRequiredFieldsMissingMessage.ts diff --git a/superset-frontend/src/components/Chart/Chart.jsx b/superset-frontend/src/components/Chart/Chart.jsx index 35209bb94af0a..7df33d0c5d7cb 100644 --- a/superset-frontend/src/components/Chart/Chart.jsx +++ b/superset-frontend/src/components/Chart/Chart.jsx @@ -22,7 +22,6 @@ import { styled, logging, t, ensureIsArray } from '@superset-ui/core'; import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; import { PLACEHOLDER_DATASOURCE } from 'src/dashboard/constants'; -import Button from 'src/components/Button'; import Loading from 'src/components/Loading'; import { EmptyStateBig } from 'src/components/EmptyState'; import ErrorBoundary from 'src/components/ErrorBoundary'; @@ -32,6 +31,7 @@ import { getUrlParam } from 'src/utils/urlUtils'; import { ResourceStatus } from 'src/hooks/apiResources/apiResources'; import ChartRenderer from './ChartRenderer'; import { ChartErrorMessage } from './ChartErrorMessage'; +import { getChartRequiredFieldsMissingMessage } from '../../utils/getChartRequiredFieldsMissingMessage'; const propTypes = { annotationData: PropTypes.object, @@ -64,7 +64,7 @@ const propTypes = { chartStackTrace: PropTypes.string, queriesResponse: PropTypes.arrayOf(PropTypes.object), triggerQuery: PropTypes.bool, - refreshOverlayVisible: PropTypes.bool, + chartIsStale: PropTypes.bool, errorMessage: PropTypes.node, // dashboard callbacks addFilter: PropTypes.func, @@ -108,20 +108,8 @@ const Styles = styled.div` } `; -const RefreshOverlayWrapper = styled.div` - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; -`; - const MonospaceDiv = styled.div` font-family: ${({ theme }) => theme.typography.families.monospace}; - white-space: pre; word-break: break-word; overflow-x: auto; white-space: pre-wrap; @@ -255,34 +243,23 @@ class Chart extends React.PureComponent { chartAlert, chartStatus, errorMessage, - onQuery, - refreshOverlayVisible, + chartIsStale, queriesResponse = [], isDeactivatedViz = false, width, } = this.props; const isLoading = chartStatus === 'loading'; - const isFaded = refreshOverlayVisible && !errorMessage; this.renderContainerStartTime = Logger.getTimestamp(); if (chartStatus === 'failed') { return queriesResponse.map(item => this.renderErrorMessage(item)); } - if (errorMessage) { - const description = isFeatureEnabled( - FeatureFlag.ENABLE_EXPLORE_DRAG_AND_DROP, - ) - ? t( - 'Drag and drop values into highlighted field(s) on the left control panel and run query', - ) - : t( - 'Select values in highlighted field(s) on the left control panel and run query', - ); + if (errorMessage && ensureIsArray(queriesResponse).length === 0) { return ( ); @@ -291,15 +268,24 @@ class Chart extends React.PureComponent { if ( !isLoading && !chartAlert && - isFaded && + !errorMessage && + chartIsStale && ensureIsArray(queriesResponse).length === 0 ) { return ( + {t( + 'Click on "Create chart" button in the control panel on the left to preview a visualization or', + )}{' '} + + {t('click here')} + + . + + } image="chart.svg" /> ); @@ -317,25 +303,13 @@ class Chart extends React.PureComponent { height={height} width={width} > -
+
- - {!isLoading && !chartAlert && isFaded && ( - - - - )} - {isLoading && !isDeactivatedViz && } diff --git a/superset-frontend/src/components/Chart/ChartRenderer.jsx b/superset-frontend/src/components/Chart/ChartRenderer.jsx index b814b6fde6d36..45feb6ffd57ee 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.jsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.jsx @@ -30,6 +30,7 @@ const propTypes = { datasource: PropTypes.object, initialValues: PropTypes.object, formData: PropTypes.object.isRequired, + latestQueryFormData: PropTypes.object, labelColors: PropTypes.object, sharedLabelColors: PropTypes.object, height: PropTypes.number, @@ -42,7 +43,7 @@ const propTypes = { chartStatus: PropTypes.string, queriesResponse: PropTypes.arrayOf(PropTypes.object), triggerQuery: PropTypes.bool, - refreshOverlayVisible: PropTypes.bool, + chartIsStale: PropTypes.bool, // dashboard callbacks addFilter: PropTypes.func, setDataMask: PropTypes.func, @@ -58,6 +59,8 @@ const BLANK = {}; const BIG_NO_RESULT_MIN_WIDTH = 300; const BIG_NO_RESULT_MIN_HEIGHT = 220; +const behaviors = [Behavior.INTERACTIVE_CHART]; + const defaultProps = { addFilter: () => BLANK, onFilterMenuOpen: () => BLANK, @@ -93,8 +96,7 @@ class ChartRenderer extends React.Component { const resultsReady = nextProps.queriesResponse && ['success', 'rendered'].indexOf(nextProps.chartStatus) > -1 && - !nextProps.queriesResponse?.[0]?.error && - !nextProps.refreshOverlayVisible; + !nextProps.queriesResponse?.[0]?.error; if (resultsReady) { this.hasQueryResponseChange = @@ -170,16 +172,10 @@ class ChartRenderer extends React.Component { } render() { - const { chartAlert, chartStatus, vizType, chartId, refreshOverlayVisible } = - this.props; + const { chartAlert, chartStatus, chartId } = this.props; // Skip chart rendering - if ( - refreshOverlayVisible || - chartStatus === 'loading' || - !!chartAlert || - chartStatus === null - ) { + if (chartStatus === 'loading' || !!chartAlert || chartStatus === null) { return null; } @@ -193,11 +189,17 @@ class ChartRenderer extends React.Component { initialValues, ownState, filterState, + chartIsStale, formData, + latestQueryFormData, queriesResponse, 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 @@ -255,11 +257,11 @@ class ChartRenderer extends React.Component { annotationData={annotationData} datasource={datasource} initialValues={initialValues} - formData={formData} + formData={currentFormData} ownState={ownState} filterState={filterState} hooks={this.hooks} - behaviors={[Behavior.INTERACTIVE_CHART]} + behaviors={behaviors} queriesData={queriesResponse} onRenderSuccess={this.handleRenderSuccess} onRenderFailure={this.handleRenderFailure} diff --git a/superset-frontend/src/components/Chart/ChartRenderer.test.jsx b/superset-frontend/src/components/Chart/ChartRenderer.test.jsx index 7e3a455631ff0..f3ce0415175fb 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.test.jsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.test.jsx @@ -25,22 +25,25 @@ import ChartRenderer from 'src/components/Chart/ChartRenderer'; const requiredProps = { chartId: 1, datasource: {}, - formData: {}, - vizType: 'foo', + formData: { testControl: 'foo' }, + latestQueryFormData: { + testControl: 'bar', + }, + vizType: 'table', }; describe('ChartRenderer', () => { it('should render SuperChart', () => { const wrapper = shallow( - , + , ); expect(wrapper.find(SuperChart)).toExist(); }); - it('should not render SuperChart when refreshOverlayVisible is true', () => { - const wrapper = shallow( - , - ); - expect(wrapper.find(SuperChart)).not.toExist(); + it('should use latestQueryFormData instead of formData when chartIsStale is true', () => { + const wrapper = shallow(); + expect(wrapper.find(SuperChart).prop('formData')).toEqual({ + testControl: 'bar', + }); }); }); diff --git a/superset-frontend/src/explore/components/ControlPanelAlert.tsx b/superset-frontend/src/explore/components/ControlPanelAlert.tsx deleted file mode 100644 index 142e074b559e6..0000000000000 --- a/superset-frontend/src/explore/components/ControlPanelAlert.tsx +++ /dev/null @@ -1,98 +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 React from 'react'; -import { styled } from '@superset-ui/core'; -import Button from 'src/components/Button'; - -interface ControlPanelAlertProps { - title: string; - bodyText: string; - primaryButtonAction: (e: React.MouseEvent) => void; - secondaryButtonAction?: (e: React.MouseEvent) => void; - primaryButtonText: string; - secondaryButtonText?: string; - type: 'info' | 'warning'; -} - -const AlertContainer = styled.div` - margin: ${({ theme }) => theme.gridUnit * 4}px; - padding: ${({ theme }) => theme.gridUnit * 4}px; - - border: ${({ theme }) => `1px solid ${theme.colors.info.base}`}; - background-color: ${({ theme }) => theme.colors.info.light2}; - border-radius: 2px; - - color: ${({ theme }) => theme.colors.info.dark2}; - font-size: ${({ theme }) => theme.typography.sizes.s}; - - &.alert-type-warning { - border-color: ${({ theme }) => theme.colors.alert.base}; - background-color: ${({ theme }) => theme.colors.alert.light2}; - - p { - color: ${({ theme }) => theme.colors.alert.dark2}; - } - } -`; - -const ButtonContainer = styled.div` - display: flex; - justify-content: flex-end; - button { - line-height: 1; - } -`; - -const Title = styled.p` - font-weight: ${({ theme }) => theme.typography.weights.bold}; -`; - -export const ControlPanelAlert = ({ - title, - bodyText, - primaryButtonAction, - secondaryButtonAction, - primaryButtonText, - secondaryButtonText, - type = 'info', -}: ControlPanelAlertProps) => ( - - {title} -

{bodyText}

- - {secondaryButtonAction && secondaryButtonText && ( - - )} - - -
-); diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx index e20e8574e35b8..d4ef2690276a8 100644 --- a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx +++ b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx @@ -60,7 +60,7 @@ import { Tooltip } from 'src/components/Tooltip'; import ControlRow from './ControlRow'; import Control from './Control'; -import { ControlPanelAlert } from './ControlPanelAlert'; +import { ExploreAlert } from './ExploreAlert'; import { RunQueryButton } from './RunQueryButton'; export type ControlPanelsContainerProps = { @@ -92,6 +92,7 @@ const actionButtonsContainerStyles = (theme: SupersetTheme) => css` flex-direction: column; align-items: center; padding: ${theme.gridUnit * 4}px; + z-index: 999; background: linear-gradient( transparent, ${theme.colors.grayscale.light5} ${theme.opacity.mediumLight} @@ -443,7 +444,7 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => { const DatasourceAlert = useCallback( () => hasControlsTransferred ? ( - { type="info" /> ) : ( - void; + secondaryButtonAction?: (e: React.MouseEvent) => void; + primaryButtonText?: string; + secondaryButtonText?: string; + type: 'info' | 'warning'; + className?: string; +} + +const AlertContainer = styled.div` + ${({ theme }) => css` + margin: ${theme.gridUnit * 4}px; + padding: ${theme.gridUnit * 4}px; + + border: 1px solid ${theme.colors.info.base}; + background-color: ${theme.colors.info.light2}; + border-radius: 2px; + + color: ${theme.colors.info.dark2}; + font-size: ${theme.typography.sizes.m}px; + + p { + margin-bottom: ${theme.gridUnit}px; + } + + & a, + & span[role='button'] { + color: inherit; + text-decoration: underline; + &:hover { + color: ${theme.colors.info.dark1}; + } + } + + &.alert-type-warning { + border-color: ${theme.colors.alert.base}; + background-color: ${theme.colors.alert.light2}; + + p { + color: ${theme.colors.alert.dark2}; + } + + & a:hover, + & span[role='button']:hover { + color: ${theme.colors.alert.dark1}; + } + } + `} +`; + +const ButtonContainer = styled.div` + display: flex; + justify-content: flex-end; + button { + line-height: 1; + } +`; + +const Title = styled.p` + font-weight: ${({ theme }) => theme.typography.weights.bold}; +`; + +export const ExploreAlert = forwardRef( + ( + { + title, + bodyText, + primaryButtonAction, + secondaryButtonAction, + primaryButtonText, + secondaryButtonText, + type = 'info', + className = '', + }: ControlPanelAlertProps, + ref: RefObject, + ) => ( + + {title} +

{bodyText}

+ {primaryButtonText && primaryButtonAction && ( + + {secondaryButtonAction && secondaryButtonText && ( + + )} + + + )} +
+ ), +); diff --git a/superset-frontend/src/explore/components/ExploreChartPanel.jsx b/superset-frontend/src/explore/components/ExploreChartPanel.jsx index 37523713a8e71..5cd818f52ee89 100644 --- a/superset-frontend/src/explore/components/ExploreChartPanel.jsx +++ b/superset-frontend/src/explore/components/ExploreChartPanel.jsx @@ -19,7 +19,14 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import PropTypes from 'prop-types'; import Split from 'react-split'; -import { css, styled, SupersetClient, useTheme } from '@superset-ui/core'; +import { + css, + ensureIsArray, + styled, + SupersetClient, + t, + useTheme, +} from '@superset-ui/core'; import { useResizeDetector } from 'react-resize-detector'; import { chartPropShape } from 'src/dashboard/util/propShapes'; import ChartContainer from 'src/components/Chart/ChartContainer'; @@ -31,6 +38,8 @@ import { import { DataTablesPane } from './DataTablesPane'; import { buildV1ChartDataPayload } from '../exploreUtils'; import { ChartPills } from './ChartPills'; +import { ExploreAlert } from './ExploreAlert'; +import { getChartRequiredFieldsMissingMessage } from '../../utils/getChartRequiredFieldsMissingMessage'; const propTypes = { actions: PropTypes.object.isRequired, @@ -51,7 +60,7 @@ const propTypes = { standalone: PropTypes.number, force: PropTypes.bool, timeout: PropTypes.number, - refreshOverlayVisible: PropTypes.bool, + chartIsStale: PropTypes.bool, chart: chartPropShape, errorMessage: PropTypes.node, triggerRender: PropTypes.bool, @@ -115,10 +124,11 @@ const ExploreChartPanel = ({ errorMessage, form_data: formData, onQuery, - refreshOverlayVisible, actions, timeout, standalone, + chartIsStale, + chartAlert, }) => { const theme = useTheme(); const gutterMargin = theme.gridUnit * GUTTER_SIZE_FACTOR; @@ -134,6 +144,13 @@ const ExploreChartPanel = ({ const [splitSizes, setSplitSizes] = useState( getItem(LocalStorageKeys.chart_split_sizes, INITIAL_SIZES), ); + + const showAlertBanner = + !chartAlert && + chartIsStale && + chart.chartStatus !== 'failed' && + ensureIsArray(chart.queriesResponse).length > 0; + const updateQueryContext = useCallback( async function fetchChartData() { if (slice && slice.query_context === null) { @@ -167,11 +184,11 @@ const ExploreChartPanel = ({ setItem(LocalStorageKeys.chart_split_sizes, splitSizes); }, [splitSizes]); - const onDragEnd = sizes => { + const onDragEnd = useCallback(sizes => { setSplitSizes(sizes); - }; + }, []); - const refreshCachedQuery = () => { + const refreshCachedQuery = useCallback(() => { actions.postChartFormData( formData, true, @@ -180,7 +197,7 @@ const ExploreChartPanel = ({ undefined, ownState, ); - }; + }, [actions, chart.id, formData, ownState, timeout]); const onCollapseChange = useCallback(isOpen => { let splitSizes; @@ -219,9 +236,10 @@ const ExploreChartPanel = ({ datasource={datasource} errorMessage={errorMessage} formData={formData} + latestQueryFormData={chart.latestQueryFormData} onQuery={onQuery} queriesResponse={chart.queriesResponse} - refreshOverlayVisible={refreshOverlayVisible} + chartIsStale={chartIsStale} setControlValue={actions.setControlValue} timeout={timeout} triggerQuery={chart.triggerQuery} @@ -237,8 +255,10 @@ const ExploreChartPanel = ({ chart.chartStackTrace, chart.chartStatus, chart.id, + chart.latestQueryFormData, chart.queriesResponse, chart.triggerQuery, + chartIsStale, chartPanelHeight, chartPanelRef, chartPanelWidth, @@ -248,7 +268,6 @@ const ExploreChartPanel = ({ formData, onQuery, ownState, - refreshOverlayVisible, timeout, triggerRender, vizType, @@ -264,6 +283,34 @@ const ExploreChartPanel = ({ flex-direction: column; `} > + {showAlertBanner && ( + + {t( + 'You updated the values in the control panel, but the chart was not updated automatically. Run the query by clicking on the "Update chart" button or', + )}{' '} + + {t('click here')} + + . + + ) + } + type="warning" + css={theme => css` + margin: 0 0 ${theme.gridUnit * 4}px 0; + `} + /> + )} ), - [chartPanelRef, renderChart], + [ + showAlertBanner, + errorMessage, + onQuery, + chart.queriesResponse, + chart.chartStatus, + chart.chartUpdateStartTime, + chart.chartUpdateEndTime, + refreshCachedQuery, + formData?.row_limit, + renderChart, + ], ); const standaloneChartBody = useMemo(() => renderChart(), [renderChart]); @@ -294,6 +352,13 @@ const ExploreChartPanel = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [chart.latestQueryFormData]); + const elementStyle = useCallback( + (dimension, elementSize, gutterSize) => ({ + [dimension]: `calc(${elementSize}% - ${gutterSize + gutterMargin}px)`, + }), + [gutterMargin], + ); + if (standalone) { // dom manipulation hack to get rid of the boostrap theme's body background const standaloneClass = 'background-transparent'; @@ -304,10 +369,6 @@ const ExploreChartPanel = ({ return standaloneChartBody; } - const elementStyle = (dimension, elementSize, gutterSize) => ({ - [dimension]: `calc(${elementSize}% - ${gutterSize + gutterMargin}px)`, - }); - return ( {vizType === 'filter_box' ? ( diff --git a/superset-frontend/src/explore/components/ExploreChartPanel.test.jsx b/superset-frontend/src/explore/components/ExploreChartPanel.test.jsx index c50a605a40aae..a779773052e69 100644 --- a/superset-frontend/src/explore/components/ExploreChartPanel.test.jsx +++ b/superset-frontend/src/explore/components/ExploreChartPanel.test.jsx @@ -17,23 +17,70 @@ * under the License. */ import React from 'react'; - +import { render, screen } from 'spec/helpers/testing-library'; import ChartContainer from 'src/explore/components/ExploreChartPanel'; -describe('ChartContainer', () => { - const mockProps = { - sliceName: 'Trend Line', - vizType: 'line', - height: '500px', - actions: {}, - can_overwrite: false, - can_download: false, - containerId: 'foo', - width: '50px', - isStarred: false, - }; +const createProps = (overrides = {}) => ({ + sliceName: 'Trend Line', + vizType: 'line', + height: '500px', + actions: {}, + can_overwrite: false, + can_download: false, + containerId: 'foo', + width: '500px', + isStarred: false, + chartIsStale: false, + chart: {}, + form_data: {}, + ...overrides, +}); +describe('ChartContainer', () => { it('renders when vizType is line', () => { - expect(React.isValidElement()).toBe(true); + const props = createProps(); + expect(React.isValidElement()).toBe(true); + }); + + it('renders with alert banner', () => { + const props = createProps({ + chartIsStale: true, + chart: { chartStatus: 'rendered', queriesResponse: [{}] }, + }); + render(, { useRedux: true }); + expect(screen.getByText('Your chart is not up to date')).toBeVisible(); + }); + + it('doesnt render alert banner when no changes in control panel were made (chart is not stale)', () => { + const props = createProps({ + chartIsStale: false, + }); + render(, { useRedux: true }); + expect( + screen.queryByText('Your chart is not up to date'), + ).not.toBeInTheDocument(); + }); + + it('doesnt render alert banner when chart not created yet (no queries response)', () => { + const props = createProps({ + chartIsStale: true, + chart: { queriesResponse: [] }, + }); + render(, { useRedux: true }); + expect( + screen.queryByText('Your chart is not up to date'), + ).not.toBeInTheDocument(); + }); + + it('renders prompt to fill required controls when required control removed', () => { + const props = createProps({ + chartIsStale: true, + chart: { chartStatus: 'rendered', queriesResponse: [{}] }, + errorMessage: 'error', + }); + render(, { useRedux: true }); + expect( + screen.getByText('Required control values have been removed'), + ).toBeVisible(); }); }); diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx b/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx index a240578c49fc5..7743997a35529 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx @@ -27,7 +27,10 @@ import ExploreViewContainer from '.'; const reduxState = { explore: { common: { conf: { SUPERSET_WEBSERVER_TIMEOUT: 60 } }, - controls: { datasource: { value: '1__table' } }, + controls: { + datasource: { value: '1__table' }, + viz_type: { value: 'table' }, + }, datasource: { id: 1, type: 'table', diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx index 7299adf251085..da18dcc4ff5c5 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx @@ -22,7 +22,7 @@ import PropTypes from 'prop-types'; import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; import { styled, t, css, useTheme, logging } from '@superset-ui/core'; -import { debounce } from 'lodash'; +import { debounce, pick } from 'lodash'; import { Resizable } from 're-resizable'; import { useChangeEffect } from 'src/hooks/useChangeEffect'; import { usePluginContext } from 'src/components/DynamicPlugins'; @@ -381,18 +381,33 @@ function ExploreViewContainer(props) { } }, []); - const reRenderChart = () => { - props.actions.updateQueryFormData( - getFormDataFromControls(props.controls), + const reRenderChart = useCallback( + controlsChanged => { + const newQueryFormData = controlsChanged + ? { + ...props.chart.latestQueryFormData, + ...getFormDataFromControls(pick(props.controls, controlsChanged)), + } + : getFormDataFromControls(props.controls); + props.actions.updateQueryFormData(newQueryFormData, props.chart.id); + props.actions.renderTriggered(new Date().getTime(), props.chart.id); + addHistory(); + }, + [ + addHistory, + props.actions, props.chart.id, - ); - props.actions.renderTriggered(new Date().getTime(), props.chart.id); - addHistory(); - }; + props.chart.latestQueryFormData, + props.controls, + ], + ); // effect to run when controls change useEffect(() => { - if (previousControls) { + if ( + previousControls && + props.chart.latestQueryFormData.viz_type === props.controls.viz_type.value + ) { if ( props.controls.datasource && (previousControls.datasource == null || @@ -412,11 +427,11 @@ function ExploreViewContainer(props) { ); // this should also be handled by the actions that are actually changing the controls - const hasDisplayControlChanged = changedControlKeys.some( + const displayControlsChanged = changedControlKeys.filter( key => props.controls[key].renderTrigger, ); - if (hasDisplayControlChanged) { - reRenderChart(); + if (displayControlsChanged.length > 0) { + reRenderChart(displayControlsChanged); } } }, [props.controls, props.ownState]); @@ -493,7 +508,7 @@ function ExploreViewContainer(props) { ); diff --git a/superset-frontend/src/explore/controlUtils/getFormDataFromControls.ts b/superset-frontend/src/explore/controlUtils/getFormDataFromControls.ts index f5ffa523c359e..ba9419da18737 100644 --- a/superset-frontend/src/explore/controlUtils/getFormDataFromControls.ts +++ b/superset-frontend/src/explore/controlUtils/getFormDataFromControls.ts @@ -22,13 +22,10 @@ import { ControlStateMapping } from '@superset-ui/chart-controls'; export function getFormDataFromControls( controlsState: ControlStateMapping, ): QueryFormData { - const formData: QueryFormData = { - viz_type: 'table', - datasource: '', - }; + const formData = {}; Object.keys(controlsState).forEach(controlName => { const control = controlsState[controlName]; formData[controlName] = control.value; }); - return formData; + return formData as QueryFormData; } diff --git a/superset-frontend/src/utils/getChartRequiredFieldsMissingMessage.ts b/superset-frontend/src/utils/getChartRequiredFieldsMissingMessage.ts new file mode 100644 index 0000000000000..ac11e8503dc2f --- /dev/null +++ b/superset-frontend/src/utils/getChartRequiredFieldsMissingMessage.ts @@ -0,0 +1,26 @@ +/** + * 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 { t } from '@superset-ui/core'; + +export const getChartRequiredFieldsMissingMessage = (isCreating: boolean) => + t( + 'Select values in highlighted field(s) in the control panel. Then run the query by clicking on the %s button.', + isCreating ? '"Create chart"' : '"Update chart"', + ); From 3663a33f14ef4bc3792e0a4089c3edd244f158ff Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Tue, 19 Apr 2022 17:47:35 +0200 Subject: [PATCH 074/136] fix(explore): Double divider if no permissions for adding reports (#19777) --- .../ExploreAdditionalActionsMenu/index.jsx | 52 ++++++++++--------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/superset-frontend/src/explore/components/ExploreAdditionalActionsMenu/index.jsx b/superset-frontend/src/explore/components/ExploreAdditionalActionsMenu/index.jsx index f02ab01622593..fa9b54acf5025 100644 --- a/superset-frontend/src/explore/components/ExploreAdditionalActionsMenu/index.jsx +++ b/superset-frontend/src/explore/components/ExploreAdditionalActionsMenu/index.jsx @@ -370,31 +370,35 @@ const ExploreAdditionalActionsMenu = ({ - {canAddReports && - (report ? ( - - - - - {t('Email reports active')} - - - - {t('Edit email report')} - - - {t('Delete email report')} + {canAddReports && ( + <> + {report ? ( + + + + + {t('Email reports active')} + + + + {t('Edit email report')} + + + {t('Delete email report')} + + + ) : ( + + {t('Set up an email report')} - - ) : ( - - {t('Set up an email report')} - - ))} - + )} + + + )} + Date: Tue, 19 Apr 2022 09:18:16 -0700 Subject: [PATCH 075/136] chore(build): upgrade less-loader (#19703) --- superset-frontend/package-lock.json | 1423 +++++++++++++++-- superset-frontend/package.json | 2 +- .../eslint-plugin-theme-colors/package.json | 6 +- superset-frontend/webpack.config.js | 4 +- 4 files changed, 1339 insertions(+), 96 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 4cf5cdba13950..51c7e12f30991 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -245,7 +245,7 @@ "jsdom": "^16.4.0", "lerna": "^4.0.0", "less": "^3.12.2", - "less-loader": "^5.0.0", + "less-loader": "^10.2.0", "mini-css-extract-plugin": "^2.3.0", "mock-socket": "^9.0.3", "node-fetch": "^2.6.1", @@ -2705,6 +2705,29 @@ "node": ">=0.1.95" } }, + "node_modules/@cspotcode/source-map-consumer": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-consumer/-/source-map-consumer-0.8.0.tgz", + "integrity": "sha512-41qniHzTU8yAGbCp04ohlmSrZf8bkf/iJsl3V0dRGsQN/5GFfx+LbCSsCpp2gqrqjTVg/K6O8ycoV35JIwAzAg==", + "dev": true, + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.7.0.tgz", + "integrity": "sha512-X4xqRHqN8ACt2aHVe51OxeA2HjbcL4MqFqXkrmQszJ1NOUuUu5u6Vqx/0lZSVNku7velL5FC/s5uEAj1lsBMhA==", + "dev": true, + "peer": true, + "dependencies": { + "@cspotcode/source-map-consumer": "0.8.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@ctrl/tinycolor": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.3.1.tgz", @@ -21907,10 +21930,6 @@ "resolved": "plugins/legacy-plugin-chart-event-flow", "link": true }, - "node_modules/@superset-ui/legacy-plugin-chart-force-directed": { - "resolved": "plugins/legacy-plugin-chart-force-directed", - "link": true - }, "node_modules/@superset-ui/legacy-plugin-chart-heatmap": { "resolved": "plugins/legacy-plugin-chart-heatmap", "link": true @@ -22410,6 +22429,34 @@ "node": ">=10.13.0" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.8.tgz", + "integrity": "sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==", + "dev": true, + "peer": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.9.tgz", + "integrity": "sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw==", + "dev": true, + "peer": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.1.tgz", + "integrity": "sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg==", + "dev": true, + "peer": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.2.tgz", + "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==", + "dev": true, + "peer": true + }, "node_modules/@types/aria-query": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.0.tgz", @@ -24408,9 +24455,9 @@ "integrity": "sha512-NBOQlm9+7RBqRqZwimpgquaLeTJFayqb9UEPtTkpC3TkkwDnlsT/TwsCC0svjt9kEZ6G9mH5AEOHSz6Q/HrzQQ==" }, "node_modules/acorn": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.1.tgz", - "integrity": "sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg==", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", + "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==", "bin": { "acorn": "bin/acorn" }, @@ -24428,6 +24475,18 @@ "acorn-walk": "^7.1.1" } }, + "node_modules/acorn-globals/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/acorn-import-assertions": { "version": "1.7.6", "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.7.6.tgz", @@ -25004,6 +25063,19 @@ "resolved": "https://registry.npmjs.org/app-root-dir/-/app-root-dir-1.0.2.tgz", "integrity": "sha1-OBh+wt6nV3//Az/8sSFyaS/24Rg=" }, + "node_modules/append-transform": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", + "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", + "dev": true, + "peer": true, + "dependencies": { + "default-require-extensions": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/aproba": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", @@ -25030,6 +25102,13 @@ ], "peer": true }, + "node_modules/archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", + "dev": true, + "peer": true + }, "node_modules/are-we-there-yet": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz", @@ -25040,6 +25119,13 @@ "readable-stream": "^2.0.6" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "peer": true + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -27119,6 +27205,48 @@ "node": ">=6" } }, + "node_modules/caching-transform": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", + "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", + "dev": true, + "peer": true, + "dependencies": { + "hasha": "^5.0.0", + "make-dir": "^3.0.0", + "package-hash": "^4.0.0", + "write-file-atomic": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/caching-transform/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "peer": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caching-transform/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -29575,6 +29703,13 @@ "react": "^0.14.0 || ^15.0.0 || ^16.0.0" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "peer": true + }, "node_modules/cross-env": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-5.2.0.tgz", @@ -31622,6 +31757,29 @@ "node": ">= 8" } }, + "node_modules/default-require-extensions": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.0.tgz", + "integrity": "sha512-ek6DpXq/SCpvjhpFsLFRVtIxJCRw6fUR42lYMVZuUMK7n8eMz4Uh5clckdBjEpLhn/gEBZo7hDJnJcwdKLKQjg==", + "dev": true, + "peer": true, + "dependencies": { + "strip-bom": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/default-require-extensions/node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/defaults": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", @@ -32905,6 +33063,13 @@ "node": ">=0.4.0" } }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "peer": true + }, "node_modules/es6-shim": { "version": "0.35.6", "resolved": "https://registry.npmjs.org/es6-shim/-/es6-shim-0.35.6.tgz", @@ -34534,6 +34699,17 @@ "node": ">=0.4.0" } }, + "node_modules/falafel/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/falafel/node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -36929,6 +37105,36 @@ "minimalistic-assert": "^1.0.1" } }, + "node_modules/hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "peer": true, + "dependencies": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hasha/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/hast-to-hyperscript": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/hast-to-hyperscript/-/hast-to-hyperscript-9.0.1.tgz", @@ -38980,6 +39186,19 @@ "node": ">=8" } }, + "node_modules/istanbul-lib-hook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", + "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", + "dev": true, + "peer": true, + "dependencies": { + "append-transform": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/istanbul-lib-instrument": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", @@ -39004,6 +39223,128 @@ "semver": "bin/semver.js" } }, + "node_modules/istanbul-lib-processinfo": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.2.tgz", + "integrity": "sha512-kOwpa7z9hme+IBPZMzQ5vdQj8srYgAtaRqeI48NGmAQ+/5yKiHLV0QbYqQpxsdEF0+w14SoB8YbnHKcXE2KnYw==", + "dev": true, + "peer": true, + "dependencies": { + "archy": "^1.0.0", + "cross-spawn": "^7.0.0", + "istanbul-lib-coverage": "^3.0.0-alpha.1", + "make-dir": "^3.0.0", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "uuid": "^3.3.3" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "peer": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "peer": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/istanbul-lib-report": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", @@ -41655,6 +41996,18 @@ } } }, + "node_modules/jsdom/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/jsdom/node_modules/escodegen": { "version": "1.14.3", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", @@ -42130,30 +42483,23 @@ } }, "node_modules/less-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-5.0.0.tgz", - "integrity": "sha512-bquCU89mO/yWLaUq0Clk7qCsKhsF/TZpJUzETRvJa9KSVEL9SO3ovCvdEHISBhrC81OwC8QSVX7E0bzElZj9cg==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-10.2.0.tgz", + "integrity": "sha512-AV5KHWvCezW27GT90WATaDnfXBv99llDbtaj4bshq6DvAihMdNjaPDcUMa6EXKLRF+P2opFenJp89BXg91XLYg==", "dev": true, "dependencies": { - "clone": "^2.1.1", - "loader-utils": "^1.1.0", - "pify": "^4.0.1" + "klona": "^2.0.4" }, "engines": { - "node": ">= 4.8.0" + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "less": "^2.3.1 || ^3.0.0", - "webpack": "^2.0.0 || ^3.0.0 || ^4.0.0" - } - }, - "node_modules/less-loader/node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true, - "engines": { - "node": ">=6" + "less": "^3.5.0 || ^4.0.0", + "webpack": "^5.0.0" } }, "node_modules/less/node_modules/source-map": { @@ -43208,6 +43554,13 @@ "node": ">=6" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "peer": true + }, "node_modules/make-fetch-happen": { "version": "8.0.14", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-8.0.14.tgz", @@ -45108,6 +45461,19 @@ "node": ">= 8" } }, + "node_modules/node-preload": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", + "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", + "dev": true, + "peer": true, + "dependencies": { + "process-on-spawn": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/node-releases": { "version": "1.1.75", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.75.tgz", @@ -45614,6 +45980,204 @@ "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==", "dev": true }, + "node_modules/nyc": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz", + "integrity": "sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==", + "dev": true, + "peer": true, + "dependencies": { + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "caching-transform": "^4.0.0", + "convert-source-map": "^1.7.0", + "decamelize": "^1.2.0", + "find-cache-dir": "^3.2.0", + "find-up": "^4.1.0", + "foreground-child": "^2.0.0", + "get-package-type": "^0.1.0", + "glob": "^7.1.6", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-hook": "^3.0.0", + "istanbul-lib-instrument": "^4.0.0", + "istanbul-lib-processinfo": "^2.0.2", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.0.2", + "make-dir": "^3.0.0", + "node-preload": "^0.2.1", + "p-map": "^3.0.0", + "process-on-spawn": "^1.0.0", + "resolve-from": "^5.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "spawn-wrap": "^2.0.0", + "test-exclude": "^6.0.0", + "yargs": "^15.0.2" + }, + "bin": { + "nyc": "bin/nyc.js" + }, + "engines": { + "node": ">=8.9" + } + }, + "node_modules/nyc/node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "peer": true, + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/nyc/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "peer": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "peer": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "peer": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nyc/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "peer": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nyc/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "peer": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "peer": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "peer": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -46262,6 +46826,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-hash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", + "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", + "dev": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.15", + "hasha": "^5.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/pacote": { "version": "12.0.2", "resolved": "https://registry.npmjs.org/pacote/-/pacote-12.0.2.tgz", @@ -47331,6 +47911,19 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==" }, + "node_modules/process-on-spawn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", + "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==", + "dev": true, + "peer": true, + "dependencies": { + "fromentries": "^1.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -50203,6 +50796,19 @@ "node": ">= 0.10" } }, + "node_modules/release-zalgo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", + "integrity": "sha1-CXALflB0Mpc5Mw5TXFqQ+2eFFzA=", + "dev": true, + "peer": true, + "dependencies": { + "es6-error": "^4.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/remark-external-links": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/remark-external-links/-/remark-external-links-8.0.0.tgz", @@ -52053,6 +52659,66 @@ "trim": "0.0.1" } }, + "node_modules/spawn-wrap": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", + "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", + "dev": true, + "peer": true, + "dependencies": { + "foreground-child": "^2.0.0", + "is-windows": "^1.0.2", + "make-dir": "^3.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "which": "^2.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/spawn-wrap/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "peer": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/spawn-wrap/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/spawn-wrap/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/spdx-correct": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.0.tgz", @@ -53967,6 +54633,60 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/ts-node": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.7.0.tgz", + "integrity": "sha512-TbIGS4xgJoX2i3do417KSaep1uRAW/Lu+WAL2doDHC0D6ummjirVOXU5/7aiZotbQ5p1Zp9tP7U6cYhA0O7M8A==", + "dev": true, + "peer": true, + "dependencies": { + "@cspotcode/source-map-support": "0.7.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.0", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ts-pnp": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz", @@ -54676,6 +55396,13 @@ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "dev": true }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.0.tgz", + "integrity": "sha512-mpSYqfsFvASnSn5qMiwrr4VKfumbPyONLCOPmsR3A6pTY/r0+tSaVbgPWSAIuzbk3lCTa+FForeTiO+wBQGkjA==", + "dev": true, + "peer": true + }, "node_modules/v8-to-istanbul": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-7.0.0.tgz", @@ -55105,18 +55832,6 @@ "node": ">= 10.13.0" } }, - "node_modules/webpack-bundle-analyzer/node_modules/acorn": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.5.0.tgz", - "integrity": "sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/webpack-bundle-analyzer/node_modules/acorn-walk": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", @@ -55767,17 +56482,6 @@ "@xtuc/long": "4.2.2" } }, - "node_modules/webpack/node_modules/acorn": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.5.0.tgz", - "integrity": "sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q==", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/webpack/node_modules/enhanced-resolve": { "version": "5.8.2", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.8.2.tgz", @@ -58301,6 +59005,16 @@ "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==", "dev": true }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -59098,6 +59812,7 @@ "plugins/legacy-plugin-chart-force-directed": { "name": "@superset-ui/legacy-plugin-chart-force-directed", "version": "0.0.1", + "extraneous": true, "dependencies": { "d3": "^3.5.17", "prop-types": "^15.7.2" @@ -59785,11 +60500,7 @@ "tools/eslint-plugin-theme-colors": { "version": "1.0.0", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^16.9.1", - "npm": "^7.5.4" - } + "license": "Apache-2.0" } }, "dependencies": { @@ -61465,6 +62176,23 @@ "minimist": "^1.2.0" } }, + "@cspotcode/source-map-consumer": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-consumer/-/source-map-consumer-0.8.0.tgz", + "integrity": "sha512-41qniHzTU8yAGbCp04ohlmSrZf8bkf/iJsl3V0dRGsQN/5GFfx+LbCSsCpp2gqrqjTVg/K6O8ycoV35JIwAzAg==", + "dev": true, + "peer": true + }, + "@cspotcode/source-map-support": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.7.0.tgz", + "integrity": "sha512-X4xqRHqN8ACt2aHVe51OxeA2HjbcL4MqFqXkrmQszJ1NOUuUu5u6Vqx/0lZSVNku7velL5FC/s5uEAj1lsBMhA==", + "dev": true, + "peer": true, + "requires": { + "@cspotcode/source-map-consumer": "0.8.0" + } + }, "@ctrl/tinycolor": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.3.1.tgz", @@ -76641,13 +77369,6 @@ "prop-types": "^15.6.2" } }, - "@superset-ui/legacy-plugin-chart-force-directed": { - "version": "file:plugins/legacy-plugin-chart-force-directed", - "requires": { - "d3": "^3.5.17", - "prop-types": "^15.7.2" - } - }, "@superset-ui/legacy-plugin-chart-heatmap": { "version": "file:plugins/legacy-plugin-chart-heatmap", "requires": { @@ -77378,6 +78099,34 @@ "integrity": "sha512-Z6DoceYb/1xSg5+e+ZlPZ9v0N16ZvZ+wYMraFue4HYrE4ttONKtsvruIRf6t9TBR0YvSOfi1hUU0fJfBLCDYow==", "dev": true }, + "@tsconfig/node10": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.8.tgz", + "integrity": "sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==", + "dev": true, + "peer": true + }, + "@tsconfig/node12": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.9.tgz", + "integrity": "sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw==", + "dev": true, + "peer": true + }, + "@tsconfig/node14": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.1.tgz", + "integrity": "sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg==", + "dev": true, + "peer": true + }, + "@tsconfig/node16": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.2.tgz", + "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==", + "dev": true, + "peer": true + }, "@types/aria-query": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.0.tgz", @@ -79099,9 +79848,9 @@ "integrity": "sha512-NBOQlm9+7RBqRqZwimpgquaLeTJFayqb9UEPtTkpC3TkkwDnlsT/TwsCC0svjt9kEZ6G9mH5AEOHSz6Q/HrzQQ==" }, "acorn": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.1.tgz", - "integrity": "sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg==" + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", + "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==" }, "acorn-globals": { "version": "6.0.0", @@ -79111,6 +79860,14 @@ "requires": { "acorn": "^7.1.1", "acorn-walk": "^7.1.1" + }, + "dependencies": { + "acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true + } } }, "acorn-import-assertions": { @@ -79573,6 +80330,16 @@ "resolved": "https://registry.npmjs.org/app-root-dir/-/app-root-dir-1.0.2.tgz", "integrity": "sha1-OBh+wt6nV3//Az/8sSFyaS/24Rg=" }, + "append-transform": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", + "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", + "dev": true, + "peer": true, + "requires": { + "default-require-extensions": "^3.0.0" + } + }, "aproba": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", @@ -79585,6 +80352,13 @@ "dev": true, "peer": true }, + "archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", + "dev": true, + "peer": true + }, "are-we-there-yet": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz", @@ -79595,6 +80369,13 @@ "readable-stream": "^2.0.6" } }, + "arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "peer": true + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -81215,6 +81996,38 @@ "dev": true, "peer": true }, + "caching-transform": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", + "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", + "dev": true, + "peer": true, + "requires": { + "hasha": "^5.0.0", + "make-dir": "^3.0.0", + "package-hash": "^4.0.0", + "write-file-atomic": "^3.0.0" + }, + "dependencies": { + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "peer": true, + "requires": { + "semver": "^6.0.0" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "peer": true + } + } + }, "call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -83137,6 +83950,13 @@ "warning": "^4.0.3" } }, + "create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "peer": true + }, "cross-env": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-5.2.0.tgz", @@ -84682,6 +85502,25 @@ } } }, + "default-require-extensions": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.0.tgz", + "integrity": "sha512-ek6DpXq/SCpvjhpFsLFRVtIxJCRw6fUR42lYMVZuUMK7n8eMz4Uh5clckdBjEpLhn/gEBZo7hDJnJcwdKLKQjg==", + "dev": true, + "peer": true, + "requires": { + "strip-bom": "^4.0.0" + }, + "dependencies": { + "strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "peer": true + } + } + }, "defaults": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", @@ -85765,6 +86604,13 @@ "resolved": "https://registry.npmjs.org/es5-shim/-/es5-shim-4.6.5.tgz", "integrity": "sha512-vfQ4UAai8szn0sAubCy97xnZ4sJVDD1gt/Grn736hg8D7540wemIb1YPrYZSTqlM2H69EQX1or4HU/tSwRTI3w==" }, + "es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "peer": true + }, "es6-shim": { "version": "0.35.6", "resolved": "https://registry.npmjs.org/es6-shim/-/es6-shim-0.35.6.tgz", @@ -86972,6 +87818,11 @@ "object-keys": "^1.0.6" }, "dependencies": { + "acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==" + }, "isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -88824,6 +89675,26 @@ "minimalistic-assert": "^1.0.1" } }, + "hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "peer": true, + "requires": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "dependencies": { + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "peer": true + } + } + }, "hast-to-hyperscript": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/hast-to-hyperscript/-/hast-to-hyperscript-9.0.1.tgz", @@ -90359,6 +91230,16 @@ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz", "integrity": "sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg==" }, + "istanbul-lib-hook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", + "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", + "dev": true, + "peer": true, + "requires": { + "append-transform": "^2.0.0" + } + }, "istanbul-lib-instrument": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", @@ -90379,6 +91260,97 @@ } } }, + "istanbul-lib-processinfo": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.2.tgz", + "integrity": "sha512-kOwpa7z9hme+IBPZMzQ5vdQj8srYgAtaRqeI48NGmAQ+/5yKiHLV0QbYqQpxsdEF0+w14SoB8YbnHKcXE2KnYw==", + "dev": true, + "peer": true, + "requires": { + "archy": "^1.0.0", + "cross-spawn": "^7.0.0", + "istanbul-lib-coverage": "^3.0.0-alpha.1", + "make-dir": "^3.0.0", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "uuid": "^3.3.3" + }, + "dependencies": { + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "peer": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "peer": true, + "requires": { + "semver": "^6.0.0" + } + }, + "p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "peer": true, + "requires": { + "aggregate-error": "^3.0.0" + } + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "peer": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "peer": true + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "peer": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "peer": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "peer": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, "istanbul-lib-report": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", @@ -92445,6 +93417,12 @@ "xml-name-validator": "^3.0.0" }, "dependencies": { + "acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true + }, "escodegen": { "version": "1.14.3", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", @@ -92823,22 +93801,12 @@ } }, "less-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-5.0.0.tgz", - "integrity": "sha512-bquCU89mO/yWLaUq0Clk7qCsKhsF/TZpJUzETRvJa9KSVEL9SO3ovCvdEHISBhrC81OwC8QSVX7E0bzElZj9cg==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-10.2.0.tgz", + "integrity": "sha512-AV5KHWvCezW27GT90WATaDnfXBv99llDbtaj4bshq6DvAihMdNjaPDcUMa6EXKLRF+P2opFenJp89BXg91XLYg==", "dev": true, "requires": { - "clone": "^2.1.1", - "loader-utils": "^1.1.0", - "pify": "^4.0.1" - }, - "dependencies": { - "pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true - } + "klona": "^2.0.4" } }, "leven": { @@ -93689,6 +94657,13 @@ } } }, + "make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "peer": true + }, "make-fetch-happen": { "version": "8.0.14", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-8.0.14.tgz", @@ -95227,6 +96202,16 @@ } } }, + "node-preload": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", + "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", + "dev": true, + "peer": true, + "requires": { + "process-on-spawn": "^1.0.0" + } + }, "node-releases": { "version": "1.1.75", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.75.tgz", @@ -95632,6 +96617,155 @@ "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==", "dev": true }, + "nyc": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz", + "integrity": "sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==", + "dev": true, + "peer": true, + "requires": { + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "caching-transform": "^4.0.0", + "convert-source-map": "^1.7.0", + "decamelize": "^1.2.0", + "find-cache-dir": "^3.2.0", + "find-up": "^4.1.0", + "foreground-child": "^2.0.0", + "get-package-type": "^0.1.0", + "glob": "^7.1.6", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-hook": "^3.0.0", + "istanbul-lib-instrument": "^4.0.0", + "istanbul-lib-processinfo": "^2.0.2", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.0.2", + "make-dir": "^3.0.0", + "node-preload": "^0.2.1", + "p-map": "^3.0.0", + "process-on-spawn": "^1.0.0", + "resolve-from": "^5.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "spawn-wrap": "^2.0.0", + "test-exclude": "^6.0.0", + "yargs": "^15.0.2" + }, + "dependencies": { + "find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "peer": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "peer": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "peer": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "peer": true, + "requires": { + "semver": "^6.0.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "peer": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "peer": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "peer": true, + "requires": { + "aggregate-error": "^3.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "peer": true + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "peer": true + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "peer": true, + "requires": { + "find-up": "^4.0.0" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "peer": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "peer": true + } + } + }, "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -96109,6 +97243,19 @@ "p-reduce": "^2.0.0" } }, + "package-hash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", + "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", + "dev": true, + "peer": true, + "requires": { + "graceful-fs": "^4.1.15", + "hasha": "^5.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + } + }, "pacote": { "version": "12.0.2", "resolved": "https://registry.npmjs.org/pacote/-/pacote-12.0.2.tgz", @@ -96938,6 +98085,16 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==" }, + "process-on-spawn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", + "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==", + "dev": true, + "peer": true, + "requires": { + "fromentries": "^1.2.0" + } + }, "progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -99240,6 +100397,16 @@ "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=" }, + "release-zalgo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", + "integrity": "sha1-CXALflB0Mpc5Mw5TXFqQ+2eFFzA=", + "dev": true, + "peer": true, + "requires": { + "es6-error": "^4.0.1" + } + }, "remark-external-links": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/remark-external-links/-/remark-external-links-8.0.0.tgz", @@ -100683,6 +101850,50 @@ "trim": "0.0.1" } }, + "spawn-wrap": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", + "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", + "dev": true, + "peer": true, + "requires": { + "foreground-child": "^2.0.0", + "is-windows": "^1.0.2", + "make-dir": "^3.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "which": "^2.0.1" + }, + "dependencies": { + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "peer": true, + "requires": { + "semver": "^6.0.0" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "peer": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "peer": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, "spdx-correct": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.0.tgz", @@ -102176,6 +103387,37 @@ } } }, + "ts-node": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.7.0.tgz", + "integrity": "sha512-TbIGS4xgJoX2i3do417KSaep1uRAW/Lu+WAL2doDHC0D6ummjirVOXU5/7aiZotbQ5p1Zp9tP7U6cYhA0O7M8A==", + "dev": true, + "peer": true, + "requires": { + "@cspotcode/source-map-support": "0.7.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.0", + "yn": "3.1.1" + }, + "dependencies": { + "acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true, + "peer": true + } + } + }, "ts-pnp": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz", @@ -102718,6 +103960,13 @@ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "dev": true }, + "v8-compile-cache-lib": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.0.tgz", + "integrity": "sha512-mpSYqfsFvASnSn5qMiwrr4VKfumbPyONLCOPmsR3A6pTY/r0+tSaVbgPWSAIuzbk3lCTa+FForeTiO+wBQGkjA==", + "dev": true, + "peer": true + }, "v8-to-istanbul": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-7.0.0.tgz", @@ -103175,11 +104424,6 @@ "@xtuc/long": "4.2.2" } }, - "acorn": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.5.0.tgz", - "integrity": "sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q==" - }, "enhanced-resolve": { "version": "5.8.2", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.8.2.tgz", @@ -103296,12 +104540,6 @@ "ws": "^7.3.1" }, "dependencies": { - "acorn": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.5.0.tgz", - "integrity": "sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q==", - "dev": true - }, "acorn-walk": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", @@ -105484,6 +106722,13 @@ } } }, + "yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "peer": true + }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 4b4146665f6be..c477a1d6e3e15 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -305,7 +305,7 @@ "jsdom": "^16.4.0", "lerna": "^4.0.0", "less": "^3.12.2", - "less-loader": "^5.0.0", + "less-loader": "^10.2.0", "mini-css-extract-plugin": "^2.3.0", "mock-socket": "^9.0.3", "node-fetch": "^2.6.1", diff --git a/superset-frontend/tools/eslint-plugin-theme-colors/package.json b/superset-frontend/tools/eslint-plugin-theme-colors/package.json index 6832811e8a386..25938c97bd8d4 100644 --- a/superset-frontend/tools/eslint-plugin-theme-colors/package.json +++ b/superset-frontend/tools/eslint-plugin-theme-colors/package.json @@ -9,9 +9,5 @@ "keywords": [], "license": "Apache-2.0", "author": "Apache", - "dependencies": {}, - "engines": { - "node": "^16.9.1", - "npm": "^7.5.4" - } + "dependencies": {} } diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js index d6b280c7537e0..6fff90105d3b1 100644 --- a/superset-frontend/webpack.config.js +++ b/superset-frontend/webpack.config.js @@ -383,7 +383,9 @@ const config = { loader: 'less-loader', options: { sourceMap: true, - javascriptEnabled: true, + lessOptions: { + javascriptEnabled: true, + }, }, }, ], From 7e92340c7085358940de5ff199b9cc919b35111f Mon Sep 17 00:00:00 2001 From: "Hugh A. Miles II" Date: Tue, 19 Apr 2022 09:49:50 -0700 Subject: [PATCH 076/136] fix: Fix migration for removing time_range_endpoints 3 (#19767) * fix migration * so dumb * update test * add code change * retest --- ...dbaba_rm_time_range_endpoints_from_qc_3.py | 84 +++++++++++++++++++ .../cecc6bf46990_rm_time_range_endpoints_2.py | 41 +-------- ...m_time_range_endpoints_from_qc_3__test.py} | 9 +- 3 files changed, 92 insertions(+), 42 deletions(-) create mode 100644 superset/migrations/versions/ad07e4fdbaba_rm_time_range_endpoints_from_qc_3.py rename tests/integration_tests/migrations/{cecc6bf46990_rm_time_range_endpoints_2__tests.py => ad07e4fdbaba_rm_time_range_endpoints_from_qc_3__test.py} (93%) diff --git a/superset/migrations/versions/ad07e4fdbaba_rm_time_range_endpoints_from_qc_3.py b/superset/migrations/versions/ad07e4fdbaba_rm_time_range_endpoints_from_qc_3.py new file mode 100644 index 0000000000000..30efb1a083fc2 --- /dev/null +++ b/superset/migrations/versions/ad07e4fdbaba_rm_time_range_endpoints_from_qc_3.py @@ -0,0 +1,84 @@ +# 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. +"""rm_time_range_endpoints_from_qc_3 + +Revision ID: ad07e4fdbaba +Revises: cecc6bf46990 +Create Date: 2022-04-18 11:20:47.390901 + +""" + +# revision identifiers, used by Alembic. +revision = "ad07e4fdbaba" +down_revision = "cecc6bf46990" + +import json + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.ext.declarative import declarative_base + +from superset import db + +Base = declarative_base() + + +class Slice(Base): + __tablename__ = "slices" + id = sa.Column(sa.Integer, primary_key=True) + query_context = sa.Column(sa.Text) + slice_name = sa.Column(sa.String(250)) + + +def upgrade_slice(slc: Slice): + try: + query_context = json.loads(slc.query_context) + except json.decoder.JSONDecodeError: + return + + query_context.get("form_data", {}).pop("time_range_endpoints", None) + + if query_context.get("queries"): + queries = query_context["queries"] + for query in queries: + query.get("extras", {}).pop("time_range_endpoints", None) + + slc.query_context = json.dumps(query_context) + + return slc + + +def upgrade(): + bind = op.get_bind() + session = db.Session(bind=bind) + slices_updated = 0 + for slc in ( + session.query(Slice) + .filter(Slice.query_context.like("%time_range_endpoints%")) + .all() + ): + updated_slice = upgrade_slice(slc) + if updated_slice: + slices_updated += 1 + + print(f"slices updated with no time_range_endpoints: {slices_updated}") + session.commit() + session.close() + + +def downgrade(): + pass diff --git a/superset/migrations/versions/cecc6bf46990_rm_time_range_endpoints_2.py b/superset/migrations/versions/cecc6bf46990_rm_time_range_endpoints_2.py index 20d797ddbaf20..bd2532e88a1c2 100644 --- a/superset/migrations/versions/cecc6bf46990_rm_time_range_endpoints_2.py +++ b/superset/migrations/versions/cecc6bf46990_rm_time_range_endpoints_2.py @@ -26,48 +26,9 @@ revision = "cecc6bf46990" down_revision = "9d8a8d575284" -import json - -import sqlalchemy as sa -from alembic import op -from sqlalchemy.ext.declarative import declarative_base - -from superset import db - -Base = declarative_base() - - -class Slice(Base): - __tablename__ = "slices" - id = sa.Column(sa.Integer, primary_key=True) - query_context = sa.Column(sa.Text) - slice_name = sa.Column(sa.String(250)) - - -def upgrade_slice(slc: Slice): - try: - query_context = json.loads(slc.query_context) - except json.decoder.JSONDecodeError: - return - - queries = query_context.get("queries") - - for query in queries: - query.get("extras", {}).pop("time_range_endpoints", None) - - slc.query_context = json.dumps(query_context) - def upgrade(): - bind = op.get_bind() - session = db.Session(bind=bind) - for slc in session.query(Slice).filter( - Slice.query_context.like("%time_range_endpoints%") - ): - upgrade_slice(slc) - - session.commit() - session.close() + pass def downgrade(): diff --git a/tests/integration_tests/migrations/cecc6bf46990_rm_time_range_endpoints_2__tests.py b/tests/integration_tests/migrations/ad07e4fdbaba_rm_time_range_endpoints_from_qc_3__test.py similarity index 93% rename from tests/integration_tests/migrations/cecc6bf46990_rm_time_range_endpoints_2__tests.py rename to tests/integration_tests/migrations/ad07e4fdbaba_rm_time_range_endpoints_from_qc_3__test.py index 26d9eec0a5e75..f2abfa9766196 100644 --- a/tests/integration_tests/migrations/cecc6bf46990_rm_time_range_endpoints_2__tests.py +++ b/tests/integration_tests/migrations/ad07e4fdbaba_rm_time_range_endpoints_from_qc_3__test.py @@ -16,7 +16,7 @@ # under the License. import json -from superset.migrations.versions.cecc6bf46990_rm_time_range_endpoints_2 import ( +from superset.migrations.versions.ad07e4fdbaba_rm_time_range_endpoints_from_qc_3 import ( Slice, upgrade_slice, ) @@ -106,7 +106,9 @@ "post_processing": [], } ], - "form_data": {}, + "form_data": { + "time_range_endpoints": ["inclusive", "exclusive"], + }, "result_format": "json", "result_type": "full", } @@ -123,6 +125,9 @@ def test_upgrade(): extras = q.get("extras", {}) assert "time_range_endpoints" not in extras + form_data = query_context.get("form_data", {}) + assert "time_range_endpoints" not in form_data + def test_upgrade_bad_json(): slc = Slice(slice_name="FOO", query_context="abc") From a6f46013d966b243fbdca072e7898cdbf2a2f3d6 Mon Sep 17 00:00:00 2001 From: "Hugh A. Miles II" Date: Tue, 19 Apr 2022 11:16:48 -0700 Subject: [PATCH 077/136] feat: 10/15/30 min grain to Pinot (#19724) * add new grains to pinot * update test --- superset/db_engine_specs/pinot.py | 8 ++++++++ .../db_engine_specs/pinot_tests.py | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/superset/db_engine_specs/pinot.py b/superset/db_engine_specs/pinot.py index 051f42501f929..38e30accecbc0 100644 --- a/superset/db_engine_specs/pinot.py +++ b/superset/db_engine_specs/pinot.py @@ -33,6 +33,10 @@ class PinotEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method _time_grain_expressions: Dict[Optional[str], str] = { "PT1S": "1:SECONDS", "PT1M": "1:MINUTES", + "PT5M": "5:MINUTES", + "PT10M": "10:MINUTES", + "PT15M": "15:MINUTES", + "PT30M": "30:MINUTES", "PT1H": "1:HOURS", "P1D": "1:DAYS", "P1W": "week", @@ -53,6 +57,10 @@ class PinotEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method _use_date_trunc_function: Dict[str, bool] = { "PT1S": False, "PT1M": False, + "PT5M": False, + "PT10M": False, + "PT15M": False, + "PT30M": False, "PT1H": False, "P1D": False, "P1W": True, diff --git a/tests/integration_tests/db_engine_specs/pinot_tests.py b/tests/integration_tests/db_engine_specs/pinot_tests.py index 803dd67cbacfa..c6e364a8ea5fe 100644 --- a/tests/integration_tests/db_engine_specs/pinot_tests.py +++ b/tests/integration_tests/db_engine_specs/pinot_tests.py @@ -45,6 +45,19 @@ def test_pinot_time_expression_simple_date_format_1d_grain(self): ), ) + def test_pinot_time_expression_simple_date_format_10m_grain(self): + col = column("tstamp") + expr = PinotEngineSpec.get_timestamp_expr(col, "%Y-%m-%d %H:%M:%S", "PT10M") + result = str(expr.compile()) + self.assertEqual( + result, + ( + "DATETIMECONVERT(tstamp, " + + "'1:SECONDS:SIMPLE_DATE_FORMAT:yyyy-MM-dd HH:mm:ss', " + + "'1:SECONDS:SIMPLE_DATE_FORMAT:yyyy-MM-dd HH:mm:ss', '10:MINUTES')" + ), + ) + def test_pinot_time_expression_simple_date_format_1w_grain(self): col = column("tstamp") expr = PinotEngineSpec.get_timestamp_expr(col, "%Y-%m-%d %H:%M:%S", "P1W") From e061955fd077a9eab6f22f081aa02690801bfd3e Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Tue, 19 Apr 2022 22:34:41 +0300 Subject: [PATCH 078/136] fix(dashboard): copy permalink to dashboard chart (#19772) * fix(dashboard): copy permalink to dashboard chart * lint * address comments --- .../components/URLShortLinkButton/index.jsx | 8 +++--- .../components/SliceHeaderControls/index.tsx | 6 ++-- .../components/menu/ShareMenuItems/index.tsx | 28 ++++++++----------- superset-frontend/src/utils/urlUtils.ts | 14 ++++++---- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/superset-frontend/src/components/URLShortLinkButton/index.jsx b/superset-frontend/src/components/URLShortLinkButton/index.jsx index 35795f81a11fa..4a03e02d3ea5a 100644 --- a/superset-frontend/src/components/URLShortLinkButton/index.jsx +++ b/superset-frontend/src/components/URLShortLinkButton/index.jsx @@ -57,11 +57,11 @@ class URLShortLinkButton extends React.Component { if (this.props.dashboardId) { getFilterValue(this.props.dashboardId, nativeFiltersKey) .then(filterState => - getDashboardPermalink( - String(this.props.dashboardId), + getDashboardPermalink({ + dashboardId: this.props.dashboardId, filterState, - this.props.anchorLinkId, - ) + hash: this.props.anchorLinkId, + }) .then(this.onShortUrlSuccess) .catch(this.props.addDangerToast), ) diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx index 09d646853d24e..86c73a09b6ecd 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx @@ -213,6 +213,8 @@ class SliceHeaderControls extends React.PureComponent< render() { const { + componentId, + dashboardId, slice, isFullSize, cachedDttm = [], @@ -221,7 +223,6 @@ class SliceHeaderControls extends React.PureComponent< addDangerToast = () => {}, supersetCanShare = false, isCached = [], - formData, } = this.props; const crossFilterItems = getChartMetadataRegistry().items; const isTable = slice.viz_type === 'table'; @@ -310,13 +311,14 @@ class SliceHeaderControls extends React.PureComponent< {supersetCanShare && ( )} diff --git a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx index c70e47dc3d01d..b196100734cc3 100644 --- a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx +++ b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx @@ -18,14 +18,10 @@ */ import React from 'react'; import copyTextToClipboard from 'src/utils/copy'; -import { t, logging, QueryFormData } from '@superset-ui/core'; +import { t, logging } from '@superset-ui/core'; import { Menu } from 'src/components/Menu'; -import { - getChartPermalink, - getDashboardPermalink, - getUrlParam, -} from 'src/utils/urlUtils'; -import { RESERVED_DASHBOARD_URL_PARAMS, URL_PARAMS } from 'src/constants'; +import { getDashboardPermalink, getUrlParam } from 'src/utils/urlUtils'; +import { URL_PARAMS } from 'src/constants'; import { getFilterValue } from 'src/dashboard/components/nativeFilters/FilterBar/keyValue'; interface ShareMenuItemProps { @@ -36,8 +32,8 @@ interface ShareMenuItemProps { emailBody: string; addDangerToast: Function; addSuccessToast: Function; - dashboardId?: string; - formData?: Pick; + dashboardId: string | number; + dashboardComponentId?: string; } const ShareMenuItems = (props: ShareMenuItemProps) => { @@ -49,23 +45,21 @@ const ShareMenuItems = (props: ShareMenuItemProps) => { addDangerToast, addSuccessToast, dashboardId, - formData, + dashboardComponentId, ...rest } = props; async function generateUrl() { - // chart - if (formData) { - // we need to remove reserved dashboard url params - return getChartPermalink(formData, RESERVED_DASHBOARD_URL_PARAMS); - } - // dashboard const nativeFiltersKey = getUrlParam(URL_PARAMS.nativeFiltersKey); let filterState = {}; if (nativeFiltersKey && dashboardId) { filterState = await getFilterValue(dashboardId, nativeFiltersKey); } - return getDashboardPermalink(String(dashboardId), filterState); + return getDashboardPermalink({ + dashboardId, + filterState, + hash: dashboardComponentId, + }); } async function onCopyLink() { diff --git a/superset-frontend/src/utils/urlUtils.ts b/superset-frontend/src/utils/urlUtils.ts index be857517e06d0..bd570291f2cba 100644 --- a/superset-frontend/src/utils/urlUtils.ts +++ b/superset-frontend/src/utils/urlUtils.ts @@ -154,11 +154,15 @@ export function getChartPermalink( }); } -export function getDashboardPermalink( - dashboardId: string, - filterState: JsonObject, - hash?: string, -) { +export function getDashboardPermalink({ + dashboardId, + filterState, + hash, // the anchor part of the link which corresponds to the tab/chart id +}: { + dashboardId: string | number; + filterState: JsonObject; + hash?: string; +}) { // only encode filter box state if non-empty return getPermalink(`/api/v1/dashboard/${dashboardId}/permalink`, { filterState, From 1c5d3b73df3553d481fc59d89f94ad15193f5775 Mon Sep 17 00:00:00 2001 From: Diego Medina Date: Tue, 19 Apr 2022 18:57:30 -0400 Subject: [PATCH 079/136] fix: dashboard top level tabs edit (#19722) --- .../DashboardBuilder/DashboardBuilder.tsx | 30 ++++++++++++++- .../DashboardBuilder/DashboardContainer.tsx | 23 +++++------ .../components/gridComponents/Tabs.jsx | 38 +++++++++---------- 3 files changed, 57 insertions(+), 34 deletions(-) diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx index 11a5b28e561b2..8352482ed8ab8 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx @@ -18,7 +18,14 @@ */ /* eslint-env browser */ import cx from 'classnames'; -import React, { FC, useCallback, useEffect, useState, useMemo } from 'react'; +import React, { + FC, + useCallback, + useEffect, + useState, + useMemo, + useRef, +} from 'react'; import { JsonObject, styled, css, t } from '@superset-ui/core'; import { Global } from '@emotion/react'; import { useDispatch, useSelector } from 'react-redux'; @@ -319,6 +326,27 @@ const DashboardBuilder: FC = () => { [dashboardFiltersOpen, editMode, nativeFiltersEnabled], ); + // If a new tab was added, update the directPathToChild to reflect it + const currentTopLevelTabs = useRef(topLevelTabs); + useEffect(() => { + const currentTabsLength = currentTopLevelTabs.current?.children?.length; + const newTabsLength = topLevelTabs?.children?.length; + + if ( + currentTabsLength !== undefined && + newTabsLength !== undefined && + newTabsLength > currentTabsLength + ) { + const lastTab = getDirectPathToTabIndex( + getRootLevelTabsComponent(dashboardLayout), + newTabsLength - 1, + ); + dispatch(setDirectPathToChild(lastTab)); + } + + currentTopLevelTabs.current = topLevelTabs; + }, [topLevelTabs]); + const renderDraggableContent = useCallback( ({ dropIndicatorProps }: { dropIndicatorProps: JsonObject }) => (
diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx index b08a7cd6339f5..c763f07267f55 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx @@ -18,7 +18,7 @@ */ // ParentSize uses resize observer so the dashboard will update size // when its container size changes, due to e.g., builder side panel opening -import React, { FC, useEffect, useMemo, useState } from 'react'; +import React, { FC, useEffect, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { FeatureFlag, @@ -36,7 +36,6 @@ import { LayoutItem, RootState, } from 'src/dashboard/types'; -import getLeafComponentIdFromPath from 'src/dashboard/util/getLeafComponentIdFromPath'; import { DASHBOARD_GRID_ID, DASHBOARD_ROOT_DEPTH, @@ -68,29 +67,27 @@ const useNativeFilterScopes = () => { }; const DashboardContainer: FC = ({ topLevelTabs }) => { + const nativeFilterScopes = useNativeFilterScopes(); + const dispatch = useDispatch(); + const dashboardLayout = useSelector( state => state.dashboardLayout.present, ); - const nativeFilterScopes = useNativeFilterScopes(); const directPathToChild = useSelector( state => state.dashboardState.directPathToChild, ); const charts = useSelector(state => state.charts); - const [tabIndex, setTabIndex] = useState( - getRootLevelTabIndex(dashboardLayout, directPathToChild), - ); - const dispatch = useDispatch(); - - useEffect(() => { + const tabIndex = useMemo(() => { const nextTabIndex = findTabIndexByComponentId({ currentComponent: getRootLevelTabsComponent(dashboardLayout), directPathToChild, }); - if (nextTabIndex > -1) { - setTabIndex(nextTabIndex); - } - }, [getLeafComponentIdFromPath(directPathToChild)]); + + return nextTabIndex > -1 + ? nextTabIndex + : getRootLevelTabIndex(dashboardLayout, directPathToChild); + }, [dashboardLayout, directPathToChild]); useEffect(() => { if ( diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx b/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx index 8f2643533f3e4..c579abb911b15 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx @@ -155,22 +155,6 @@ export class Tabs extends React.PureComponent { this.setState(() => ({ tabIndex: maxIndex })); } - if (nextTabsIds.length) { - const lastTabId = nextTabsIds[nextTabsIds.length - 1]; - // if a new tab is added focus on it immediately - if (nextTabsIds.length > currTabsIds.length) { - // a new tab's path may be empty, here also need to set tabIndex - this.setState(() => ({ - activeKey: lastTabId, - tabIndex: maxIndex, - })); - } - // if a tab is removed focus on the first - if (nextTabsIds.length < currTabsIds.length) { - this.setState(() => ({ activeKey: nextTabsIds[0] })); - } - } - if (nextProps.isComponentVisible) { const nextFocusComponent = getLeafComponentIdFromPath( nextProps.directPathToChild, @@ -179,7 +163,14 @@ export class Tabs extends React.PureComponent { this.props.directPathToChild, ); - if (nextFocusComponent !== currentFocusComponent) { + // If the currently selected component is different than the new one, + // or the tab length/order changed, calculate the new tab index and + // replace it if it's different than the current one + if ( + nextFocusComponent !== currentFocusComponent || + (nextFocusComponent === currentFocusComponent && + currTabsIds !== nextTabsIds) + ) { const nextTabIndex = findTabIndexByComponentId({ currentComponent: nextProps.component, directPathToChild: nextProps.directPathToChild, @@ -219,9 +210,12 @@ export class Tabs extends React.PureComponent { }); }; - handleEdit = (key, action) => { + handleEdit = (event, action) => { const { component, createComponent } = this.props; if (action === 'add') { + // Prevent the tab container to be selected + event?.stopPropagation?.(); + createComponent({ destination: { id: component.id, @@ -234,7 +228,7 @@ export class Tabs extends React.PureComponent { }, }); } else if (action === 'remove') { - this.showDeleteConfirmModal(key); + this.showDeleteConfirmModal(event); } }; @@ -261,7 +255,11 @@ export class Tabs extends React.PureComponent { } handleDeleteTab(tabIndex) { - this.handleClickTab(Math.max(0, tabIndex - 1)); + // If we're removing the currently selected tab, + // select the previous one (if any) + if (this.state.tabIndex === tabIndex) { + this.handleClickTab(Math.max(0, tabIndex - 1)); + } } handleDropOnTab(dropResult) { From 231716cb50983b04178602b86c846b7673f9d8c3 Mon Sep 17 00:00:00 2001 From: Jesse Yang Date: Tue, 19 Apr 2022 18:58:18 -0700 Subject: [PATCH 080/136] perf: refactor SIP-68 db migrations with INSERT SELECT FROM (#19421) --- superset/columns/models.py | 72 +- superset/connectors/base/models.py | 6 +- superset/connectors/sqla/models.py | 731 ++++++------ superset/connectors/sqla/utils.py | 123 +- superset/datasets/models.py | 80 +- superset/examples/birth_names.py | 17 +- superset/migrations/shared/utils.py | 115 +- ...b176a0_add_import_mixing_to_saved_query.py | 6 +- superset/migrations/versions/9d8a8d575284_.py | 2 +- .../a9422eeaae74_new_dataset_models_take_2.py | 905 +++++++++++++++ ...0de1855_add_uuid_column_to_import_mixin.py | 49 +- .../b8d3a24d9131_new_dataset_models.py | 616 +--------- .../c501b7c653a3_add_missing_uuid_column.py | 4 +- ...95_migrate_native_filters_to_new_schema.py | 2 +- superset/models/core.py | 8 +- superset/models/helpers.py | 15 +- superset/sql_lab.py | 2 +- superset/sql_parse.py | 83 +- superset/tables/models.py | 136 ++- tests/integration_tests/commands_test.py | 20 +- .../fixtures/world_bank_dashboard.py | 3 +- tests/integration_tests/sqla_models_tests.py | 5 +- tests/integration_tests/utils_tests.py | 1 - tests/unit_tests/conftest.py | 34 +- tests/unit_tests/datasets/conftest.py | 118 ++ tests/unit_tests/datasets/test_models.py | 1018 +++++++---------- .../unit_tests/migrations/shared/__init__.py | 16 - .../migrations/shared/utils_test.py | 56 - tests/unit_tests/sql_parse_tests.py | 49 + .../{migrations/__init__.py => utils/db.py} | 14 + 30 files changed, 2337 insertions(+), 1969 deletions(-) create mode 100644 superset/migrations/versions/a9422eeaae74_new_dataset_models_take_2.py create mode 100644 tests/unit_tests/datasets/conftest.py delete mode 100644 tests/unit_tests/migrations/shared/__init__.py delete mode 100644 tests/unit_tests/migrations/shared/utils_test.py rename tests/unit_tests/{migrations/__init__.py => utils/db.py} (69%) diff --git a/superset/columns/models.py b/superset/columns/models.py index fbe045e3d3925..bfee3de859819 100644 --- a/superset/columns/models.py +++ b/superset/columns/models.py @@ -23,7 +23,6 @@ These models are not fully implemented, and shouldn't be used yet. """ - import sqlalchemy as sa from flask_appbuilder import Model @@ -33,6 +32,8 @@ ImportExportMixin, ) +UNKOWN_TYPE = "UNKNOWN" + class Column( Model, @@ -52,51 +53,58 @@ class Column( id = sa.Column(sa.Integer, primary_key=True) + # Assuming the column is an aggregation, is it additive? Useful for determining which + # aggregations can be done on the metric. Eg, ``COUNT(DISTINCT user_id)`` is not + # additive, so it shouldn't be used in a ``SUM``. + is_additive = sa.Column(sa.Boolean, default=False) + + # Is this column an aggregation (metric)? + is_aggregation = sa.Column(sa.Boolean, default=False) + + is_filterable = sa.Column(sa.Boolean, nullable=False, default=True) + is_dimensional = sa.Column(sa.Boolean, nullable=False, default=False) + + # Is an increase desired? Useful for displaying the results of A/B tests, or setting + # up alerts. Eg, this is true for "revenue", but false for "latency". + is_increase_desired = sa.Column(sa.Boolean, default=True) + + # Column is managed externally and should be read-only inside Superset + is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) + + # Is this column a partition? Useful for scheduling queries and previewing the latest + # data. + is_partition = sa.Column(sa.Boolean, default=False) + + # Does the expression point directly to a physical column? + is_physical = sa.Column(sa.Boolean, default=True) + + # Is this a spatial column? This could be leveraged in the future for spatial + # visualizations. + is_spatial = sa.Column(sa.Boolean, default=False) + + # Is this a time column? Useful for plotting time series. + is_temporal = sa.Column(sa.Boolean, default=False) + # We use ``sa.Text`` for these attributes because (1) in modern databases the # performance is the same as ``VARCHAR``[1] and (2) because some table names can be # **really** long (eg, Google Sheets URLs). # # [1] https://www.postgresql.org/docs/9.1/datatype-character.html name = sa.Column(sa.Text) - type = sa.Column(sa.Text) + # Raw type as returned and used by db engine. + type = sa.Column(sa.Text, default=UNKOWN_TYPE) # Columns are defined by expressions. For tables, these are the actual columns names, # and should match the ``name`` attribute. For datasets, these can be any valid SQL # expression. If the SQL expression is an aggregation the column is a metric, # otherwise it's a computed column. expression = sa.Column(sa.Text) - - # Does the expression point directly to a physical column? - is_physical = sa.Column(sa.Boolean, default=True) + unit = sa.Column(sa.Text) # Additional metadata describing the column. description = sa.Column(sa.Text) warning_text = sa.Column(sa.Text) - unit = sa.Column(sa.Text) - - # Is this a time column? Useful for plotting time series. - is_temporal = sa.Column(sa.Boolean, default=False) - - # Is this a spatial column? This could be leveraged in the future for spatial - # visualizations. - is_spatial = sa.Column(sa.Boolean, default=False) - - # Is this column a partition? Useful for scheduling queries and previewing the latest - # data. - is_partition = sa.Column(sa.Boolean, default=False) - - # Is this column an aggregation (metric)? - is_aggregation = sa.Column(sa.Boolean, default=False) - - # Assuming the column is an aggregation, is it additive? Useful for determining which - # aggregations can be done on the metric. Eg, ``COUNT(DISTINCT user_id)`` is not - # additive, so it shouldn't be used in a ``SUM``. - is_additive = sa.Column(sa.Boolean, default=False) - - # Is an increase desired? Useful for displaying the results of A/B tests, or setting - # up alerts. Eg, this is true for "revenue", but false for "latency". - is_increase_desired = sa.Column(sa.Boolean, default=True) - - # Column is managed externally and should be read-only inside Superset - is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) external_url = sa.Column(sa.Text, nullable=True) + + def __repr__(self) -> str: + return f"" diff --git a/superset/connectors/base/models.py b/superset/connectors/base/models.py index 9aacb0dc8c641..3d22857912f10 100644 --- a/superset/connectors/base/models.py +++ b/superset/connectors/base/models.py @@ -31,7 +31,7 @@ from superset.models.slice import Slice from superset.superset_typing import FilterValue, FilterValues, QueryObjectDict from superset.utils import core as utils -from superset.utils.core import GenericDataType +from superset.utils.core import GenericDataType, MediumText METRIC_FORM_DATA_PARAMS = [ "metric", @@ -586,7 +586,7 @@ class BaseColumn(AuditMixinNullable, ImportExportMixin): type = Column(Text) groupby = Column(Boolean, default=True) filterable = Column(Boolean, default=True) - description = Column(Text) + description = Column(MediumText()) is_dttm = None # [optional] Set this to support import/export functionality @@ -672,7 +672,7 @@ class BaseMetric(AuditMixinNullable, ImportExportMixin): metric_name = Column(String(255), nullable=False) verbose_name = Column(String(1024)) metric_type = Column(String(32)) - description = Column(Text) + description = Column(MediumText()) d3format = Column(String(128)) warning_text = Column(Text) diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index d7d62db2a7e0e..e0382c659514c 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -24,6 +24,7 @@ from datetime import datetime, timedelta from typing import ( Any, + Callable, cast, Dict, Hashable, @@ -34,6 +35,7 @@ Type, Union, ) +from uuid import uuid4 import dateutil.parser import numpy as np @@ -72,13 +74,13 @@ from sqlalchemy.sql.selectable import Alias, TableClause from superset import app, db, is_feature_enabled, security_manager -from superset.columns.models import Column as NewColumn +from superset.columns.models import Column as NewColumn, UNKOWN_TYPE from superset.common.db_query_status import QueryStatus from superset.connectors.base.models import BaseColumn, BaseDatasource, BaseMetric from superset.connectors.sqla.utils import ( + find_cached_objects_in_session, get_physical_table_metadata, get_virtual_table_metadata, - load_or_create_tables, validate_adhoc_subquery, ) from superset.datasets.models import Dataset as NewDataset @@ -100,7 +102,12 @@ clone_model, QueryResult, ) -from superset.sql_parse import ParsedQuery, sanitize_clause +from superset.sql_parse import ( + extract_table_references, + ParsedQuery, + sanitize_clause, + Table as TableName, +) from superset.superset_typing import ( AdhocColumn, AdhocMetric, @@ -114,6 +121,7 @@ GenericDataType, get_column_name, is_adhoc_column, + MediumText, QueryObjectFilterClause, remove_duplicates, ) @@ -130,6 +138,7 @@ "sum", "doubleSum", } +ADDITIVE_METRIC_TYPES_LOWER = {op.lower() for op in ADDITIVE_METRIC_TYPES} class SqlaQuery(NamedTuple): @@ -215,13 +224,13 @@ class TableColumn(Model, BaseColumn, CertificationMixin): __tablename__ = "table_columns" __table_args__ = (UniqueConstraint("table_id", "column_name"),) table_id = Column(Integer, ForeignKey("tables.id")) - table = relationship( + table: "SqlaTable" = relationship( "SqlaTable", backref=backref("columns", cascade="all, delete-orphan"), foreign_keys=[table_id], ) is_dttm = Column(Boolean, default=False) - expression = Column(Text) + expression = Column(MediumText()) python_date_format = Column(String(255)) extra = Column(Text) @@ -417,6 +426,59 @@ def data(self) -> Dict[str, Any]: return attr_dict + def to_sl_column( + self, known_columns: Optional[Dict[str, NewColumn]] = None + ) -> NewColumn: + """Convert a TableColumn to NewColumn""" + column = known_columns.get(self.uuid) if known_columns else None + if not column: + column = NewColumn() + + extra_json = self.get_extra_dict() + for attr in { + "verbose_name", + "python_date_format", + }: + value = getattr(self, attr) + if value: + extra_json[attr] = value + + column.uuid = self.uuid + column.created_on = self.created_on + column.changed_on = self.changed_on + column.created_by = self.created_by + column.changed_by = self.changed_by + column.name = self.column_name + column.type = self.type or UNKOWN_TYPE + column.expression = self.expression or self.table.quote_identifier( + self.column_name + ) + column.description = self.description + column.is_aggregation = False + column.is_dimensional = self.groupby + column.is_filterable = self.filterable + column.is_increase_desired = True + column.is_managed_externally = self.table.is_managed_externally + column.is_partition = False + column.is_physical = not self.expression + column.is_spatial = False + column.is_temporal = self.is_dttm + column.extra_json = json.dumps(extra_json) if extra_json else None + column.external_url = self.table.external_url + + return column + + @staticmethod + def after_delete( # pylint: disable=unused-argument + mapper: Mapper, + connection: Connection, + target: "TableColumn", + ) -> None: + session = inspect(target).session + column = session.query(NewColumn).filter_by(uuid=target.uuid).one_or_none() + if column: + session.delete(column) + class SqlMetric(Model, BaseMetric, CertificationMixin): @@ -430,7 +492,7 @@ class SqlMetric(Model, BaseMetric, CertificationMixin): backref=backref("metrics", cascade="all, delete-orphan"), foreign_keys=[table_id], ) - expression = Column(Text, nullable=False) + expression = Column(MediumText(), nullable=False) extra = Column(Text) export_fields = [ @@ -479,6 +541,58 @@ def data(self) -> Dict[str, Any]: attr_dict.update(super().data) return attr_dict + def to_sl_column( + self, known_columns: Optional[Dict[str, NewColumn]] = None + ) -> NewColumn: + """Convert a SqlMetric to NewColumn. Find and update existing or + create a new one.""" + column = known_columns.get(self.uuid) if known_columns else None + if not column: + column = NewColumn() + + extra_json = self.get_extra_dict() + for attr in {"verbose_name", "metric_type", "d3format"}: + value = getattr(self, attr) + if value is not None: + extra_json[attr] = value + is_additive = ( + self.metric_type and self.metric_type.lower() in ADDITIVE_METRIC_TYPES_LOWER + ) + + column.uuid = self.uuid + column.name = self.metric_name + column.created_on = self.created_on + column.changed_on = self.changed_on + column.created_by = self.created_by + column.changed_by = self.changed_by + column.type = UNKOWN_TYPE + column.expression = self.expression + column.warning_text = self.warning_text + column.description = self.description + column.is_aggregation = True + column.is_additive = is_additive + column.is_filterable = False + column.is_increase_desired = True + column.is_managed_externally = self.table.is_managed_externally + column.is_partition = False + column.is_physical = False + column.is_spatial = False + column.extra_json = json.dumps(extra_json) if extra_json else None + column.external_url = self.table.external_url + + return column + + @staticmethod + def after_delete( # pylint: disable=unused-argument + mapper: Mapper, + connection: Connection, + target: "SqlMetric", + ) -> None: + session = inspect(target).session + column = session.query(NewColumn).filter_by(uuid=target.uuid).one_or_none() + if column: + session.delete(column) + sqlatable_user = Table( "sqlatable_user", @@ -544,7 +658,7 @@ class SqlaTable(Model, BaseDatasource): # pylint: disable=too-many-public-metho foreign_keys=[database_id], ) schema = Column(String(255)) - sql = Column(Text) + sql = Column(MediumText()) is_sqllab_view = Column(Boolean, default=False) template_params = Column(Text) extra = Column(Text) @@ -1731,7 +1845,19 @@ def fetch_metadata(self, commit: bool = True) -> MetadataResult: metrics = [] any_date_col = None db_engine_spec = self.db_engine_spec - old_columns = db.session.query(TableColumn).filter(TableColumn.table == self) + + # If no `self.id`, then this is a new table, no need to fetch columns + # from db. Passing in `self.id` to query will actually automatically + # generate a new id, which can be tricky during certain transactions. + old_columns = ( + ( + db.session.query(TableColumn) + .filter(TableColumn.table_id == self.id) + .all() + ) + if self.id + else self.columns + ) old_columns_by_name: Dict[str, TableColumn] = { col.column_name: col for col in old_columns @@ -1745,13 +1871,15 @@ def fetch_metadata(self, commit: bool = True) -> MetadataResult: ) # clear old columns before adding modified columns back - self.columns = [] + columns = [] for col in new_columns: old_column = old_columns_by_name.pop(col["name"], None) if not old_column: results.added.append(col["name"]) new_column = TableColumn( - column_name=col["name"], type=col["type"], table=self + column_name=col["name"], + type=col["type"], + table=self, ) new_column.is_dttm = new_column.is_temporal db_engine_spec.alter_new_orm_column(new_column) @@ -1763,12 +1891,14 @@ def fetch_metadata(self, commit: bool = True) -> MetadataResult: new_column.expression = "" new_column.groupby = True new_column.filterable = True - self.columns.append(new_column) + columns.append(new_column) if not any_date_col and new_column.is_temporal: any_date_col = col["name"] - self.columns.extend( - [col for col in old_columns_by_name.values() if col.expression] - ) + + # add back calculated (virtual) columns + columns.extend([col for col in old_columns if col.expression]) + self.columns = columns + metrics.append( SqlMetric( metric_name="count", @@ -1854,6 +1984,10 @@ class and any keys added via `ExtraCache`. extra_cache_keys += sqla_query.extra_cache_keys return extra_cache_keys + @property + def quote_identifier(self) -> Callable[[str], str]: + return self.database.quote_identifier + @staticmethod def before_update( mapper: Mapper, # pylint: disable=unused-argument @@ -1895,14 +2029,44 @@ def before_update( ): raise Exception(get_dataset_exist_error_msg(target.full_name)) + def get_sl_columns(self) -> List[NewColumn]: + """ + Convert `SqlaTable.columns` and `SqlaTable.metrics` to the new Column model + """ + session: Session = inspect(self).session + + uuids = set() + for column_or_metric in self.columns + self.metrics: + # pre-assign uuid after new columns or metrics are inserted so + # the related `NewColumn` can have a deterministic uuid, too + if not column_or_metric.uuid: + column_or_metric.uuid = uuid4() + else: + uuids.add(column_or_metric.uuid) + + # load existing columns from cached session states first + existing_columns = set( + find_cached_objects_in_session(session, NewColumn, uuids=uuids) + ) + for column in existing_columns: + uuids.remove(column.uuid) + + if uuids: + # load those not found from db + existing_columns |= set( + session.query(NewColumn).filter(NewColumn.uuid.in_(uuids)) + ) + + known_columns = {column.uuid: column for column in existing_columns} + return [ + item.to_sl_column(known_columns) for item in self.columns + self.metrics + ] + @staticmethod def update_table( # pylint: disable=unused-argument mapper: Mapper, connection: Connection, target: Union[SqlMetric, TableColumn] ) -> None: """ - Forces an update to the table's changed_on value when a metric or column on the - table is updated. This busts the cache key for all charts that use the table. - :param mapper: Unused. :param connection: Unused. :param target: The metric or column that was updated. @@ -1910,90 +2074,43 @@ def update_table( # pylint: disable=unused-argument inspector = inspect(target) session = inspector.session - # get DB-specific conditional quoter for expressions that point to columns or - # table names - database = ( - target.table.database - or session.query(Database).filter_by(id=target.database_id).one() - ) - engine = database.get_sqla_engine(schema=target.table.schema) - conditional_quote = engine.dialect.identifier_preparer.quote - + # Forces an update to the table's changed_on value when a metric or column on the + # table is updated. This busts the cache key for all charts that use the table. session.execute(update(SqlaTable).where(SqlaTable.id == target.table.id)) - dataset = ( - session.query(NewDataset) - .filter_by(sqlatable_id=target.table.id) - .one_or_none() - ) - - if not dataset: - # if dataset is not found create a new copy - # of the dataset instead of updating the existing - - SqlaTable.write_shadow_dataset(target.table, database, session) - return - - # update ``Column`` model as well - if isinstance(target, TableColumn): - columns = [ - column - for column in dataset.columns - if column.name == target.column_name - ] - if not columns: - return - - column = columns[0] - extra_json = json.loads(target.extra or "{}") - for attr in {"groupby", "filterable", "verbose_name", "python_date_format"}: - value = getattr(target, attr) - if value: - extra_json[attr] = value - - column.name = target.column_name - column.type = target.type or "Unknown" - column.expression = target.expression or conditional_quote( - target.column_name + # if table itself has changed, shadow-writing will happen in `after_udpate` anyway + if target.table not in session.dirty: + dataset: NewDataset = ( + session.query(NewDataset) + .filter_by(uuid=target.table.uuid) + .one_or_none() ) - column.description = target.description - column.is_temporal = target.is_dttm - column.is_physical = target.expression is None - column.extra_json = json.dumps(extra_json) if extra_json else None - - else: # SqlMetric - columns = [ - column - for column in dataset.columns - if column.name == target.metric_name - ] - if not columns: + # Update shadow dataset and columns + # did we find the dataset? + if not dataset: + # if dataset is not found create a new copy + target.table.write_shadow_dataset() return - column = columns[0] - extra_json = json.loads(target.extra or "{}") - for attr in {"verbose_name", "metric_type", "d3format"}: - value = getattr(target, attr) - if value: - extra_json[attr] = value - - is_additive = ( - target.metric_type - and target.metric_type.lower() in ADDITIVE_METRIC_TYPES + # update changed_on timestamp + session.execute(update(NewDataset).where(NewDataset.id == dataset.id)) + + # update `Column` model as well + session.add( + target.to_sl_column( + { + target.uuid: session.query(NewColumn) + .filter_by(uuid=target.uuid) + .one_or_none() + } + ) ) - column.name = target.metric_name - column.expression = target.expression - column.warning_text = target.warning_text - column.description = target.description - column.is_additive = is_additive - column.extra_json = json.dumps(extra_json) if extra_json else None - @staticmethod def after_insert( mapper: Mapper, connection: Connection, - target: "SqlaTable", + sqla_table: "SqlaTable", ) -> None: """ Shadow write the dataset to new models. @@ -2007,24 +2124,14 @@ def after_insert( For more context: https://github.com/apache/superset/issues/14909 """ - session = inspect(target).session - # set permissions - security_manager.set_perm(mapper, connection, target) - - # get DB-specific conditional quoter for expressions that point to columns or - # table names - database = ( - target.database - or session.query(Database).filter_by(id=target.database_id).one() - ) - - SqlaTable.write_shadow_dataset(target, database, session) + security_manager.set_perm(mapper, connection, sqla_table) + sqla_table.write_shadow_dataset() @staticmethod def after_delete( # pylint: disable=unused-argument mapper: Mapper, connection: Connection, - target: "SqlaTable", + sqla_table: "SqlaTable", ) -> None: """ Shadow write the dataset to new models. @@ -2038,18 +2145,18 @@ def after_delete( # pylint: disable=unused-argument For more context: https://github.com/apache/superset/issues/14909 """ - session = inspect(target).session + session = inspect(sqla_table).session dataset = ( - session.query(NewDataset).filter_by(sqlatable_id=target.id).one_or_none() + session.query(NewDataset).filter_by(uuid=sqla_table.uuid).one_or_none() ) if dataset: session.delete(dataset) @staticmethod - def after_update( # pylint: disable=too-many-branches, too-many-locals, too-many-statements + def after_update( mapper: Mapper, connection: Connection, - target: "SqlaTable", + sqla_table: "SqlaTable", ) -> None: """ Shadow write the dataset to new models. @@ -2063,172 +2170,76 @@ def after_update( # pylint: disable=too-many-branches, too-many-locals, too-man For more context: https://github.com/apache/superset/issues/14909 """ - inspector = inspect(target) + # set permissions + security_manager.set_perm(mapper, connection, sqla_table) + + inspector = inspect(sqla_table) session = inspector.session # double-check that ``UPDATE``s are actually pending (this method is called even # for instances that have no net changes to their column-based attributes) - if not session.is_modified(target, include_collections=True): + if not session.is_modified(sqla_table, include_collections=True): return - # set permissions - security_manager.set_perm(mapper, connection, target) - - dataset = ( - session.query(NewDataset).filter_by(sqlatable_id=target.id).one_or_none() + # find the dataset from the known instance list first + # (it could be either from a previous query or newly created) + dataset = next( + find_cached_objects_in_session( + session, NewDataset, uuids=[sqla_table.uuid] + ), + None, ) + # if not found, pull from database + if not dataset: + dataset = ( + session.query(NewDataset).filter_by(uuid=sqla_table.uuid).one_or_none() + ) if not dataset: + sqla_table.write_shadow_dataset() return - # get DB-specific conditional quoter for expressions that point to columns or - # table names - database = ( - target.database - or session.query(Database).filter_by(id=target.database_id).one() - ) - engine = database.get_sqla_engine(schema=target.schema) - conditional_quote = engine.dialect.identifier_preparer.quote - - # update columns - if inspector.attrs.columns.history.has_changes(): - # handle deleted columns - if inspector.attrs.columns.history.deleted: - column_names = { - column.column_name - for column in inspector.attrs.columns.history.deleted - } - dataset.columns = [ - column - for column in dataset.columns - if column.name not in column_names - ] - - # handle inserted columns - for column in inspector.attrs.columns.history.added: - # ``is_active`` might be ``None``, but it defaults to ``True``. - if column.is_active is False: - continue - - extra_json = json.loads(column.extra or "{}") - for attr in { - "groupby", - "filterable", - "verbose_name", - "python_date_format", - }: - value = getattr(column, attr) - if value: - extra_json[attr] = value - - dataset.columns.append( - NewColumn( - name=column.column_name, - type=column.type or "Unknown", - expression=column.expression - or conditional_quote(column.column_name), - description=column.description, - is_temporal=column.is_dttm, - is_aggregation=False, - is_physical=column.expression is None, - is_spatial=False, - is_partition=False, - is_increase_desired=True, - extra_json=json.dumps(extra_json) if extra_json else None, - is_managed_externally=target.is_managed_externally, - external_url=target.external_url, - ) - ) - - # update metrics - if inspector.attrs.metrics.history.has_changes(): - # handle deleted metrics - if inspector.attrs.metrics.history.deleted: - column_names = { - metric.metric_name - for metric in inspector.attrs.metrics.history.deleted - } - dataset.columns = [ - column - for column in dataset.columns - if column.name not in column_names - ] - - # handle inserted metrics - for metric in inspector.attrs.metrics.history.added: - extra_json = json.loads(metric.extra or "{}") - for attr in {"verbose_name", "metric_type", "d3format"}: - value = getattr(metric, attr) - if value: - extra_json[attr] = value - - is_additive = ( - metric.metric_type - and metric.metric_type.lower() in ADDITIVE_METRIC_TYPES - ) - - dataset.columns.append( - NewColumn( - name=metric.metric_name, - type="Unknown", - expression=metric.expression, - warning_text=metric.warning_text, - description=metric.description, - is_aggregation=True, - is_additive=is_additive, - is_physical=False, - is_spatial=False, - is_partition=False, - is_increase_desired=True, - extra_json=json.dumps(extra_json) if extra_json else None, - is_managed_externally=target.is_managed_externally, - external_url=target.external_url, - ) - ) + # sync column list and delete removed columns + if ( + inspector.attrs.columns.history.has_changes() + or inspector.attrs.metrics.history.has_changes() + ): + # add pending new columns to known columns list, too, so if calling + # `after_update` twice before changes are persisted will not create + # two duplicate columns with the same uuids. + dataset.columns = sqla_table.get_sl_columns() # physical dataset - if target.sql is None: - physical_columns = [ - column for column in dataset.columns if column.is_physical - ] - - # if the table name changed we should create a new table instance, instead - # of reusing the original one + if not sqla_table.sql: + # if the table name changed we should relink the dataset to another table + # (and create one if necessary) if ( inspector.attrs.table_name.history.has_changes() or inspector.attrs.schema.history.has_changes() - or inspector.attrs.database_id.history.has_changes() + or inspector.attrs.database.history.has_changes() ): - # does the dataset point to an existing table? - table = ( - session.query(NewTable) - .filter_by( - database_id=target.database_id, - schema=target.schema, - name=target.table_name, - ) - .first() + tables = NewTable.bulk_load_or_create( + sqla_table.database, + [TableName(schema=sqla_table.schema, table=sqla_table.table_name)], + sync_columns=False, + default_props=dict( + changed_by=sqla_table.changed_by, + created_by=sqla_table.created_by, + is_managed_externally=sqla_table.is_managed_externally, + external_url=sqla_table.external_url, + ), ) - if not table: - # create new columns + if not tables[0].id: + # dataset columns will only be assigned to newly created tables + # existing tables should manage column syncing in another process physical_columns = [ - clone_model(column, ignore=["uuid"]) - for column in physical_columns + clone_model( + column, ignore=["uuid"], keep_relations=["changed_by"] + ) + for column in dataset.columns + if column.is_physical ] - - # create new table - table = NewTable( - name=target.table_name, - schema=target.schema, - catalog=None, - database_id=target.database_id, - columns=physical_columns, - is_managed_externally=target.is_managed_externally, - external_url=target.external_url, - ) - dataset.tables = [table] - elif dataset.tables: - table = dataset.tables[0] - table.columns = physical_columns + tables[0].columns = physical_columns + dataset.tables = tables # virtual dataset else: @@ -2237,29 +2248,34 @@ def after_update( # pylint: disable=too-many-branches, too-many-locals, too-man column.is_physical = False # update referenced tables if SQL changed - if inspector.attrs.sql.history.has_changes(): - parsed = ParsedQuery(target.sql) - referenced_tables = parsed.tables - - predicate = or_( - *[ - and_( - NewTable.schema == (table.schema or target.schema), - NewTable.name == table.table, - ) - for table in referenced_tables - ] + if sqla_table.sql and inspector.attrs.sql.history.has_changes(): + referenced_tables = extract_table_references( + sqla_table.sql, sqla_table.database.get_dialect().name + ) + dataset.tables = NewTable.bulk_load_or_create( + sqla_table.database, + referenced_tables, + default_schema=sqla_table.schema, + # sync metadata is expensive, we'll do it in another process + # e.g. when users open a Table page + sync_columns=False, + default_props=dict( + changed_by=sqla_table.changed_by, + created_by=sqla_table.created_by, + is_managed_externally=sqla_table.is_managed_externally, + external_url=sqla_table.external_url, + ), ) - dataset.tables = session.query(NewTable).filter(predicate).all() # update other attributes - dataset.name = target.table_name - dataset.expression = target.sql or conditional_quote(target.table_name) - dataset.is_physical = target.sql is None + dataset.name = sqla_table.table_name + dataset.expression = sqla_table.sql or sqla_table.quote_identifier( + sqla_table.table_name + ) + dataset.is_physical = not sqla_table.sql - @staticmethod - def write_shadow_dataset( # pylint: disable=too-many-locals - dataset: "SqlaTable", database: Database, session: Session + def write_shadow_dataset( + self: "SqlaTable", ) -> None: """ Shadow write the dataset to new models. @@ -2273,95 +2289,57 @@ def write_shadow_dataset( # pylint: disable=too-many-locals For more context: https://github.com/apache/superset/issues/14909 """ - - engine = database.get_sqla_engine(schema=dataset.schema) - conditional_quote = engine.dialect.identifier_preparer.quote + session = inspect(self).session + # make sure database points to the right instance, in case only + # `table.database_id` is updated and the changes haven't been + # consolidated by SQLA + if self.database_id and ( + not self.database or self.database.id != self.database_id + ): + self.database = session.query(Database).filter_by(id=self.database_id).one() # create columns columns = [] - for column in dataset.columns: - # ``is_active`` might be ``None`` at this point, but it defaults to ``True``. - if column.is_active is False: - continue - - try: - extra_json = json.loads(column.extra or "{}") - except json.decoder.JSONDecodeError: - extra_json = {} - for attr in {"groupby", "filterable", "verbose_name", "python_date_format"}: - value = getattr(column, attr) - if value: - extra_json[attr] = value - - columns.append( - NewColumn( - name=column.column_name, - type=column.type or "Unknown", - expression=column.expression - or conditional_quote(column.column_name), - description=column.description, - is_temporal=column.is_dttm, - is_aggregation=False, - is_physical=column.expression is None, - is_spatial=False, - is_partition=False, - is_increase_desired=True, - extra_json=json.dumps(extra_json) if extra_json else None, - is_managed_externally=dataset.is_managed_externally, - external_url=dataset.external_url, - ), - ) - - # create metrics - for metric in dataset.metrics: - try: - extra_json = json.loads(metric.extra or "{}") - except json.decoder.JSONDecodeError: - extra_json = {} - for attr in {"verbose_name", "metric_type", "d3format"}: - value = getattr(metric, attr) - if value: - extra_json[attr] = value - - is_additive = ( - metric.metric_type - and metric.metric_type.lower() in ADDITIVE_METRIC_TYPES - ) - - columns.append( - NewColumn( - name=metric.metric_name, - type="Unknown", # figuring this out would require a type inferrer - expression=metric.expression, - warning_text=metric.warning_text, - description=metric.description, - is_aggregation=True, - is_additive=is_additive, - is_physical=False, - is_spatial=False, - is_partition=False, - is_increase_desired=True, - extra_json=json.dumps(extra_json) if extra_json else None, - is_managed_externally=dataset.is_managed_externally, - external_url=dataset.external_url, - ), - ) + for item in self.columns + self.metrics: + item.created_by = self.created_by + item.changed_by = self.changed_by + # on `SqlaTable.after_insert`` event, although the table itself + # already has a `uuid`, the associated columns will not. + # Here we pre-assign a uuid so they can still be matched to the new + # Column after creation. + if not item.uuid: + item.uuid = uuid4() + columns.append(item.to_sl_column()) # physical dataset - if not dataset.sql: - physical_columns = [column for column in columns if column.is_physical] - - # create table - table = NewTable( - name=dataset.table_name, - schema=dataset.schema, - catalog=None, # currently not supported - database_id=dataset.database_id, - columns=physical_columns, - is_managed_externally=dataset.is_managed_externally, - external_url=dataset.external_url, + if not self.sql: + # always create separate column entries for Dataset and Table + # so updating a dataset would not update columns in the related table + physical_columns = [ + clone_model( + column, + ignore=["uuid"], + # `created_by` will always be left empty because it'd always + # be created via some sort of automated system. + # But keep `changed_by` in case someone manually changes + # column attributes such as `is_dttm`. + keep_relations=["changed_by"], + ) + for column in columns + if column.is_physical + ] + tables = NewTable.bulk_load_or_create( + self.database, + [TableName(schema=self.schema, table=self.table_name)], + sync_columns=False, + default_props=dict( + created_by=self.created_by, + changed_by=self.changed_by, + is_managed_externally=self.is_managed_externally, + external_url=self.external_url, + ), ) - tables = [table] + tables[0].columns = physical_columns # virtual dataset else: @@ -2370,26 +2348,39 @@ def write_shadow_dataset( # pylint: disable=too-many-locals column.is_physical = False # find referenced tables - parsed = ParsedQuery(dataset.sql) - referenced_tables = parsed.tables - tables = load_or_create_tables( - session, - database, - dataset.schema, + referenced_tables = extract_table_references( + self.sql, self.database.get_dialect().name + ) + tables = NewTable.bulk_load_or_create( + self.database, referenced_tables, - conditional_quote, + default_schema=self.schema, + # syncing table columns can be slow so we are not doing it here + sync_columns=False, + default_props=dict( + created_by=self.created_by, + changed_by=self.changed_by, + is_managed_externally=self.is_managed_externally, + external_url=self.external_url, + ), ) # create the new dataset new_dataset = NewDataset( - sqlatable_id=dataset.id, - name=dataset.table_name, - expression=dataset.sql or conditional_quote(dataset.table_name), + uuid=self.uuid, + database_id=self.database_id, + created_on=self.created_on, + created_by=self.created_by, + changed_by=self.changed_by, + changed_on=self.changed_on, + owners=self.owners, + name=self.table_name, + expression=self.sql or self.quote_identifier(self.table_name), tables=tables, columns=columns, - is_physical=not dataset.sql, - is_managed_externally=dataset.is_managed_externally, - external_url=dataset.external_url, + is_physical=not self.sql, + is_managed_externally=self.is_managed_externally, + external_url=self.external_url, ) session.add(new_dataset) @@ -2399,7 +2390,9 @@ def write_shadow_dataset( # pylint: disable=too-many-locals sa.event.listen(SqlaTable, "after_delete", SqlaTable.after_delete) sa.event.listen(SqlaTable, "after_update", SqlaTable.after_update) sa.event.listen(SqlMetric, "after_update", SqlaTable.update_table) +sa.event.listen(SqlMetric, "after_delete", SqlMetric.after_delete) sa.event.listen(TableColumn, "after_update", SqlaTable.update_table) +sa.event.listen(TableColumn, "after_delete", TableColumn.after_delete) RLSFilterRoles = Table( "rls_filter_roles", diff --git a/superset/connectors/sqla/utils.py b/superset/connectors/sqla/utils.py index f8ed7a956704a..1786c5bf17169 100644 --- a/superset/connectors/sqla/utils.py +++ b/superset/connectors/sqla/utils.py @@ -15,16 +15,28 @@ # specific language governing permissions and limitations # under the License. from contextlib import closing -from typing import Any, Callable, Dict, List, Optional, Set, TYPE_CHECKING +from typing import ( + Any, + Callable, + Dict, + Iterable, + Iterator, + List, + Optional, + Type, + TYPE_CHECKING, + TypeVar, +) +from uuid import UUID import sqlparse from flask_babel import lazy_gettext as _ -from sqlalchemy import and_, or_ +from sqlalchemy.engine.url import URL as SqlaURL from sqlalchemy.exc import NoSuchTableError +from sqlalchemy.ext.declarative import DeclarativeMeta from sqlalchemy.orm import Session from sqlalchemy.sql.type_api import TypeEngine -from superset.columns.models import Column as NewColumn from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.exceptions import ( SupersetGenericDBErrorException, @@ -32,9 +44,9 @@ ) from superset.models.core import Database from superset.result_set import SupersetResultSet -from superset.sql_parse import has_table_query, insert_rls, ParsedQuery, Table +from superset.sql_parse import has_table_query, insert_rls, ParsedQuery from superset.superset_typing import ResultSetColumnType -from superset.tables.models import Table as NewTable +from superset.utils.memoized import memoized if TYPE_CHECKING: from superset.connectors.sqla.models import SqlaTable @@ -168,75 +180,38 @@ def validate_adhoc_subquery( return ";\n".join(str(statement) for statement in statements) -def load_or_create_tables( # pylint: disable=too-many-arguments +@memoized +def get_dialect_name(drivername: str) -> str: + return SqlaURL(drivername).get_dialect().name + + +@memoized +def get_identifier_quoter(drivername: str) -> Dict[str, Callable[[str], str]]: + return SqlaURL(drivername).get_dialect()().identifier_preparer.quote + + +DeclarativeModel = TypeVar("DeclarativeModel", bound=DeclarativeMeta) + + +def find_cached_objects_in_session( session: Session, - database: Database, - default_schema: Optional[str], - tables: Set[Table], - conditional_quote: Callable[[str], str], -) -> List[NewTable]: + cls: Type[DeclarativeModel], + ids: Optional[Iterable[int]] = None, + uuids: Optional[Iterable[UUID]] = None, +) -> Iterator[DeclarativeModel]: + """Find known ORM instances in cached SQLA session states. + + :param session: a SQLA session + :param cls: a SQLA DeclarativeModel + :param ids: ids of the desired model instances (optional) + :param uuids: uuids of the desired instances, will be ignored if `ids` are provides """ - Load or create new table model instances. - """ - if not tables: - return [] - - # set the default schema in tables that don't have it - if default_schema: - fixed_tables = list(tables) - for i, table in enumerate(fixed_tables): - if table.schema is None: - fixed_tables[i] = Table(table.table, default_schema, table.catalog) - tables = set(fixed_tables) - - # load existing tables - predicate = or_( - *[ - and_( - NewTable.database_id == database.id, - NewTable.schema == table.schema, - NewTable.name == table.table, - ) - for table in tables - ] + if not ids and not uuids: + return iter([]) + uuids = uuids or [] + return ( + item + # `session` is an iterator of all known items + for item in set(session) + if isinstance(item, cls) and (item.id in ids if ids else item.uuid in uuids) ) - new_tables = session.query(NewTable).filter(predicate).all() - - # add missing tables - existing = {(table.schema, table.name) for table in new_tables} - for table in tables: - if (table.schema, table.table) not in existing: - try: - column_metadata = get_physical_table_metadata( - database=database, - table_name=table.table, - schema_name=table.schema, - ) - except Exception: # pylint: disable=broad-except - continue - columns = [ - NewColumn( - name=column["name"], - type=str(column["type"]), - expression=conditional_quote(column["name"]), - is_temporal=column["is_dttm"], - is_aggregation=False, - is_physical=True, - is_spatial=False, - is_partition=False, - is_increase_desired=True, - ) - for column in column_metadata - ] - new_tables.append( - NewTable( - name=table.table, - schema=table.schema, - catalog=None, - database_id=database.id, - columns=columns, - ) - ) - existing.add((table.schema, table.table)) - - return new_tables diff --git a/superset/datasets/models.py b/superset/datasets/models.py index 56a6fbf4000e3..b433709f2c779 100644 --- a/superset/datasets/models.py +++ b/superset/datasets/models.py @@ -28,9 +28,11 @@ import sqlalchemy as sa from flask_appbuilder import Model -from sqlalchemy.orm import relationship +from sqlalchemy.orm import backref, relationship +from superset import security_manager from superset.columns.models import Column +from superset.models.core import Database from superset.models.helpers import ( AuditMixinNullable, ExtraJSONMixin, @@ -38,18 +40,33 @@ ) from superset.tables.models import Table -column_association_table = sa.Table( +dataset_column_association_table = sa.Table( "sl_dataset_columns", Model.metadata, # pylint: disable=no-member - sa.Column("dataset_id", sa.ForeignKey("sl_datasets.id")), - sa.Column("column_id", sa.ForeignKey("sl_columns.id")), + sa.Column( + "dataset_id", + sa.ForeignKey("sl_datasets.id"), + primary_key=True, + ), + sa.Column( + "column_id", + sa.ForeignKey("sl_columns.id"), + primary_key=True, + ), ) -table_association_table = sa.Table( +dataset_table_association_table = sa.Table( "sl_dataset_tables", Model.metadata, # pylint: disable=no-member - sa.Column("dataset_id", sa.ForeignKey("sl_datasets.id")), - sa.Column("table_id", sa.ForeignKey("sl_tables.id")), + sa.Column("dataset_id", sa.ForeignKey("sl_datasets.id"), primary_key=True), + sa.Column("table_id", sa.ForeignKey("sl_tables.id"), primary_key=True), +) + +dataset_user_association_table = sa.Table( + "sl_dataset_users", + Model.metadata, # pylint: disable=no-member + sa.Column("dataset_id", sa.ForeignKey("sl_datasets.id"), primary_key=True), + sa.Column("user_id", sa.ForeignKey("ab_user.id"), primary_key=True), ) @@ -61,10 +78,34 @@ class Dataset(Model, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin): __tablename__ = "sl_datasets" id = sa.Column(sa.Integer, primary_key=True) + database_id = sa.Column(sa.Integer, sa.ForeignKey("dbs.id"), nullable=False) + database: Database = relationship( + "Database", + backref=backref("datasets", cascade="all, delete-orphan"), + foreign_keys=[database_id], + ) + # The relationship between datasets and columns is 1:n, but we use a + # many-to-many association table to avoid adding two mutually exclusive + # columns(dataset_id and table_id) to Column + columns: List[Column] = relationship( + "Column", + secondary=dataset_column_association_table, + cascade="all, delete-orphan", + single_parent=True, + backref="datasets", + ) + owners = relationship( + security_manager.user_model, secondary=dataset_user_association_table + ) + tables: List[Table] = relationship( + "Table", secondary=dataset_table_association_table, backref="datasets" + ) + + # Does the dataset point directly to a ``Table``? + is_physical = sa.Column(sa.Boolean, default=False) - # A temporary column, used for shadow writing to the new model. Once the ``SqlaTable`` - # model has been deleted this column can be removed. - sqlatable_id = sa.Column(sa.Integer, nullable=True, unique=True) + # Column is managed externally and should be read-only inside Superset + is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) # We use ``sa.Text`` for these attributes because (1) in modern databases the # performance is the same as ``VARCHAR``[1] and (2) because some table names can be @@ -72,21 +113,8 @@ class Dataset(Model, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin): # # [1] https://www.postgresql.org/docs/9.1/datatype-character.html name = sa.Column(sa.Text) - expression = sa.Column(sa.Text) - - # n:n relationship - tables: List[Table] = relationship("Table", secondary=table_association_table) - - # The relationship between datasets and columns is 1:n, but we use a many-to-many - # association to differentiate between the relationship between tables and columns. - columns: List[Column] = relationship( - "Column", secondary=column_association_table, cascade="all, delete" - ) - - # Does the dataset point directly to a ``Table``? - is_physical = sa.Column(sa.Boolean, default=False) - - # Column is managed externally and should be read-only inside Superset - is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) external_url = sa.Column(sa.Text, nullable=True) + + def __repr__(self) -> str: + return f"" diff --git a/superset/examples/birth_names.py b/superset/examples/birth_names.py index 1380958b2ad4a..8d7c02799dd57 100644 --- a/superset/examples/birth_names.py +++ b/superset/examples/birth_names.py @@ -135,23 +135,26 @@ def _set_table_metadata(datasource: SqlaTable, database: "Database") -> None: def _add_table_metrics(datasource: SqlaTable) -> None: - if not any(col.column_name == "num_california" for col in datasource.columns): + # By accessing the attribute first, we make sure `datasource.columns` and + # `datasource.metrics` are already loaded. Otherwise accessing them later + # may trigger an unnecessary and unexpected `after_update` event. + columns, metrics = datasource.columns, datasource.metrics + + if not any(col.column_name == "num_california" for col in columns): col_state = str(column("state").compile(db.engine)) col_num = str(column("num").compile(db.engine)) - datasource.columns.append( + columns.append( TableColumn( column_name="num_california", expression=f"CASE WHEN {col_state} = 'CA' THEN {col_num} ELSE 0 END", ) ) - if not any(col.metric_name == "sum__num" for col in datasource.metrics): + if not any(col.metric_name == "sum__num" for col in metrics): col = str(column("num").compile(db.engine)) - datasource.metrics.append( - SqlMetric(metric_name="sum__num", expression=f"SUM({col})") - ) + metrics.append(SqlMetric(metric_name="sum__num", expression=f"SUM({col})")) - for col in datasource.columns: + for col in columns: if col.column_name == "ds": col.is_dttm = True break diff --git a/superset/migrations/shared/utils.py b/superset/migrations/shared/utils.py index c54de83c42af0..4b0c4e1440dd5 100644 --- a/superset/migrations/shared/utils.py +++ b/superset/migrations/shared/utils.py @@ -15,42 +15,22 @@ # specific language governing permissions and limitations # under the License. import logging -from typing import Any, Iterator, Optional, Set +import os +import time +from typing import Any +from uuid import uuid4 from alembic import op from sqlalchemy import engine_from_config +from sqlalchemy.dialects.mysql.base import MySQLDialect +from sqlalchemy.dialects.postgresql.base import PGDialect from sqlalchemy.engine import reflection from sqlalchemy.exc import NoSuchTableError +from sqlalchemy.orm import Session -try: - from sqloxide import parse_sql -except ImportError: - parse_sql = None +logger = logging.getLogger(__name__) -from superset.sql_parse import ParsedQuery, Table - -logger = logging.getLogger("alembic") - - -# mapping between sqloxide and SQLAlchemy dialects -sqloxide_dialects = { - "ansi": {"trino", "trinonative", "presto"}, - "hive": {"hive", "databricks"}, - "ms": {"mssql"}, - "mysql": {"mysql"}, - "postgres": { - "cockroachdb", - "hana", - "netezza", - "postgres", - "postgresql", - "redshift", - "vertica", - }, - "snowflake": {"snowflake"}, - "sqlite": {"sqlite", "gsheets", "shillelagh"}, - "clickhouse": {"clickhouse"}, -} +DEFAULT_BATCH_SIZE = int(os.environ.get("BATCH_SIZE", 1000)) def table_has_column(table: str, column: str) -> bool: @@ -61,7 +41,6 @@ def table_has_column(table: str, column: str) -> bool: :param column: A column name :returns: True iff the column exists in the table """ - config = op.get_context().config engine = engine_from_config( config.get_section(config.config_ini_section), prefix="sqlalchemy." @@ -73,42 +52,44 @@ def table_has_column(table: str, column: str) -> bool: return False -def find_nodes_by_key(element: Any, target: str) -> Iterator[Any]: - """ - Find all nodes in a SQL tree matching a given key. - """ - if isinstance(element, list): - for child in element: - yield from find_nodes_by_key(child, target) - elif isinstance(element, dict): - for key, value in element.items(): - if key == target: - yield value - else: - yield from find_nodes_by_key(value, target) - - -def extract_table_references(sql_text: str, sqla_dialect: str) -> Set[Table]: - """ - Return all the dependencies from a SQL sql_text. - """ - if not parse_sql: - parsed = ParsedQuery(sql_text) - return parsed.tables +uuid_by_dialect = { + MySQLDialect: "UNHEX(REPLACE(CONVERT(UUID() using utf8mb4), '-', ''))", + PGDialect: "uuid_in(md5(random()::text || clock_timestamp()::text)::cstring)", +} - dialect = "generic" - for dialect, sqla_dialects in sqloxide_dialects.items(): - if sqla_dialect in sqla_dialects: - break - try: - tree = parse_sql(sql_text, dialect=dialect) - except Exception: # pylint: disable=broad-except - logger.warning("Unable to parse query with sqloxide: %s", sql_text) - # fallback to sqlparse - parsed = ParsedQuery(sql_text) - return parsed.tables - return { - Table(*[part["value"] for part in table["name"][::-1]]) - for table in find_nodes_by_key(tree, "Table") - } +def assign_uuids( + model: Any, session: Session, batch_size: int = DEFAULT_BATCH_SIZE +) -> None: + """Generate new UUIDs for all rows in a table""" + bind = op.get_bind() + table_name = model.__tablename__ + count = session.query(model).count() + # silently skip if the table is empty (suitable for db initialization) + if count == 0: + return + + start_time = time.time() + print(f"\nAdding uuids for `{table_name}`...") + # Use dialect specific native SQL queries if possible + for dialect, sql in uuid_by_dialect.items(): + if isinstance(bind.dialect, dialect): + op.execute( + f"UPDATE {dialect().identifier_preparer.quote(table_name)} SET uuid = {sql}" + ) + print(f"Done. Assigned {count} uuids in {time.time() - start_time:.3f}s.\n") + return + + # Othwewise Use Python uuid function + start = 0 + while start < count: + end = min(start + batch_size, count) + for obj in session.query(model)[start:end]: + obj.uuid = uuid4() + session.merge(obj) + session.commit() + if start + batch_size < count: + print(f" uuid assigned to {end} out of {count}\r", end="") + start += batch_size + + print(f"Done. Assigned {count} uuids in {time.time() - start_time:.3f}s.\n") diff --git a/superset/migrations/versions/96e99fb176a0_add_import_mixing_to_saved_query.py b/superset/migrations/versions/96e99fb176a0_add_import_mixing_to_saved_query.py index 57d22aa089aa2..f93deb1d0c950 100644 --- a/superset/migrations/versions/96e99fb176a0_add_import_mixing_to_saved_query.py +++ b/superset/migrations/versions/96e99fb176a0_add_import_mixing_to_saved_query.py @@ -32,9 +32,7 @@ from sqlalchemy_utils import UUIDType from superset import db -from superset.migrations.versions.b56500de1855_add_uuid_column_to_import_mixin import ( - add_uuids, -) +from superset.migrations.shared.utils import assign_uuids # revision identifiers, used by Alembic. revision = "96e99fb176a0" @@ -75,7 +73,7 @@ def upgrade(): # Ignore column update errors so that we can run upgrade multiple times pass - add_uuids(SavedQuery, "saved_query", session) + assign_uuids(SavedQuery, session) try: # Add uniqueness constraint diff --git a/superset/migrations/versions/9d8a8d575284_.py b/superset/migrations/versions/9d8a8d575284_.py index daa84a2ad0647..fbbfac231b0e8 100644 --- a/superset/migrations/versions/9d8a8d575284_.py +++ b/superset/migrations/versions/9d8a8d575284_.py @@ -14,7 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -"""empty message +"""merge point Revision ID: 9d8a8d575284 Revises: ('8b841273bec3', 'b0d0249074e4') diff --git a/superset/migrations/versions/a9422eeaae74_new_dataset_models_take_2.py b/superset/migrations/versions/a9422eeaae74_new_dataset_models_take_2.py new file mode 100644 index 0000000000000..efb7d1a01b0ee --- /dev/null +++ b/superset/migrations/versions/a9422eeaae74_new_dataset_models_take_2.py @@ -0,0 +1,905 @@ +# 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. +"""new_dataset_models_take_2 + +Revision ID: a9422eeaae74 +Revises: ad07e4fdbaba +Create Date: 2022-04-01 14:38:09.499483 + +""" + +# revision identifiers, used by Alembic. +revision = "a9422eeaae74" +down_revision = "ad07e4fdbaba" + +import json +import os +from datetime import datetime +from typing import List, Optional, Set, Type, Union +from uuid import uuid4 + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import select +from sqlalchemy.ext.declarative import declarative_base, declared_attr +from sqlalchemy.orm import backref, relationship, Session +from sqlalchemy.schema import UniqueConstraint +from sqlalchemy.sql import functions as func +from sqlalchemy.sql.expression import and_, or_ +from sqlalchemy_utils import UUIDType + +from superset import app, db +from superset.connectors.sqla.models import ADDITIVE_METRIC_TYPES_LOWER +from superset.connectors.sqla.utils import get_dialect_name, get_identifier_quoter +from superset.extensions import encrypted_field_factory +from superset.migrations.shared.utils import assign_uuids +from superset.sql_parse import extract_table_references, Table +from superset.utils.core import MediumText + +Base = declarative_base() +custom_password_store = app.config["SQLALCHEMY_CUSTOM_PASSWORD_STORE"] +DB_CONNECTION_MUTATOR = app.config["DB_CONNECTION_MUTATOR"] +SHOW_PROGRESS = os.environ.get("SHOW_PROGRESS") == "1" +UNKNOWN_TYPE = "UNKNOWN" + + +user_table = sa.Table( + "ab_user", Base.metadata, sa.Column("id", sa.Integer(), primary_key=True) +) + + +class UUIDMixin: + uuid = sa.Column( + UUIDType(binary=True), primary_key=False, unique=True, default=uuid4 + ) + + +class AuxiliaryColumnsMixin(UUIDMixin): + """ + Auxiliary columns, a combination of columns added by + AuditMixinNullable + ImportExportMixin + """ + + created_on = sa.Column(sa.DateTime, default=datetime.now, nullable=True) + changed_on = sa.Column( + sa.DateTime, default=datetime.now, onupdate=datetime.now, nullable=True + ) + + @declared_attr + def created_by_fk(cls): + return sa.Column(sa.Integer, sa.ForeignKey("ab_user.id"), nullable=True) + + @declared_attr + def changed_by_fk(cls): + return sa.Column(sa.Integer, sa.ForeignKey("ab_user.id"), nullable=True) + + +def insert_from_select( + target: Union[str, sa.Table, Type[Base]], source: sa.sql.expression.Select +) -> None: + """ + Execute INSERT FROM SELECT to copy data from a SELECT query to the target table. + """ + if isinstance(target, sa.Table): + target_table = target + elif hasattr(target, "__tablename__"): + target_table: sa.Table = Base.metadata.tables[target.__tablename__] + else: + target_table: sa.Table = Base.metadata.tables[target] + cols = [col.name for col in source.columns if col.name in target_table.columns] + query = target_table.insert().from_select(cols, source) + return op.execute(query) + + +class Database(Base): + + __tablename__ = "dbs" + __table_args__ = (UniqueConstraint("database_name"),) + + id = sa.Column(sa.Integer, primary_key=True) + database_name = sa.Column(sa.String(250), unique=True, nullable=False) + sqlalchemy_uri = sa.Column(sa.String(1024), nullable=False) + password = sa.Column(encrypted_field_factory.create(sa.String(1024))) + impersonate_user = sa.Column(sa.Boolean, default=False) + encrypted_extra = sa.Column(encrypted_field_factory.create(sa.Text), nullable=True) + extra = sa.Column(sa.Text) + server_cert = sa.Column(encrypted_field_factory.create(sa.Text), nullable=True) + + +class TableColumn(AuxiliaryColumnsMixin, Base): + + __tablename__ = "table_columns" + __table_args__ = (UniqueConstraint("table_id", "column_name"),) + + id = sa.Column(sa.Integer, primary_key=True) + table_id = sa.Column(sa.Integer, sa.ForeignKey("tables.id")) + is_active = sa.Column(sa.Boolean, default=True) + extra = sa.Column(sa.Text) + column_name = sa.Column(sa.String(255), nullable=False) + type = sa.Column(sa.String(32)) + expression = sa.Column(MediumText()) + description = sa.Column(MediumText()) + is_dttm = sa.Column(sa.Boolean, default=False) + filterable = sa.Column(sa.Boolean, default=True) + groupby = sa.Column(sa.Boolean, default=True) + verbose_name = sa.Column(sa.String(1024)) + python_date_format = sa.Column(sa.String(255)) + + +class SqlMetric(AuxiliaryColumnsMixin, Base): + + __tablename__ = "sql_metrics" + __table_args__ = (UniqueConstraint("table_id", "metric_name"),) + + id = sa.Column(sa.Integer, primary_key=True) + table_id = sa.Column(sa.Integer, sa.ForeignKey("tables.id")) + extra = sa.Column(sa.Text) + metric_type = sa.Column(sa.String(32)) + metric_name = sa.Column(sa.String(255), nullable=False) + expression = sa.Column(MediumText(), nullable=False) + warning_text = sa.Column(MediumText()) + description = sa.Column(MediumText()) + d3format = sa.Column(sa.String(128)) + verbose_name = sa.Column(sa.String(1024)) + + +sqlatable_user_table = sa.Table( + "sqlatable_user", + Base.metadata, + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("user_id", sa.Integer, sa.ForeignKey("ab_user.id")), + sa.Column("table_id", sa.Integer, sa.ForeignKey("tables.id")), +) + + +class SqlaTable(AuxiliaryColumnsMixin, Base): + + __tablename__ = "tables" + __table_args__ = (UniqueConstraint("database_id", "schema", "table_name"),) + + id = sa.Column(sa.Integer, primary_key=True) + extra = sa.Column(sa.Text) + database_id = sa.Column(sa.Integer, sa.ForeignKey("dbs.id"), nullable=False) + database: Database = relationship( + "Database", + backref=backref("tables", cascade="all, delete-orphan"), + foreign_keys=[database_id], + ) + schema = sa.Column(sa.String(255)) + table_name = sa.Column(sa.String(250), nullable=False) + sql = sa.Column(MediumText()) + is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) + external_url = sa.Column(sa.Text, nullable=True) + + +table_column_association_table = sa.Table( + "sl_table_columns", + Base.metadata, + sa.Column("table_id", sa.ForeignKey("sl_tables.id"), primary_key=True), + sa.Column("column_id", sa.ForeignKey("sl_columns.id"), primary_key=True), +) + +dataset_column_association_table = sa.Table( + "sl_dataset_columns", + Base.metadata, + sa.Column("dataset_id", sa.ForeignKey("sl_datasets.id"), primary_key=True), + sa.Column("column_id", sa.ForeignKey("sl_columns.id"), primary_key=True), +) + +dataset_table_association_table = sa.Table( + "sl_dataset_tables", + Base.metadata, + sa.Column("dataset_id", sa.ForeignKey("sl_datasets.id"), primary_key=True), + sa.Column("table_id", sa.ForeignKey("sl_tables.id"), primary_key=True), +) + +dataset_user_association_table = sa.Table( + "sl_dataset_users", + Base.metadata, + sa.Column("dataset_id", sa.ForeignKey("sl_datasets.id"), primary_key=True), + sa.Column("user_id", sa.ForeignKey("ab_user.id"), primary_key=True), +) + + +class NewColumn(AuxiliaryColumnsMixin, Base): + + __tablename__ = "sl_columns" + + id = sa.Column(sa.Integer, primary_key=True) + # A temporary column to link physical columns with tables so we don't + # have to insert a record in the relationship table while creating new columns. + table_id = sa.Column(sa.Integer, nullable=True) + + is_aggregation = sa.Column(sa.Boolean, nullable=False, default=False) + is_additive = sa.Column(sa.Boolean, nullable=False, default=False) + is_dimensional = sa.Column(sa.Boolean, nullable=False, default=False) + is_filterable = sa.Column(sa.Boolean, nullable=False, default=True) + is_increase_desired = sa.Column(sa.Boolean, nullable=False, default=True) + is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) + is_partition = sa.Column(sa.Boolean, nullable=False, default=False) + is_physical = sa.Column(sa.Boolean, nullable=False, default=False) + is_temporal = sa.Column(sa.Boolean, nullable=False, default=False) + is_spatial = sa.Column(sa.Boolean, nullable=False, default=False) + + name = sa.Column(sa.Text) + type = sa.Column(sa.Text) + unit = sa.Column(sa.Text) + expression = sa.Column(MediumText()) + description = sa.Column(MediumText()) + warning_text = sa.Column(MediumText()) + external_url = sa.Column(sa.Text, nullable=True) + extra_json = sa.Column(MediumText(), default="{}") + + +class NewTable(AuxiliaryColumnsMixin, Base): + + __tablename__ = "sl_tables" + + id = sa.Column(sa.Integer, primary_key=True) + # A temporary column to keep the link between NewTable to SqlaTable + sqlatable_id = sa.Column(sa.Integer, primary_key=False, nullable=True, unique=True) + database_id = sa.Column(sa.Integer, sa.ForeignKey("dbs.id"), nullable=False) + is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) + catalog = sa.Column(sa.Text) + schema = sa.Column(sa.Text) + name = sa.Column(sa.Text) + external_url = sa.Column(sa.Text, nullable=True) + extra_json = sa.Column(MediumText(), default="{}") + database: Database = relationship( + "Database", + backref=backref("new_tables", cascade="all, delete-orphan"), + foreign_keys=[database_id], + ) + + +class NewDataset(Base, AuxiliaryColumnsMixin): + + __tablename__ = "sl_datasets" + + id = sa.Column(sa.Integer, primary_key=True) + database_id = sa.Column(sa.Integer, sa.ForeignKey("dbs.id"), nullable=False) + is_physical = sa.Column(sa.Boolean, default=False) + is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) + name = sa.Column(sa.Text) + expression = sa.Column(MediumText()) + external_url = sa.Column(sa.Text, nullable=True) + extra_json = sa.Column(MediumText(), default="{}") + + +def find_tables( + session: Session, + database_id: int, + default_schema: Optional[str], + tables: Set[Table], +) -> List[int]: + """ + Look for NewTable's of from a specific database + """ + if not tables: + return [] + + predicate = or_( + *[ + and_( + NewTable.database_id == database_id, + NewTable.schema == (table.schema or default_schema), + NewTable.name == table.table, + ) + for table in tables + ] + ) + return session.query(NewTable.id).filter(predicate).all() + + +# helper SQLA elements for easier querying +is_physical_table = or_(SqlaTable.sql.is_(None), SqlaTable.sql == "") +is_physical_column = or_(TableColumn.expression.is_(None), TableColumn.expression == "") + +# filtering out table columns with valid associated SqlTable +active_table_columns = sa.join( + TableColumn, + SqlaTable, + TableColumn.table_id == SqlaTable.id, +) +active_metrics = sa.join(SqlMetric, SqlaTable, SqlMetric.table_id == SqlaTable.id) + + +def copy_tables(session: Session) -> None: + """Copy Physical tables""" + count = session.query(SqlaTable).filter(is_physical_table).count() + if not count: + return + print(f">> Copy {count:,} physical tables to sl_tables...") + insert_from_select( + NewTable, + select( + [ + # Tables need different uuid than datasets, since they are different + # entities. When INSERT FROM SELECT, we must provide a value for `uuid`, + # otherwise it'd use the default generated on Python side, which + # will cause duplicate values. They will be replaced by `assign_uuids` later. + SqlaTable.uuid, + SqlaTable.id.label("sqlatable_id"), + SqlaTable.created_on, + SqlaTable.changed_on, + SqlaTable.created_by_fk, + SqlaTable.changed_by_fk, + SqlaTable.table_name.label("name"), + SqlaTable.schema, + SqlaTable.database_id, + SqlaTable.is_managed_externally, + SqlaTable.external_url, + ] + ) + # use an inner join to filter out only tables with valid database ids + .select_from( + sa.join(SqlaTable, Database, SqlaTable.database_id == Database.id) + ).where(is_physical_table), + ) + + +def copy_datasets(session: Session) -> None: + """Copy all datasets""" + count = session.query(SqlaTable).count() + if not count: + return + print(f">> Copy {count:,} SqlaTable to sl_datasets...") + insert_from_select( + NewDataset, + select( + [ + SqlaTable.uuid, + SqlaTable.created_on, + SqlaTable.changed_on, + SqlaTable.created_by_fk, + SqlaTable.changed_by_fk, + SqlaTable.database_id, + SqlaTable.table_name.label("name"), + func.coalesce(SqlaTable.sql, SqlaTable.table_name).label("expression"), + is_physical_table.label("is_physical"), + SqlaTable.is_managed_externally, + SqlaTable.external_url, + SqlaTable.extra.label("extra_json"), + ] + ), + ) + + print(" Copy dataset owners...") + insert_from_select( + dataset_user_association_table, + select( + [NewDataset.id.label("dataset_id"), sqlatable_user_table.c.user_id] + ).select_from( + sqlatable_user_table.join( + SqlaTable, SqlaTable.id == sqlatable_user_table.c.table_id + ).join(NewDataset, NewDataset.uuid == SqlaTable.uuid) + ), + ) + + print(" Link physical datasets with tables...") + insert_from_select( + dataset_table_association_table, + select( + [ + NewDataset.id.label("dataset_id"), + NewTable.id.label("table_id"), + ] + ).select_from( + sa.join(SqlaTable, NewTable, NewTable.sqlatable_id == SqlaTable.id).join( + NewDataset, NewDataset.uuid == SqlaTable.uuid + ) + ), + ) + + +def copy_columns(session: Session) -> None: + """Copy columns with active associated SqlTable""" + count = session.query(TableColumn).select_from(active_table_columns).count() + if not count: + return + print(f">> Copy {count:,} table columns to sl_columns...") + insert_from_select( + NewColumn, + select( + [ + TableColumn.uuid, + TableColumn.created_on, + TableColumn.changed_on, + TableColumn.created_by_fk, + TableColumn.changed_by_fk, + TableColumn.groupby.label("is_dimensional"), + TableColumn.filterable.label("is_filterable"), + TableColumn.column_name.label("name"), + TableColumn.description, + func.coalesce(TableColumn.expression, TableColumn.column_name).label( + "expression" + ), + sa.literal(False).label("is_aggregation"), + is_physical_column.label("is_physical"), + TableColumn.is_dttm.label("is_temporal"), + func.coalesce(TableColumn.type, UNKNOWN_TYPE).label("type"), + TableColumn.extra.label("extra_json"), + ] + ).select_from(active_table_columns), + ) + + joined_columns_table = active_table_columns.join( + NewColumn, TableColumn.uuid == NewColumn.uuid + ) + print(" Link all columns to sl_datasets...") + insert_from_select( + dataset_column_association_table, + select( + [ + NewDataset.id.label("dataset_id"), + NewColumn.id.label("column_id"), + ], + ).select_from( + joined_columns_table.join(NewDataset, NewDataset.uuid == SqlaTable.uuid) + ), + ) + + +def copy_metrics(session: Session) -> None: + """Copy metrics as virtual columns""" + metrics_count = session.query(SqlMetric).select_from(active_metrics).count() + if not metrics_count: + return + + print(f">> Copy {metrics_count:,} metrics to sl_columns...") + insert_from_select( + NewColumn, + select( + [ + SqlMetric.uuid, + SqlMetric.created_on, + SqlMetric.changed_on, + SqlMetric.created_by_fk, + SqlMetric.changed_by_fk, + SqlMetric.metric_name.label("name"), + SqlMetric.expression, + SqlMetric.description, + sa.literal(UNKNOWN_TYPE).label("type"), + ( + func.coalesce( + sa.func.lower(SqlMetric.metric_type).in_( + ADDITIVE_METRIC_TYPES_LOWER + ), + sa.literal(False), + ).label("is_additive") + ), + sa.literal(True).label("is_aggregation"), + # metrics are by default not filterable + sa.literal(False).label("is_filterable"), + sa.literal(False).label("is_dimensional"), + sa.literal(False).label("is_physical"), + sa.literal(False).label("is_temporal"), + SqlMetric.extra.label("extra_json"), + SqlMetric.warning_text, + ] + ).select_from(active_metrics), + ) + + print(" Link metric columns to datasets...") + insert_from_select( + dataset_column_association_table, + select( + [ + NewDataset.id.label("dataset_id"), + NewColumn.id.label("column_id"), + ], + ).select_from( + active_metrics.join(NewDataset, NewDataset.uuid == SqlaTable.uuid).join( + NewColumn, NewColumn.uuid == SqlMetric.uuid + ) + ), + ) + + +def postprocess_datasets(session: Session) -> None: + """ + Postprocess datasets after insertion to + - Quote table names for physical datasets (if needed) + - Link referenced tables to virtual datasets + """ + total = session.query(SqlaTable).count() + if not total: + return + + offset = 0 + limit = 10000 + + joined_tables = sa.join( + NewDataset, + SqlaTable, + NewDataset.uuid == SqlaTable.uuid, + ).join( + Database, + Database.id == SqlaTable.database_id, + isouter=True, + ) + assert session.query(func.count()).select_from(joined_tables).scalar() == total + + print(f">> Run postprocessing on {total} datasets") + + update_count = 0 + + def print_update_count(): + if SHOW_PROGRESS: + print( + f" Will update {update_count} datasets" + " " * 20, + end="\r", + ) + + while offset < total: + print( + f" Process dataset {offset + 1}~{min(total, offset + limit)}..." + + " " * 30 + ) + for ( + database_id, + dataset_id, + expression, + extra, + is_physical, + schema, + sqlalchemy_uri, + ) in session.execute( + select( + [ + NewDataset.database_id, + NewDataset.id.label("dataset_id"), + NewDataset.expression, + SqlaTable.extra, + NewDataset.is_physical, + SqlaTable.schema, + Database.sqlalchemy_uri, + ] + ) + .select_from(joined_tables) + .offset(offset) + .limit(limit) + ): + drivername = (sqlalchemy_uri or "").split("://")[0] + updates = {} + updated = False + if is_physical and drivername: + quoted_expression = get_identifier_quoter(drivername)(expression) + if quoted_expression != expression: + updates["expression"] = quoted_expression + + # add schema name to `dataset.extra_json` so we don't have to join + # tables in order to use datasets + if schema: + try: + extra_json = json.loads(extra) if extra else {} + except json.decoder.JSONDecodeError: + extra_json = {} + extra_json["schema"] = schema + updates["extra_json"] = json.dumps(extra_json) + + if updates: + session.execute( + sa.update(NewDataset) + .where(NewDataset.id == dataset_id) + .values(**updates) + ) + updated = True + + if not is_physical and expression: + table_refrences = extract_table_references( + expression, get_dialect_name(drivername), show_warning=False + ) + found_tables = find_tables( + session, database_id, schema, table_refrences + ) + if found_tables: + op.bulk_insert( + dataset_table_association_table, + [ + {"dataset_id": dataset_id, "table_id": table.id} + for table in found_tables + ], + ) + updated = True + + if updated: + update_count += 1 + print_update_count() + + session.flush() + offset += limit + + if SHOW_PROGRESS: + print("") + + +def postprocess_columns(session: Session) -> None: + """ + At this step, we will + - Add engine specific quotes to `expression` of physical columns + - Tuck some extra metadata to `extra_json` + """ + total = session.query(NewColumn).count() + if not total: + return + + def get_joined_tables(offset, limit): + return ( + sa.join( + session.query(NewColumn) + .offset(offset) + .limit(limit) + .subquery("sl_columns"), + dataset_column_association_table, + dataset_column_association_table.c.column_id == NewColumn.id, + ) + .join( + NewDataset, + NewDataset.id == dataset_column_association_table.c.dataset_id, + ) + .join( + dataset_table_association_table, + # Join tables with physical datasets + and_( + NewDataset.is_physical, + dataset_table_association_table.c.dataset_id == NewDataset.id, + ), + isouter=True, + ) + .join(Database, Database.id == NewDataset.database_id) + .join( + TableColumn, + TableColumn.uuid == NewColumn.uuid, + isouter=True, + ) + .join( + SqlMetric, + SqlMetric.uuid == NewColumn.uuid, + isouter=True, + ) + ) + + offset = 0 + limit = 100000 + + print(f">> Run postprocessing on {total:,} columns") + + update_count = 0 + + def print_update_count(): + if SHOW_PROGRESS: + print( + f" Will update {update_count} columns" + " " * 20, + end="\r", + ) + + while offset < total: + query = ( + select( + # sorted alphabetically + [ + NewColumn.id.label("column_id"), + TableColumn.column_name, + NewColumn.changed_by_fk, + NewColumn.changed_on, + NewColumn.created_on, + NewColumn.description, + SqlMetric.d3format, + NewDataset.external_url, + NewColumn.extra_json, + NewColumn.is_dimensional, + NewColumn.is_filterable, + NewDataset.is_managed_externally, + NewColumn.is_physical, + SqlMetric.metric_type, + TableColumn.python_date_format, + Database.sqlalchemy_uri, + dataset_table_association_table.c.table_id, + func.coalesce( + TableColumn.verbose_name, SqlMetric.verbose_name + ).label("verbose_name"), + NewColumn.warning_text, + ] + ) + .select_from(get_joined_tables(offset, limit)) + .where( + # pre-filter to columns with potential updates + or_( + NewColumn.is_physical, + TableColumn.verbose_name.isnot(None), + TableColumn.verbose_name.isnot(None), + SqlMetric.verbose_name.isnot(None), + SqlMetric.d3format.isnot(None), + SqlMetric.metric_type.isnot(None), + ) + ) + ) + + start = offset + 1 + end = min(total, offset + limit) + count = session.query(func.count()).select_from(query).scalar() + print(f" [Column {start:,} to {end:,}] {count:,} may be updated") + + physical_columns = [] + + for ( + # sorted alphabetically + column_id, + column_name, + changed_by_fk, + changed_on, + created_on, + description, + d3format, + external_url, + extra_json, + is_dimensional, + is_filterable, + is_managed_externally, + is_physical, + metric_type, + python_date_format, + sqlalchemy_uri, + table_id, + verbose_name, + warning_text, + ) in session.execute(query): + try: + extra = json.loads(extra_json) if extra_json else {} + except json.decoder.JSONDecodeError: + extra = {} + updated_extra = {**extra} + updates = {} + + if is_managed_externally: + updates["is_managed_externally"] = True + if external_url: + updates["external_url"] = external_url + + # update extra json + for (key, val) in ( + { + "verbose_name": verbose_name, + "python_date_format": python_date_format, + "d3format": d3format, + "metric_type": metric_type, + } + ).items(): + # save the original val, including if it's `false` + if val is not None: + updated_extra[key] = val + + if updated_extra != extra: + updates["extra_json"] = json.dumps(updated_extra) + + # update expression for physical table columns + if is_physical: + if column_name and sqlalchemy_uri: + drivername = sqlalchemy_uri.split("://")[0] + if is_physical and drivername: + quoted_expression = get_identifier_quoter(drivername)( + column_name + ) + if quoted_expression != column_name: + updates["expression"] = quoted_expression + # duplicate physical columns for tables + physical_columns.append( + dict( + created_on=created_on, + changed_on=changed_on, + changed_by_fk=changed_by_fk, + description=description, + expression=updates.get("expression", column_name), + external_url=external_url, + extra_json=updates.get("extra_json", extra_json), + is_aggregation=False, + is_dimensional=is_dimensional, + is_filterable=is_filterable, + is_managed_externally=is_managed_externally, + is_physical=True, + name=column_name, + table_id=table_id, + warning_text=warning_text, + ) + ) + + if updates: + session.execute( + sa.update(NewColumn) + .where(NewColumn.id == column_id) + .values(**updates) + ) + update_count += 1 + print_update_count() + + if physical_columns: + op.bulk_insert(NewColumn.__table__, physical_columns) + + session.flush() + offset += limit + + if SHOW_PROGRESS: + print("") + + print(" Assign table column relations...") + insert_from_select( + table_column_association_table, + select([NewColumn.table_id, NewColumn.id.label("column_id")]) + .select_from(NewColumn) + .where(and_(NewColumn.is_physical, NewColumn.table_id.isnot(None))), + ) + + +new_tables: sa.Table = [ + NewTable.__table__, + NewDataset.__table__, + NewColumn.__table__, + table_column_association_table, + dataset_column_association_table, + dataset_table_association_table, + dataset_user_association_table, +] + + +def reset_postgres_id_sequence(table: str) -> None: + op.execute( + f""" + SELECT setval( + pg_get_serial_sequence('{table}', 'id'), + COALESCE(max(id) + 1, 1), + false + ) + FROM {table}; + """ + ) + + +def upgrade() -> None: + bind = op.get_bind() + session: Session = db.Session(bind=bind) + Base.metadata.drop_all(bind=bind, tables=new_tables) + Base.metadata.create_all(bind=bind, tables=new_tables) + + copy_tables(session) + copy_datasets(session) + copy_columns(session) + copy_metrics(session) + session.commit() + + postprocess_columns(session) + session.commit() + + postprocess_datasets(session) + session.commit() + + # Table were created with the same uuids are datasets. They should + # have different uuids as they are different entities. + print(">> Assign new UUIDs to tables...") + assign_uuids(NewTable, session) + + print(">> Drop intermediate columns...") + # These columns are are used during migration, as datasets are independent of tables once created, + # dataset columns also the same to table columns. + with op.batch_alter_table(NewTable.__tablename__) as batch_op: + batch_op.drop_column("sqlatable_id") + with op.batch_alter_table(NewColumn.__tablename__) as batch_op: + batch_op.drop_column("table_id") + + +def downgrade(): + Base.metadata.drop_all(bind=op.get_bind(), tables=new_tables) diff --git a/superset/migrations/versions/b56500de1855_add_uuid_column_to_import_mixin.py b/superset/migrations/versions/b56500de1855_add_uuid_column_to_import_mixin.py index 747ec9fb4f77f..0872cf5b3bb5d 100644 --- a/superset/migrations/versions/b56500de1855_add_uuid_column_to_import_mixin.py +++ b/superset/migrations/versions/b56500de1855_add_uuid_column_to_import_mixin.py @@ -23,19 +23,17 @@ """ import json import os -import time from json.decoder import JSONDecodeError from uuid import uuid4 import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects.mysql.base import MySQLDialect -from sqlalchemy.dialects.postgresql.base import PGDialect from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import load_only from sqlalchemy_utils import UUIDType from superset import db +from superset.migrations.shared.utils import assign_uuids from superset.utils import core as utils # revision identifiers, used by Alembic. @@ -78,47 +76,6 @@ class ImportMixin: default_batch_size = int(os.environ.get("BATCH_SIZE", 200)) -# Add uuids directly using built-in SQL uuid function -add_uuids_by_dialect = { - MySQLDialect: """UPDATE %s SET uuid = UNHEX(REPLACE(CONVERT(UUID() using utf8mb4), '-', ''));""", - PGDialect: """UPDATE %s SET uuid = uuid_in(md5(random()::text || clock_timestamp()::text)::cstring);""", -} - - -def add_uuids(model, table_name, session, batch_size=default_batch_size): - """Populate columns with pre-computed uuids""" - bind = op.get_bind() - objects_query = session.query(model) - count = objects_query.count() - - # silently skip if the table is empty (suitable for db initialization) - if count == 0: - return - - print(f"\nAdding uuids for `{table_name}`...") - start_time = time.time() - - # Use dialect specific native SQL queries if possible - for dialect, sql in add_uuids_by_dialect.items(): - if isinstance(bind.dialect, dialect): - op.execute(sql % table_name) - print(f"Done. Assigned {count} uuids in {time.time() - start_time:.3f}s.") - return - - # Othwewise Use Python uuid function - start = 0 - while start < count: - end = min(start + batch_size, count) - for obj, uuid in map(lambda obj: (obj, uuid4()), objects_query[start:end]): - obj.uuid = uuid - session.merge(obj) - session.commit() - if start + batch_size < count: - print(f" uuid assigned to {end} out of {count}\r", end="") - start += batch_size - - print(f"Done. Assigned {count} uuids in {time.time() - start_time:.3f}s.") - def update_position_json(dashboard, session, uuid_map): try: @@ -178,7 +135,7 @@ def upgrade(): ), ) - add_uuids(model, table_name, session) + assign_uuids(model, session) # add uniqueness constraint with op.batch_alter_table(table_name) as batch_op: @@ -203,7 +160,7 @@ def downgrade(): update_dashboards(session, {}) # remove uuid column - for table_name, model in models.items(): + for table_name in models: with op.batch_alter_table(table_name) as batch_op: batch_op.drop_constraint(f"uq_{table_name}_uuid", type_="unique") batch_op.drop_column("uuid") diff --git a/superset/migrations/versions/b8d3a24d9131_new_dataset_models.py b/superset/migrations/versions/b8d3a24d9131_new_dataset_models.py index 8728e9adb7b8d..e69d1606e3e71 100644 --- a/superset/migrations/versions/b8d3a24d9131_new_dataset_models.py +++ b/superset/migrations/versions/b8d3a24d9131_new_dataset_models.py @@ -23,619 +23,23 @@ Create Date: 2021-11-11 16:41:53.266965 """ - -import json -from datetime import date, datetime, time, timedelta -from typing import Callable, List, Optional, Set -from uuid import uuid4 - -import sqlalchemy as sa -from alembic import op -from sqlalchemy import and_, inspect, or_ -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import backref, relationship, Session -from sqlalchemy.schema import UniqueConstraint -from sqlalchemy.sql.type_api import TypeEngine -from sqlalchemy_utils import UUIDType - -from superset import app, db -from superset.connectors.sqla.models import ADDITIVE_METRIC_TYPES -from superset.databases.utils import make_url_safe -from superset.extensions import encrypted_field_factory -from superset.migrations.shared.utils import extract_table_references -from superset.models.core import Database as OriginalDatabase -from superset.sql_parse import Table - # revision identifiers, used by Alembic. revision = "b8d3a24d9131" down_revision = "5afbb1a5849b" -Base = declarative_base() -custom_password_store = app.config["SQLALCHEMY_CUSTOM_PASSWORD_STORE"] -DB_CONNECTION_MUTATOR = app.config["DB_CONNECTION_MUTATOR"] - - -class Database(Base): - - __tablename__ = "dbs" - __table_args__ = (UniqueConstraint("database_name"),) - - id = sa.Column(sa.Integer, primary_key=True) - database_name = sa.Column(sa.String(250), unique=True, nullable=False) - sqlalchemy_uri = sa.Column(sa.String(1024), nullable=False) - password = sa.Column(encrypted_field_factory.create(sa.String(1024))) - impersonate_user = sa.Column(sa.Boolean, default=False) - encrypted_extra = sa.Column(encrypted_field_factory.create(sa.Text), nullable=True) - extra = sa.Column( - sa.Text, - default=json.dumps( - dict( - metadata_params={}, - engine_params={}, - metadata_cache_timeout={}, - schemas_allowed_for_file_upload=[], - ) - ), - ) - server_cert = sa.Column(encrypted_field_factory.create(sa.Text), nullable=True) - - -class TableColumn(Base): - - __tablename__ = "table_columns" - __table_args__ = (UniqueConstraint("table_id", "column_name"),) - - id = sa.Column(sa.Integer, primary_key=True) - table_id = sa.Column(sa.Integer, sa.ForeignKey("tables.id")) - is_active = sa.Column(sa.Boolean, default=True) - extra = sa.Column(sa.Text) - column_name = sa.Column(sa.String(255), nullable=False) - type = sa.Column(sa.String(32)) - expression = sa.Column(sa.Text) - description = sa.Column(sa.Text) - is_dttm = sa.Column(sa.Boolean, default=False) - filterable = sa.Column(sa.Boolean, default=True) - groupby = sa.Column(sa.Boolean, default=True) - verbose_name = sa.Column(sa.String(1024)) - python_date_format = sa.Column(sa.String(255)) - - -class SqlMetric(Base): - - __tablename__ = "sql_metrics" - __table_args__ = (UniqueConstraint("table_id", "metric_name"),) - - id = sa.Column(sa.Integer, primary_key=True) - table_id = sa.Column(sa.Integer, sa.ForeignKey("tables.id")) - extra = sa.Column(sa.Text) - metric_type = sa.Column(sa.String(32)) - metric_name = sa.Column(sa.String(255), nullable=False) - expression = sa.Column(sa.Text, nullable=False) - warning_text = sa.Column(sa.Text) - description = sa.Column(sa.Text) - d3format = sa.Column(sa.String(128)) - verbose_name = sa.Column(sa.String(1024)) - - -class SqlaTable(Base): - - __tablename__ = "tables" - __table_args__ = (UniqueConstraint("database_id", "schema", "table_name"),) - - def fetch_columns_and_metrics(self, session: Session) -> None: - self.columns = session.query(TableColumn).filter( - TableColumn.table_id == self.id - ) - self.metrics = session.query(SqlMetric).filter(TableColumn.table_id == self.id) - - id = sa.Column(sa.Integer, primary_key=True) - columns: List[TableColumn] = [] - column_class = TableColumn - metrics: List[SqlMetric] = [] - metric_class = SqlMetric - - database_id = sa.Column(sa.Integer, sa.ForeignKey("dbs.id"), nullable=False) - database: Database = relationship( - "Database", - backref=backref("tables", cascade="all, delete-orphan"), - foreign_keys=[database_id], - ) - schema = sa.Column(sa.String(255)) - table_name = sa.Column(sa.String(250), nullable=False) - sql = sa.Column(sa.Text) - is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) - external_url = sa.Column(sa.Text, nullable=True) - - -table_column_association_table = sa.Table( - "sl_table_columns", - Base.metadata, - sa.Column("table_id", sa.ForeignKey("sl_tables.id")), - sa.Column("column_id", sa.ForeignKey("sl_columns.id")), -) - -dataset_column_association_table = sa.Table( - "sl_dataset_columns", - Base.metadata, - sa.Column("dataset_id", sa.ForeignKey("sl_datasets.id")), - sa.Column("column_id", sa.ForeignKey("sl_columns.id")), -) - -dataset_table_association_table = sa.Table( - "sl_dataset_tables", - Base.metadata, - sa.Column("dataset_id", sa.ForeignKey("sl_datasets.id")), - sa.Column("table_id", sa.ForeignKey("sl_tables.id")), -) - - -class NewColumn(Base): - - __tablename__ = "sl_columns" - - id = sa.Column(sa.Integer, primary_key=True) - name = sa.Column(sa.Text) - type = sa.Column(sa.Text) - expression = sa.Column(sa.Text) - is_physical = sa.Column(sa.Boolean, default=True) - description = sa.Column(sa.Text) - warning_text = sa.Column(sa.Text) - is_temporal = sa.Column(sa.Boolean, default=False) - is_aggregation = sa.Column(sa.Boolean, default=False) - is_additive = sa.Column(sa.Boolean, default=False) - is_spatial = sa.Column(sa.Boolean, default=False) - is_partition = sa.Column(sa.Boolean, default=False) - is_increase_desired = sa.Column(sa.Boolean, default=True) - is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) - external_url = sa.Column(sa.Text, nullable=True) - extra_json = sa.Column(sa.Text, default="{}") - - -class NewTable(Base): - - __tablename__ = "sl_tables" - __table_args__ = (UniqueConstraint("database_id", "catalog", "schema", "name"),) - - id = sa.Column(sa.Integer, primary_key=True) - name = sa.Column(sa.Text) - schema = sa.Column(sa.Text) - catalog = sa.Column(sa.Text) - database_id = sa.Column(sa.Integer, sa.ForeignKey("dbs.id"), nullable=False) - database: Database = relationship( - "Database", - backref=backref("new_tables", cascade="all, delete-orphan"), - foreign_keys=[database_id], - ) - columns: List[NewColumn] = relationship( - "NewColumn", secondary=table_column_association_table, cascade="all, delete" - ) - is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) - external_url = sa.Column(sa.Text, nullable=True) - - -class NewDataset(Base): - __tablename__ = "sl_datasets" - - id = sa.Column(sa.Integer, primary_key=True) - sqlatable_id = sa.Column(sa.Integer, nullable=True, unique=True) - name = sa.Column(sa.Text) - expression = sa.Column(sa.Text) - tables: List[NewTable] = relationship( - "NewTable", secondary=dataset_table_association_table - ) - columns: List[NewColumn] = relationship( - "NewColumn", secondary=dataset_column_association_table, cascade="all, delete" - ) - is_physical = sa.Column(sa.Boolean, default=False) - is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) - external_url = sa.Column(sa.Text, nullable=True) - - -TEMPORAL_TYPES = {date, datetime, time, timedelta} - - -def is_column_type_temporal(column_type: TypeEngine) -> bool: - try: - return column_type.python_type in TEMPORAL_TYPES - except NotImplementedError: - return False - - -def load_or_create_tables( - session: Session, - database_id: int, - default_schema: Optional[str], - tables: Set[Table], - conditional_quote: Callable[[str], str], -) -> List[NewTable]: - """ - Load or create new table model instances. - """ - if not tables: - return [] - - # set the default schema in tables that don't have it - if default_schema: - tables = list(tables) - for i, table in enumerate(tables): - if table.schema is None: - tables[i] = Table(table.table, default_schema, table.catalog) - - # load existing tables - predicate = or_( - *[ - and_( - NewTable.database_id == database_id, - NewTable.schema == table.schema, - NewTable.name == table.table, - ) - for table in tables - ] - ) - new_tables = session.query(NewTable).filter(predicate).all() - - # use original database model to get the engine - engine = ( - session.query(OriginalDatabase) - .filter_by(id=database_id) - .one() - .get_sqla_engine(default_schema) - ) - inspector = inspect(engine) - - # add missing tables - existing = {(table.schema, table.name) for table in new_tables} - for table in tables: - if (table.schema, table.table) not in existing: - column_metadata = inspector.get_columns(table.table, schema=table.schema) - columns = [ - NewColumn( - name=column["name"], - type=str(column["type"]), - expression=conditional_quote(column["name"]), - is_temporal=is_column_type_temporal(column["type"]), - is_aggregation=False, - is_physical=True, - is_spatial=False, - is_partition=False, - is_increase_desired=True, - ) - for column in column_metadata - ] - new_tables.append( - NewTable( - name=table.table, - schema=table.schema, - catalog=None, - database_id=database_id, - columns=columns, - ) - ) - existing.add((table.schema, table.table)) - - return new_tables - - -def after_insert(target: SqlaTable) -> None: # pylint: disable=too-many-locals - """ - Copy old datasets to the new models. - """ - session = inspect(target).session - - # get DB-specific conditional quoter for expressions that point to columns or - # table names - database = ( - target.database - or session.query(Database).filter_by(id=target.database_id).first() - ) - if not database: - return - url = make_url_safe(database.sqlalchemy_uri) - dialect_class = url.get_dialect() - conditional_quote = dialect_class().identifier_preparer.quote - - # create columns - columns = [] - for column in target.columns: - # ``is_active`` might be ``None`` at this point, but it defaults to ``True``. - if column.is_active is False: - continue - - try: - extra_json = json.loads(column.extra or "{}") - except json.decoder.JSONDecodeError: - extra_json = {} - for attr in {"groupby", "filterable", "verbose_name", "python_date_format"}: - value = getattr(column, attr) - if value: - extra_json[attr] = value - - columns.append( - NewColumn( - name=column.column_name, - type=column.type or "Unknown", - expression=column.expression or conditional_quote(column.column_name), - description=column.description, - is_temporal=column.is_dttm, - is_aggregation=False, - is_physical=column.expression is None or column.expression == "", - is_spatial=False, - is_partition=False, - is_increase_desired=True, - extra_json=json.dumps(extra_json) if extra_json else None, - is_managed_externally=target.is_managed_externally, - external_url=target.external_url, - ), - ) - - # create metrics - for metric in target.metrics: - try: - extra_json = json.loads(metric.extra or "{}") - except json.decoder.JSONDecodeError: - extra_json = {} - for attr in {"verbose_name", "metric_type", "d3format"}: - value = getattr(metric, attr) - if value: - extra_json[attr] = value - - is_additive = ( - metric.metric_type and metric.metric_type.lower() in ADDITIVE_METRIC_TYPES - ) - - columns.append( - NewColumn( - name=metric.metric_name, - type="Unknown", # figuring this out would require a type inferrer - expression=metric.expression, - warning_text=metric.warning_text, - description=metric.description, - is_aggregation=True, - is_additive=is_additive, - is_physical=False, - is_spatial=False, - is_partition=False, - is_increase_desired=True, - extra_json=json.dumps(extra_json) if extra_json else None, - is_managed_externally=target.is_managed_externally, - external_url=target.external_url, - ), - ) - - # physical dataset - if not target.sql: - physical_columns = [column for column in columns if column.is_physical] - - # create table - table = NewTable( - name=target.table_name, - schema=target.schema, - catalog=None, # currently not supported - database_id=target.database_id, - columns=physical_columns, - is_managed_externally=target.is_managed_externally, - external_url=target.external_url, - ) - tables = [table] - - # virtual dataset - else: - # mark all columns as virtual (not physical) - for column in columns: - column.is_physical = False - - # find referenced tables - referenced_tables = extract_table_references(target.sql, dialect_class.name) - tables = load_or_create_tables( - session, - target.database_id, - target.schema, - referenced_tables, - conditional_quote, - ) - - # create the new dataset - dataset = NewDataset( - sqlatable_id=target.id, - name=target.table_name, - expression=target.sql or conditional_quote(target.table_name), - tables=tables, - columns=columns, - is_physical=not target.sql, - is_managed_externally=target.is_managed_externally, - external_url=target.external_url, - ) - session.add(dataset) - - -def upgrade(): - # Create tables for the new models. - op.create_table( - "sl_columns", - # AuditMixinNullable - sa.Column("created_on", sa.DateTime(), nullable=True), - sa.Column("changed_on", sa.DateTime(), nullable=True), - sa.Column("created_by_fk", sa.Integer(), nullable=True), - sa.Column("changed_by_fk", sa.Integer(), nullable=True), - # ExtraJSONMixin - sa.Column("extra_json", sa.Text(), nullable=True), - # ImportExportMixin - sa.Column("uuid", UUIDType(binary=True), primary_key=False, default=uuid4), - # Column - sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), - sa.Column("name", sa.TEXT(), nullable=False), - sa.Column("type", sa.TEXT(), nullable=False), - sa.Column("expression", sa.TEXT(), nullable=False), - sa.Column( - "is_physical", - sa.BOOLEAN(), - nullable=False, - default=True, - ), - sa.Column("description", sa.TEXT(), nullable=True), - sa.Column("warning_text", sa.TEXT(), nullable=True), - sa.Column("unit", sa.TEXT(), nullable=True), - sa.Column("is_temporal", sa.BOOLEAN(), nullable=False), - sa.Column( - "is_spatial", - sa.BOOLEAN(), - nullable=False, - default=False, - ), - sa.Column( - "is_partition", - sa.BOOLEAN(), - nullable=False, - default=False, - ), - sa.Column( - "is_aggregation", - sa.BOOLEAN(), - nullable=False, - default=False, - ), - sa.Column( - "is_additive", - sa.BOOLEAN(), - nullable=False, - default=False, - ), - sa.Column( - "is_increase_desired", - sa.BOOLEAN(), - nullable=False, - default=True, - ), - sa.Column( - "is_managed_externally", - sa.Boolean(), - nullable=False, - server_default=sa.false(), - ), - sa.Column("external_url", sa.Text(), nullable=True), - sa.PrimaryKeyConstraint("id"), - ) - with op.batch_alter_table("sl_columns") as batch_op: - batch_op.create_unique_constraint("uq_sl_columns_uuid", ["uuid"]) - - op.create_table( - "sl_tables", - # AuditMixinNullable - sa.Column("created_on", sa.DateTime(), nullable=True), - sa.Column("changed_on", sa.DateTime(), nullable=True), - sa.Column("created_by_fk", sa.Integer(), nullable=True), - sa.Column("changed_by_fk", sa.Integer(), nullable=True), - # ExtraJSONMixin - sa.Column("extra_json", sa.Text(), nullable=True), - # ImportExportMixin - sa.Column("uuid", UUIDType(binary=True), primary_key=False, default=uuid4), - # Table - sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), - sa.Column("database_id", sa.INTEGER(), autoincrement=False, nullable=False), - sa.Column("catalog", sa.TEXT(), nullable=True), - sa.Column("schema", sa.TEXT(), nullable=True), - sa.Column("name", sa.TEXT(), nullable=False), - sa.Column( - "is_managed_externally", - sa.Boolean(), - nullable=False, - server_default=sa.false(), - ), - sa.Column("external_url", sa.Text(), nullable=True), - sa.ForeignKeyConstraint(["database_id"], ["dbs.id"], name="sl_tables_ibfk_1"), - sa.PrimaryKeyConstraint("id"), - ) - with op.batch_alter_table("sl_tables") as batch_op: - batch_op.create_unique_constraint("uq_sl_tables_uuid", ["uuid"]) - - op.create_table( - "sl_table_columns", - sa.Column("table_id", sa.INTEGER(), autoincrement=False, nullable=False), - sa.Column("column_id", sa.INTEGER(), autoincrement=False, nullable=False), - sa.ForeignKeyConstraint( - ["column_id"], ["sl_columns.id"], name="sl_table_columns_ibfk_2" - ), - sa.ForeignKeyConstraint( - ["table_id"], ["sl_tables.id"], name="sl_table_columns_ibfk_1" - ), - ) - - op.create_table( - "sl_datasets", - # AuditMixinNullable - sa.Column("created_on", sa.DateTime(), nullable=True), - sa.Column("changed_on", sa.DateTime(), nullable=True), - sa.Column("created_by_fk", sa.Integer(), nullable=True), - sa.Column("changed_by_fk", sa.Integer(), nullable=True), - # ExtraJSONMixin - sa.Column("extra_json", sa.Text(), nullable=True), - # ImportExportMixin - sa.Column("uuid", UUIDType(binary=True), primary_key=False, default=uuid4), - # Dataset - sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), - sa.Column("sqlatable_id", sa.INTEGER(), nullable=True), - sa.Column("name", sa.TEXT(), nullable=False), - sa.Column("expression", sa.TEXT(), nullable=False), - sa.Column( - "is_physical", - sa.BOOLEAN(), - nullable=False, - default=False, - ), - sa.Column( - "is_managed_externally", - sa.Boolean(), - nullable=False, - server_default=sa.false(), - ), - sa.Column("external_url", sa.Text(), nullable=True), - sa.PrimaryKeyConstraint("id"), - ) - with op.batch_alter_table("sl_datasets") as batch_op: - batch_op.create_unique_constraint("uq_sl_datasets_uuid", ["uuid"]) - batch_op.create_unique_constraint( - "uq_sl_datasets_sqlatable_id", ["sqlatable_id"] - ) - - op.create_table( - "sl_dataset_columns", - sa.Column("dataset_id", sa.INTEGER(), autoincrement=False, nullable=False), - sa.Column("column_id", sa.INTEGER(), autoincrement=False, nullable=False), - sa.ForeignKeyConstraint( - ["column_id"], ["sl_columns.id"], name="sl_dataset_columns_ibfk_2" - ), - sa.ForeignKeyConstraint( - ["dataset_id"], ["sl_datasets.id"], name="sl_dataset_columns_ibfk_1" - ), - ) - - op.create_table( - "sl_dataset_tables", - sa.Column("dataset_id", sa.INTEGER(), autoincrement=False, nullable=False), - sa.Column("table_id", sa.INTEGER(), autoincrement=False, nullable=False), - sa.ForeignKeyConstraint( - ["dataset_id"], ["sl_datasets.id"], name="sl_dataset_tables_ibfk_1" - ), - sa.ForeignKeyConstraint( - ["table_id"], ["sl_tables.id"], name="sl_dataset_tables_ibfk_2" - ), - ) +# ===================== Notice ======================== +# +# Migrations made in this revision has been moved to `new_dataset_models_take_2` +# to fix performance issues as well as a couple of shortcomings in the original +# design. +# +# ====================================================== - # migrate existing datasets to the new models - bind = op.get_bind() - session = db.Session(bind=bind) # pylint: disable=no-member - datasets = session.query(SqlaTable).all() - for dataset in datasets: - dataset.fetch_columns_and_metrics(session) - after_insert(target=dataset) +def upgrade() -> None: + pass def downgrade(): - op.drop_table("sl_dataset_columns") - op.drop_table("sl_dataset_tables") - op.drop_table("sl_datasets") - op.drop_table("sl_table_columns") - op.drop_table("sl_tables") - op.drop_table("sl_columns") + pass diff --git a/superset/migrations/versions/c501b7c653a3_add_missing_uuid_column.py b/superset/migrations/versions/c501b7c653a3_add_missing_uuid_column.py index 4cfbc104c01db..786b41a1c72b8 100644 --- a/superset/migrations/versions/c501b7c653a3_add_missing_uuid_column.py +++ b/superset/migrations/versions/c501b7c653a3_add_missing_uuid_column.py @@ -38,7 +38,7 @@ from superset import db from superset.migrations.versions.b56500de1855_add_uuid_column_to_import_mixin import ( - add_uuids, + assign_uuids, models, update_dashboards, ) @@ -73,7 +73,7 @@ def upgrade(): default=uuid4, ), ) - add_uuids(model, table_name, session) + assign_uuids(model, session) # add uniqueness constraint with op.batch_alter_table(table_name) as batch_op: diff --git a/superset/migrations/versions/f1410ed7ec95_migrate_native_filters_to_new_schema.py b/superset/migrations/versions/f1410ed7ec95_migrate_native_filters_to_new_schema.py index 630a7b1062ac6..46b8e5f958670 100644 --- a/superset/migrations/versions/f1410ed7ec95_migrate_native_filters_to_new_schema.py +++ b/superset/migrations/versions/f1410ed7ec95_migrate_native_filters_to_new_schema.py @@ -71,7 +71,7 @@ def downgrade_filters(native_filters: Iterable[Dict[str, Any]]) -> int: filter_state = default_data_mask.get("filterState") if filter_state is not None: changed_filters += 1 - value = filter_state["value"] + value = filter_state.get("value") native_filter["defaultValue"] = value return changed_filters diff --git a/superset/models/core.py b/superset/models/core.py index daa0fb9a7ddfc..c2052749ad8a0 100755 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -408,12 +408,14 @@ def get_sqla_engine( except Exception as ex: raise self.db_engine_spec.get_dbapi_mapped_exception(ex) + @property + def quote_identifier(self) -> Callable[[str], str]: + """Add quotes to potential identifiter expressions if needed""" + return self.get_dialect().identifier_preparer.quote + def get_reserved_words(self) -> Set[str]: return self.get_dialect().preparer.reserved_words - def get_quoter(self) -> Callable[[str, Any], str]: - return self.get_dialect().identifier_preparer.quote - def get_df( # pylint: disable=too-many-locals self, sql: str, diff --git a/superset/models/helpers.py b/superset/models/helpers.py index baa0566c01119..3b4e99159f0b8 100644 --- a/superset/models/helpers.py +++ b/superset/models/helpers.py @@ -477,7 +477,7 @@ class ExtraJSONMixin: @property def extra(self) -> Dict[str, Any]: try: - return json.loads(self.extra_json) + return json.loads(self.extra_json) if self.extra_json else {} except (TypeError, JSONDecodeError) as exc: logger.error( "Unable to load an extra json: %r. Leaving empty.", exc, exc_info=True @@ -522,18 +522,23 @@ def warning_markdown(self) -> Optional[str]: def clone_model( - target: Model, ignore: Optional[List[str]] = None, **kwargs: Any + target: Model, + ignore: Optional[List[str]] = None, + keep_relations: Optional[List[str]] = None, + **kwargs: Any, ) -> Model: """ - Clone a SQLAlchemy model. + Clone a SQLAlchemy model. By default will only clone naive column attributes. + To include relationship attributes, use `keep_relations`. """ ignore = ignore or [] table = target.__table__ + primary_keys = table.primary_key.columns.keys() data = { attr: getattr(target, attr) - for attr in table.columns.keys() - if attr not in table.primary_key.columns.keys() and attr not in ignore + for attr in list(table.columns.keys()) + (keep_relations or []) + if attr not in primary_keys and attr not in ignore } data.update(kwargs) diff --git a/superset/sql_lab.py b/superset/sql_lab.py index d3e08de92a9cf..567ff0d13d592 100644 --- a/superset/sql_lab.py +++ b/superset/sql_lab.py @@ -186,7 +186,7 @@ def execute_sql_statement( # pylint: disable=too-many-arguments,too-many-locals apply_ctas: bool = False, ) -> SupersetResultSet: """Executes a single SQL statement""" - database = query.database + database: Database = query.database db_engine_spec = database.db_engine_spec parsed_query = ParsedQuery(sql_statement) sql = parsed_query.stripped() diff --git a/superset/sql_parse.py b/superset/sql_parse.py index e3b2e7c196834..d377986f56573 100644 --- a/superset/sql_parse.py +++ b/superset/sql_parse.py @@ -18,7 +18,7 @@ import re from dataclasses import dataclass from enum import Enum -from typing import cast, List, Optional, Set, Tuple +from typing import Any, cast, Iterator, List, Optional, Set, Tuple from urllib import parse import sqlparse @@ -47,10 +47,16 @@ from superset.exceptions import QueryClauseValidationException +try: + from sqloxide import parse_sql as sqloxide_parse +except: # pylint: disable=bare-except + sqloxide_parse = None + RESULT_OPERATIONS = {"UNION", "INTERSECT", "EXCEPT", "SELECT"} ON_KEYWORD = "ON" PRECEDES_TABLE_NAME = {"FROM", "JOIN", "DESCRIBE", "WITH", "LEFT JOIN", "RIGHT JOIN"} CTE_PREFIX = "CTE__" + logger = logging.getLogger(__name__) @@ -176,6 +182,9 @@ def __str__(self) -> str: if part ) + def __eq__(self, __o: object) -> bool: + return str(self) == str(__o) + class ParsedQuery: def __init__(self, sql_statement: str, strip_comments: bool = False): @@ -698,3 +707,75 @@ def insert_rls( ) return token_list + + +# mapping between sqloxide and SQLAlchemy dialects +SQLOXITE_DIALECTS = { + "ansi": {"trino", "trinonative", "presto"}, + "hive": {"hive", "databricks"}, + "ms": {"mssql"}, + "mysql": {"mysql"}, + "postgres": { + "cockroachdb", + "hana", + "netezza", + "postgres", + "postgresql", + "redshift", + "vertica", + }, + "snowflake": {"snowflake"}, + "sqlite": {"sqlite", "gsheets", "shillelagh"}, + "clickhouse": {"clickhouse"}, +} + +RE_JINJA_VAR = re.compile(r"\{\{[^\{\}]+\}\}") +RE_JINJA_BLOCK = re.compile(r"\{[%#][^\{\}%#]+[%#]\}") + + +def extract_table_references( + sql_text: str, sqla_dialect: str, show_warning: bool = True +) -> Set["Table"]: + """ + Return all the dependencies from a SQL sql_text. + """ + dialect = "generic" + tree = None + + if sqloxide_parse: + for dialect, sqla_dialects in SQLOXITE_DIALECTS.items(): + if sqla_dialect in sqla_dialects: + break + sql_text = RE_JINJA_BLOCK.sub(" ", sql_text) + sql_text = RE_JINJA_VAR.sub("abc", sql_text) + try: + tree = sqloxide_parse(sql_text, dialect=dialect) + except Exception as ex: # pylint: disable=broad-except + if show_warning: + logger.warning( + "\nUnable to parse query with sqloxide:\n%s\n%s", sql_text, ex + ) + + # fallback to sqlparse + if not tree: + parsed = ParsedQuery(sql_text) + return parsed.tables + + def find_nodes_by_key(element: Any, target: str) -> Iterator[Any]: + """ + Find all nodes in a SQL tree matching a given key. + """ + if isinstance(element, list): + for child in element: + yield from find_nodes_by_key(child, target) + elif isinstance(element, dict): + for key, value in element.items(): + if key == target: + yield value + else: + yield from find_nodes_by_key(value, target) + + return { + Table(*[part["value"] for part in table["name"][::-1]]) + for table in find_nodes_by_key(tree, "Table") + } diff --git a/superset/tables/models.py b/superset/tables/models.py index e2489445c686b..9a0c07fdcf5a4 100644 --- a/superset/tables/models.py +++ b/superset/tables/models.py @@ -24,26 +24,41 @@ These models are not fully implemented, and shouldn't be used yet. """ -from typing import List +from typing import Any, Dict, Iterable, List, Optional, TYPE_CHECKING import sqlalchemy as sa from flask_appbuilder import Model -from sqlalchemy.orm import backref, relationship +from sqlalchemy import inspect +from sqlalchemy.orm import backref, relationship, Session from sqlalchemy.schema import UniqueConstraint +from sqlalchemy.sql import and_, or_ from superset.columns.models import Column +from superset.connectors.sqla.utils import get_physical_table_metadata from superset.models.core import Database from superset.models.helpers import ( AuditMixinNullable, ExtraJSONMixin, ImportExportMixin, ) +from superset.sql_parse import Table as TableName -association_table = sa.Table( +if TYPE_CHECKING: + from superset.datasets.models import Dataset + +table_column_association_table = sa.Table( "sl_table_columns", Model.metadata, # pylint: disable=no-member - sa.Column("table_id", sa.ForeignKey("sl_tables.id")), - sa.Column("column_id", sa.ForeignKey("sl_columns.id")), + sa.Column( + "table_id", + sa.ForeignKey("sl_tables.id", ondelete="cascade"), + primary_key=True, + ), + sa.Column( + "column_id", + sa.ForeignKey("sl_columns.id", ondelete="cascade"), + primary_key=True, + ), ) @@ -61,7 +76,6 @@ class Table(Model, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin): __table_args__ = (UniqueConstraint("database_id", "catalog", "schema", "name"),) id = sa.Column(sa.Integer, primary_key=True) - database_id = sa.Column(sa.Integer, sa.ForeignKey("dbs.id"), nullable=False) database: Database = relationship( "Database", @@ -70,6 +84,19 @@ class Table(Model, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin): backref=backref("new_tables", cascade="all, delete-orphan"), foreign_keys=[database_id], ) + # The relationship between datasets and columns is 1:n, but we use a + # many-to-many association table to avoid adding two mutually exclusive + # columns(dataset_id and table_id) to Column + columns: List[Column] = relationship( + "Column", + secondary=table_column_association_table, + cascade="all, delete-orphan", + single_parent=True, + # backref is needed for session to skip detaching `dataset` if only `column` + # is loaded. + backref="tables", + ) + datasets: List["Dataset"] # will be populated by Dataset.tables backref # We use ``sa.Text`` for these attributes because (1) in modern databases the # performance is the same as ``VARCHAR``[1] and (2) because some table names can be @@ -80,13 +107,96 @@ class Table(Model, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin): schema = sa.Column(sa.Text) name = sa.Column(sa.Text) - # The relationship between tables and columns is 1:n, but we use a many-to-many - # association to differentiate between the relationship between datasets and - # columns. - columns: List[Column] = relationship( - "Column", secondary=association_table, cascade="all, delete" - ) - # Column is managed externally and should be read-only inside Superset is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) external_url = sa.Column(sa.Text, nullable=True) + + @property + def fullname(self) -> str: + return str(TableName(table=self.name, schema=self.schema, catalog=self.catalog)) + + def __repr__(self) -> str: + return f"" + + def sync_columns(self) -> None: + """Sync table columns with the database. Keep metadata for existing columns""" + try: + column_metadata = get_physical_table_metadata( + self.database, self.name, self.schema + ) + except Exception: # pylint: disable=broad-except + column_metadata = [] + + existing_columns = {column.name: column for column in self.columns} + quote_identifier = self.database.quote_identifier + + def update_or_create_column(column_meta: Dict[str, Any]) -> Column: + column_name: str = column_meta["name"] + if column_name in existing_columns: + column = existing_columns[column_name] + else: + column = Column(name=column_name) + column.type = column_meta["type"] + column.is_temporal = column_meta["is_dttm"] + column.expression = quote_identifier(column_name) + column.is_aggregation = False + column.is_physical = True + column.is_spatial = False + column.is_partition = False # TODO: update with accurate is_partition + return column + + self.columns = [update_or_create_column(col) for col in column_metadata] + + @staticmethod + def bulk_load_or_create( + database: Database, + table_names: Iterable[TableName], + default_schema: Optional[str] = None, + sync_columns: Optional[bool] = False, + default_props: Optional[Dict[str, Any]] = None, + ) -> List["Table"]: + """ + Load or create multiple Table instances. + """ + if not table_names: + return [] + + if not database.id: + raise Exception("Database must be already saved to metastore") + + default_props = default_props or {} + session: Session = inspect(database).session + # load existing tables + predicate = or_( + *[ + and_( + Table.database_id == database.id, + Table.schema == (table.schema or default_schema), + Table.name == table.table, + ) + for table in table_names + ] + ) + all_tables = session.query(Table).filter(predicate).order_by(Table.id).all() + + # add missing tables and pull its columns + existing = {(table.schema, table.name) for table in all_tables} + for table in table_names: + schema = table.schema or default_schema + name = table.table + if (schema, name) not in existing: + new_table = Table( + database=database, + database_id=database.id, + name=name, + schema=schema, + catalog=None, + **default_props, + ) + if sync_columns: + new_table.sync_columns() + all_tables.append(new_table) + existing.add((schema, name)) + session.add(new_table) + + return all_tables diff --git a/tests/integration_tests/commands_test.py b/tests/integration_tests/commands_test.py index 5ff18b02a93e4..77fbad05f3a39 100644 --- a/tests/integration_tests/commands_test.py +++ b/tests/integration_tests/commands_test.py @@ -16,11 +16,11 @@ # under the License. import copy import json -from unittest.mock import patch import yaml +from flask import g -from superset import db, security_manager +from superset import db from superset.commands.exceptions import CommandInvalidError from superset.commands.importers.v1.assets import ImportAssetsCommand from superset.commands.importers.v1.utils import is_valid_config @@ -58,10 +58,13 @@ def test_is_valid_config(self): class TestImportAssetsCommand(SupersetTestCase): - @patch("superset.dashboards.commands.importers.v1.utils.g") - def test_import_assets(self, mock_g): + def setUp(self): + user = self.get_user("admin") + self.user = user + setattr(g, "user", user) + + def test_import_assets(self): """Test that we can import multiple assets""" - mock_g.user = security_manager.find_user("admin") contents = { "metadata.yaml": yaml.safe_dump(metadata_config), "databases/imported_database.yaml": yaml.safe_dump(database_config), @@ -141,7 +144,7 @@ def test_import_assets(self, mock_g): database = dataset.database assert str(database.uuid) == database_config["uuid"] - assert dashboard.owners == [mock_g.user] + assert dashboard.owners == [self.user] dashboard.owners = [] chart.owners = [] @@ -153,11 +156,8 @@ def test_import_assets(self, mock_g): db.session.delete(database) db.session.commit() - @patch("superset.dashboards.commands.importers.v1.utils.g") - def test_import_v1_dashboard_overwrite(self, mock_g): + def test_import_v1_dashboard_overwrite(self): """Test that assets can be overwritten""" - mock_g.user = security_manager.find_user("admin") - contents = { "metadata.yaml": yaml.safe_dump(metadata_config), "databases/imported_database.yaml": yaml.safe_dump(database_config), diff --git a/tests/integration_tests/fixtures/world_bank_dashboard.py b/tests/integration_tests/fixtures/world_bank_dashboard.py index 1ac1706a9dc05..e767036b7d857 100644 --- a/tests/integration_tests/fixtures/world_bank_dashboard.py +++ b/tests/integration_tests/fixtures/world_bank_dashboard.py @@ -111,11 +111,10 @@ def _commit_slices(slices: List[Slice]): def _create_world_bank_dashboard(table: SqlaTable, slices: List[Slice]) -> Dashboard: + from superset.examples.helpers import update_slice_ids from superset.examples.world_bank import dashboard_positions pos = dashboard_positions - from superset.examples.helpers import update_slice_ids - update_slice_ids(pos, slices) table.fetch_metadata() diff --git a/tests/integration_tests/sqla_models_tests.py b/tests/integration_tests/sqla_models_tests.py index bbe062e509ba9..d23b95f53cd3d 100644 --- a/tests/integration_tests/sqla_models_tests.py +++ b/tests/integration_tests/sqla_models_tests.py @@ -455,7 +455,8 @@ def test_fetch_metadata_for_updated_virtual_table(self): # make sure the columns have been mapped properly assert len(table.columns) == 4 - table.fetch_metadata() + table.fetch_metadata(commit=False) + # assert that the removed column has been dropped and # the physical and calculated columns are present assert {col.column_name for col in table.columns} == { @@ -473,6 +474,8 @@ def test_fetch_metadata_for_updated_virtual_table(self): assert VIRTUAL_TABLE_STRING_TYPES[backend].match(cols["mycase"].type) assert cols["expr"].expression == "case when 1 then 1 else 0 end" + db.session.delete(table) + @patch("superset.models.core.Database.db_engine_spec", BigQueryEngineSpec) def test_labels_expected_on_mutated_query(self): query_obj = { diff --git a/tests/integration_tests/utils_tests.py b/tests/integration_tests/utils_tests.py index 5add2c5f6e014..7e8aede6a39c7 100644 --- a/tests/integration_tests/utils_tests.py +++ b/tests/integration_tests/utils_tests.py @@ -15,7 +15,6 @@ # specific language governing permissions and limitations # under the License. # isort:skip_file -import unittest import uuid from datetime import date, datetime, time, timedelta from decimal import Decimal diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 4987aaf0e0e5c..86fb0127b84f3 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -17,7 +17,7 @@ # pylint: disable=redefined-outer-name, import-outside-toplevel import importlib -from typing import Any, Iterator +from typing import Any, Callable, Iterator import pytest from pytest_mock import MockFixture @@ -31,25 +31,33 @@ @pytest.fixture -def session(mocker: MockFixture) -> Iterator[Session]: +def get_session(mocker: MockFixture) -> Callable[[], Session]: """ Create an in-memory SQLite session to test models. """ engine = create_engine("sqlite://") - Session_ = sessionmaker(bind=engine) # pylint: disable=invalid-name - in_memory_session = Session_() - # flask calls session.remove() - in_memory_session.remove = lambda: None + def get_session(): + Session_ = sessionmaker(bind=engine) # pylint: disable=invalid-name + in_memory_session = Session_() - # patch session - mocker.patch( - "superset.security.SupersetSecurityManager.get_session", - return_value=in_memory_session, - ) - mocker.patch("superset.db.session", in_memory_session) + # flask calls session.remove() + in_memory_session.remove = lambda: None - yield in_memory_session + # patch session + mocker.patch( + "superset.security.SupersetSecurityManager.get_session", + return_value=in_memory_session, + ) + mocker.patch("superset.db.session", in_memory_session) + return in_memory_session + + return get_session + + +@pytest.fixture +def session(get_session) -> Iterator[Session]: + yield get_session() @pytest.fixture(scope="module") diff --git a/tests/unit_tests/datasets/conftest.py b/tests/unit_tests/datasets/conftest.py new file mode 100644 index 0000000000000..9d9403934d0e1 --- /dev/null +++ b/tests/unit_tests/datasets/conftest.py @@ -0,0 +1,118 @@ +# 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. +from typing import Any, Dict, TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from superset.connectors.sqla.models import SqlMetric, TableColumn + + +@pytest.fixture +def columns_default() -> Dict[str, Any]: + """Default props for new columns""" + return { + "changed_by": 1, + "created_by": 1, + "datasets": [], + "tables": [], + "is_additive": False, + "is_aggregation": False, + "is_dimensional": False, + "is_filterable": True, + "is_increase_desired": True, + "is_partition": False, + "is_physical": True, + "is_spatial": False, + "is_temporal": False, + "description": None, + "extra_json": "{}", + "unit": None, + "warning_text": None, + "is_managed_externally": False, + "external_url": None, + } + + +@pytest.fixture +def sample_columns() -> Dict["TableColumn", Dict[str, Any]]: + from superset.connectors.sqla.models import TableColumn + + return { + TableColumn(column_name="ds", is_dttm=1, type="TIMESTAMP"): { + "name": "ds", + "expression": "ds", + "type": "TIMESTAMP", + "is_temporal": True, + "is_physical": True, + }, + TableColumn(column_name="num_boys", type="INTEGER", groupby=True): { + "name": "num_boys", + "expression": "num_boys", + "type": "INTEGER", + "is_dimensional": True, + "is_physical": True, + }, + TableColumn(column_name="region", type="VARCHAR", groupby=True): { + "name": "region", + "expression": "region", + "type": "VARCHAR", + "is_dimensional": True, + "is_physical": True, + }, + TableColumn( + column_name="profit", + type="INTEGER", + groupby=False, + expression="revenue-expenses", + ): { + "name": "profit", + "expression": "revenue-expenses", + "type": "INTEGER", + "is_physical": False, + }, + } + + +@pytest.fixture +def sample_metrics() -> Dict["SqlMetric", Dict[str, Any]]: + from superset.connectors.sqla.models import SqlMetric + + return { + SqlMetric(metric_name="cnt", expression="COUNT(*)", metric_type="COUNT"): { + "name": "cnt", + "expression": "COUNT(*)", + "extra_json": '{"metric_type": "COUNT"}', + "type": "UNKNOWN", + "is_additive": True, + "is_aggregation": True, + "is_filterable": False, + "is_physical": False, + }, + SqlMetric( + metric_name="avg revenue", expression="AVG(revenue)", metric_type="AVG" + ): { + "name": "avg revenue", + "expression": "AVG(revenue)", + "extra_json": '{"metric_type": "AVG"}', + "type": "UNKNOWN", + "is_additive": False, + "is_aggregation": True, + "is_filterable": False, + "is_physical": False, + }, + } diff --git a/tests/unit_tests/datasets/test_models.py b/tests/unit_tests/datasets/test_models.py index d21ef8ea60a94..08e0f11e0d354 100644 --- a/tests/unit_tests/datasets/test_models.py +++ b/tests/unit_tests/datasets/test_models.py @@ -15,14 +15,17 @@ # specific language governing permissions and limitations # under the License. -# pylint: disable=import-outside-toplevel, unused-argument, unused-import, too-many-locals, invalid-name, too-many-lines - import json -from datetime import datetime, timezone +from typing import Any, Callable, Dict, List, TYPE_CHECKING from pytest_mock import MockFixture from sqlalchemy.orm.session import Session +from tests.unit_tests.utils.db import get_test_user + +if TYPE_CHECKING: + from superset.connectors.sqla.models import SqlMetric, TableColumn + def test_dataset_model(app_context: None, session: Session) -> None: """ @@ -50,6 +53,7 @@ def test_dataset_model(app_context: None, session: Session) -> None: session.flush() dataset = Dataset( + database=table.database, name="positions", expression=""" SELECT array_agg(array[longitude,latitude]) AS position @@ -148,6 +152,7 @@ def test_cascade_delete_dataset(app_context: None, session: Session) -> None: SELECT array_agg(array[longitude,latitude]) AS position FROM my_catalog.my_schema.my_table """, + database=table.database, tables=[table], columns=[ Column( @@ -185,7 +190,7 @@ def test_dataset_attributes(app_context: None, session: Session) -> None: columns = [ TableColumn(column_name="ds", is_dttm=1, type="TIMESTAMP"), - TableColumn(column_name="user_id", type="INTEGER"), + TableColumn(column_name="num_boys", type="INTEGER"), TableColumn(column_name="revenue", type="INTEGER"), TableColumn(column_name="expenses", type="INTEGER"), TableColumn( @@ -254,6 +259,7 @@ def test_dataset_attributes(app_context: None, session: Session) -> None: "main_dttm_col", "metrics", "offset", + "owners", "params", "perm", "schema", @@ -265,7 +271,13 @@ def test_dataset_attributes(app_context: None, session: Session) -> None: ] -def test_create_physical_sqlatable(app_context: None, session: Session) -> None: +def test_create_physical_sqlatable( + app_context: None, + session: Session, + sample_columns: Dict["TableColumn", Dict[str, Any]], + sample_metrics: Dict["SqlMetric", Dict[str, Any]], + columns_default: Dict[str, Any], +) -> None: """ Test shadow write when creating a new ``SqlaTable``. @@ -274,7 +286,7 @@ def test_create_physical_sqlatable(app_context: None, session: Session) -> None: """ from superset.columns.models import Column from superset.columns.schemas import ColumnSchema - from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn + from superset.connectors.sqla.models import SqlaTable from superset.datasets.models import Dataset from superset.datasets.schemas import DatasetSchema from superset.models.core import Database @@ -283,19 +295,11 @@ def test_create_physical_sqlatable(app_context: None, session: Session) -> None: engine = session.get_bind() Dataset.metadata.create_all(engine) # pylint: disable=no-member - - columns = [ - TableColumn(column_name="ds", is_dttm=1, type="TIMESTAMP"), - TableColumn(column_name="user_id", type="INTEGER"), - TableColumn(column_name="revenue", type="INTEGER"), - TableColumn(column_name="expenses", type="INTEGER"), - TableColumn( - column_name="profit", type="INTEGER", expression="revenue-expenses" - ), - ] - metrics = [ - SqlMetric(metric_name="cnt", expression="COUNT(*)"), - ] + user1 = get_test_user(1, "abc") + columns = list(sample_columns.keys()) + metrics = list(sample_metrics.keys()) + expected_table_columns = list(sample_columns.values()) + expected_metric_columns = list(sample_metrics.values()) sqla_table = SqlaTable( table_name="old_dataset", @@ -317,6 +321,9 @@ def test_create_physical_sqlatable(app_context: None, session: Session) -> None: "import_time": 1606677834, } ), + created_by=user1, + changed_by=user1, + owners=[user1], perm=None, filter_select_enabled=1, fetch_values_predicate="foo IN (1, 2)", @@ -329,164 +336,85 @@ def test_create_physical_sqlatable(app_context: None, session: Session) -> None: session.flush() # ignore these keys when comparing results - ignored_keys = {"created_on", "changed_on", "uuid"} + ignored_keys = {"created_on", "changed_on"} # check that columns were created column_schema = ColumnSchema() - column_schemas = [ + actual_columns = [ {k: v for k, v in column_schema.dump(column).items() if k not in ignored_keys} for column in session.query(Column).all() ] - assert column_schemas == [ - { - "changed_by": None, - "created_by": None, - "description": None, - "expression": "ds", - "extra_json": "{}", - "id": 1, - "is_increase_desired": True, - "is_additive": False, - "is_aggregation": False, - "is_partition": False, - "is_physical": True, - "is_spatial": False, - "is_temporal": True, - "name": "ds", - "type": "TIMESTAMP", - "unit": None, - "warning_text": None, - "is_managed_externally": False, - "external_url": None, - }, - { - "changed_by": None, - "created_by": None, - "description": None, - "expression": "user_id", - "extra_json": "{}", - "id": 2, - "is_increase_desired": True, - "is_additive": False, - "is_aggregation": False, - "is_partition": False, - "is_physical": True, - "is_spatial": False, - "is_temporal": False, - "name": "user_id", - "type": "INTEGER", - "unit": None, - "warning_text": None, - "is_managed_externally": False, - "external_url": None, - }, - { - "changed_by": None, - "created_by": None, - "description": None, - "expression": "revenue", - "extra_json": "{}", - "id": 3, - "is_increase_desired": True, - "is_additive": False, - "is_aggregation": False, - "is_partition": False, - "is_physical": True, - "is_spatial": False, - "is_temporal": False, - "name": "revenue", - "type": "INTEGER", - "unit": None, - "warning_text": None, - "is_managed_externally": False, - "external_url": None, - }, - { - "changed_by": None, - "created_by": None, - "description": None, - "expression": "expenses", - "extra_json": "{}", - "id": 4, - "is_increase_desired": True, - "is_additive": False, - "is_aggregation": False, - "is_partition": False, + num_physical_columns = len( + [col for col in expected_table_columns if col.get("is_physical") == True] + ) + num_dataset_table_columns = len(columns) + num_dataset_metric_columns = len(metrics) + assert ( + len(actual_columns) + == num_physical_columns + num_dataset_table_columns + num_dataset_metric_columns + ) + + # table columns are created before dataset columns are created + offset = 0 + for i in range(num_physical_columns): + assert actual_columns[i + offset] == { + **columns_default, + **expected_table_columns[i], + "id": i + offset + 1, + # physical columns for table have its own uuid + "uuid": actual_columns[i + offset]["uuid"], "is_physical": True, - "is_spatial": False, - "is_temporal": False, - "name": "expenses", - "type": "INTEGER", - "unit": None, - "warning_text": None, - "is_managed_externally": False, - "external_url": None, - }, - { - "changed_by": None, - "created_by": None, - "description": None, - "expression": "revenue-expenses", - "extra_json": "{}", - "id": 5, - "is_increase_desired": True, - "is_additive": False, - "is_aggregation": False, - "is_partition": False, - "is_physical": False, - "is_spatial": False, - "is_temporal": False, - "name": "profit", - "type": "INTEGER", - "unit": None, - "warning_text": None, - "is_managed_externally": False, - "external_url": None, - }, - { - "changed_by": None, + # table columns do not have creators "created_by": None, - "description": None, - "expression": "COUNT(*)", - "extra_json": "{}", - "id": 6, - "is_increase_desired": True, - "is_additive": False, - "is_aggregation": True, - "is_partition": False, - "is_physical": False, - "is_spatial": False, - "is_temporal": False, - "name": "cnt", - "type": "Unknown", - "unit": None, - "warning_text": None, - "is_managed_externally": False, - "external_url": None, - }, - ] + "tables": [1], + } + + offset += num_physical_columns + for i, column in enumerate(sqla_table.columns): + assert actual_columns[i + offset] == { + **columns_default, + **expected_table_columns[i], + "id": i + offset + 1, + # columns for dataset reuses the same uuid of TableColumn + "uuid": str(column.uuid), + "datasets": [1], + } + + offset += num_dataset_table_columns + for i, metric in enumerate(sqla_table.metrics): + assert actual_columns[i + offset] == { + **columns_default, + **expected_metric_columns[i], + "id": i + offset + 1, + "uuid": str(metric.uuid), + "datasets": [1], + } # check that table was created table_schema = TableSchema() tables = [ - {k: v for k, v in table_schema.dump(table).items() if k not in ignored_keys} - for table in session.query(Table).all() - ] - assert tables == [ { - "extra_json": "{}", - "catalog": None, - "schema": "my_schema", - "name": "old_dataset", - "id": 1, - "database": 1, - "columns": [1, 2, 3, 4], - "created_by": None, - "changed_by": None, - "is_managed_externally": False, - "external_url": None, + k: v + for k, v in table_schema.dump(table).items() + if k not in (ignored_keys | {"uuid"}) } + for table in session.query(Table).all() ] + assert len(tables) == 1 + assert tables[0] == { + "id": 1, + "database": 1, + "created_by": 1, + "changed_by": 1, + "datasets": [1], + "columns": [1, 2, 3], + "extra_json": "{}", + "catalog": None, + "schema": "my_schema", + "name": "old_dataset", + "is_managed_externally": False, + "external_url": None, + } # check that dataset was created dataset_schema = DatasetSchema() @@ -494,26 +422,32 @@ def test_create_physical_sqlatable(app_context: None, session: Session) -> None: {k: v for k, v in dataset_schema.dump(dataset).items() if k not in ignored_keys} for dataset in session.query(Dataset).all() ] - assert datasets == [ - { - "id": 1, - "sqlatable_id": 1, - "name": "old_dataset", - "changed_by": None, - "created_by": None, - "columns": [1, 2, 3, 4, 5, 6], - "is_physical": True, - "tables": [1], - "extra_json": "{}", - "expression": "old_dataset", - "is_managed_externally": False, - "external_url": None, - } - ] + assert len(datasets) == 1 + assert datasets[0] == { + "id": 1, + "uuid": str(sqla_table.uuid), + "created_by": 1, + "changed_by": 1, + "owners": [1], + "name": "old_dataset", + "columns": [4, 5, 6, 7, 8, 9], + "is_physical": True, + "database": 1, + "tables": [1], + "extra_json": "{}", + "expression": "old_dataset", + "is_managed_externally": False, + "external_url": None, + } def test_create_virtual_sqlatable( - mocker: MockFixture, app_context: None, session: Session + app_context: None, + mocker: MockFixture, + session: Session, + sample_columns: Dict["TableColumn", Dict[str, Any]], + sample_metrics: Dict["SqlMetric", Dict[str, Any]], + columns_default: Dict[str, Any], ) -> None: """ Test shadow write when creating a new ``SqlaTable``. @@ -528,7 +462,7 @@ def test_create_virtual_sqlatable( from superset.columns.models import Column from superset.columns.schemas import ColumnSchema - from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn + from superset.connectors.sqla.models import SqlaTable from superset.datasets.models import Dataset from superset.datasets.schemas import DatasetSchema from superset.models.core import Database @@ -536,8 +470,20 @@ def test_create_virtual_sqlatable( engine = session.get_bind() Dataset.metadata.create_all(engine) # pylint: disable=no-member - - # create the ``Table`` that the virtual dataset points to + user1 = get_test_user(1, "abc") + physical_table_columns: List[Dict[str, Any]] = [ + dict( + name="ds", + is_temporal=True, + type="TIMESTAMP", + expression="ds", + is_physical=True, + ), + dict(name="num_boys", type="INTEGER", expression="num_boys", is_physical=True), + dict(name="revenue", type="INTEGER", expression="revenue", is_physical=True), + dict(name="expenses", type="INTEGER", expression="expenses", is_physical=True), + ] + # create a physical ``Table`` that the virtual dataset points to database = Database(database_name="my_database", sqlalchemy_uri="sqlite://") table = Table( name="some_table", @@ -545,30 +491,26 @@ def test_create_virtual_sqlatable( catalog=None, database=database, columns=[ - Column(name="ds", is_temporal=True, type="TIMESTAMP"), - Column(name="user_id", type="INTEGER"), - Column(name="revenue", type="INTEGER"), - Column(name="expenses", type="INTEGER"), + Column(**props, created_by=user1, changed_by=user1) + for props in physical_table_columns ], ) session.add(table) session.commit() + assert session.query(Table).count() == 1 + assert session.query(Dataset).count() == 0 + # create virtual dataset - columns = [ - TableColumn(column_name="ds", is_dttm=1, type="TIMESTAMP"), - TableColumn(column_name="user_id", type="INTEGER"), - TableColumn(column_name="revenue", type="INTEGER"), - TableColumn(column_name="expenses", type="INTEGER"), - TableColumn( - column_name="profit", type="INTEGER", expression="revenue-expenses" - ), - ] - metrics = [ - SqlMetric(metric_name="cnt", expression="COUNT(*)"), - ] + columns = list(sample_columns.keys()) + metrics = list(sample_metrics.keys()) + expected_table_columns = list(sample_columns.values()) + expected_metric_columns = list(sample_metrics.values()) sqla_table = SqlaTable( + created_by=user1, + changed_by=user1, + owners=[user1], table_name="old_dataset", columns=columns, metrics=metrics, @@ -583,7 +525,7 @@ def test_create_virtual_sqlatable( sql=""" SELECT ds, - user_id, + num_boys, revenue, expenses, revenue - expenses AS profit @@ -607,227 +549,54 @@ def test_create_virtual_sqlatable( session.add(sqla_table) session.flush() - # ignore these keys when comparing results - ignored_keys = {"created_on", "changed_on", "uuid"} + # should not add a new table + assert session.query(Table).count() == 1 + assert session.query(Dataset).count() == 1 - # check that columns were created + # ignore these keys when comparing results + ignored_keys = {"created_on", "changed_on"} column_schema = ColumnSchema() - column_schemas = [ + actual_columns = [ {k: v for k, v in column_schema.dump(column).items() if k not in ignored_keys} for column in session.query(Column).all() ] - assert column_schemas == [ - { - "type": "TIMESTAMP", - "is_additive": False, - "extra_json": "{}", - "is_partition": False, - "expression": None, - "unit": None, - "warning_text": None, - "created_by": None, - "is_increase_desired": True, - "description": None, - "is_spatial": False, - "name": "ds", - "is_physical": True, - "changed_by": None, - "is_temporal": True, - "id": 1, - "is_aggregation": False, - "external_url": None, - "is_managed_externally": False, - }, - { - "type": "INTEGER", - "is_additive": False, - "extra_json": "{}", - "is_partition": False, - "expression": None, - "unit": None, - "warning_text": None, - "created_by": None, - "is_increase_desired": True, - "description": None, - "is_spatial": False, - "name": "user_id", - "is_physical": True, - "changed_by": None, - "is_temporal": False, - "id": 2, - "is_aggregation": False, - "external_url": None, - "is_managed_externally": False, - }, - { - "type": "INTEGER", - "is_additive": False, - "extra_json": "{}", - "is_partition": False, - "expression": None, - "unit": None, - "warning_text": None, - "created_by": None, - "is_increase_desired": True, - "description": None, - "is_spatial": False, - "name": "revenue", - "is_physical": True, - "changed_by": None, - "is_temporal": False, - "id": 3, - "is_aggregation": False, - "external_url": None, - "is_managed_externally": False, - }, - { - "type": "INTEGER", - "is_additive": False, - "extra_json": "{}", - "is_partition": False, - "expression": None, - "unit": None, - "warning_text": None, - "created_by": None, - "is_increase_desired": True, - "description": None, - "is_spatial": False, - "name": "expenses", - "is_physical": True, - "changed_by": None, - "is_temporal": False, - "id": 4, - "is_aggregation": False, - "external_url": None, - "is_managed_externally": False, - }, - { - "type": "TIMESTAMP", - "is_additive": False, - "extra_json": "{}", - "is_partition": False, - "expression": "ds", - "unit": None, - "warning_text": None, - "created_by": None, - "is_increase_desired": True, - "description": None, - "is_spatial": False, - "name": "ds", - "is_physical": False, - "changed_by": None, - "is_temporal": True, - "id": 5, - "is_aggregation": False, - "external_url": None, - "is_managed_externally": False, - }, - { - "type": "INTEGER", - "is_additive": False, - "extra_json": "{}", - "is_partition": False, - "expression": "user_id", - "unit": None, - "warning_text": None, - "created_by": None, - "is_increase_desired": True, - "description": None, - "is_spatial": False, - "name": "user_id", - "is_physical": False, - "changed_by": None, - "is_temporal": False, - "id": 6, - "is_aggregation": False, - "external_url": None, - "is_managed_externally": False, - }, - { - "type": "INTEGER", - "is_additive": False, - "extra_json": "{}", - "is_partition": False, - "expression": "revenue", - "unit": None, - "warning_text": None, - "created_by": None, - "is_increase_desired": True, - "description": None, - "is_spatial": False, - "name": "revenue", - "is_physical": False, - "changed_by": None, - "is_temporal": False, - "id": 7, - "is_aggregation": False, - "external_url": None, - "is_managed_externally": False, - }, - { - "type": "INTEGER", - "is_additive": False, - "extra_json": "{}", - "is_partition": False, - "expression": "expenses", - "unit": None, - "warning_text": None, - "created_by": None, - "is_increase_desired": True, - "description": None, - "is_spatial": False, - "name": "expenses", - "is_physical": False, - "changed_by": None, - "is_temporal": False, - "id": 8, - "is_aggregation": False, - "external_url": None, - "is_managed_externally": False, - }, - { - "type": "INTEGER", - "is_additive": False, - "extra_json": "{}", - "is_partition": False, - "expression": "revenue-expenses", - "unit": None, - "warning_text": None, - "created_by": None, - "is_increase_desired": True, - "description": None, - "is_spatial": False, - "name": "profit", - "is_physical": False, - "changed_by": None, - "is_temporal": False, - "id": 9, - "is_aggregation": False, - "external_url": None, - "is_managed_externally": False, - }, - { - "type": "Unknown", - "is_additive": False, - "extra_json": "{}", - "is_partition": False, - "expression": "COUNT(*)", - "unit": None, - "warning_text": None, - "created_by": None, - "is_increase_desired": True, - "description": None, - "is_spatial": False, - "name": "cnt", + num_physical_columns = len(physical_table_columns) + num_dataset_table_columns = len(columns) + num_dataset_metric_columns = len(metrics) + assert ( + len(actual_columns) + == num_physical_columns + num_dataset_table_columns + num_dataset_metric_columns + ) + + for i, column in enumerate(table.columns): + assert actual_columns[i] == { + **columns_default, + **physical_table_columns[i], + "id": i + 1, + "uuid": str(column.uuid), + "tables": [1], + } + + offset = num_physical_columns + for i, column in enumerate(sqla_table.columns): + assert actual_columns[i + offset] == { + **columns_default, + **expected_table_columns[i], + "id": i + offset + 1, + "uuid": str(column.uuid), "is_physical": False, - "changed_by": None, - "is_temporal": False, - "id": 10, - "is_aggregation": True, - "external_url": None, - "is_managed_externally": False, - }, - ] + "datasets": [1], + } + + offset = num_physical_columns + num_dataset_table_columns + for i, metric in enumerate(sqla_table.metrics): + assert actual_columns[i + offset] == { + **columns_default, + **expected_metric_columns[i], + "id": i + offset + 1, + "uuid": str(metric.uuid), + "datasets": [1], + } # check that dataset was created, and has a reference to the table dataset_schema = DatasetSchema() @@ -835,30 +604,31 @@ def test_create_virtual_sqlatable( {k: v for k, v in dataset_schema.dump(dataset).items() if k not in ignored_keys} for dataset in session.query(Dataset).all() ] - assert datasets == [ - { - "id": 1, - "sqlatable_id": 1, - "name": "old_dataset", - "changed_by": None, - "created_by": None, - "columns": [5, 6, 7, 8, 9, 10], - "is_physical": False, - "tables": [1], - "extra_json": "{}", - "external_url": None, - "is_managed_externally": False, - "expression": """ + assert len(datasets) == 1 + assert datasets[0] == { + "id": 1, + "database": 1, + "uuid": str(sqla_table.uuid), + "name": "old_dataset", + "changed_by": 1, + "created_by": 1, + "owners": [1], + "columns": [5, 6, 7, 8, 9, 10], + "is_physical": False, + "tables": [1], + "extra_json": "{}", + "external_url": None, + "is_managed_externally": False, + "expression": """ SELECT ds, - user_id, + num_boys, revenue, expenses, revenue - expenses AS profit FROM some_table""", - } - ] + } def test_delete_sqlatable(app_context: None, session: Session) -> None: @@ -886,18 +656,21 @@ def test_delete_sqlatable(app_context: None, session: Session) -> None: session.add(sqla_table) session.flush() - datasets = session.query(Dataset).all() - assert len(datasets) == 1 + assert session.query(Dataset).count() == 1 + assert session.query(Table).count() == 1 + assert session.query(Column).count() == 2 session.delete(sqla_table) session.flush() - # test that dataset was also deleted - datasets = session.query(Dataset).all() - assert len(datasets) == 0 + # test that dataset and dataset columns are also deleted + # but the physical table and table columns are kept + assert session.query(Dataset).count() == 0 + assert session.query(Table).count() == 1 + assert session.query(Column).count() == 1 -def test_update_sqlatable( +def test_update_physical_sqlatable_columns( mocker: MockFixture, app_context: None, session: Session ) -> None: """ @@ -929,21 +702,33 @@ def test_update_sqlatable( session.add(sqla_table) session.flush() + assert session.query(Table).count() == 1 + assert session.query(Dataset).count() == 1 + assert session.query(Column).count() == 2 # 1 for table, 1 for dataset + dataset = session.query(Dataset).one() assert len(dataset.columns) == 1 # add a column to the original ``SqlaTable`` instance - sqla_table.columns.append(TableColumn(column_name="user_id", type="INTEGER")) + sqla_table.columns.append(TableColumn(column_name="num_boys", type="INTEGER")) session.flush() - # check that the column was added to the dataset + assert session.query(Column).count() == 3 dataset = session.query(Dataset).one() assert len(dataset.columns) == 2 + for table_column, dataset_column in zip(sqla_table.columns, dataset.columns): + assert table_column.uuid == dataset_column.uuid # delete the column in the original instance sqla_table.columns = sqla_table.columns[1:] session.flush() + # check that the column was added to the dataset and the added columns have + # the correct uuid. + assert session.query(TableColumn).count() == 1 + # the extra Dataset.column is deleted, but Table.column is kept + assert session.query(Column).count() == 2 + # check that the column was also removed from the dataset dataset = session.query(Dataset).one() assert len(dataset.columns) == 1 @@ -957,7 +742,7 @@ def test_update_sqlatable( assert dataset.columns[0].is_temporal is True -def test_update_sqlatable_schema( +def test_update_physical_sqlatable_schema( mocker: MockFixture, app_context: None, session: Session ) -> None: """ @@ -1003,8 +788,11 @@ def test_update_sqlatable_schema( assert new_dataset.tables[0].id == 2 -def test_update_sqlatable_metric( - mocker: MockFixture, app_context: None, session: Session +def test_update_physical_sqlatable_metrics( + mocker: MockFixture, + app_context: None, + session: Session, + get_session: Callable[[], Session], ) -> None: """ Test that updating a ``SqlaTable`` also updates the corresponding ``Dataset``. @@ -1042,6 +830,9 @@ def test_update_sqlatable_metric( session.flush() # check that the metric was created + # 1 physical column for table + (1 column + 1 metric for datasets) + assert session.query(Column).count() == 3 + column = session.query(Column).filter_by(is_physical=False).one() assert column.expression == "COUNT(*)" @@ -1051,111 +842,35 @@ def test_update_sqlatable_metric( assert column.expression == "MAX(ds)" - -def test_update_virtual_sqlatable_references( - mocker: MockFixture, app_context: None, session: Session -) -> None: - """ - Test that changing the SQL of a virtual ``SqlaTable`` updates ``Dataset``. - - When the SQL is modified the list of referenced tables should be updated in the new - ``Dataset`` model. - """ - # patch session - mocker.patch( - "superset.security.SupersetSecurityManager.get_session", return_value=session - ) - - from superset.columns.models import Column - from superset.connectors.sqla.models import SqlaTable, TableColumn - from superset.datasets.models import Dataset - from superset.models.core import Database - from superset.tables.models import Table - - engine = session.get_bind() - Dataset.metadata.create_all(engine) # pylint: disable=no-member - - database = Database(database_name="my_database", sqlalchemy_uri="sqlite://") - table1 = Table( - name="table_a", - schema="my_schema", - catalog=None, - database=database, - columns=[Column(name="a", type="INTEGER")], - ) - table2 = Table( - name="table_b", - schema="my_schema", - catalog=None, - database=database, - columns=[Column(name="b", type="INTEGER")], - ) - session.add(table1) - session.add(table2) - session.commit() - - # create virtual dataset - columns = [TableColumn(column_name="a", type="INTEGER")] - - sqla_table = SqlaTable( - table_name="old_dataset", - columns=columns, - database=database, - schema="my_schema", - sql="SELECT a FROM table_a", + # in a new session, update new columns and metrics at the same time + # reload the sqla_table so we can test the case that accessing an not already + # loaded attribute (`sqla_table.metrics`) while there are updates on the instance + # may trigger `after_update` before the attribute is loaded + session = get_session() + sqla_table = session.query(SqlaTable).filter(SqlaTable.id == sqla_table.id).one() + sqla_table.columns.append( + TableColumn( + column_name="another_column", + is_dttm=0, + type="TIMESTAMP", + expression="concat('a', 'b')", + ) ) - session.add(sqla_table) - session.flush() - - # check that new dataset has table1 - dataset = session.query(Dataset).one() - assert dataset.tables == [table1] - - # change SQL - sqla_table.sql = "SELECT a, b FROM table_a JOIN table_b" - session.flush() - - # check that new dataset has both tables - new_dataset = session.query(Dataset).one() - assert new_dataset.tables == [table1, table2] - assert new_dataset.expression == "SELECT a, b FROM table_a JOIN table_b" - - -def test_quote_expressions(app_context: None, session: Session) -> None: - """ - Test that expressions are quoted appropriately in columns and datasets. - """ - from superset.columns.models import Column - from superset.connectors.sqla.models import SqlaTable, TableColumn - from superset.datasets.models import Dataset - from superset.models.core import Database - from superset.tables.models import Table - - engine = session.get_bind() - Dataset.metadata.create_all(engine) # pylint: disable=no-member - - columns = [ - TableColumn(column_name="has space", type="INTEGER"), - TableColumn(column_name="no_need", type="INTEGER"), - ] - - sqla_table = SqlaTable( - table_name="old dataset", - columns=columns, - metrics=[], - database=Database(database_name="my_database", sqlalchemy_uri="sqlite://"), + # Here `SqlaTable.after_update` is triggered + # before `sqla_table.metrics` is loaded + sqla_table.metrics.append( + SqlMetric(metric_name="another_metric", expression="COUNT(*)") ) - session.add(sqla_table) + # `SqlaTable.after_update` will trigger again at flushing session.flush() - - dataset = session.query(Dataset).one() - assert dataset.expression == '"old dataset"' - assert dataset.columns[0].expression == '"has space"' - assert dataset.columns[1].expression == "no_need" + assert session.query(Column).count() == 5 -def test_update_physical_sqlatable( - mocker: MockFixture, app_context: None, session: Session +def test_update_physical_sqlatable_database( + mocker: MockFixture, + app_context: None, + session: Session, + get_session: Callable[[], Session], ) -> None: """ Test updating the table on a physical dataset. @@ -1172,9 +887,9 @@ def test_update_physical_sqlatable( from superset.columns.models import Column from superset.connectors.sqla.models import SqlaTable, TableColumn - from superset.datasets.models import Dataset + from superset.datasets.models import Dataset, dataset_column_association_table from superset.models.core import Database - from superset.tables.models import Table + from superset.tables.models import Table, table_column_association_table from superset.tables.schemas import TableSchema engine = session.get_bind() @@ -1184,19 +899,26 @@ def test_update_physical_sqlatable( TableColumn(column_name="a", type="INTEGER"), ] + original_database = Database( + database_name="my_database", sqlalchemy_uri="sqlite://" + ) sqla_table = SqlaTable( - table_name="old_dataset", + table_name="original_table", columns=columns, metrics=[], - database=Database(database_name="my_database", sqlalchemy_uri="sqlite://"), + database=original_database, ) session.add(sqla_table) session.flush() + assert session.query(Table).count() == 1 + assert session.query(Dataset).count() == 1 + assert session.query(Column).count() == 2 # 1 for table, 1 for dataset + # check that the table was created, and that the created dataset points to it table = session.query(Table).one() assert table.id == 1 - assert table.name == "old_dataset" + assert table.name == "original_table" assert table.schema is None assert table.database_id == 1 @@ -1210,122 +932,200 @@ def test_update_physical_sqlatable( session.add(new_database) session.flush() sqla_table.database = new_database + sqla_table.table_name = "new_table" session.flush() + assert session.query(Dataset).count() == 1 + assert session.query(Table).count() == 2 + # is kept for the old table + # is kept for the updated dataset + # is created for the new table + assert session.query(Column).count() == 3 + # ignore these keys when comparing results ignored_keys = {"created_on", "changed_on", "uuid"} # check that the old table still exists, and that the dataset points to the newly - # created table (id=2) and column (id=2), on the new database (also id=2) + # created table, column and dataset table_schema = TableSchema() tables = [ {k: v for k, v in table_schema.dump(table).items() if k not in ignored_keys} for table in session.query(Table).all() ] - assert tables == [ - { - "created_by": None, - "extra_json": "{}", - "name": "old_dataset", - "changed_by": None, - "catalog": None, - "columns": [1], - "database": 1, - "external_url": None, - "schema": None, - "id": 1, - "is_managed_externally": False, - }, - { - "created_by": None, - "extra_json": "{}", - "name": "old_dataset", - "changed_by": None, - "catalog": None, - "columns": [2], - "database": 2, - "external_url": None, - "schema": None, - "id": 2, - "is_managed_externally": False, - }, - ] + assert tables[0] == { + "id": 1, + "database": 1, + "columns": [1], + "datasets": [], + "created_by": None, + "changed_by": None, + "extra_json": "{}", + "catalog": None, + "schema": None, + "name": "original_table", + "external_url": None, + "is_managed_externally": False, + } + assert tables[1] == { + "id": 2, + "database": 2, + "datasets": [1], + "columns": [3], + "created_by": None, + "changed_by": None, + "catalog": None, + "schema": None, + "name": "new_table", + "is_managed_externally": False, + "extra_json": "{}", + "external_url": None, + } # check that dataset now points to the new table assert dataset.tables[0].database_id == 2 + # and a new column is created + assert len(dataset.columns) == 1 + assert dataset.columns[0].id == 2 # point ``SqlaTable`` back - sqla_table.database_id = 1 + sqla_table.database = original_database + sqla_table.table_name = "original_table" session.flush() - # check that dataset points to the original table + # should not create more table and datasets + assert session.query(Dataset).count() == 1 + assert session.query(Table).count() == 2 + # is deleted for the old table + # is kept for the updated dataset + # is kept for the new table + assert session.query(Column.id).order_by(Column.id).all() == [ + (1,), + (2,), + (3,), + ] + assert session.query(dataset_column_association_table).all() == [(1, 2)] + assert session.query(table_column_association_table).all() == [(1, 1), (2, 3)] + assert session.query(Dataset).filter_by(id=1).one().columns[0].id == 2 + assert session.query(Table).filter_by(id=2).one().columns[0].id == 3 + assert session.query(Table).filter_by(id=1).one().columns[0].id == 1 + + # the dataset points back to the original table assert dataset.tables[0].database_id == 1 + assert dataset.tables[0].name == "original_table" + + # kept the original column + assert dataset.columns[0].id == 2 + session.commit() + session.close() + # querying in a new session should still return the same result + session = get_session() + assert session.query(table_column_association_table).all() == [(1, 1), (2, 3)] -def test_update_physical_sqlatable_no_dataset( + +def test_update_virtual_sqlatable_references( mocker: MockFixture, app_context: None, session: Session ) -> None: """ - Test updating the table on a physical dataset that it creates - a new dataset if one didn't already exist. + Test that changing the SQL of a virtual ``SqlaTable`` updates ``Dataset``. - When updating the table on a physical dataset by pointing it somewhere else (change - in database ID, schema, or table name) we should point the ``Dataset`` to an - existing ``Table`` if possible, and create a new one otherwise. + When the SQL is modified the list of referenced tables should be updated in the new + ``Dataset`` model. """ # patch session mocker.patch( "superset.security.SupersetSecurityManager.get_session", return_value=session ) - mocker.patch("superset.datasets.dao.db.session", session) from superset.columns.models import Column from superset.connectors.sqla.models import SqlaTable, TableColumn from superset.datasets.models import Dataset from superset.models.core import Database from superset.tables.models import Table - from superset.tables.schemas import TableSchema engine = session.get_bind() Dataset.metadata.create_all(engine) # pylint: disable=no-member - columns = [ - TableColumn(column_name="a", type="INTEGER"), - ] + database = Database(database_name="my_database", sqlalchemy_uri="sqlite://") + table1 = Table( + name="table_a", + schema="my_schema", + catalog=None, + database=database, + columns=[Column(name="a", type="INTEGER")], + ) + table2 = Table( + name="table_b", + schema="my_schema", + catalog=None, + database=database, + columns=[Column(name="b", type="INTEGER")], + ) + session.add(table1) + session.add(table2) + session.commit() + + # create virtual dataset + columns = [TableColumn(column_name="a", type="INTEGER")] sqla_table = SqlaTable( table_name="old_dataset", columns=columns, - metrics=[], - database=Database(database_name="my_database", sqlalchemy_uri="sqlite://"), + database=database, + schema="my_schema", + sql="SELECT a FROM table_a", ) session.add(sqla_table) session.flush() - # check that the table was created - table = session.query(Table).one() - assert table.id == 1 - - dataset = session.query(Dataset).one() - assert dataset.tables == [table] + # check that new dataset has table1 + dataset: Dataset = session.query(Dataset).one() + assert dataset.tables == [table1] - # point ``SqlaTable`` to a different database - new_database = Database( - database_name="my_other_database", sqlalchemy_uri="sqlite://" - ) - session.add(new_database) + # change SQL + sqla_table.sql = "SELECT a, b FROM table_a JOIN table_b" session.flush() - sqla_table.database = new_database + + # check that new dataset has both tables + new_dataset: Dataset = session.query(Dataset).one() + assert new_dataset.tables == [table1, table2] + assert new_dataset.expression == "SELECT a, b FROM table_a JOIN table_b" + + # automatically add new referenced table + sqla_table.sql = "SELECT a, b, c FROM table_a JOIN table_b JOIN table_c" session.flush() new_dataset = session.query(Dataset).one() + assert len(new_dataset.tables) == 3 + assert new_dataset.tables[2].name == "table_c" - # check that dataset now points to the new table - assert new_dataset.tables[0].database_id == 2 - # point ``SqlaTable`` back - sqla_table.database_id = 1 +def test_quote_expressions(app_context: None, session: Session) -> None: + """ + Test that expressions are quoted appropriately in columns and datasets. + """ + from superset.connectors.sqla.models import SqlaTable, TableColumn + from superset.datasets.models import Dataset + from superset.models.core import Database + + engine = session.get_bind() + Dataset.metadata.create_all(engine) # pylint: disable=no-member + + columns = [ + TableColumn(column_name="has space", type="INTEGER"), + TableColumn(column_name="no_need", type="INTEGER"), + ] + + sqla_table = SqlaTable( + table_name="old dataset", + columns=columns, + metrics=[], + database=Database(database_name="my_database", sqlalchemy_uri="sqlite://"), + ) + session.add(sqla_table) session.flush() - # check that dataset points to the original table - assert new_dataset.tables[0].database_id == 1 + dataset = session.query(Dataset).one() + assert dataset.expression == '"old dataset"' + assert dataset.columns[0].expression == '"has space"' + assert dataset.columns[1].expression == "no_need" diff --git a/tests/unit_tests/migrations/shared/__init__.py b/tests/unit_tests/migrations/shared/__init__.py deleted file mode 100644 index 13a83393a9124..0000000000000 --- a/tests/unit_tests/migrations/shared/__init__.py +++ /dev/null @@ -1,16 +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. diff --git a/tests/unit_tests/migrations/shared/utils_test.py b/tests/unit_tests/migrations/shared/utils_test.py deleted file mode 100644 index cb5b2cbd0e82b..0000000000000 --- a/tests/unit_tests/migrations/shared/utils_test.py +++ /dev/null @@ -1,56 +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. -# pylint: disable=import-outside-toplevel, unused-argument - -""" -Test the SIP-68 migration. -""" - -from pytest_mock import MockerFixture - -from superset.sql_parse import Table - - -def test_extract_table_references(mocker: MockerFixture, app_context: None) -> None: - """ - Test the ``extract_table_references`` helper function. - """ - from superset.migrations.shared.utils import extract_table_references - - assert extract_table_references("SELECT 1", "trino") == set() - assert extract_table_references("SELECT 1 FROM some_table", "trino") == { - Table(table="some_table", schema=None, catalog=None) - } - assert extract_table_references( - "SELECT 1 FROM some_catalog.some_schema.some_table", "trino" - ) == {Table(table="some_table", schema="some_schema", catalog="some_catalog")} - assert extract_table_references( - "SELECT * FROM some_table JOIN other_table ON some_table.id = other_table.id", - "trino", - ) == { - Table(table="some_table", schema=None, catalog=None), - Table(table="other_table", schema=None, catalog=None), - } - - # test falling back to sqlparse - logger = mocker.patch("superset.migrations.shared.utils.logger") - sql = "SELECT * FROM table UNION ALL SELECT * FROM other_table" - assert extract_table_references( - sql, - "trino", - ) == {Table(table="other_table", schema=None, catalog=None)} - logger.warning.assert_called_with("Unable to parse query with sqloxide: %s", sql) diff --git a/tests/unit_tests/sql_parse_tests.py b/tests/unit_tests/sql_parse_tests.py index 4a1ff89d74cc6..d9c5d64c5950c 100644 --- a/tests/unit_tests/sql_parse_tests.py +++ b/tests/unit_tests/sql_parse_tests.py @@ -29,6 +29,7 @@ from superset.exceptions import QueryClauseValidationException from superset.sql_parse import ( add_table_name, + extract_table_references, get_rls_for_table, has_table_query, insert_rls, @@ -1468,3 +1469,51 @@ def test_get_rls_for_table(mocker: MockerFixture, app_context: None) -> None: dataset.get_sqla_row_level_filters.return_value = [] assert get_rls_for_table(candidate, 1, "public") is None + + +def test_extract_table_references(mocker: MockerFixture) -> None: + """ + Test the ``extract_table_references`` helper function. + """ + assert extract_table_references("SELECT 1", "trino") == set() + assert extract_table_references("SELECT 1 FROM some_table", "trino") == { + Table(table="some_table", schema=None, catalog=None) + } + assert extract_table_references("SELECT {{ jinja }} FROM some_table", "trino") == { + Table(table="some_table", schema=None, catalog=None) + } + assert extract_table_references( + "SELECT 1 FROM some_catalog.some_schema.some_table", "trino" + ) == {Table(table="some_table", schema="some_schema", catalog="some_catalog")} + + # with identifier quotes + assert extract_table_references( + "SELECT 1 FROM `some_catalog`.`some_schema`.`some_table`", "mysql" + ) == {Table(table="some_table", schema="some_schema", catalog="some_catalog")} + assert extract_table_references( + 'SELECT 1 FROM "some_catalog".some_schema."some_table"', "trino" + ) == {Table(table="some_table", schema="some_schema", catalog="some_catalog")} + + assert extract_table_references( + "SELECT * FROM some_table JOIN other_table ON some_table.id = other_table.id", + "trino", + ) == { + Table(table="some_table", schema=None, catalog=None), + Table(table="other_table", schema=None, catalog=None), + } + + # test falling back to sqlparse + logger = mocker.patch("superset.sql_parse.logger") + sql = "SELECT * FROM table UNION ALL SELECT * FROM other_table" + assert extract_table_references( + sql, + "trino", + ) == {Table(table="other_table", schema=None, catalog=None)} + logger.warning.assert_called_once() + + logger = mocker.patch("superset.migrations.shared.utils.logger") + sql = "SELECT * FROM table UNION ALL SELECT * FROM other_table" + assert extract_table_references(sql, "trino", show_warning=False) == { + Table(table="other_table", schema=None, catalog=None) + } + logger.warning.assert_not_called() diff --git a/tests/unit_tests/migrations/__init__.py b/tests/unit_tests/utils/db.py similarity index 69% rename from tests/unit_tests/migrations/__init__.py rename to tests/unit_tests/utils/db.py index 13a83393a9124..554c95bd43187 100644 --- a/tests/unit_tests/migrations/__init__.py +++ b/tests/unit_tests/utils/db.py @@ -14,3 +14,17 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from typing import Any + +from superset import security_manager + + +def get_test_user(id_: int, username: str) -> Any: + """Create a sample test user""" + return security_manager.user_model( + id=id_, + username=username, + first_name=username, + last_name=username, + email=f"{username}@example.com", + ) From dfba9ea596605dc11b29ca1c82615db539e394b2 Mon Sep 17 00:00:00 2001 From: Diego Medina Date: Wed, 20 Apr 2022 02:18:46 -0400 Subject: [PATCH 081/136] fix: SQL Lab UI Error: Objects are not valid as a React child (#19783) --- superset-frontend/src/SqlLab/components/QueryTable/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/superset-frontend/src/SqlLab/components/QueryTable/index.tsx b/superset-frontend/src/SqlLab/components/QueryTable/index.tsx index dffb65a1f1072..a50779d6eb9c1 100644 --- a/superset-frontend/src/SqlLab/components/QueryTable/index.tsx +++ b/superset-frontend/src/SqlLab/components/QueryTable/index.tsx @@ -254,6 +254,8 @@ const QueryTable = ({ responsive /> ); + } else { + q.results = <>; } q.progress = From 5134c63ae289a583e52ddd692848461f227aec50 Mon Sep 17 00:00:00 2001 From: Cedric Gampert Date: Wed, 20 Apr 2022 09:03:59 +0200 Subject: [PATCH 082/136] fix: dashboard standalone class not added when parameter set (#16619) Co-authored-by: Ville Brofeldt --- superset/views/core.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/superset/views/core.py b/superset/views/core.py index 27a9a039b2d7b..1aa348d71300a 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -1926,6 +1926,8 @@ def dashboard( request.args.get(utils.ReservedUrlParameters.EDIT_MODE.value) == "true" ) + standalone_mode = ReservedUrlParameters.is_standalone_mode() + add_extra_log_payload( dashboard_id=dashboard.id, dashboard_version="v2", @@ -1944,6 +1946,7 @@ def dashboard( bootstrap_data=json.dumps( bootstrap_data, default=utils.pessimistic_json_iso_dttm_ser ), + standalone_mode=standalone_mode, ) @has_access From 22a92ed72278d45049cc33eba5219d215625e4d9 Mon Sep 17 00:00:00 2001 From: Todd Papaioannou Date: Wed, 20 Apr 2022 00:04:20 -0700 Subject: [PATCH 083/136] Remove broken link to gallery (#19784) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9d95180f26370..5bea6eed78ba0 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Superset provides: **Large Gallery of Visualizations** -
+
**Craft Beautiful, Dynamic Dashboards** From f06db796b5a609915d96b0a176f474d5142d9813 Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Wed, 20 Apr 2022 12:11:38 +0100 Subject: [PATCH 084/136] fix: small cleanup for created by me dashboards API (#19755) --- superset/constants.py | 1 - superset/dashboards/api.py | 1 - superset/views/core.py | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/superset/constants.py b/superset/constants.py index 2269bdc7b1362..8399aa457a882 100644 --- a/superset/constants.py +++ b/superset/constants.py @@ -100,7 +100,6 @@ class RouteMethod: # pylint: disable=too-few-public-methods MODEL_API_RW_METHOD_PERMISSION_MAP = { "bulk_delete": "write", - "created_by_me": "read", "delete": "write", "distinct": "read", "get": "read", diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index 277e0d10c34a6..5e3f78a9536ae 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -140,7 +140,6 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]: "set_embedded", "delete_embedded", "thumbnail", - "created_by_me", } resource_name = "dashboard" allow_browser_login = True diff --git a/superset/views/core.py b/superset/views/core.py index 1aa348d71300a..68ac74b365852 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -1587,7 +1587,7 @@ def fave_dashboards(self, user_id: int) -> FlaskResponse: @event_logger.log_this @expose("/created_dashboards//", methods=["GET"]) def created_dashboards(self, user_id: int) -> FlaskResponse: - logging.warning( + logger.warning( "%s.created_dashboards " "This API endpoint is deprecated and will be removed in version 3.0.0", self.__class__.__name__, From 3c28cd4625fdeeaeeac3ed730907af1fb86bc86e Mon Sep 17 00:00:00 2001 From: Yongjie Zhao Date: Wed, 20 Apr 2022 19:48:12 +0800 Subject: [PATCH 085/136] feat: add renameOperator (#19776) --- .../src/operators/flattenOperator.ts | 17 +- .../src/operators/index.ts | 1 + .../src/operators/renameOperator.ts | 89 +++++++++ .../utils/operators/flattenOperator.test.ts | 31 ---- .../utils/operators/renameOperator.test.ts | 146 +++++++++++++++ .../src/query/types/PostProcessing.ts | 13 ++ .../src/Timeseries/buildQuery.ts | 6 + superset/charts/schemas.py | 27 +-- .../utils/pandas_postprocessing/__init__.py | 2 + .../utils/pandas_postprocessing/flatten.py | 18 +- .../utils/pandas_postprocessing/rename.py | 58 ++++++ .../pandas_postprocessing/test_rename.py | 175 ++++++++++++++++++ 12 files changed, 512 insertions(+), 71 deletions(-) create mode 100644 superset-frontend/packages/superset-ui-chart-controls/src/operators/renameOperator.ts create mode 100644 superset-frontend/packages/superset-ui-chart-controls/test/utils/operators/renameOperator.test.ts create mode 100644 superset/utils/pandas_postprocessing/rename.py create mode 100644 tests/unit_tests/pandas_postprocessing/test_rename.py diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/flattenOperator.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/flattenOperator.ts index 1670a84170249..5188b34f2f03d 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/flattenOperator.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/flattenOperator.ts @@ -17,21 +17,12 @@ * specific language governing permissions and limitationsxw * under the License. */ -import { ensureIsArray, PostProcessingFlatten } from '@superset-ui/core'; +import { PostProcessingFlatten } from '@superset-ui/core'; import { PostProcessingFactory } from './types'; export const flattenOperator: PostProcessingFactory = ( formData, queryObject, -) => { - const drop_levels: number[] = []; - if (ensureIsArray(queryObject.metrics).length === 1) { - drop_levels.push(0); - } - return { - operation: 'flatten', - options: { - drop_levels, - }, - }; -}; +) => ({ + operation: 'flatten', +}); diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/index.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/index.ts index 28e7e70070e87..f39d649f8864b 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/index.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/index.ts @@ -23,6 +23,7 @@ export { timeComparePivotOperator } from './timeComparePivotOperator'; export { sortOperator } from './sortOperator'; export { pivotOperator } from './pivotOperator'; export { resampleOperator } from './resampleOperator'; +export { renameOperator } from './renameOperator'; export { contributionOperator } from './contributionOperator'; export { prophetOperator } from './prophetOperator'; export { boxplotOperator } from './boxplotOperator'; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/renameOperator.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/renameOperator.ts new file mode 100644 index 0000000000000..66909047b8275 --- /dev/null +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/renameOperator.ts @@ -0,0 +1,89 @@ +/* eslint-disable camelcase */ +/** + * 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 limitationsxw + * under the License. + */ +import { + PostProcessingRename, + ensureIsArray, + getMetricLabel, + ComparisionType, +} from '@superset-ui/core'; +import { PostProcessingFactory } from './types'; +import { getMetricOffsetsMap, isValidTimeCompare } from './utils'; + +export const renameOperator: PostProcessingFactory = ( + formData, + queryObject, +) => { + const metrics = ensureIsArray(queryObject.metrics); + const columns = ensureIsArray(queryObject.columns); + const { x_axis: xAxis } = formData; + // remove or rename top level of column name(metric name) in the MultiIndex when + // 1) only 1 metric + // 2) exist dimentsion + // 3) exist xAxis + // 4) exist time comparison, and comparison type is "actual values" + if ( + metrics.length === 1 && + columns.length > 0 && + (xAxis || queryObject.is_timeseries) && + !( + // todo: we should provide an approach to handle derived metrics + ( + isValidTimeCompare(formData, queryObject) && + [ + ComparisionType.Difference, + ComparisionType.Ratio, + ComparisionType.Percentage, + ].includes(formData.comparison_type) + ) + ) + ) { + const renamePairs: [string, string | null][] = []; + + if ( + // "actual values" will add derived metric. + // we will rename the "metric" from the metricWithOffset label + // for example: "count__1 year ago" => "1 year ago" + isValidTimeCompare(formData, queryObject) && + formData.comparison_type === ComparisionType.Values + ) { + const metricOffsetMap = getMetricOffsetsMap(formData, queryObject); + const timeOffsets = ensureIsArray(formData.time_compare); + [...metricOffsetMap.keys()].forEach(metricWithOffset => { + const offsetLabel = timeOffsets.find(offset => + metricWithOffset.includes(offset), + ); + renamePairs.push([metricWithOffset, offsetLabel]); + }); + } + + renamePairs.push([getMetricLabel(metrics[0]), null]); + + return { + operation: 'rename', + options: { + columns: Object.fromEntries(renamePairs), + level: 0, + inplace: true, + }, + }; + } + + return undefined; +}; diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/utils/operators/flattenOperator.test.ts b/superset-frontend/packages/superset-ui-chart-controls/test/utils/operators/flattenOperator.test.ts index e63525b82e781..94a9b0068705a 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/utils/operators/flattenOperator.test.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/test/utils/operators/flattenOperator.test.ts @@ -51,40 +51,9 @@ const queryObject: QueryObject = { }, ], }; -const singleMetricQueryObject: QueryObject = { - metrics: ['count(*)'], - time_range: '2015 : 2016', - granularity: 'month', - post_processing: [ - { - operation: 'pivot', - options: { - index: ['__timestamp'], - columns: ['nation'], - aggregates: { - 'count(*)': { - operator: 'sum', - }, - }, - }, - }, - ], -}; test('should do flattenOperator', () => { expect(flattenOperator(formData, queryObject)).toEqual({ operation: 'flatten', - options: { - drop_levels: [], - }, - }); -}); - -test('should add drop level', () => { - expect(flattenOperator(formData, singleMetricQueryObject)).toEqual({ - operation: 'flatten', - options: { - drop_levels: [0], - }, }); }); diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/utils/operators/renameOperator.test.ts b/superset-frontend/packages/superset-ui-chart-controls/test/utils/operators/renameOperator.test.ts new file mode 100644 index 0000000000000..2c32e0791ba17 --- /dev/null +++ b/superset-frontend/packages/superset-ui-chart-controls/test/utils/operators/renameOperator.test.ts @@ -0,0 +1,146 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { ComparisionType, QueryObject, SqlaFormData } from '@superset-ui/core'; +import { renameOperator } from '@superset-ui/chart-controls'; + +const formData: SqlaFormData = { + x_axis: 'dttm', + metrics: ['count(*)'], + groupby: ['gender'], + time_range: '2015 : 2016', + granularity: 'month', + datasource: 'foo', + viz_type: 'table', +}; +const queryObject: QueryObject = { + is_timeseries: true, + metrics: ['count(*)'], + columns: ['gender', 'dttm'], + time_range: '2015 : 2016', + granularity: 'month', + post_processing: [], +}; + +test('should skip renameOperator if exists multiple metrics', () => { + expect( + renameOperator(formData, { + ...queryObject, + ...{ + metrics: ['count(*)', 'sum(sales)'], + }, + }), + ).toEqual(undefined); +}); + +test('should skip renameOperator if does not exist series', () => { + expect( + renameOperator(formData, { + ...queryObject, + ...{ + columns: [], + }, + }), + ).toEqual(undefined); +}); + +test('should skip renameOperator if does not exist x_axis and is_timeseries', () => { + expect( + renameOperator( + { + ...formData, + ...{ x_axis: null }, + }, + { ...queryObject, ...{ is_timeseries: false } }, + ), + ).toEqual(undefined); +}); + +test('should skip renameOperator if exists derived metrics', () => { + [ + ComparisionType.Difference, + ComparisionType.Ratio, + ComparisionType.Percentage, + ].forEach(type => { + expect( + renameOperator( + { + ...formData, + ...{ + comparison_type: type, + time_compare: ['1 year ago'], + }, + }, + { + ...queryObject, + ...{ + metrics: ['count(*)'], + }, + }, + ), + ).toEqual(undefined); + }); +}); + +test('should add renameOperator', () => { + expect(renameOperator(formData, queryObject)).toEqual({ + operation: 'rename', + options: { columns: { 'count(*)': null }, inplace: true, level: 0 }, + }); +}); + +test('should add renameOperator if does not exist x_axis', () => { + expect( + renameOperator( + { + ...formData, + ...{ x_axis: null }, + }, + queryObject, + ), + ).toEqual({ + operation: 'rename', + options: { columns: { 'count(*)': null }, inplace: true, level: 0 }, + }); +}); + +test('should add renameOperator if exist "actual value" time comparison', () => { + expect( + renameOperator( + { + ...formData, + ...{ + comparison_type: ComparisionType.Values, + time_compare: ['1 year ago', '1 year later'], + }, + }, + queryObject, + ), + ).toEqual({ + operation: 'rename', + options: { + columns: { + 'count(*)': null, + 'count(*)__1 year ago': '1 year ago', + 'count(*)__1 year later': '1 year later', + }, + inplace: true, + level: 0, + }, + }); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/PostProcessing.ts b/superset-frontend/packages/superset-ui-core/src/query/types/PostProcessing.ts index 0ba7e4fc4af59..315cdb8456cda 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/PostProcessing.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/PostProcessing.ts @@ -201,6 +201,18 @@ export type PostProcessingResample = | _PostProcessingResample | DefaultPostProcessing; +interface _PostProcessingRename { + operation: 'rename'; + options: { + columns: Record; + inplace?: boolean; + level?: number | string; + }; +} +export type PostProcessingRename = + | _PostProcessingRename + | DefaultPostProcessing; + interface _PostProcessingFlatten { operation: 'flatten'; options?: { @@ -228,6 +240,7 @@ export type PostProcessingRule = | PostProcessingCompare | PostProcessingSort | PostProcessingResample + | PostProcessingRename | PostProcessingFlatten; export function isPostProcessingAggregation( diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts index c4cdaa9360a64..c2f603bfc45cd 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts @@ -30,6 +30,7 @@ import { isValidTimeCompare, pivotOperator, resampleOperator, + renameOperator, contributionOperator, prophetOperator, timeComparePivotOperator, @@ -91,7 +92,12 @@ export default function buildQuery(formData: QueryFormData) { rollingWindowOperator(formData, baseQueryObject), timeCompareOperator(formData, baseQueryObject), resampleOperator(formData, baseQueryObject), + renameOperator(formData, { + ...baseQueryObject, + ...{ is_timeseries }, + }), flattenOperator(formData, baseQueryObject), + // todo: move contribution and prophet before flatten contributionOperator(formData, baseQueryObject), prophetOperator(formData, baseQueryObject), ], diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py index 2a967eda27f9d..614bfeb0cae19 100644 --- a/superset/charts/schemas.py +++ b/superset/charts/schemas.py @@ -17,6 +17,7 @@ # pylint: disable=too-many-lines from __future__ import annotations +import inspect from typing import Any, Dict, Optional, TYPE_CHECKING from flask_babel import gettext as _ @@ -27,7 +28,7 @@ from superset import app from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType from superset.db_engine_specs.base import builtin_time_grains -from superset.utils import schema as utils +from superset.utils import pandas_postprocessing, schema as utils from superset.utils.core import ( AnnotationType, FilterOperator, @@ -770,24 +771,12 @@ class ChartDataPostProcessingOperationSchema(Schema): description="Post processing operation type", required=True, validate=validate.OneOf( - choices=( - "aggregate", - "boxplot", - "contribution", - "cum", - "geodetic_parse", - "geohash_decode", - "geohash_encode", - "pivot", - "prophet", - "rolling", - "select", - "sort", - "diff", - "compare", - "resample", - "flatten", - ) + choices=[ + name + for name, value in inspect.getmembers( + pandas_postprocessing, inspect.isfunction + ) + ] ), example="aggregate", ) diff --git a/superset/utils/pandas_postprocessing/__init__.py b/superset/utils/pandas_postprocessing/__init__.py index 3d180bc372020..9755df984cc56 100644 --- a/superset/utils/pandas_postprocessing/__init__.py +++ b/superset/utils/pandas_postprocessing/__init__.py @@ -28,6 +28,7 @@ ) from superset.utils.pandas_postprocessing.pivot import pivot from superset.utils.pandas_postprocessing.prophet import prophet +from superset.utils.pandas_postprocessing.rename import rename from superset.utils.pandas_postprocessing.resample import resample from superset.utils.pandas_postprocessing.rolling import rolling from superset.utils.pandas_postprocessing.select import select @@ -46,6 +47,7 @@ "geodetic_parse", "pivot", "prophet", + "rename", "resample", "rolling", "select", diff --git a/superset/utils/pandas_postprocessing/flatten.py b/superset/utils/pandas_postprocessing/flatten.py index 3d5a003bf1e5d..2874ac57970a4 100644 --- a/superset/utils/pandas_postprocessing/flatten.py +++ b/superset/utils/pandas_postprocessing/flatten.py @@ -81,14 +81,16 @@ def flatten( """ if _is_multi_index_on_columns(df): df.columns = df.columns.droplevel(drop_levels) - # every cell should be converted to string - df.columns = [ - FLAT_COLUMN_SEPARATOR.join( - # pylint: disable=superfluous-parens - [str(cell) for cell in (series if is_sequence(series) else [series])] - ) - for series in df.columns.to_flat_index() - ] + _columns = [] + for series in df.columns.to_flat_index(): + _cells = [] + for cell in series if is_sequence(series) else [series]: + if pd.notnull(cell): + # every cell should be converted to string + _cells.append(str(cell)) + _columns.append(FLAT_COLUMN_SEPARATOR.join(_cells)) + + df.columns = _columns if reset_index and not isinstance(df.index, pd.RangeIndex): df = df.reset_index(level=0) diff --git a/superset/utils/pandas_postprocessing/rename.py b/superset/utils/pandas_postprocessing/rename.py new file mode 100644 index 0000000000000..0e35a651a8073 --- /dev/null +++ b/superset/utils/pandas_postprocessing/rename.py @@ -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. +from typing import Dict, Optional, Union + +import pandas as pd +from flask_babel import gettext as _ +from pandas._typing import Level + +from superset.exceptions import InvalidPostProcessingError +from superset.utils.pandas_postprocessing.utils import validate_column_args + + +@validate_column_args("columns") +def rename( + df: pd.DataFrame, + columns: Dict[str, Union[str, None]], + inplace: bool = False, + level: Optional[Level] = None, +) -> pd.DataFrame: + """ + Alter column name of DataFrame + + :param df: DataFrame to rename. + :param columns: The offset string representing target conversion. + :param inplace: Whether to return a new DataFrame. + :param level: In case of a MultiIndex, only rename labels in the specified level. + :return: DataFrame after rename + :raises InvalidPostProcessingError: If the request is unexpected + """ + if not columns: + return df + + try: + _rename_level = df.columns.get_level_values(level=level) + except (IndexError, KeyError) as err: + raise InvalidPostProcessingError from err + + if all(new_name in _rename_level for new_name in columns.values()): + raise InvalidPostProcessingError(_("Label already exists")) + + if inplace: + df.rename(columns=columns, inplace=inplace, level=level) + return df + return df.rename(columns=columns, inplace=inplace, level=level) diff --git a/tests/unit_tests/pandas_postprocessing/test_rename.py b/tests/unit_tests/pandas_postprocessing/test_rename.py new file mode 100644 index 0000000000000..f49680a352618 --- /dev/null +++ b/tests/unit_tests/pandas_postprocessing/test_rename.py @@ -0,0 +1,175 @@ +# 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 pandas as pd +import pytest + +from superset.exceptions import InvalidPostProcessingError +from superset.utils import pandas_postprocessing as pp +from tests.unit_tests.fixtures.dataframes import categories_df + + +def test_rename_should_not_side_effect(): + _categories_df = categories_df.copy() + pp.rename( + df=_categories_df, + columns={ + "constant": "constant_newname", + "category": "category_namename", + }, + ) + assert _categories_df.equals(categories_df) + + +def test_rename(): + new_categories_df = pp.rename( + df=categories_df, + columns={ + "constant": "constant_newname", + "category": "category_newname", + }, + ) + assert list(new_categories_df.columns.values) == [ + "constant_newname", + "category_newname", + "dept", + "name", + "asc_idx", + "desc_idx", + "idx_nulls", + ] + assert not new_categories_df.equals(categories_df) + + +def test_should_inplace_rename(): + _categories_df = categories_df.copy() + _categories_df_inplaced = pp.rename( + df=_categories_df, + columns={ + "constant": "constant_newname", + "category": "category_namename", + }, + inplace=True, + ) + assert _categories_df_inplaced.equals(_categories_df) + + +def test_should_rename_on_level(): + iterables = [["m1", "m2"], ["a", "b"], ["x", "y"]] + columns = pd.MultiIndex.from_product(iterables, names=[None, "level1", "level2"]) + df = pd.DataFrame(index=[0, 1, 2], columns=columns, data=1) + """ + m1 m2 + level1 a b a b + level2 x y x y x y x y + 0 1 1 1 1 1 1 1 1 + 1 1 1 1 1 1 1 1 1 + 2 1 1 1 1 1 1 1 1 + """ + post_df = pp.rename( + df=df, + columns={"m1": "new_m1"}, + level=0, + ) + assert post_df.columns.get_level_values(level=0).equals( + pd.Index( + [ + "new_m1", + "new_m1", + "new_m1", + "new_m1", + "m2", + "m2", + "m2", + "m2", + ] + ) + ) + + +def test_should_raise_exception_no_column(): + with pytest.raises(InvalidPostProcessingError): + pp.rename( + df=categories_df, + columns={ + "foobar": "foobar2", + }, + ) + + +def test_should_raise_exception_duplication(): + with pytest.raises(InvalidPostProcessingError): + pp.rename( + df=categories_df, + columns={ + "constant": "category", + }, + ) + + +def test_should_raise_exception_duplication_on_multiindx(): + iterables = [["m1", "m2"], ["a", "b"], ["x", "y"]] + columns = pd.MultiIndex.from_product(iterables, names=[None, "level1", "level2"]) + df = pd.DataFrame(index=[0, 1, 2], columns=columns, data=1) + """ + m1 m2 + level1 a b a b + level2 x y x y x y x y + 0 1 1 1 1 1 1 1 1 + 1 1 1 1 1 1 1 1 1 + 2 1 1 1 1 1 1 1 1 + """ + + with pytest.raises(InvalidPostProcessingError): + pp.rename( + df=df, + columns={ + "m1": "m2", + }, + level=0, + ) + pp.rename( + df=df, + columns={ + "a": "b", + }, + level=1, + ) + + +def test_should_raise_exception_invalid_level(): + with pytest.raises(InvalidPostProcessingError): + pp.rename( + df=categories_df, + columns={ + "constant": "new_constant", + }, + level=100, + ) + pp.rename( + df=categories_df, + columns={ + "constant": "new_constant", + }, + level="xxxxx", + ) + + +def test_should_return_df_empty_columns(): + assert pp.rename( + df=categories_df, + columns={}, + ).equals(categories_df) From 4f997cd9ace0c59751bab8e4f910ce693fe3483f Mon Sep 17 00:00:00 2001 From: Yongjie Zhao Date: Wed, 20 Apr 2022 21:02:14 +0800 Subject: [PATCH 086/136] chore: fix grammar error (#19740) --- .../src/operators/flattenOperator.ts | 1 - .../src/operators/renameOperator.ts | 6 +++--- .../src/operators/rollingWindowOperator.ts | 4 ++-- .../src/operators/timeCompareOperator.ts | 4 ++-- .../src/operators/timeComparePivotOperator.ts | 4 ++-- .../superset-ui-chart-controls/src/operators/utils/index.ts | 2 +- .../utils/{isValidTimeCompare.ts => isTimeComparison.ts} | 2 +- .../plugin-chart-echarts/src/Timeseries/buildQuery.ts | 6 +++--- 8 files changed, 14 insertions(+), 15 deletions(-) rename superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/{isValidTimeCompare.ts => isTimeComparison.ts} (94%) diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/flattenOperator.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/flattenOperator.ts index 5188b34f2f03d..2fe732fc83d06 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/flattenOperator.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/flattenOperator.ts @@ -1,4 +1,3 @@ -/* eslint-disable camelcase */ /** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/renameOperator.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/renameOperator.ts index 66909047b8275..94dfa70bbc8f2 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/renameOperator.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/renameOperator.ts @@ -24,7 +24,7 @@ import { ComparisionType, } from '@superset-ui/core'; import { PostProcessingFactory } from './types'; -import { getMetricOffsetsMap, isValidTimeCompare } from './utils'; +import { getMetricOffsetsMap, isTimeComparison } from './utils'; export const renameOperator: PostProcessingFactory = ( formData, @@ -45,7 +45,7 @@ export const renameOperator: PostProcessingFactory = ( !( // todo: we should provide an approach to handle derived metrics ( - isValidTimeCompare(formData, queryObject) && + isTimeComparison(formData, queryObject) && [ ComparisionType.Difference, ComparisionType.Ratio, @@ -60,7 +60,7 @@ export const renameOperator: PostProcessingFactory = ( // "actual values" will add derived metric. // we will rename the "metric" from the metricWithOffset label // for example: "count__1 year ago" => "1 year ago" - isValidTimeCompare(formData, queryObject) && + isTimeComparison(formData, queryObject) && formData.comparison_type === ComparisionType.Values ) { const metricOffsetMap = getMetricOffsetsMap(formData, queryObject); diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/rollingWindowOperator.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/rollingWindowOperator.ts index 563b3e0544faa..0ab459e5cae03 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/rollingWindowOperator.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/rollingWindowOperator.ts @@ -24,14 +24,14 @@ import { PostProcessingRolling, RollingType, } from '@superset-ui/core'; -import { getMetricOffsetsMap, isValidTimeCompare } from './utils'; +import { getMetricOffsetsMap, isTimeComparison } from './utils'; import { PostProcessingFactory } from './types'; export const rollingWindowOperator: PostProcessingFactory< PostProcessingRolling | PostProcessingCum > = (formData, queryObject) => { let columns: (string | undefined)[]; - if (isValidTimeCompare(formData, queryObject)) { + if (isTimeComparison(formData, queryObject)) { const metricsMap = getMetricOffsetsMap(formData, queryObject); columns = [ ...Array.from(metricsMap.values()), diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/timeCompareOperator.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/timeCompareOperator.ts index ec62384615f74..3fe253edfdfd1 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/timeCompareOperator.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/timeCompareOperator.ts @@ -18,7 +18,7 @@ * under the License. */ import { ComparisionType, PostProcessingCompare } from '@superset-ui/core'; -import { getMetricOffsetsMap, isValidTimeCompare } from './utils'; +import { getMetricOffsetsMap, isTimeComparison } from './utils'; import { PostProcessingFactory } from './types'; export const timeCompareOperator: PostProcessingFactory = @@ -27,7 +27,7 @@ export const timeCompareOperator: PostProcessingFactory = const metricOffsetMap = getMetricOffsetsMap(formData, queryObject); if ( - isValidTimeCompare(formData, queryObject) && + isTimeComparison(formData, queryObject) && comparisonType !== ComparisionType.Values ) { return { diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/timeComparePivotOperator.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/timeComparePivotOperator.ts index 44a1825ff8ee5..f7bbd238c6f54 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/timeComparePivotOperator.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/timeComparePivotOperator.ts @@ -24,14 +24,14 @@ import { NumpyFunction, PostProcessingPivot, } from '@superset-ui/core'; -import { getMetricOffsetsMap, isValidTimeCompare } from './utils'; +import { getMetricOffsetsMap, isTimeComparison } from './utils'; import { PostProcessingFactory } from './types'; export const timeComparePivotOperator: PostProcessingFactory = (formData, queryObject) => { const metricOffsetMap = getMetricOffsetsMap(formData, queryObject); - if (isValidTimeCompare(formData, queryObject)) { + if (isTimeComparison(formData, queryObject)) { const aggregates = Object.fromEntries( [...metricOffsetMap.values(), ...metricOffsetMap.keys()].map(metric => [ metric, diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/index.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/index.ts index d591dbd23edde..e4dfbd776908d 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/index.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/index.ts @@ -18,5 +18,5 @@ * under the License. */ export { getMetricOffsetsMap } from './getMetricOffsetsMap'; -export { isValidTimeCompare } from './isValidTimeCompare'; +export { isTimeComparison } from './isTimeComparison'; export { TIME_COMPARISON_SEPARATOR } from './constants'; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/isValidTimeCompare.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/isTimeComparison.ts similarity index 94% rename from superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/isValidTimeCompare.ts rename to superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/isTimeComparison.ts index 793bb392315d8..4430b9541cdbb 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/isValidTimeCompare.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/isTimeComparison.ts @@ -21,7 +21,7 @@ import { ComparisionType } from '@superset-ui/core'; import { getMetricOffsetsMap } from './getMetricOffsetsMap'; import { PostProcessingFactory } from '../types'; -export const isValidTimeCompare: PostProcessingFactory = ( +export const isTimeComparison: PostProcessingFactory = ( formData, queryObject, ) => { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts index c2f603bfc45cd..3478c73470fc7 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts @@ -27,7 +27,7 @@ import { import { rollingWindowOperator, timeCompareOperator, - isValidTimeCompare, + isTimeComparison, pivotOperator, resampleOperator, renameOperator, @@ -61,7 +61,7 @@ export default function buildQuery(formData: QueryFormData) { 2015-03-01 318.0 0.0 */ - const pivotOperatorInRuntime: PostProcessingPivot = isValidTimeCompare( + const pivotOperatorInRuntime: PostProcessingPivot = isTimeComparison( formData, baseQueryObject, ) @@ -80,7 +80,7 @@ export default function buildQuery(formData: QueryFormData) { is_timeseries, // todo: move `normalizeOrderBy to extractQueryFields` orderby: normalizeOrderBy(baseQueryObject).orderby, - time_offsets: isValidTimeCompare(formData, baseQueryObject) + time_offsets: isTimeComparison(formData, baseQueryObject) ? formData.time_compare : [], /* Note that: From e3a54aa3c15bdd0c970aa73f898288a408205c97 Mon Sep 17 00:00:00 2001 From: Stephen Liu <750188453@qq.com> Date: Wed, 20 Apr 2022 21:06:33 +0800 Subject: [PATCH 087/136] feat(explore): improve UI in the control panel (#19748) * feat(explore): improve section header of control panel * fix checkbox control color and radio button color --- .../src/sections/advancedAnalytics.tsx | 6 ++--- .../src/sections/chartTitle.tsx | 4 +-- .../components/RadioButtonControl.tsx | 5 +++- .../src/controlPanel.tsx | 9 +++---- .../src/controlPanel.tsx | 9 +++---- .../src/NVD3Controls.tsx | 7 +++--- .../BigNumber/BigNumberTotal/controlPanel.ts | 2 +- .../BigNumberWithTrendline/controlPanel.tsx | 5 ++-- .../src/Funnel/controlPanel.tsx | 2 +- .../src/Gauge/controlPanel.tsx | 8 +++--- .../src/Graph/controlPanel.tsx | 2 +- .../src/MixedTimeseries/controlPanel.tsx | 6 ++--- .../src/Pie/controlPanel.tsx | 4 +-- .../src/Radar/controlPanel.tsx | 4 +-- .../src/Timeseries/Area/controlPanel.tsx | 4 +-- .../Timeseries/Regular/Bar/controlPanel.tsx | 4 +-- .../Regular/Scatter/controlPanel.tsx | 4 +-- .../src/Timeseries/Regular/controlPanel.tsx | 4 +-- .../src/Timeseries/Step/controlPanel.tsx | 4 +-- .../src/Timeseries/controlPanel.tsx | 4 +-- .../src/Tree/controlPanel.tsx | 2 +- .../src/Treemap/controlPanel.tsx | 2 +- .../plugin-chart-echarts/src/controls.tsx | 4 +-- .../assets/stylesheets/less/variables.less | 1 + .../src/explore/components/Control.tsx | 2 +- .../src/explore/components/ControlHeader.tsx | 12 +++++---- .../components/ControlPanelsContainer.tsx | 18 +++++++++++-- .../components/controls/CheckboxControl.jsx | 25 +++++++++++++------ .../src/explore/controlPanels/sections.tsx | 8 +++--- superset-frontend/src/explore/main.less | 13 +++------- 30 files changed, 99 insertions(+), 85 deletions(-) diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/sections/advancedAnalytics.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/sections/advancedAnalytics.tsx index ebd118d88122c..3d562309ca948 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/sections/advancedAnalytics.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/sections/advancedAnalytics.tsx @@ -30,7 +30,7 @@ export const advancedAnalyticsControls: ControlPanelSectionConfig = { 'of query results', ), controlSetRows: [ - [

{t('Rolling window')}

], + [
{t('Rolling window')}
], [ { name: 'rolling_type', @@ -85,7 +85,7 @@ export const advancedAnalyticsControls: ControlPanelSectionConfig = { }, }, ], - [

{t('Time comparison')}

], + [
{t('Time comparison')}
], [ { name: 'time_compare', @@ -136,7 +136,7 @@ export const advancedAnalyticsControls: ControlPanelSectionConfig = { }, }, ], - [

{t('Resample')}

], + [
{t('Resample')}
], [ { name: 'resample_rule', diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/sections/chartTitle.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/sections/chartTitle.tsx index 5e99d976c55b3..314e983c589ae 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/sections/chartTitle.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/sections/chartTitle.tsx @@ -28,7 +28,7 @@ export const titleControls: ControlPanelSectionConfig = { tabOverride: 'customize', expanded: true, controlSetRows: [ - [

{t('X Axis')}

], + [
{t('X Axis')}
], [ { name: 'x_axis_title', @@ -56,7 +56,7 @@ export const titleControls: ControlPanelSectionConfig = { }, }, ], - [

{t('Y Axis')}

], + [
{t('Y Axis')}
], [ { name: 'y_axis_title', diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/components/RadioButtonControl.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/components/RadioButtonControl.tsx index e9f6a6f9bc4d5..b613fed93aa87 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/components/RadioButtonControl.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/components/RadioButtonControl.tsx @@ -56,8 +56,11 @@ export default function RadioButtonControl({ '.control-label + .btn-group': { marginTop: 1, }, + '.btn-group .btn-default': { + color: theme.colors.grayscale.dark1, + }, '.btn-group .btn.active': { - background: theme.colors.secondary.light5, + background: theme.colors.grayscale.light4, fontWeight: theme.typography.weights.bold, boxShadow: 'none', }, diff --git a/superset-frontend/plugins/legacy-plugin-chart-partition/src/controlPanel.tsx b/superset-frontend/plugins/legacy-plugin-chart-partition/src/controlPanel.tsx index c742e6d1335cb..93139f7ff7b8d 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-partition/src/controlPanel.tsx +++ b/superset-frontend/plugins/legacy-plugin-chart-partition/src/controlPanel.tsx @@ -240,7 +240,7 @@ const config: ControlPanelConfig = { ), controlSetRows: [ // eslint-disable-next-line react/jsx-key - [

{t('Rolling Window')}

], + [
{t('Rolling Window')}
], [ { name: 'rolling_type', @@ -292,7 +292,7 @@ const config: ControlPanelConfig = { }, ], // eslint-disable-next-line react/jsx-key - [

{t('Time Comparison')}

], + [
{t('Time Comparison')}
], [ { name: 'time_compare', @@ -341,10 +341,7 @@ const config: ControlPanelConfig = { }, }, ], - // eslint-disable-next-line react/jsx-key - [

{t('Python Functions')}

], - // eslint-disable-next-line react/jsx-key - [

pandas.resample

], + [
{t('Resample')}
], [ { name: 'resample_rule', diff --git a/superset-frontend/plugins/legacy-plugin-chart-rose/src/controlPanel.tsx b/superset-frontend/plugins/legacy-plugin-chart-rose/src/controlPanel.tsx index fd04117e6217c..e43da2de7237a 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-rose/src/controlPanel.tsx +++ b/superset-frontend/plugins/legacy-plugin-chart-rose/src/controlPanel.tsx @@ -123,7 +123,7 @@ const config: ControlPanelConfig = { ), controlSetRows: [ // eslint-disable-next-line react/jsx-key - [

{t('Rolling Window')}

], + [
{t('Rolling Window')}
], [ { name: 'rolling_type', @@ -175,7 +175,7 @@ const config: ControlPanelConfig = { }, ], // eslint-disable-next-line react/jsx-key - [

{t('Time Comparison')}

], + [
{t('Time Comparison')}
], [ { name: 'time_compare', @@ -224,10 +224,7 @@ const config: ControlPanelConfig = { }, }, ], - // eslint-disable-next-line react/jsx-key - [

{t('Python Functions')}

], - // eslint-disable-next-line react/jsx-key - [

pandas.resample

], + [
{t('Resample')}
], [ { name: 'resample_rule', diff --git a/superset-frontend/plugins/legacy-preset-chart-nvd3/src/NVD3Controls.tsx b/superset-frontend/plugins/legacy-preset-chart-nvd3/src/NVD3Controls.tsx index 3b0bb92ac758b..151c53e41f2ff 100644 --- a/superset-frontend/plugins/legacy-preset-chart-nvd3/src/NVD3Controls.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-nvd3/src/NVD3Controls.tsx @@ -370,7 +370,7 @@ export const timeSeriesSection: ControlPanelSectionConfig[] = [ 'of query results', ), controlSetRows: [ - [

{t('Rolling Window')}

], + [
{t('Rolling Window')}
], [ { name: 'rolling_type', @@ -423,7 +423,7 @@ export const timeSeriesSection: ControlPanelSectionConfig[] = [ }, }, ], - [

{t('Time Comparison')}

], + [
{t('Time Comparison')}
], [ { name: 'time_compare', @@ -474,8 +474,7 @@ export const timeSeriesSection: ControlPanelSectionConfig[] = [ }, }, ], - [

{t('Python Functions')}

], - [

pandas.resample

], + [
{t('Resample')}
], [ { name: 'resample_rule', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/controlPanel.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/controlPanel.ts index e30dcbe6bee6d..8511c3ca5645e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/controlPanel.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/controlPanel.ts @@ -34,7 +34,7 @@ export default { controlSetRows: [['metric'], ['adhoc_filters']], }, { - label: t('Options'), + label: t('Display settings'), expanded: true, tabOverride: 'data', controlSetRows: [ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/controlPanel.tsx index 6b99af91ce9c4..3ba00f55ea212 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/controlPanel.tsx @@ -164,7 +164,7 @@ const config: ControlPanelConfig = { expanded: false, controlSetRows: [ // eslint-disable-next-line react/jsx-key - [

{t('Rolling Window')}

], + [
{t('Rolling Window')}
], [ { name: 'rolling_type', @@ -217,8 +217,7 @@ const config: ControlPanelConfig = { }, }, ], - // eslint-disable-next-line react/jsx-key - [

{t('Resample')}

], + [
{t('Resample')}
], [ { name: 'resample_rule', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/controlPanel.tsx index e1950bf9a5b37..fe2269cf89c05 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/controlPanel.tsx @@ -76,7 +76,7 @@ const config: ControlPanelConfig = { ['color_scheme'], ...funnelLegendSection, // eslint-disable-next-line react/jsx-key - [

{t('Labels')}

], + [
{t('Labels')}
], [ { name: 'label_type', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/controlPanel.tsx index 581d98c6b99f7..ff03da4153b18 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/controlPanel.tsx @@ -75,7 +75,7 @@ const config: ControlPanelConfig = { label: t('Chart Options'), expanded: true, controlSetRows: [ - [

{t('General')}

], + [
{t('General')}
], [ { name: 'min_val', @@ -197,7 +197,7 @@ const config: ControlPanelConfig = { }, }, ], - [

{t('Axis')}

], + [
{t('Axis')}
], [ { name: 'show_axis_tick', @@ -236,7 +236,7 @@ const config: ControlPanelConfig = { }, }, ], - [

{t('Progress')}

], + [
{t('Progress')}
], [ { name: 'show_progress', @@ -277,7 +277,7 @@ const config: ControlPanelConfig = { }, }, ], - [

{t('Intervals')}

], + [
{t('Intervals')}
], [ { name: 'intervals', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/controlPanel.tsx index cdefae16cab54..cb2f586110177 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/controlPanel.tsx @@ -98,7 +98,7 @@ const controlPanel: ControlPanelConfig = { controlSetRows: [ ['color_scheme'], ...legendSection, - [

{t('Layout')}

], + [
{t('Layout')}
], [ { name: 'layout', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx index 8cd681c5e33e1..97955eec3500c 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx @@ -126,7 +126,7 @@ function createCustomizeSection( controlSuffix: string, ): ControlSetRow[] { return [ - [

{label}

], + [
{label}
], [ { name: `seriesType${controlSuffix}`, @@ -296,7 +296,7 @@ const config: ControlPanelConfig = { }, ], ...legendSection, - [

{t('X Axis')}

], + [
{t('X Axis')}
], ['x_axis_time_format'], [ { @@ -320,7 +320,7 @@ const config: ControlPanelConfig = { ], ...richTooltipSection, // eslint-disable-next-line react/jsx-key - [

{t('Y Axis')}

], + [
{t('Y Axis')}
], [ { name: 'minorSplitLine', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/controlPanel.tsx index c195c5e2214d9..9056446f9f412 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/controlPanel.tsx @@ -90,7 +90,7 @@ const config: ControlPanelConfig = { ], ...legendSection, // eslint-disable-next-line react/jsx-key - [

{t('Labels')}

], + [
{t('Labels')}
], [ { name: 'label_type', @@ -196,7 +196,7 @@ const config: ControlPanelConfig = { }, ], // eslint-disable-next-line react/jsx-key - [

{t('Pie shape')}

], + [
{t('Pie shape')}
], [ { name: 'outerRadius', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/controlPanel.tsx index 0f8e390802a56..d24497280ae6b 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/controlPanel.tsx @@ -85,7 +85,7 @@ const config: ControlPanelConfig = { controlSetRows: [ ['color_scheme'], ...legendSection, - [

{t('Labels')}

], + [
{t('Labels')}
], [ { name: 'show_labels', @@ -158,7 +158,7 @@ const config: ControlPanelConfig = { }, }, ], - [

{t('Radar')}

], + [
{t('Radar')}
], [ { name: 'column_config', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx index 87503166b7977..b973cb6782c03 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx @@ -178,7 +178,7 @@ const config: ControlPanelConfig = { }, ], ...legendSection, - [

{t('X Axis')}

], + [
{t('X Axis')}
], [ { name: 'x_axis_time_format', @@ -213,7 +213,7 @@ const config: ControlPanelConfig = { ], ...richTooltipSection, // eslint-disable-next-line react/jsx-key - [

{t('Y Axis')}

], + [
{t('Y Axis')}
], ['y_axis_format'], [ { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx index bd40eeebe0e75..a3b74aa12f4fe 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx @@ -139,7 +139,7 @@ const config: ControlPanelConfig = { }, ], ...legendSection, - [

{t('X Axis')}

], + [
{t('X Axis')}
], [ { name: 'x_axis_time_format', @@ -175,7 +175,7 @@ const config: ControlPanelConfig = { // eslint-disable-next-line react/jsx-key ...richTooltipSection, // eslint-disable-next-line react/jsx-key - [

{t('Y Axis')}

], + [
{t('Y Axis')}
], ['y_axis_format'], [ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx index 4cdf16c8395a2..abc5e9a29e724 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx @@ -119,7 +119,7 @@ const config: ControlPanelConfig = { }, ], ...legendSection, - [

{t('X Axis')}

], + [
{t('X Axis')}
], [ { @@ -156,7 +156,7 @@ const config: ControlPanelConfig = { // eslint-disable-next-line react/jsx-key ...richTooltipSection, // eslint-disable-next-line react/jsx-key - [

{t('Y Axis')}

], + [
{t('Y Axis')}
], ['y_axis_format'], [ { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/controlPanel.tsx index d2f3acce9e08f..f234df0c82b4a 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/controlPanel.tsx @@ -136,7 +136,7 @@ const config: ControlPanelConfig = { }, ], ...legendSection, - [

{t('X Axis')}

], + [
{t('X Axis')}
], [ { name: 'x_axis_time_format', @@ -172,7 +172,7 @@ const config: ControlPanelConfig = { // eslint-disable-next-line react/jsx-key ...richTooltipSection, // eslint-disable-next-line react/jsx-key - [

{t('Y Axis')}

], + [
{t('Y Axis')}
], ['y_axis_format'], [ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/controlPanel.tsx index 1416a7db4686c..b8d3a31b2c295 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/controlPanel.tsx @@ -194,7 +194,7 @@ const config: ControlPanelConfig = { }, ], ...legendSection, - [

{t('X Axis')}

], + [
{t('X Axis')}
], [ { name: 'x_axis_time_format', @@ -229,7 +229,7 @@ const config: ControlPanelConfig = { ], ...richTooltipSection, // eslint-disable-next-line react/jsx-key - [

{t('Y Axis')}

], + [
{t('Y Axis')}
], ['y_axis_format'], [ { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/controlPanel.tsx index 1f1e22b49b3a5..8f22acadeefc3 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/controlPanel.tsx @@ -197,7 +197,7 @@ const config: ControlPanelConfig = { }, ], ...legendSection, - [

{t('X Axis')}

], + [
{t('X Axis')}
], [ { name: 'x_axis_time_format', @@ -232,7 +232,7 @@ const config: ControlPanelConfig = { ], ...richTooltipSection, // eslint-disable-next-line react/jsx-key - [

{t('Y Axis')}

], + [
{t('Y Axis')}
], ['y_axis_format'], [ { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Tree/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Tree/controlPanel.tsx index aa4a38fca871b..cd48e0f636e0b 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Tree/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Tree/controlPanel.tsx @@ -107,7 +107,7 @@ const controlPanel: ControlPanelConfig = { label: t('Chart options'), expanded: true, controlSetRows: [ - [

{t('Layout')}

], + [
{t('Layout')}
], [ { name: 'layout', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/controlPanel.tsx index 9f6d4e297e031..63ca40225ffe6 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/controlPanel.tsx @@ -62,7 +62,7 @@ const config: ControlPanelConfig = { expanded: true, controlSetRows: [ ['color_scheme'], - [

{t('Labels')}

], + [
{t('Labels')}
], [ { name: 'show_labels', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx index 053d0db8359fd..df050e6dbb418 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx @@ -94,7 +94,7 @@ const legendOrientationControl: ControlSetItem = { }; export const legendSection: ControlSetRow[] = [ - [

{t('Legend')}

], + [
{t('Legend')}
], [showLegendControl], [legendTypeControl], [legendOrientationControl], @@ -219,7 +219,7 @@ const tooltipSortByMetricControl: ControlSetItem = { }; export const richTooltipSection: ControlSetRow[] = [ - [

{t('Tooltip')}

], + [
{t('Tooltip')}
], [richTooltipControl], [tooltipSortByMetricControl], [tooltipTimeFormatControl], diff --git a/superset-frontend/src/assets/stylesheets/less/variables.less b/superset-frontend/src/assets/stylesheets/less/variables.less index 3f4fad5572708..e997f5fb78c96 100644 --- a/superset-frontend/src/assets/stylesheets/less/variables.less +++ b/superset-frontend/src/assets/stylesheets/less/variables.less @@ -48,6 +48,7 @@ @almost-black: #263238; @gray-dark: #484848; @gray-light: #e0e0e0; +@gray-light5: #666666; @gray: #879399; @gray-bg: #f7f7f7; @gray-heading: #a3a3a3; diff --git a/superset-frontend/src/explore/components/Control.tsx b/superset-frontend/src/explore/components/Control.tsx index 804c3d6b10da0..4c8345d438609 100644 --- a/superset-frontend/src/explore/components/Control.tsx +++ b/superset-frontend/src/explore/components/Control.tsx @@ -55,7 +55,7 @@ export type ControlComponentProps = Omit & BaseControlComponentProps; const StyledControl = styled.div` - padding-bottom: ${({ theme }) => theme.gridUnit}px; + padding-bottom: ${({ theme }) => theme.gridUnit * 4}px; `; export default function Control(props: ControlProps) { diff --git a/superset-frontend/src/explore/components/ControlHeader.tsx b/superset-frontend/src/explore/components/ControlHeader.tsx index ce240704b5d3f..16bf93d047c4a 100644 --- a/superset-frontend/src/explore/components/ControlHeader.tsx +++ b/superset-frontend/src/explore/components/ControlHeader.tsx @@ -17,7 +17,7 @@ * under the License. */ import React, { FC, ReactNode } from 'react'; -import { t, css, useTheme } from '@superset-ui/core'; +import { t, css, useTheme, SupersetTheme } from '@superset-ui/core'; import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls'; import { Tooltip } from 'src/components/Tooltip'; import { FormLabel } from 'src/components/Form'; @@ -106,10 +106,12 @@ const ControlHeader: FC = ({
+ css` + margin-bottom: ${theme.gridUnit * 0.5}px; + position: relative; + ` + } > {leftNode && {leftNode}} .ant-collapse-item { + &:not(:last-child) { + border-bottom: 1px solid ${theme.colors.grayscale.light3}; + } + + & > .ant-collapse-header { + font-size: ${theme.typography.sizes.s}px; + } + + & > .ant-collapse-content > .ant-collapse-content-box { + padding-bottom: 0; + font-size: ${theme.typography.sizes.s}px; + } + } `} `; @@ -388,6 +403,7 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => { &:last-child { padding-bottom: ${theme.gridUnit * 16}px; + border-bottom: 0; } .panel-body { @@ -515,7 +531,6 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => { > { {showCustomizeTab && ( {}, }; -const checkboxStyle = { paddingRight: '5px' }; +const CheckBoxControlWrapper = styled.div` + ${({ theme }) => css` + .ControlHeader label { + color: ${theme.colors.grayscale.dark1}; + } + span[role='checkbox'] { + padding-right: ${theme.gridUnit * 2}px; + } + `} +`; export default class CheckboxControl extends React.Component { onChange() { @@ -43,7 +53,6 @@ export default class CheckboxControl extends React.Component { return ( ); @@ -52,11 +61,13 @@ export default class CheckboxControl extends React.Component { render() { if (this.props.label) { return ( - + + + ); } return this.renderCheckbox(); diff --git a/superset-frontend/src/explore/controlPanels/sections.tsx b/superset-frontend/src/explore/controlPanels/sections.tsx index a1c786a73c15d..a6adbf3af23c3 100644 --- a/superset-frontend/src/explore/controlPanels/sections.tsx +++ b/superset-frontend/src/explore/controlPanels/sections.tsx @@ -132,7 +132,7 @@ export const NVD3TimeSeries: ControlPanelSectionConfig[] = [ 'of query results', ), controlSetRows: [ - [

{t('Rolling window')}

], + [
{t('Rolling window')}
], [ { name: 'rolling_type', @@ -181,7 +181,7 @@ export const NVD3TimeSeries: ControlPanelSectionConfig[] = [ }, }, ], - [

{t('Time comparison')}

], + [
{t('Time comparison')}
], [ { name: 'time_compare', @@ -230,9 +230,7 @@ export const NVD3TimeSeries: ControlPanelSectionConfig[] = [ }, }, ], - [

{t('Python functions')}

], - // eslint-disable-next-line jsx-a11y/heading-has-content - [

pandas.resample

], + [
{t('Resample')}
], [ { name: 'resample_rule', diff --git a/superset-frontend/src/explore/main.less b/superset-frontend/src/explore/main.less index d85e855b4d2cc..015a8a1a3bed3 100644 --- a/superset-frontend/src/explore/main.less +++ b/superset-frontend/src/explore/main.less @@ -127,18 +127,11 @@ } } -h1.section-header { - font-size: @font-size-m; - font-weight: @font-weight-bold; - margin-bottom: 0; - margin-top: 0; - padding-bottom: 5px; -} - -h2.section-header { +div.section-header { font-size: @font-size-s; font-weight: @font-weight-bold; + color: @gray-light5; margin-bottom: 0; margin-top: 0; - padding-bottom: 5px; + padding-bottom: 16px; } From 9c20299039e5c2ad0136b6f1f0a9954a5a19116e Mon Sep 17 00:00:00 2001 From: Cemre Mengu Date: Wed, 20 Apr 2022 17:31:40 +0300 Subject: [PATCH 088/136] fix(migrations): sl_columns is_temporal mapping (#19786) --- .../versions/a9422eeaae74_new_dataset_models_take_2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset/migrations/versions/a9422eeaae74_new_dataset_models_take_2.py b/superset/migrations/versions/a9422eeaae74_new_dataset_models_take_2.py index efb7d1a01b0ee..0ded98b93cd98 100644 --- a/superset/migrations/versions/a9422eeaae74_new_dataset_models_take_2.py +++ b/superset/migrations/versions/a9422eeaae74_new_dataset_models_take_2.py @@ -430,7 +430,7 @@ def copy_columns(session: Session) -> None: ), sa.literal(False).label("is_aggregation"), is_physical_column.label("is_physical"), - TableColumn.is_dttm.label("is_temporal"), + func.coalesce(TableColumn.is_dttm, False).label("is_temporal"), func.coalesce(TableColumn.type, UNKNOWN_TYPE).label("type"), TableColumn.extra.label("extra_json"), ] From fcc8080ff3b99e2f5f5cdbd48335d7ab83aba16a Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Wed, 20 Apr 2022 17:25:31 +0200 Subject: [PATCH 089/136] fix(plugin-chart-table): Resetting controls when switching query mode (#19792) --- .../plugins/plugin-chart-table/src/controlPanel.tsx | 10 ++++++++++ superset-frontend/src/explore/components/Control.tsx | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx index 5b9abfb163d9b..c121547518e46 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx @@ -117,6 +117,7 @@ const all_columns: typeof sharedControls.groupby = { : [], }), visibility: isRawMode, + resetOnHide: false, }; const dnd_all_columns: typeof sharedControls.groupby = { @@ -140,6 +141,7 @@ const dnd_all_columns: typeof sharedControls.groupby = { return newState; }, visibility: isRawMode, + resetOnHide: false, }; const percent_metrics: typeof sharedControls.metrics = { @@ -150,6 +152,7 @@ const percent_metrics: typeof sharedControls.metrics = { ), multi: true, visibility: isAggMode, + resetOnHide: false, mapStateToProps: ({ datasource, controls }, controlState) => ({ columns: datasource?.columns || [], savedMetrics: datasource?.metrics || [], @@ -190,6 +193,7 @@ const config: ControlPanelConfig = { name: 'groupby', override: { visibility: isAggMode, + resetOnHide: false, mapStateToProps: ( state: ControlPanelState, controlState: ControlState, @@ -220,6 +224,7 @@ const config: ControlPanelConfig = { override: { validators: [], visibility: isAggMode, + resetOnHide: false, mapStateToProps: ( { controls, datasource, form_data }: ControlPanelState, controlState: ControlState, @@ -263,6 +268,7 @@ const config: ControlPanelConfig = { name: 'timeseries_limit_metric', override: { visibility: isAggMode, + resetOnHide: false, }, }, { @@ -277,6 +283,7 @@ const config: ControlPanelConfig = { choices: datasource?.order_by_choices || [], }), visibility: isRawMode, + resetOnHide: false, }, }, ], @@ -329,6 +336,7 @@ const config: ControlPanelConfig = { ), default: false, visibility: isAggMode, + resetOnHide: false, }, }, { @@ -339,6 +347,7 @@ const config: ControlPanelConfig = { default: true, description: t('Whether to sort descending or ascending'), visibility: isAggMode, + resetOnHide: false, }, }, ], @@ -353,6 +362,7 @@ const config: ControlPanelConfig = { 'Show total aggregations of selected metrics. Note that row limit does not apply to the result.', ), visibility: isAggMode, + resetOnHide: false, }, }, ], diff --git a/superset-frontend/src/explore/components/Control.tsx b/superset-frontend/src/explore/components/Control.tsx index 4c8345d438609..5e202fdf10dad 100644 --- a/superset-frontend/src/explore/components/Control.tsx +++ b/superset-frontend/src/explore/components/Control.tsx @@ -46,6 +46,7 @@ export type ControlProps = { renderTrigger?: boolean; default?: JsonValue; isVisible?: boolean; + resetOnHide?: boolean; }; /** @@ -65,6 +66,7 @@ export default function Control(props: ControlProps) { type, hidden, isVisible, + resetOnHide = true, } = props; const [hovered, setHovered] = useState(false); @@ -79,7 +81,8 @@ export default function Control(props: ControlProps) { wasVisible === true && isVisible === false && props.default !== undefined && - !isEqual(props.value, props.default) + !isEqual(props.value, props.default) && + resetOnHide ) { // reset control value if setting to invisible setControlValue?.(name, props.default); From 5e468f7a4cccc496ccafa52f9aba5b7688145fe4 Mon Sep 17 00:00:00 2001 From: serenajiang Date: Wed, 20 Apr 2022 09:36:28 -0700 Subject: [PATCH 090/136] fix(world-map): remove categorical color option (#19781) --- .../src/WorldMap.js | 24 +++++-------------- .../src/controlPanel.ts | 4 ---- .../src/transformProps.js | 12 ++-------- 3 files changed, 8 insertions(+), 32 deletions(-) diff --git a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.js b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.js index 0c81e98560166..c7253e10d0e68 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.js +++ b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.js @@ -23,7 +23,6 @@ import { extent as d3Extent } from 'd3-array'; import { getNumberFormatter, getSequentialSchemeRegistry, - CategoricalColorNamespace, } from '@superset-ui/core'; import Datamap from 'datamaps/dist/datamaps.world.min'; @@ -56,8 +55,6 @@ function WorldMap(element, props) { showBubbles, linearColorScheme, color, - colorScheme, - sliceId, } = props; const div = d3.select(element); div.classed('superset-legacy-chart-world-map', true); @@ -72,24 +69,15 @@ function WorldMap(element, props) { .domain([extRadius[0], extRadius[1]]) .range([1, maxBubbleSize]); - const linearColorScale = getSequentialSchemeRegistry() + const colorScale = getSequentialSchemeRegistry() .get(linearColorScheme) .createLinearScale(d3Extent(filteredData, d => d.m1)); - const colorScale = CategoricalColorNamespace.getScale(colorScheme); - - const processedData = filteredData.map(d => { - let color = linearColorScale(d.m1); - if (colorScheme) { - // use color scheme instead - color = colorScale(d.name, sliceId); - } - return { - ...d, - radius: radiusScale(Math.sqrt(d.m2)), - fillColor: color, - }; - }); + const processedData = filteredData.map(d => ({ + ...d, + radius: radiusScale(Math.sqrt(d.m2)), + fillColor: colorScale(d.m1), + })); const mapData = {}; processedData.forEach(d => { diff --git a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/controlPanel.ts b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/controlPanel.ts index 91664290dcb02..ec8aafc7b872a 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/controlPanel.ts +++ b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/controlPanel.ts @@ -106,7 +106,6 @@ const config: ControlPanelConfig = { }, ], ['color_picker'], - ['color_scheme'], ['linear_color_scheme'], ], }, @@ -127,9 +126,6 @@ const config: ControlPanelConfig = { color_picker: { label: t('Bubble Color'), }, - color_scheme: { - label: t('Categorical Color Scheme'), - }, linear_color_scheme: { label: t('Country Color Scheme'), }, diff --git a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/transformProps.js b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/transformProps.js index 3838ebfa5c10a..464dd53afa4fc 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/transformProps.js +++ b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/transformProps.js @@ -20,14 +20,8 @@ import { rgb } from 'd3-color'; export default function transformProps(chartProps) { const { width, height, formData, queriesData } = chartProps; - const { - maxBubbleSize, - showBubbles, - linearColorScheme, - colorPicker, - colorScheme, - sliceId, - } = formData; + const { maxBubbleSize, showBubbles, linearColorScheme, colorPicker } = + formData; const { r, g, b } = colorPicker; return { @@ -38,7 +32,5 @@ export default function transformProps(chartProps) { showBubbles, linearColorScheme, color: rgb(r, g, b).hex(), - colorScheme, - sliceId, }; } From 7f22edfd0600e14b0d23fe09fd87b28d1cc8363f Mon Sep 17 00:00:00 2001 From: Diego Medina Date: Wed, 20 Apr 2022 15:51:09 -0400 Subject: [PATCH 091/136] fix: remove & reimplement the tests for AlertReportCronScheduler component (#19288) --- .../AlertReportCronScheduler.test.tsx | 164 +++++++++++++----- .../components/AlertReportCronScheduler.tsx | 48 +++-- 2 files changed, 153 insertions(+), 59 deletions(-) diff --git a/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.test.tsx b/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.test.tsx index 822b129c56de7..5d36c2994dcab 100644 --- a/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.test.tsx +++ b/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.test.tsx @@ -16,58 +16,138 @@ * specific language governing permissions and limitations * under the License. */ - import React from 'react'; -import { ReactWrapper } from 'enzyme'; -import { styledMount as mount } from 'spec/helpers/theming'; -import { CronPicker } from 'src/components/CronPicker'; -import { Input } from 'src/components/Input'; -import { AlertReportCronScheduler } from './AlertReportCronScheduler'; +import { render, screen, waitFor, within } from 'spec/helpers/testing-library'; +import userEvent from '@testing-library/user-event'; +import { act } from 'react-dom/test-utils'; + +import { + AlertReportCronScheduler, + AlertReportCronSchedulerProps, +} from './AlertReportCronScheduler'; + +const createProps = (props: Partial = {}) => ({ + onChange: jest.fn(), + value: '* * * * *', + ...props, +}); + +test('should render', () => { + const props = createProps(); + render(); + + // Text found in the first radio option + expect(screen.getByText('Every')).toBeInTheDocument(); + // Text found in the second radio option + expect(screen.getByText('CRON Schedule')).toBeInTheDocument(); +}); + +test('only one radio option should be enabled at a time', () => { + const props = createProps(); + const { container } = render(); + + expect(screen.getByTestId('picker')).toBeChecked(); + expect(screen.getByTestId('input')).not.toBeChecked(); + + const pickerContainer = container.querySelector( + '.react-js-cron-select', + ) as HTMLElement; + const inputContainer = screen.getByTestId('input-content'); + + expect(within(pickerContainer).getAllByRole('combobox')[0]).toBeEnabled(); + expect(inputContainer.querySelector('input[name="crontab"]')).toBeDisabled(); + + userEvent.click(screen.getByTestId('input')); + + expect(within(pickerContainer).getAllByRole('combobox')[0]).toBeDisabled(); + expect(inputContainer.querySelector('input[name="crontab"]')).toBeEnabled(); + + userEvent.click(screen.getByTestId('picker')); + + expect(within(pickerContainer).getAllByRole('combobox')[0]).toBeEnabled(); + expect(inputContainer.querySelector('input[name="crontab"]')).toBeDisabled(); +}); + +test('picker mode updates correctly', async () => { + const onChangeCallback = jest.fn(); + const props = createProps({ + onChange: onChangeCallback, + }); + + const { container } = render(); -describe('AlertReportCronScheduler', () => { - let wrapper: ReactWrapper; + expect(screen.getByTestId('picker')).toBeChecked(); - it('calls onChnage when value chnages', () => { - const onChangeMock = jest.fn(); - wrapper = mount( - , - ); + const pickerContainer = container.querySelector( + '.react-js-cron-select', + ) as HTMLElement; - const changeValue = '1,7 * * * *'; + const firstSelect = within(pickerContainer).getAllByRole('combobox')[0]; + act(() => { + userEvent.click(firstSelect); + }); - wrapper.find(CronPicker).props().setValue(changeValue); - expect(onChangeMock).toHaveBeenLastCalledWith(changeValue); + expect(await within(pickerContainer).findByText('day')).toBeInTheDocument(); + act(() => { + userEvent.click(within(pickerContainer).getByText('day')); }); - it.skip('sets input value when cron picker changes', () => { - const onChangeMock = jest.fn(); - wrapper = mount( - , - ); + expect(onChangeCallback).toHaveBeenLastCalledWith('* * * * *'); + + const secondSelect = container.querySelector( + '.react-js-cron-hours .ant-select-selector', + ) as HTMLElement; + await waitFor(() => { + expect(secondSelect).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(secondSelect); + }); - const changeValue = '1,7 * * * *'; + expect(await screen.findByText('9')).toBeInTheDocument(); + act(() => { + userEvent.click(screen.getByText('9')); + }); - wrapper.find(CronPicker).props().setValue(changeValue); - // TODO fix this class-style assertion that doesn't work on function components - // @ts-ignore - expect(wrapper.find(Input).state().value).toEqual(changeValue); + await waitFor(() => { + expect(onChangeCallback).toHaveBeenLastCalledWith('* 9 * * *'); }); +}); - it('calls onChange when input value changes', () => { - const onChangeMock = jest.fn(); - wrapper = mount( - , - ); - - const changeValue = '1,7 * * * *'; - const event = { - target: { value: changeValue }, - } as React.FocusEvent; - - const inputProps = wrapper.find(Input).props(); - if (inputProps.onBlur) { - inputProps.onBlur(event); - } - expect(onChangeMock).toHaveBeenLastCalledWith(changeValue); +test('input mode updates correctly', async () => { + const onChangeCallback = jest.fn(); + const props = createProps({ + onChange: onChangeCallback, }); + + render(); + + const inputContainer = screen.getByTestId('input-content'); + userEvent.click(screen.getByTestId('input')); + + const input = inputContainer.querySelector( + 'input[name="crontab"]', + ) as HTMLElement; + await waitFor(() => { + expect(input).toBeEnabled(); + }); + + userEvent.clear(input); + expect(input).toHaveValue(''); + + const value = '* 10 2 * *'; + await act(async () => { + await userEvent.type(input, value, { delay: 1 }); + }); + + await waitFor(() => { + expect(input).toHaveValue(value); + }); + + act(() => { + userEvent.click(inputContainer); + }); + + expect(onChangeCallback).toHaveBeenLastCalledWith(value); }); diff --git a/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.tsx b/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.tsx index 867ee880d7d73..5418842aeaaa5 100644 --- a/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.tsx +++ b/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.tsx @@ -16,27 +16,33 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useState, useCallback, useRef, FunctionComponent } from 'react'; +import React, { useState, useCallback, useRef, FocusEvent } from 'react'; import { t, useTheme } from '@superset-ui/core'; -import { AntdInput } from 'src/components'; +import { AntdInput, RadioChangeEvent } from 'src/components'; import { Input } from 'src/components/Input'; import { Radio } from 'src/components/Radio'; import { CronPicker, CronError } from 'src/components/CronPicker'; import { StyledInputContainer } from 'src/views/CRUD/alert/AlertReportModal'; -interface AlertReportCronSchedulerProps { +export interface AlertReportCronSchedulerProps { value: string; onChange: (change: string) => any; } -export const AlertReportCronScheduler: FunctionComponent = +export const AlertReportCronScheduler: React.FC = ({ value, onChange }) => { const theme = useTheme(); const inputRef = useRef(null); const [scheduleFormat, setScheduleFormat] = useState<'picker' | 'input'>( 'picker', ); + + const handleRadioButtonChange = useCallback( + (e: RadioChangeEvent) => setScheduleFormat(e.target.value), + [], + ); + const customSetValue = useCallback( (newValue: string) => { onChange(newValue); @@ -44,16 +50,25 @@ export const AlertReportCronScheduler: FunctionComponent) => { + onChange(event.target.value); + }, + [onChange], + ); + + const handlePressEnter = useCallback(() => { + onChange(inputRef.current?.input.value || ''); + }, [onChange]); + const [error, onError] = useState(); return ( <> - setScheduleFormat(e.target.value)} - value={scheduleFormat} - > +
- +
- + CRON Schedule - +
{ - onChange(event.target.value); - }} - onPressEnter={() => { - onChange(inputRef.current?.input.value || ''); - }} + onBlur={handleBlur} + onPressEnter={handlePressEnter} />
From dfbaba97c61c28ecde8ce134a1f6ec385467c383 Mon Sep 17 00:00:00 2001 From: smileydev <47900232+prosdev0107@users.noreply.github.com> Date: Wed, 20 Apr 2022 15:58:25 -0400 Subject: [PATCH 092/136] fix(chart & explore): Show labels for `SliderControl` (#19765) * fix(chart & explore): make to show label of slidercontrol * fix(chart & explore): make to update SliderControl props * fix(chart & explore): make to fix lint --- .../components/controls/SliderControl.tsx | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/superset-frontend/src/explore/components/controls/SliderControl.tsx b/superset-frontend/src/explore/components/controls/SliderControl.tsx index 5907e26ba8348..a2d3b7c2bced1 100644 --- a/superset-frontend/src/explore/components/controls/SliderControl.tsx +++ b/superset-frontend/src/explore/components/controls/SliderControl.tsx @@ -18,19 +18,50 @@ */ import React from 'react'; import Slider from 'src/components/Slider'; -import ControlHeader from 'src/explore/components/ControlHeader'; +import ControlHeader, { + ControlHeaderProps, +} from 'src/explore/components/ControlHeader'; -type SliderControlProps = { +type SliderControlProps = ControlHeaderProps & { onChange: (value: number) => void; value: number; default?: number; }; -export default function SliderControl(props: SliderControlProps) { - const { onChange = () => {}, default: defaultValue, ...rest } = props; +export default function SliderControl({ + default: defaultValue, + name, + label, + description, + renderTrigger, + rightNode, + leftNode, + validationErrors, + hovered, + warning, + danger, + onClick, + tooltipOnClick, + onChange = () => {}, + ...rest +}: SliderControlProps) { + const headerProps = { + name, + label, + description, + renderTrigger, + rightNode, + leftNode, + validationErrors, + onClick, + hovered, + tooltipOnClick, + warning, + danger, + }; return ( <> - + ); From 4513cc475831c3fd4869b44255edf91dabe18e0f Mon Sep 17 00:00:00 2001 From: Diego Medina Date: Wed, 20 Apr 2022 17:03:02 -0400 Subject: [PATCH 093/136] fix: trap SQLAlchemy common exceptions & throw 422 error instead (#19672) --- superset/views/base.py | 5 ++++- superset/views/base_api.py | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/superset/views/base.py b/superset/views/base.py index 863ca2f84ab67..22e4c5f8d163b 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -45,7 +45,7 @@ from flask_wtf.csrf import CSRFError from flask_wtf.form import FlaskForm from pkg_resources import resource_filename -from sqlalchemy import or_ +from sqlalchemy import exc, or_ from sqlalchemy.orm import Query from werkzeug.exceptions import HTTPException from wtforms import Form @@ -231,6 +231,9 @@ def wraps(self: "BaseSupersetView", *args: Any, **kwargs: Any) -> FlaskResponse: return json_error_response( utils.error_msg_from_exception(ex), status=cast(int, ex.code) ) + except (exc.IntegrityError, exc.DatabaseError, exc.DataError) as ex: + logger.exception(ex) + return json_error_response(utils.error_msg_from_exception(ex), status=422) except Exception as ex: # pylint: disable=broad-except logger.exception(ex) return json_error_response(utils.error_msg_from_exception(ex)) diff --git a/superset/views/base_api.py b/superset/views/base_api.py index 260e5731788bc..01b462bb321f6 100644 --- a/superset/views/base_api.py +++ b/superset/views/base_api.py @@ -39,6 +39,7 @@ from superset.stats_logger import BaseStatsLogger from superset.superset_typing import FlaskResponse from superset.utils.core import time_function +from superset.views.base import handle_api_exception logger = logging.getLogger(__name__) get_related_schema = { @@ -386,6 +387,7 @@ def send_stats_metrics( object_ref=False, log_to_statsd=False, ) + @handle_api_exception def info_headless(self, **kwargs: Any) -> Response: """ Add statsd metrics to builtin FAB _info endpoint @@ -399,6 +401,7 @@ def info_headless(self, **kwargs: Any) -> Response: object_ref=False, log_to_statsd=False, ) + @handle_api_exception def get_headless(self, pk: int, **kwargs: Any) -> Response: """ Add statsd metrics to builtin FAB GET endpoint @@ -412,6 +415,7 @@ def get_headless(self, pk: int, **kwargs: Any) -> Response: object_ref=False, log_to_statsd=False, ) + @handle_api_exception def get_list_headless(self, **kwargs: Any) -> Response: """ Add statsd metrics to builtin FAB GET list endpoint @@ -425,6 +429,7 @@ def get_list_headless(self, **kwargs: Any) -> Response: object_ref=False, log_to_statsd=False, ) + @handle_api_exception def post_headless(self) -> Response: """ Add statsd metrics to builtin FAB POST endpoint @@ -438,6 +443,7 @@ def post_headless(self) -> Response: object_ref=False, log_to_statsd=False, ) + @handle_api_exception def put_headless(self, pk: int) -> Response: """ Add statsd metrics to builtin FAB PUT endpoint @@ -451,6 +457,7 @@ def put_headless(self, pk: int) -> Response: object_ref=False, log_to_statsd=False, ) + @handle_api_exception def delete_headless(self, pk: int) -> Response: """ Add statsd metrics to builtin FAB DELETE endpoint @@ -464,6 +471,7 @@ def delete_headless(self, pk: int) -> Response: @safe @statsd_metrics @rison(get_related_schema) + @handle_api_exception def related(self, column_name: str, **kwargs: Any) -> FlaskResponse: """Get related fields data --- @@ -542,6 +550,7 @@ def related(self, column_name: str, **kwargs: Any) -> FlaskResponse: @safe @statsd_metrics @rison(get_related_schema) + @handle_api_exception def distinct(self, column_name: str, **kwargs: Any) -> FlaskResponse: """Get distinct values from field data --- From c763baf09ec64efa70516c1c0fa5e289ea8a86dc Mon Sep 17 00:00:00 2001 From: David Aaron Suddjian <1858430+suddjian@users.noreply.github.com> Date: Wed, 20 Apr 2022 14:26:38 -0700 Subject: [PATCH 094/136] add missing init files (#19797) --- superset/embedded_dashboard/__init__.py | 16 ++++++++++++++++ superset/embedded_dashboard/commands/__init__.py | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 superset/embedded_dashboard/__init__.py create mode 100644 superset/embedded_dashboard/commands/__init__.py diff --git a/superset/embedded_dashboard/__init__.py b/superset/embedded_dashboard/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/superset/embedded_dashboard/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/superset/embedded_dashboard/commands/__init__.py b/superset/embedded_dashboard/commands/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/superset/embedded_dashboard/commands/__init__.py @@ -0,0 +1,16 @@ +# 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. From 1b55778427cdb5e4b40074536a3ae2a597f30a69 Mon Sep 17 00:00:00 2001 From: Kenny Kwan Date: Wed, 20 Apr 2022 15:01:24 -0700 Subject: [PATCH 095/136] fix(sql_lab): Add custom timestamp type for literal casting for presto timestamps (#13082) * Add custom timestamp type for literal casting for presto timestamps * Remove typo in comment * Use process_bind_params as in sqla docs * Uncommit local superset config * Add DATE literal casting * Fix lint errors and change var name * Get rid of col_type and whitespace * Fix linting * Fix arg type * Fix isort lint error * ran black and isort locally.. * accidentally removed EOF * Dont need eof * Use newer string formatting style from comments Co-authored-by: John Bodley <4567245+john-bodley@users.noreply.github.com> * Trigger notification * Trigger notification Co-authored-by: Kenny Kwan Co-authored-by: John Bodley <4567245+john-bodley@users.noreply.github.com> --- superset/db_engine_specs/presto.py | 16 ++++++-- superset/models/sql_types/presto_sql_types.py | 38 ++++++++++++++++++- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/superset/db_engine_specs/presto.py b/superset/db_engine_specs/presto.py index 8675607848328..645dd32d26424 100644 --- a/superset/db_engine_specs/presto.py +++ b/superset/db_engine_specs/presto.py @@ -47,9 +47,11 @@ from superset.models.sql_lab import Query from superset.models.sql_types.presto_sql_types import ( Array, + Date, Interval, Map, Row, + TimeStamp, TinyInteger, ) from superset.result_set import destringify @@ -1096,10 +1098,18 @@ def where_latest_partition( # pylint: disable=too-many-arguments if values is None: return None - column_names = {column.get("name") for column in columns or []} + column_type_by_name = { + column.get("name"): column.get("type") for column in columns or [] + } + for col_name, value in zip(col_names, values): - if col_name in column_names: - query = query.where(Column(col_name) == value) + if col_name in column_type_by_name: + if column_type_by_name.get(col_name) == "TIMESTAMP": + query = query.where(Column(col_name, TimeStamp()) == value) + elif column_type_by_name.get(col_name) == "DATE": + query = query.where(Column(col_name, Date()) == value) + else: + query = query.where(Column(col_name) == value) return query @classmethod diff --git a/superset/models/sql_types/presto_sql_types.py b/superset/models/sql_types/presto_sql_types.py index a314639ca6907..5f36266ccaa4f 100644 --- a/superset/models/sql_types/presto_sql_types.py +++ b/superset/models/sql_types/presto_sql_types.py @@ -14,11 +14,15 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. + +# pylint: disable=abstract-method from typing import Any, Dict, List, Optional, Type -from sqlalchemy.sql.sqltypes import Integer +from sqlalchemy.engine.interfaces import Dialect +from sqlalchemy.sql.sqltypes import DATE, Integer, TIMESTAMP from sqlalchemy.sql.type_api import TypeEngine from sqlalchemy.sql.visitors import Visitable +from sqlalchemy.types import TypeDecorator # _compiler_dispatch is defined to help with type compilation @@ -91,3 +95,35 @@ def python_type(self) -> Optional[Type[Any]]: @classmethod def _compiler_dispatch(cls, _visitor: Visitable, **_kw: Any) -> str: return "ROW" + + +class TimeStamp(TypeDecorator): + """ + A type to extend functionality of timestamp data type. + """ + + impl = TIMESTAMP + + @classmethod + def process_bind_param(cls, value: str, dialect: Dialect) -> str: + """ + Used for in-line rendering of TIMESTAMP data type + as Presto does not support automatic casting. + """ + return f"TIMESTAMP '{value}'" + + +class Date(TypeDecorator): + """ + A type to extend functionality of date data type. + """ + + impl = DATE + + @classmethod + def process_bind_param(cls, value: str, dialect: Dialect) -> str: + """ + Used for in-line rendering of DATE data type + as Presto does not support automatic casting. + """ + return f"DATE '{value}'" From 1dabebb015e0ed02d80da02ad8d1e3d92c9a1674 Mon Sep 17 00:00:00 2001 From: John Bodley <4567245+john-bodley@users.noreply.github.com> Date: Thu, 21 Apr 2022 10:34:28 +1200 Subject: [PATCH 096/136] chore: Update UPDATING.md (#19480) * Update UPDATING.md * Update UPDATING.md --- UPDATING.md | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/UPDATING.md b/UPDATING.md index 2915976bcd66a..fb6565848a164 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -30,27 +30,25 @@ assists people when migrating to a new version. ### Breaking Changes -- [19230](https://github.com/apache/superset/pull/19230): The `ROW_LEVEL_SECURITY` feature flag has been removed (permanently enabled). Any deployments which had set this flag to false will need to verify that the presence of the Row Level Security feature does not interfere with their use case. -- [19168](https://github.com/apache/superset/pull/19168): Celery upgrade to 5.X has breaking changes on it's command line invocation. - Please follow: https://docs.celeryq.dev/en/stable/whatsnew-5.2.html#step-1-adjust-your-command-line-invocation - Consider migrating you celery config if you haven't already: https://docs.celeryq.dev/en/stable/userguide/configuration.html#conf-old-settings-map -- [19049](https://github.com/apache/superset/pull/19049): APP_ICON_WIDTH has been removed from the config. Superset should now be able to handle different logo sizes without having to explicitly set an APP_ICON_WIDTH. This might affect the size of existing custom logos as the UI will now resize them according to the specified space of maximum 148px and not according to the value of APP_ICON_WIDTH. -- [19274](https://github.com/apache/superset/pull/19274): The `PUBLIC_ROLE_LIKE_GAMMA` config key has been removed, set `PUBLIC_ROLE_LIKE` = "Gamma" to have the same functionality. -- [19273](https://github.com/apache/superset/pull/19273): The `SUPERSET_CELERY_WORKERS` and `SUPERSET_WORKERS` config keys has been removed. Configure celery directly using `CELERY_CONFIG` on Superset -- [19231](https://github.com/apache/superset/pull/19231): The `ENABLE_REACT_CRUD_VIEWS` feature flag has been removed (permanently enabled). Any deployments which had set this flag to false will need to verify that the React views support their use case. -- [17556](https://github.com/apache/superset/pull/17556): Bumps mysqlclient from v1 to v2 -- [19113](https://github.com/apache/superset/pull/19113): The `ENABLE_JAVASCRIPT_CONTROLS` setting has moved from app config to a feature flag. Any deployments who overrode this setting will now need to override the feature flag from here onward. -- [17881](https://github.com/apache/superset/pull/17881): Previously simple adhoc filter values on string columns were stripped of enclosing single and double quotes. To fully support literal quotes in filters, both single and double quotes will no longer be removed from filter values. -- [17984](https://github.com/apache/superset/pull/17984): Default Flask SECRET_KEY has changed for security reasons. You should always override with your own secret. Set `PREVIOUS_SECRET_KEY` (ex: PREVIOUS_SECRET_KEY = "\2\1thisismyscretkey\1\2\\e\\y\\y\\h") with your previous key and use `superset re-encrypt-secrets` to rotate you current secrets -- [15254](https://github.com/apache/superset/pull/15254): Previously `QUERY_COST_FORMATTERS_BY_ENGINE`, `SQL_VALIDATORS_BY_ENGINE` and `SCHEDULED_QUERIES` were expected to be defined in the feature flag dictionary in the `config.py` file. These should now be defined as a top-level config, with the feature flag dictionary being reserved for boolean only values. -- [17539](https://github.com/apache/superset/pull/17539): all Superset CLI commands (init, load_examples and etc) require setting the FLASK_APP environment variable (which is set by default when `.flaskenv` is loaded) -- [18970](https://github.com/apache/superset/pull/18970): Changes feature -flag for the legacy datasource editor (DISABLE_LEGACY_DATASOURCE_EDITOR) in config.py to True, thus disabling the feature from being shown in the client. -- [19083](https://github.com/apache/superset/pull/19083): Updates the mutator function in the config file to take a sql argument and a list of kwargs. Any `SQL_QUERY_MUTATOR` config function overrides will need to be updated to match the new set of params. It is advised regardless of the dictionary args that you list in your function arguments, to keep **kwargs as the last argument to allow for any new kwargs to be passed in. +- [19274](https://github.com/apache/superset/pull/19274): The `PUBLIC_ROLE_LIKE_GAMMA` config key has been removed, set `PUBLIC_ROLE_LIKE = "Gamma"` to have the same functionality. +- [19273](https://github.com/apache/superset/pull/19273): The `SUPERSET_CELERY_WORKERS` and `SUPERSET_WORKERS` config keys has been removed. Configure Celery directly using `CELERY_CONFIG` on Superset. +- [19262](https://github.com/apache/superset/pull/19262): Per [SIP-11](https://github.com/apache/superset/issues/6032) and [SIP-68](https://github.com/apache/superset/issues/14909) the native NoSQL Druid connector is deprecated and will no longer be supported. Druid SQL is still [supported](https://superset.apache.org/docs/databases/druid). +- [19231](https://github.com/apache/superset/pull/19231): The `ENABLE_REACT_CRUD_VIEWS` feature flag has been removed (premantly enabled). Any deployments which had set this flag to false will need to verify that the React views support their use case. +- [19230](https://github.com/apache/superset/pull/19230): The `ROW_LEVEL_SECURITY` feature flag has been removed (permantly enabled). Any deployments which had set this flag to false will need to verify that the presence of the Row Level Security feature does not interfere with their use case. +- [19168](https://github.com/apache/superset/pull/19168): Celery upgrade to 5.X resulted in breaking changes to its command line invocation. Please follow [these](https://docs.celeryq.dev/en/stable/whatsnew-5.2.html#step-1-adjust-your-command-line-invocation) instructions for adjustments. Also consider migrating you Celery config per [here](https://docs.celeryq.dev/en/stable/userguide/configuration.html#conf-old-settings-map). +- [19142](https://github.com/apache/superset/pull/19142): The `VERSIONED_EXPORT` config key is now `True` by default. +- [19113](https://github.com/apache/superset/pull/19113): The `ENABLE_JAVASCRIPT_CONTROLS` config key has moved from an app config to a feature flag. Any deployments who overrode this setting will now need to override the feature flag from here onward. +- [19107](https://github.com/apache/superset/pull/19107): The `SQLLAB_BACKEND_PERSISTENCE` feature flag is now `True` by default, which enables persisting SQL Lab tabs in the backend instead of the browser's `localStorage`. +- [19083](https://github.com/apache/superset/pull/19083): Updates the mutator function in the config file to take a SQL argument and a list of kwargs. Any `SQL_QUERY_MUTATOR` config function overrides will need to be updated to match the new set of params. It is advised regardless of the dictionary args that you list in your function arguments, to keep `**kwargs` as the last argument to allow for any new kwargs to be passed in. +- [19049](https://github.com/apache/superset/pull/19049): The `APP_ICON_WIDTH` config key has been removed. Superset should now be able to handle different logo sizes without having to explicitly set an `APP_ICON_WIDTH`. This might affect the size of existing custom logos as the UI will now resize them according to the specified space of maximum 148px and not according to the value of `APP_ICON_WIDTH`. - [19017](https://github.com/apache/superset/pull/19017): Removes Python 3.7 support. -- [19142](https://github.com/apache/superset/pull/19142): Changes feature flag for versioned export(VERSIONED_EXPORT) to be true. -- [19107](https://github.com/apache/superset/pull/19107): Feature flag `SQLLAB_BACKEND_PERSISTENCE` is now on by default, which enables persisting SQL Lab tabs in the backend instead of the browser's `localStorage`. -- [19262](https://github.com/apache/superset/pull/19262): As per SIPs 11 and 68, the native NoSQL Druid connector is deprecated as of 2.0 and will no longer be supported. Druid is still supported through SQLAlchemy via pydruid. +- [18976](https://github.com/apache/superset/pull/18976): When running the app in debug mode, the app will default to use `SimpleCache` for `FILTER_STATE_CACHE_CONFIG` and `EXPLORE_FORM_DATA_CACHE_CONFIG`. When running in non-debug mode, a cache backend will need to be defined, otherwise the application will fail to start. For installations using Redis or other caching backends, it is recommended to use the same backend for both cache configs. +- [18970](https://github.com/apache/superset/pull/18970): The `DISABLE_LEGACY_DATASOURCE_EDITOR` feature flag is now `True` by default which disables the legacy datasource editor from being shown in the client. +- [17984](https://github.com/apache/superset/pull/17984): The default Flask SECRET_KEY has changed for security reasons. You should always override with your own secret. Set `PREVIOUS_SECRET_KEY`, e.g. `PREVIOUS_SECRET_KEY = "\2\1thisismyscretkey\1\2\\e\\y\\y\\h"`, with your previous key and use `superset re-encrypt-secrets` to rotate you current secrets. +- [17881](https://github.com/apache/superset/pull/17881): Previously simple adhoc filter values on string columns were stripped of enclosing single and double quotes. To fully support literal quotes in filters, both single and double quotes will no longer be removed from filter values. +- [17556](https://github.com/apache/superset/pull/17556): Bumps `mysqlclient` from v1 to v2. +- [17539](https://github.com/apache/superset/pull/17539): All Superset CLI commands, e.g. `init`, `load_examples`, etc. require setting the `FLASK_APP` environment variable (which is set by default when `.flaskenv` is loaded). +- [15254](https://github.com/apache/superset/pull/15254): The `QUERY_COST_FORMATTERS_BY_ENGINE`, `SQL_VALIDATORS_BY_ENGINE` and `SCHEDULED_QUERIES` feature flags are now defined as config keys given that feature flags are reserved for boolean only values. ### Potential Downtime From 108a2a4eafc3150f7b7c33ed734e843a5d5c9f62 Mon Sep 17 00:00:00 2001 From: Yongjie Zhao Date: Thu, 21 Apr 2022 15:20:09 +0800 Subject: [PATCH 097/136] fix: lost renameOperator in mixed timeseries chart (#19802) --- .../src/MixedTimeseries/buildQuery.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/buildQuery.ts index b85feb1eee5fa..9adc149489a27 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/buildQuery.ts @@ -22,7 +22,11 @@ import { QueryObject, normalizeOrderBy, } from '@superset-ui/core'; -import { flattenOperator, pivotOperator } from '@superset-ui/chart-controls'; +import { + pivotOperator, + renameOperator, + flattenOperator, +} from '@superset-ui/chart-controls'; export default function buildQuery(formData: QueryFormData) { const { @@ -66,7 +70,11 @@ export default function buildQuery(formData: QueryFormData) { is_timeseries: true, post_processing: [ pivotOperator(formData1, { ...baseQueryObject, is_timeseries: true }), - flattenOperator(formData1, { ...baseQueryObject, is_timeseries: true }), + renameOperator(formData1, { + ...baseQueryObject, + ...{ is_timeseries: true }, + }), + flattenOperator(formData1, baseQueryObject), ], } as QueryObject; return [normalizeOrderBy(queryObjectA)]; @@ -78,7 +86,11 @@ export default function buildQuery(formData: QueryFormData) { is_timeseries: true, post_processing: [ pivotOperator(formData2, { ...baseQueryObject, is_timeseries: true }), - flattenOperator(formData2, { ...baseQueryObject, is_timeseries: true }), + renameOperator(formData2, { + ...baseQueryObject, + ...{ is_timeseries: true }, + }), + flattenOperator(formData2, baseQueryObject), ], } as QueryObject; return [normalizeOrderBy(queryObjectB)]; From 3db4a1cb8016dae71b5bfca09ef723c042dff671 Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Thu, 21 Apr 2022 10:16:00 +0100 Subject: [PATCH 098/136] chore: bump postgres from 10 to 14 (#19790) * chore: bump postgres from 10 to 14 * update helm chart * adding docs * fix docs * Update docs/docs/installation/configuring-superset.mdx Co-authored-by: Ville Brofeldt <33317356+villebro@users.noreply.github.com> * Update docs/docs/installation/configuring-superset.mdx Co-authored-by: Ville Brofeldt <33317356+villebro@users.noreply.github.com> * improve docs * improve docs Co-authored-by: Ville Brofeldt <33317356+villebro@users.noreply.github.com> --- .github/workflows/superset-e2e.yml | 2 +- .../superset-python-integrationtest.yml | 2 +- .../workflows/superset-python-presto-hive.yml | 4 +-- docker-compose.yml | 2 +- .../installation/configuring-superset.mdx | 26 +++++++++++++++++++ ...stalling-superset-using-docker-compose.mdx | 2 +- helm/superset/Chart.yaml | 4 +-- 7 files changed, 34 insertions(+), 8 deletions(-) diff --git a/.github/workflows/superset-e2e.yml b/.github/workflows/superset-e2e.yml index be0df99551a40..e99c5ee05ef51 100644 --- a/.github/workflows/superset-e2e.yml +++ b/.github/workflows/superset-e2e.yml @@ -31,7 +31,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} services: postgres: - image: postgres:10-alpine + image: postgres:14-alpine env: POSTGRES_USER: superset POSTGRES_PASSWORD: superset diff --git a/.github/workflows/superset-python-integrationtest.yml b/.github/workflows/superset-python-integrationtest.yml index a5a7705bcd88e..926d6185bf4e8 100644 --- a/.github/workflows/superset-python-integrationtest.yml +++ b/.github/workflows/superset-python-integrationtest.yml @@ -88,7 +88,7 @@ jobs: SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset services: postgres: - image: postgres:10-alpine + image: postgres:14-alpine env: POSTGRES_USER: superset POSTGRES_PASSWORD: superset diff --git a/.github/workflows/superset-python-presto-hive.yml b/.github/workflows/superset-python-presto-hive.yml index 3a4022d893eef..097b2f45adf9b 100644 --- a/.github/workflows/superset-python-presto-hive.yml +++ b/.github/workflows/superset-python-presto-hive.yml @@ -23,7 +23,7 @@ jobs: SUPERSET__SQLALCHEMY_EXAMPLES_URI: presto://localhost:15433/memory/default services: postgres: - image: postgres:10-alpine + image: postgres:14-alpine env: POSTGRES_USER: superset POSTGRES_PASSWORD: superset @@ -101,7 +101,7 @@ jobs: UPLOAD_FOLDER: /tmp/.superset/uploads/ services: postgres: - image: postgres:10-alpine + image: postgres:14-alpine env: POSTGRES_USER: superset POSTGRES_PASSWORD: superset diff --git a/docker-compose.yml b/docker-compose.yml index b7bf745ad6ff6..907ca51129caa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,7 +40,7 @@ services: db: env_file: docker/.env - image: postgres:10 + image: postgres:14 container_name: superset_db restart: unless-stopped ports: diff --git a/docs/docs/installation/configuring-superset.mdx b/docs/docs/installation/configuring-superset.mdx index 86bddda180f30..1384b62741cba 100644 --- a/docs/docs/installation/configuring-superset.mdx +++ b/docs/docs/installation/configuring-superset.mdx @@ -69,6 +69,32 @@ you can add the endpoints to `WTF_CSRF_EXEMPT_LIST`: WTF_CSRF_EXEMPT_LIST = [‘’] ``` +### Using a production metastore + +By default Superset is configured to use SQLite, it's a simple and fast way to get you started +(no installation needed). But for production environments you should use a different database engine on +a separate host or container. + +Superset supports the following database engines/versions: + +| Database Engine | Supported Versions | +| --------------------------------------------------------- | --------------------------------- | +| [PostgreSQL](https://www.postgresql.org/) | 10.X, 11.X, 12.X, 13.X, 14.X | +| [MySQL](https://www.mysql.com/) | 5.X | + + +Use the following database drivers and connection strings: + +| Database | PyPI package | Connection String | +| ----------------------------------------- | --------------------------------- | ------------------------------------------------------------------------ | +| [PostgreSQL](https://www.postgresql.org/) | `pip install psycopg2` | `postgresql://:@/` | +| [MySQL](https://www.mysql.com/) | `pip install mysqlclient` | `mysql://:@/` | +| SQLite | No additional library needed | `sqlite://` | + +To configure Superset metastore set `SQLALCHEMY_DATABASE_URI` config key on `superset_config` +to the appropriate connection string. + + ### Running on a WSGI HTTP Server While you can run Superset on NGINX or Apache, we recommend using Gunicorn in async mode. This diff --git a/docs/docs/installation/installing-superset-using-docker-compose.mdx b/docs/docs/installation/installing-superset-using-docker-compose.mdx index 4d7056a165d8b..ced6ba5660a3b 100644 --- a/docs/docs/installation/installing-superset-using-docker-compose.mdx +++ b/docs/docs/installation/installing-superset-using-docker-compose.mdx @@ -109,7 +109,7 @@ username: admin password: admin ``` -### 5. Connecting your local database instance to superset +### 5. Connecting Superset to your local database instance When running Superset using `docker` or `docker-compose` it runs in its own docker container, as if the Superset was running in a separate machine entirely. Therefore attempts to connect to your local database with hostname `localhost` won't work as `localhost` refers to the docker container Superset is running in, and not your actual host machine. Fortunately, docker provides an easy way to access network resources in the host machine from inside a container, and we will leverage this capability to connect to our local database instance. diff --git a/helm/superset/Chart.yaml b/helm/superset/Chart.yaml index 64600f5973ed4..2ac78630149b5 100644 --- a/helm/superset/Chart.yaml +++ b/helm/superset/Chart.yaml @@ -22,10 +22,10 @@ maintainers: - name: craig-rueda email: craig@craigrueda.com url: https://github.com/craig-rueda -version: 0.5.11 +version: 0.6.0 dependencies: - name: postgresql - version: 10.2.0 + version: 11.1.22 repository: https://charts.bitnami.com/bitnami condition: postgresql.enabled - name: redis From 12bc30e2c7b6d0993b1b0cea9205e8592ec8a3d8 Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Thu, 21 Apr 2022 19:08:04 +0200 Subject: [PATCH 099/136] Fix display of column config in table chart (#19806) --- .../components/ColumnConfigControl/ColumnConfigItem.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/components/ColumnConfigControl/ColumnConfigItem.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/components/ColumnConfigControl/ColumnConfigItem.tsx index 06429ef593a5b..f28d5b8d2332d 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/components/ColumnConfigControl/ColumnConfigItem.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/components/ColumnConfigControl/ColumnConfigItem.tsx @@ -48,8 +48,10 @@ export default React.memo(function ColumnConfigItem({ >
Date: Thu, 21 Apr 2022 10:49:25 -0700 Subject: [PATCH 100/136] fix(SIP-68): handle empty table name during migration (#19793) --- .../versions/a9422eeaae74_new_dataset_models_take_2.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/superset/migrations/versions/a9422eeaae74_new_dataset_models_take_2.py b/superset/migrations/versions/a9422eeaae74_new_dataset_models_take_2.py index 0ded98b93cd98..9a2498bfa8590 100644 --- a/superset/migrations/versions/a9422eeaae74_new_dataset_models_take_2.py +++ b/superset/migrations/versions/a9422eeaae74_new_dataset_models_take_2.py @@ -42,7 +42,6 @@ from sqlalchemy.sql.expression import and_, or_ from sqlalchemy_utils import UUIDType -from superset import app, db from superset.connectors.sqla.models import ADDITIVE_METRIC_TYPES_LOWER from superset.connectors.sqla.utils import get_dialect_name, get_identifier_quoter from superset.extensions import encrypted_field_factory @@ -51,8 +50,6 @@ from superset.utils.core import MediumText Base = declarative_base() -custom_password_store = app.config["SQLALCHEMY_CUSTOM_PASSWORD_STORE"] -DB_CONNECTION_MUTATOR = app.config["DB_CONNECTION_MUTATOR"] SHOW_PROGRESS = os.environ.get("SHOW_PROGRESS") == "1" UNKNOWN_TYPE = "UNKNOWN" @@ -577,7 +574,7 @@ def print_update_count(): drivername = (sqlalchemy_uri or "").split("://")[0] updates = {} updated = False - if is_physical and drivername: + if is_physical and drivername and expression: quoted_expression = get_identifier_quoter(drivername)(expression) if quoted_expression != expression: updates["expression"] = quoted_expression @@ -871,7 +868,7 @@ def reset_postgres_id_sequence(table: str) -> None: def upgrade() -> None: bind = op.get_bind() - session: Session = db.Session(bind=bind) + session: Session = Session(bind=bind) Base.metadata.drop_all(bind=bind, tables=new_tables) Base.metadata.create_all(bind=bind, tables=new_tables) From ad715429f92323057110b39d36653018d05c2505 Mon Sep 17 00:00:00 2001 From: Phillip Kelley-Dotson Date: Thu, 21 Apr 2022 12:44:21 -0700 Subject: [PATCH 101/136] chore: simplify error messaging in database modal (#19165) * testing for dbconn modal error message * remove consoles and error alert mapping * lint fix * update url message * add modal fix * update comments * fix err msg bugs * fix pylint * fix line * fix tests * fix assertions --- .../DatabaseConnectionForm/TableCatalog.tsx | 1 - .../data/database/DatabaseModal/index.tsx | 87 +++---------------- superset-frontend/src/views/CRUD/hooks.ts | 1 + superset/db_engine_specs/gsheets.py | 6 +- .../db_engine_specs/test_gsheets.py | 18 +++- 5 files changed, 31 insertions(+), 82 deletions(-) diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm/TableCatalog.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm/TableCatalog.tsx index bc8cb40c161c7..fb70b9c3652a1 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm/TableCatalog.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm/TableCatalog.tsx @@ -34,7 +34,6 @@ export const TableCatalog = ({ }: FieldPropTypes) => { const tableCatalog = db?.catalog || []; const catalogError = validationErrors || {}; - return (

diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx index c4faa8a483ebe..a6e93f8653271 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx @@ -96,49 +96,6 @@ const engineSpecificAlertMapping = { }, }; -const errorAlertMapping = { - GENERIC_DB_ENGINE_ERROR: { - message: t('Generic database engine error'), - }, - CONNECTION_MISSING_PARAMETERS_ERROR: { - message: t('Missing Required Fields'), - description: t('Please complete all required fields.'), - }, - CONNECTION_INVALID_HOSTNAME_ERROR: { - message: t('Could not verify the host'), - description: t( - 'The host is invalid. Please verify that this field is entered correctly.', - ), - }, - CONNECTION_PORT_CLOSED_ERROR: { - message: t('Port is closed'), - description: t('Please verify that port is open to connect.'), - }, - CONNECTION_INVALID_PORT_ERROR: { - message: t('Invalid Port Number'), - description: t( - 'The port must be a whole number less than or equal to 65535.', - ), - }, - CONNECTION_ACCESS_DENIED_ERROR: { - message: t('Invalid account information'), - description: t('Either the username or password is incorrect.'), - }, - CONNECTION_INVALID_PASSWORD_ERROR: { - message: t('Invalid account information'), - description: t('Either the username or password is incorrect.'), - }, - INVALID_PAYLOAD_SCHEMA_ERROR: { - message: t('Incorrect Fields'), - description: t('Please make sure all fields are filled out correctly'), - }, - TABLE_DOES_NOT_EXIST_ERROR: { - message: t('URL could not be identified'), - description: t( - 'The URL could not be identified. Please check for typos and make sure that "Type of google sheet allowed" selection matches the input', - ), - }, -}; const googleSheetConnectionEngine = 'gsheets'; interface DatabaseModalProps { @@ -227,7 +184,7 @@ function dbReducer( }; let query = {}; let query_input = ''; - let deserializeExtraJSON = { allows_virtual_table_explore: true }; + let deserializeExtraJSON = {}; let extra_json: DatabaseObject['extra_json']; switch (action.type) { @@ -576,8 +533,8 @@ const DatabaseModal: FunctionComponent = ({ if (dbToUpdate.configuration_method === CONFIGURATION_METHOD.DYNAMIC_FORM) { // Validate DB before saving - await getValidation(dbToUpdate, true); - if (validationErrors && !isEmpty(validationErrors)) { + const errors = await getValidation(dbToUpdate, true); + if ((validationErrors && !isEmpty(validationErrors)) || errors) { return; } const parameters_schema = isEditMode @@ -679,7 +636,6 @@ const DatabaseModal: FunctionComponent = ({ passwords, confirmedOverwrite, ); - if (dbId) { onClose(); addSuccessToast(t('Database connected')); @@ -1112,44 +1068,21 @@ const DatabaseModal: FunctionComponent = ({ ); }; + // eslint-disable-next-line consistent-return const errorAlert = () => { - if ( - isEmpty(dbErrors) || - (isEmpty(validationErrors) && - !(validationErrors?.error_type in errorAlertMapping)) - ) { - return <>; - } - - if (validationErrors) { + if (isEmpty(dbErrors) === false) { + const message: Array = + typeof dbErrors === 'object' ? Object.values(dbErrors) : []; return ( antDErrorAlertStyles(theme)} - message={ - errorAlertMapping[validationErrors?.error_type]?.message || - validationErrors?.error_type - } - description={ - errorAlertMapping[validationErrors?.error_type]?.description || - validationErrors?.description || - JSON.stringify(validationErrors) - } - showIcon - closable={false} + message={t('Database Creation Error')} + description={message?.[0] || dbErrors} /> ); } - const message: Array = - typeof dbErrors === 'object' ? Object.values(dbErrors) : []; - return ( - antDErrorAlertStyles(theme)} - message={t('Database Creation Error')} - description={message?.[0] || dbErrors} - /> - ); + return <>; }; const renderFinishState = () => { diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts index 5a0e26131efc0..ba544909cbead 100644 --- a/superset-frontend/src/views/CRUD/hooks.ts +++ b/superset-frontend/src/views/CRUD/hooks.ts @@ -777,6 +777,7 @@ export function useDatabaseValidation() { {}, ); setValidationErrors(parsedErrors); + return parsedErrors; }); } // eslint-disable-next-line no-console diff --git a/superset/db_engine_specs/gsheets.py b/superset/db_engine_specs/gsheets.py index 888513f518482..94c4cf424b0b3 100644 --- a/superset/db_engine_specs/gsheets.py +++ b/superset/db_engine_specs/gsheets.py @@ -216,7 +216,11 @@ def validate_parameters( except Exception: # pylint: disable=broad-except errors.append( SupersetError( - message="URL could not be identified", + message=( + "The URL could not be identified. Please check for typos " + "and make sure that ‘Type of Google Sheets allowed’ " + "selection matches the input." + ), error_type=SupersetErrorType.TABLE_DOES_NOT_EXIST_ERROR, level=ErrorLevel.WARNING, extra={"catalog": {"idx": idx, "url": True}}, diff --git a/tests/unit_tests/db_engine_specs/test_gsheets.py b/tests/unit_tests/db_engine_specs/test_gsheets.py index a13895e75e1d5..b050c6fdbf2ab 100644 --- a/tests/unit_tests/db_engine_specs/test_gsheets.py +++ b/tests/unit_tests/db_engine_specs/test_gsheets.py @@ -76,7 +76,11 @@ def test_validate_parameters_catalog( assert errors == [ SupersetError( - message="URL could not be identified", + message=( + "The URL could not be identified. Please check for typos " + "and make sure that ‘Type of Google Sheets allowed’ " + "selection matches the input." + ), error_type=SupersetErrorType.TABLE_DOES_NOT_EXIST_ERROR, level=ErrorLevel.WARNING, extra={ @@ -97,7 +101,11 @@ def test_validate_parameters_catalog( }, ), SupersetError( - message="URL could not be identified", + message=( + "The URL could not be identified. Please check for typos " + "and make sure that ‘Type of Google Sheets allowed’ " + "selection matches the input." + ), error_type=SupersetErrorType.TABLE_DOES_NOT_EXIST_ERROR, level=ErrorLevel.WARNING, extra={ @@ -158,7 +166,11 @@ def test_validate_parameters_catalog_and_credentials( errors = GSheetsEngineSpec.validate_parameters(parameters) # ignore: type assert errors == [ SupersetError( - message="URL could not be identified", + message=( + "The URL could not be identified. Please check for typos " + "and make sure that ‘Type of Google Sheets allowed’ " + "selection matches the input." + ), error_type=SupersetErrorType.TABLE_DOES_NOT_EXIST_ERROR, level=ErrorLevel.WARNING, extra={ From 3ccfa564d710480b55898d6e3ac42ccdd4ccdbcf Mon Sep 17 00:00:00 2001 From: smileydev <47900232+prosdev0107@users.noreply.github.com> Date: Thu, 21 Apr 2022 16:59:01 -0400 Subject: [PATCH 102/136] =?UTF-8?q?fix(dashboard):=20make=20to=20filter=20?= =?UTF-8?q?the=20correct=20certified=20or=20non-certified=E2=80=A6=20(#194?= =?UTF-8?q?29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard): make to filter the correct certified or non-certified dashboards * fix(dashboard): make to fix python lint issue --- superset/dashboards/filters.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/superset/dashboards/filters.py b/superset/dashboards/filters.py index 3bbef14f4cb0e..7b02c23679540 100644 --- a/superset/dashboards/filters.py +++ b/superset/dashboards/filters.py @@ -224,12 +224,14 @@ def apply(self, query: Query, value: Any) -> Query: return query.filter( and_( Dashboard.certified_by.isnot(None), + Dashboard.certified_by != "", ) ) if value is False: return query.filter( - and_( + or_( Dashboard.certified_by.is_(None), + Dashboard.certified_by == "", ) ) return query From a1bd5b283cc3b766d54c7c61d6487b4bce7ce916 Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Fri, 22 Apr 2022 13:12:29 +0300 Subject: [PATCH 103/136] fix(key_value): use longblob on mysql (#19805) * fix(key_value): use longblob on mysql * set length --- .../migrations/versions/6766938c6065_add_key_value_store.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset/migrations/versions/6766938c6065_add_key_value_store.py b/superset/migrations/versions/6766938c6065_add_key_value_store.py index 0a756386aee98..26b1d28e0d49b 100644 --- a/superset/migrations/versions/6766938c6065_add_key_value_store.py +++ b/superset/migrations/versions/6766938c6065_add_key_value_store.py @@ -38,7 +38,7 @@ def upgrade(): "key_value", sa.Column("id", sa.Integer(), nullable=False), sa.Column("resource", sa.String(32), nullable=False), - sa.Column("value", sa.LargeBinary(), nullable=False), + sa.Column("value", sa.LargeBinary(length=2**31), nullable=False), sa.Column("uuid", UUIDType(binary=True), default=uuid4), sa.Column("created_on", sa.DateTime(), nullable=True), sa.Column("created_by_fk", sa.Integer(), nullable=True), From c5d6beab1dd341e313b7e4c307258a96a4966c2e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Apr 2022 13:52:41 -0600 Subject: [PATCH 104/136] chore(deps): bump minimist from 1.2.5 to 1.2.6 in /superset-websocket (#19551) Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6. - [Release notes](https://github.com/substack/minimist/releases) - [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6) --- updated-dependencies: - dependency-name: minimist dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-websocket/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/superset-websocket/package-lock.json b/superset-websocket/package-lock.json index 07c3ef4c9b8e6..c2fce25ccc3b7 100644 --- a/superset-websocket/package-lock.json +++ b/superset-websocket/package-lock.json @@ -4341,9 +4341,9 @@ } }, "node_modules/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, "node_modules/ms": { @@ -9172,9 +9172,9 @@ } }, "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, "ms": { From 461286df11f7e2f37cda9722290313e27f46c0ad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Apr 2022 13:54:08 -0600 Subject: [PATCH 105/136] chore(deps): bump async from 2.6.3 to 2.6.4 in /docs (#19727) Bumps [async](https://github.com/caolan/async) from 2.6.3 to 2.6.4. - [Release notes](https://github.com/caolan/async/releases) - [Changelog](https://github.com/caolan/async/blob/v2.6.4/CHANGELOG.md) - [Commits](https://github.com/caolan/async/compare/v2.6.3...v2.6.4) --- updated-dependencies: - dependency-name: async dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/yarn.lock b/docs/yarn.lock index f58b8a5078d35..e5d08fa18c5a6 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -3862,9 +3862,9 @@ async-validator@^4.0.2: integrity sha512-Pj2IR7u8hmUEDOwB++su6baaRi+QvsgajuFB9j95foM1N2gy5HM4z60hfusIO0fBPG5uLAEl6yCJr1jNSVugEQ== async@^2.6.2: - version "2.6.3" - resolved "https://registry.npmjs.org/async/-/async-2.6.3.tgz" - integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg== + version "2.6.4" + resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221" + integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA== dependencies: lodash "^4.17.14" From e98199d43d90963a39644f3397d6ae6cebbc0ac9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Apr 2022 14:50:43 -0600 Subject: [PATCH 106/136] chore(deps): bump @types/d3-time in /superset-frontend (#17978) Bumps [@types/d3-time](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/d3-time) from 1.1.1 to 3.0.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/d3-time) --- updated-dependencies: - dependency-name: "@types/d3-time" dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 14 ++++++++++++-- .../packages/superset-ui-core/package.json | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 51c7e12f30991..c3d62da852c45 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -59212,7 +59212,7 @@ "@types/d3-format": "^1.3.0", "@types/d3-interpolate": "^1.3.1", "@types/d3-scale": "^2.1.1", - "@types/d3-time": "^1.0.9", + "@types/d3-time": "^3.0.0", "@types/d3-time-format": "^2.1.0", "@types/enzyme": "^3.10.5", "@types/fetch-mock": "^7.3.3", @@ -59258,6 +59258,11 @@ "tinycolor2": "*" } }, + "packages/superset-ui-core/node_modules/@types/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==" + }, "packages/superset-ui-core/node_modules/@vx/responsive": { "version": "0.0.199", "resolved": "https://registry.npmjs.org/@vx/responsive/-/responsive-0.0.199.tgz", @@ -76920,7 +76925,7 @@ "@types/d3-format": "^1.3.0", "@types/d3-interpolate": "^1.3.1", "@types/d3-scale": "^2.1.1", - "@types/d3-time": "^1.0.9", + "@types/d3-time": "^3.0.0", "@types/d3-time-format": "^2.1.0", "@types/enzyme": "^3.10.5", "@types/fetch-mock": "^7.3.3", @@ -76952,6 +76957,11 @@ "whatwg-fetch": "^3.0.0" }, "dependencies": { + "@types/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==" + }, "@vx/responsive": { "version": "0.0.199", "resolved": "https://registry.npmjs.org/@vx/responsive/-/responsive-0.0.199.tgz", diff --git a/superset-frontend/packages/superset-ui-core/package.json b/superset-frontend/packages/superset-ui-core/package.json index 13e29e54a8c09..424f3b877620c 100644 --- a/superset-frontend/packages/superset-ui-core/package.json +++ b/superset-frontend/packages/superset-ui-core/package.json @@ -36,7 +36,7 @@ "@types/d3-format": "^1.3.0", "@types/d3-interpolate": "^1.3.1", "@types/d3-scale": "^2.1.1", - "@types/d3-time": "^1.0.9", + "@types/d3-time": "^3.0.0", "@types/d3-time-format": "^2.1.0", "@types/lodash": "^4.14.149", "@types/math-expression-evaluator": "^1.2.1", From 0cc2d71d1a5acc5696f16cf07aaffec063d6b998 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Apr 2022 14:51:10 -0600 Subject: [PATCH 107/136] chore(deps): bump url-parse from 1.5.7 to 1.5.10 in /superset-frontend (#19020) Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.7 to 1.5.10. - [Release notes](https://github.com/unshiftio/url-parse/releases) - [Commits](https://github.com/unshiftio/url-parse/compare/1.5.7...1.5.10) --- updated-dependencies: - dependency-name: url-parse dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index c3d62da852c45..804282374446a 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -55238,9 +55238,9 @@ } }, "node_modules/url-parse": { - "version": "1.5.7", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.7.tgz", - "integrity": "sha512-HxWkieX+STA38EDk7CE9MEryFeHCKzgagxlGvsdS7WBImq9Mk+PGwiT56w82WI3aicwJA8REp42Cxo98c8FZMA==", + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", "dev": true, "dependencies": { "querystringify": "^2.1.1", @@ -103855,9 +103855,9 @@ } }, "url-parse": { - "version": "1.5.7", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.7.tgz", - "integrity": "sha512-HxWkieX+STA38EDk7CE9MEryFeHCKzgagxlGvsdS7WBImq9Mk+PGwiT56w82WI3aicwJA8REp42Cxo98c8FZMA==", + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", "dev": true, "requires": { "querystringify": "^2.1.1", From 5a3031d35ff6f0d7fbc3926d4854d6ab344f9f60 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Apr 2022 14:51:37 -0600 Subject: [PATCH 108/136] chore(deps): bump async from 3.2.0 to 3.2.3 in /superset-websocket (#19680) Bumps [async](https://github.com/caolan/async) from 3.2.0 to 3.2.3. - [Release notes](https://github.com/caolan/async/releases) - [Changelog](https://github.com/caolan/async/blob/master/CHANGELOG.md) - [Commits](https://github.com/caolan/async/compare/v3.2.0...v3.2.3) --- updated-dependencies: - dependency-name: async dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-websocket/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/superset-websocket/package-lock.json b/superset-websocket/package-lock.json index c2fce25ccc3b7..808666237672a 100644 --- a/superset-websocket/package-lock.json +++ b/superset-websocket/package-lock.json @@ -1541,9 +1541,9 @@ } }, "node_modules/async": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.0.tgz", - "integrity": "sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==" + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", + "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==" }, "node_modules/asynckit": { "version": "0.4.0", @@ -6966,9 +6966,9 @@ "dev": true }, "async": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.0.tgz", - "integrity": "sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==" + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", + "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==" }, "asynckit": { "version": "0.4.0", From 4dc19345d8b1144b6b72cd139313b33fd950936c Mon Sep 17 00:00:00 2001 From: Smart-Codi Date: Fri, 22 Apr 2022 16:55:00 -0400 Subject: [PATCH 109/136] fix: Show full long number in text email report for table chart. (#19575) * fix lint issue * resolve comment * fix pipeline broken issue * resolve pipeline broken issue --- superset/utils/csv.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/superset/utils/csv.py b/superset/utils/csv.py index 42d2c557832e9..0dc84ff36a3de 100644 --- a/superset/utils/csv.py +++ b/superset/utils/csv.py @@ -90,11 +90,16 @@ def get_chart_csv_data( def get_chart_dataframe( chart_url: str, auth_cookies: Optional[Dict[str, str]] = None ) -> Optional[pd.DataFrame]: + # Disable all the unnecessary-lambda violations in this function + # pylint: disable=unnecessary-lambda content = get_chart_csv_data(chart_url, auth_cookies) if content is None: return None result = simplejson.loads(content.decode("utf-8")) + + # need to convert float value to string to show full long number + pd.set_option("display.float_format", lambda x: str(x)) df = pd.DataFrame.from_dict(result["result"][0]["data"]) # rebuild hierarchical columns and index From 3f0413b8cbf54bac94ea52dd9d49f07f835e6f0a Mon Sep 17 00:00:00 2001 From: Diego Medina Date: Fri, 22 Apr 2022 16:57:25 -0400 Subject: [PATCH 110/136] fix: Cypress tests reliability improvements (#19800) --- .../integration/chart_list/list_view.test.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/superset-frontend/cypress-base/cypress/integration/chart_list/list_view.test.ts b/superset-frontend/cypress-base/cypress/integration/chart_list/list_view.test.ts index 6da5d90106d15..42313d78495f4 100644 --- a/superset-frontend/cypress-base/cypress/integration/chart_list/list_view.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/chart_list/list_view.test.ts @@ -21,11 +21,12 @@ import { CHART_LIST } from './chart_list.helper'; describe('chart list view', () => { beforeEach(() => { cy.login(); - cy.visit(CHART_LIST); - cy.get('[aria-label="list-view"]').click(); }); it('should load rows', () => { + cy.visit(CHART_LIST); + cy.get('[aria-label="list-view"]').click(); + cy.get('[data-test="listview-table"]').should('be.visible'); // check chart list view header cy.get('[data-test="sort-header"]').eq(1).contains('Chart'); @@ -49,6 +50,17 @@ describe('chart list view', () => { }); it('should bulk delete correctly', () => { + // Load the chart list order by name asc. + // This will ensure the tests stay consistent, and the + // same charts get deleted every time + cy.visit(CHART_LIST, { + qs: { + sortColumn: 'slice_name', + sortOrder: 'asc', + }, + }); + cy.get('[aria-label="list-view"]').click(); + cy.get('[data-test="listview-table"]').should('be.visible'); cy.get('[data-test="bulk-select"]').eq(0).click(); cy.get('[aria-label="checkbox-off"]').eq(1).siblings('input').click(); From 800ced5e257d5d83d6dbe4ced0e7318ac40d026f Mon Sep 17 00:00:00 2001 From: Diego Medina Date: Fri, 22 Apr 2022 17:23:30 -0400 Subject: [PATCH 111/136] fix(sql lab): when editing a saved query, the status is lost when switching tabs (#19448) --- superset-frontend/src/SqlLab/actions/sqlLab.js | 1 + 1 file changed, 1 insertion(+) diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.js b/superset-frontend/src/SqlLab/actions/sqlLab.js index 3d1298e6c3b73..41717dd17488b 100644 --- a/superset-frontend/src/SqlLab/actions/sqlLab.js +++ b/superset-frontend/src/SqlLab/actions/sqlLab.js @@ -1279,6 +1279,7 @@ export function popSavedQuery(saveQueryId) { .then(({ json }) => { const queryEditorProps = { ...convertQueryToClient(json.result), + loaded: true, autorun: false, }; return dispatch(addQueryEditor(queryEditorProps)); From f8f057d7befd48b9969912538ae4a8252ab07d04 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 Apr 2022 22:53:04 -0600 Subject: [PATCH 112/136] chore(deps): bump async in /superset-frontend/cypress-base (#19681) Bumps [async](https://github.com/caolan/async) from 3.2.0 to 3.2.3. - [Release notes](https://github.com/caolan/async/releases) - [Changelog](https://github.com/caolan/async/blob/master/CHANGELOG.md) - [Commits](https://github.com/caolan/async/compare/v3.2.0...v3.2.3) --- updated-dependencies: - dependency-name: async dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/cypress-base/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/superset-frontend/cypress-base/package-lock.json b/superset-frontend/cypress-base/package-lock.json index d3a3153eff7f0..eb4367a35e1fa 100644 --- a/superset-frontend/cypress-base/package-lock.json +++ b/superset-frontend/cypress-base/package-lock.json @@ -2593,9 +2593,9 @@ } }, "node_modules/async": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.0.tgz", - "integrity": "sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==" + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", + "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==" }, "node_modules/asynckit": { "version": "0.4.0", @@ -10487,9 +10487,9 @@ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==" }, "async": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.0.tgz", - "integrity": "sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==" + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", + "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==" }, "asynckit": { "version": "0.4.0", From fbedfa3838176d54ab4033575ccbeae7ff30daaa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 Apr 2022 22:53:26 -0600 Subject: [PATCH 113/136] chore(deps): bump moment from 2.29.1 to 2.29.2 in /superset-frontend (#19637) Bumps [moment](https://github.com/moment/moment) from 2.29.1 to 2.29.2. - [Release notes](https://github.com/moment/moment/releases) - [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md) - [Commits](https://github.com/moment/moment/compare/2.29.1...2.29.2) --- updated-dependencies: - dependency-name: moment dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 804282374446a..ba37e4f8be4f3 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -44802,9 +44802,9 @@ } }, "node_modules/moment": { - "version": "2.29.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", - "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==", + "version": "2.29.2", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.2.tgz", + "integrity": "sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg==", "engines": { "node": "*" } @@ -95657,9 +95657,9 @@ "dev": true }, "moment": { - "version": "2.29.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", - "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" + "version": "2.29.2", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.2.tgz", + "integrity": "sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg==" }, "moment-timezone": { "version": "0.5.33", From 523bd8b79cfd48d1cb3a94f89c8095976844ce59 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 Apr 2022 22:53:52 -0600 Subject: [PATCH 114/136] chore(deps-dev): bump babel-loader in /superset-frontend (#19403) Bumps [babel-loader](https://github.com/babel/babel-loader) from 8.2.2 to 8.2.4. - [Release notes](https://github.com/babel/babel-loader/releases) - [Changelog](https://github.com/babel/babel-loader/blob/main/CHANGELOG.md) - [Commits](https://github.com/babel/babel-loader/compare/v8.2.2...v8.2.4) --- updated-dependencies: - dependency-name: babel-loader dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 55 ++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index ba37e4f8be4f3..87737f53ea6c8 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -25605,12 +25605,12 @@ } }, "node_modules/babel-loader": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.2.tgz", - "integrity": "sha512-JvTd0/D889PQBtUXJ2PXaKU/pjZDMtHA9V2ecm+eNRmmBCMR09a+fmpGTNwnJtFmFl5Ei7Vy47LjBb+L0wQ99g==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.4.tgz", + "integrity": "sha512-8dytA3gcvPPPv4Grjhnt8b5IIiTcq/zeXOPk4iTYI0SVXcsmuGg7JtBRDp8S9X+gJfhQ8ektjXZlDu1Bb33U8A==", "dependencies": { "find-cache-dir": "^3.3.1", - "loader-utils": "^1.4.0", + "loader-utils": "^2.0.0", "make-dir": "^3.1.0", "schema-utils": "^2.6.5" }, @@ -25650,6 +25650,30 @@ "node": ">=8" } }, + "node_modules/babel-loader/node_modules/json5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", + "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/babel-loader/node_modules/loader-utils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz", + "integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, "node_modules/babel-loader/node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -80765,12 +80789,12 @@ } }, "babel-loader": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.2.tgz", - "integrity": "sha512-JvTd0/D889PQBtUXJ2PXaKU/pjZDMtHA9V2ecm+eNRmmBCMR09a+fmpGTNwnJtFmFl5Ei7Vy47LjBb+L0wQ99g==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.4.tgz", + "integrity": "sha512-8dytA3gcvPPPv4Grjhnt8b5IIiTcq/zeXOPk4iTYI0SVXcsmuGg7JtBRDp8S9X+gJfhQ8ektjXZlDu1Bb33U8A==", "requires": { "find-cache-dir": "^3.3.1", - "loader-utils": "^1.4.0", + "loader-utils": "^2.0.0", "make-dir": "^3.1.0", "schema-utils": "^2.6.5" }, @@ -80794,6 +80818,21 @@ "path-exists": "^4.0.0" } }, + "json5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", + "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==" + }, + "loader-utils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz", + "integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==", + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + }, "locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", From 69aeff911bce267481266fe1b29521fd73d4e3ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Apr 2022 12:26:59 -0600 Subject: [PATCH 115/136] chore(deps): bump react-hot-loader in /superset-frontend (#19830) Bumps [react-hot-loader](https://github.com/gaearon/react-hot-loader) from 4.12.20 to 4.13.0. - [Release notes](https://github.com/gaearon/react-hot-loader/releases) - [Changelog](https://github.com/gaearon/react-hot-loader/blob/master/CHANGELOG.md) - [Commits](https://github.com/gaearon/react-hot-loader/compare/v4.12.20...v4.13.0) --- updated-dependencies: - dependency-name: react-hot-loader dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 87737f53ea6c8..9ead2fa2d8129 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -49403,9 +49403,9 @@ } }, "node_modules/react-hot-loader": { - "version": "4.12.20", - "resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.12.20.tgz", - "integrity": "sha512-lPlv1HVizi0lsi+UFACBJaydtRYILWkfHAC/lyCs6ZlAxlOZRQIfYHDqiGaRvL/GF7zyti+Qn9XpnDAUvdFA4A==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.13.0.tgz", + "integrity": "sha512-JrLlvUPqh6wIkrK2hZDfOyq/Uh/WeVEr8nc7hkn2/3Ul0sx1Kr5y4kOGNacNRoj7RhwLNcQ3Udf1KJXrqc0ZtA==", "dependencies": { "fast-levenshtein": "^2.0.6", "global": "^4.3.0", @@ -49420,9 +49420,14 @@ "node": ">= 6" }, "peerDependencies": { - "@types/react": "^15.0.0 || ^16.0.0", - "react": "^15.0.0 || ^16.0.0", - "react-dom": "^15.0.0 || ^16.0.0" + "@types/react": "^15.0.0 || ^16.0.0 || ^17.0.0 ", + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 ", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 " + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/react-hot-loader/node_modules/source-map": { @@ -99306,9 +99311,9 @@ } }, "react-hot-loader": { - "version": "4.12.20", - "resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.12.20.tgz", - "integrity": "sha512-lPlv1HVizi0lsi+UFACBJaydtRYILWkfHAC/lyCs6ZlAxlOZRQIfYHDqiGaRvL/GF7zyti+Qn9XpnDAUvdFA4A==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.13.0.tgz", + "integrity": "sha512-JrLlvUPqh6wIkrK2hZDfOyq/Uh/WeVEr8nc7hkn2/3Ul0sx1Kr5y4kOGNacNRoj7RhwLNcQ3Udf1KJXrqc0ZtA==", "requires": { "fast-levenshtein": "^2.0.6", "global": "^4.3.0", From 28742e5474b6b3ec5baa38b6bc0fb92adfcea9ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Apr 2022 12:27:21 -0600 Subject: [PATCH 116/136] chore(deps-dev): bump babel-loader in /superset-frontend (#19829) Bumps [babel-loader](https://github.com/babel/babel-loader) from 8.2.4 to 8.2.5. - [Release notes](https://github.com/babel/babel-loader/releases) - [Changelog](https://github.com/babel/babel-loader/blob/main/CHANGELOG.md) - [Commits](https://github.com/babel/babel-loader/compare/v8.2.4...v8.2.5) --- updated-dependencies: - dependency-name: babel-loader dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 9ead2fa2d8129..046e1536e87d5 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -25605,9 +25605,9 @@ } }, "node_modules/babel-loader": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.4.tgz", - "integrity": "sha512-8dytA3gcvPPPv4Grjhnt8b5IIiTcq/zeXOPk4iTYI0SVXcsmuGg7JtBRDp8S9X+gJfhQ8ektjXZlDu1Bb33U8A==", + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.5.tgz", + "integrity": "sha512-OSiFfH89LrEMiWd4pLNqGz4CwJDtbs2ZVc+iGu2HrkRfPxId9F2anQj38IxWpmRfsUY0aBZYi1EFcd3mhtRMLQ==", "dependencies": { "find-cache-dir": "^3.3.1", "loader-utils": "^2.0.0", @@ -80794,9 +80794,9 @@ } }, "babel-loader": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.4.tgz", - "integrity": "sha512-8dytA3gcvPPPv4Grjhnt8b5IIiTcq/zeXOPk4iTYI0SVXcsmuGg7JtBRDp8S9X+gJfhQ8ektjXZlDu1Bb33U8A==", + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.5.tgz", + "integrity": "sha512-OSiFfH89LrEMiWd4pLNqGz4CwJDtbs2ZVc+iGu2HrkRfPxId9F2anQj38IxWpmRfsUY0aBZYi1EFcd3mhtRMLQ==", "requires": { "find-cache-dir": "^3.3.1", "loader-utils": "^2.0.0", From ae384111c1887a4d18fbd78e66868ea4184243f1 Mon Sep 17 00:00:00 2001 From: Jesse Yang Date: Mon, 25 Apr 2022 13:01:41 -0700 Subject: [PATCH 117/136] docs: updated links for country map scripts (#19823) --- docs/docs/miscellaneous/country-map-tools.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/docs/miscellaneous/country-map-tools.mdx b/docs/docs/miscellaneous/country-map-tools.mdx index 7f3d79e3ecc8e..5e490b2057f4f 100644 --- a/docs/docs/miscellaneous/country-map-tools.mdx +++ b/docs/docs/miscellaneous/country-map-tools.mdx @@ -54,8 +54,8 @@ The Country Maps visualization already ships with the maps for the following cou To add a new country to the list, you'd have to edit files in [@superset-ui/legacy-plugin-chart-country-map](https://github.com/apache-superset/superset-ui/tree/master/plugins/legacy-plugin-chart-country-map). -1. Generate a new GeoJSON file for your country following the guide in [this Jupyter notebook](https://github.com/apache-superset/superset-ui/blob/master/plugins/legacy-plugin-chart-country-map/scripts/Country%20Map%20GeoJSON%20Generator.ipynb). -2. Edit the countries list in [legacy-plugin-chart-country-map/src/countries.js](https://github.com/apache-superset/superset-ui/blob/master/plugins/legacy-plugin-chart-country-map/src/countries.js). -3. Ping one of the Superset committers to get the `@superset-ui/legacy-plugin-chart-country-map` package published, or - publish it under another name yourself. -4. Update npm dependencies in `superset-frontend/package.json` to install the updated plugin package. +1. Generate a new GeoJSON file for your country following the guide in [this Jupyter notebook](https://github.com/apache/superset/blob/master/superset-frontend/plugins/legacy-plugin-chart-country-map/scripts/Country%20Map%20GeoJSON%20Generator.ipynb). +2. Edit the countries list in [legacy-plugin-chart-country-map/src/countries.ts](https://github.com/apache/superset/blob/master/superset-frontend/plugins/legacy-plugin-chart-country-map/src/countries.ts). +3. Install superset-frontend dependencies: `cd superset-frontend && npm install` +4. Verify your countries in Superset plugins storybook: `npm run plugins:storybook`. +5. Build and install Superset from source code. From 2dafff12ef78082b8a0448e4b9e26ea6d21745ca Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Tue, 26 Apr 2022 11:58:47 +0300 Subject: [PATCH 118/136] fix(explore): ignore temporary controls in altered pill (#19843) --- .../AlteredSliceTag/AlteredSliceTag.test.jsx | 11 +++++++++++ .../src/components/AlteredSliceTag/index.jsx | 5 +++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.test.jsx b/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.test.jsx index 6f5890018ce6c..7501ce6382a4c 100644 --- a/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.test.jsx +++ b/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.test.jsx @@ -69,6 +69,17 @@ describe('AlteredSliceTag', () => { expect(wrapper.instance().render()).toBeNull(); }); + it('does not run when temporary controls have changes', () => { + props = { + origFormData: { ...props.origFormData, url_params: { foo: 'foo' } }, + currentFormData: { ...props.origFormData, url_params: { bar: 'bar' } }, + }; + wrapper = mount(); + expect(wrapper.instance().state.rows).toEqual([]); + expect(wrapper.instance().state.hasDiffs).toBe(false); + expect(wrapper.instance().render()).toBeNull(); + }); + it('sets new rows when receiving new props', () => { const testRows = ['testValue']; const getRowsFromDiffsStub = jest diff --git a/superset-frontend/src/components/AlteredSliceTag/index.jsx b/superset-frontend/src/components/AlteredSliceTag/index.jsx index 3e2d21ab139ae..e4a0d1bdebea7 100644 --- a/superset-frontend/src/components/AlteredSliceTag/index.jsx +++ b/superset-frontend/src/components/AlteredSliceTag/index.jsx @@ -20,6 +20,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { isEqual, isEmpty } from 'lodash'; import { t } from '@superset-ui/core'; +import { sanitizeFormData } from 'src/explore/exploreUtils/formData'; import getControlsForVizType from 'src/utils/getControlsForVizType'; import { safeStringify } from 'src/utils/safeStringify'; import { Tooltip } from 'src/components/Tooltip'; @@ -82,8 +83,8 @@ export default class AlteredSliceTag extends React.Component { getDiffs(props) { // Returns all properties that differ in the // current form data and the saved form data - const ofd = props.origFormData; - const cfd = props.currentFormData; + const ofd = sanitizeFormData(props.origFormData); + const cfd = sanitizeFormData(props.currentFormData); const fdKeys = Object.keys(cfd); const diffs = {}; From e632b82395bd379e2c4d42cb581972e6fe690a50 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 26 Apr 2022 06:34:28 -0500 Subject: [PATCH 119/136] feat: Adds plugin-chart-handlebars (#17903) * adds: plugin chart handlebars * adds: handlebars plugin to main presets * update: npm install * chore: lint * adds: dateFormat handlebars helper * deletes: unused props * chore: linting plugin-chart-handlebars * docs: chart-plugin-handlebars * adds: moment to peer deps * update: use error handling * update: inline config, adds renderTrigger * update: inline config, adds renderTrigger * camelCase controls * (plugins-chart-handlebars) adds: missing props Adds missing propeties in test formData * (plugin-chart-handlebars) fixes test * (plugin-handlebars-chart) use numbers for size * (feature-handlebars-chart) fix viz_type * (plugin-handlebars-chart) revert revert the viz_type change. it was in the wrong place. * fix test and add license headers Co-authored-by: Ville Brofeldt --- superset-frontend/package-lock.json | 49 ++++++ superset-frontend/package.json | 1 + .../plugins/plugin-chart-handlebars/README.md | 74 ++++++++ .../plugin-chart-handlebars/package.json | 45 +++++ .../src/Handlebars.tsx | 73 ++++++++ .../src/components/CodeEditor/CodeEditor.tsx | 80 +++++++++ .../ControlHeader/controlHeader.tsx | 33 ++++ .../Handlebars/HandlebarsViewer.tsx | 66 ++++++++ .../plugin-chart-handlebars/src/consts.ts | 37 ++++ .../plugin-chart-handlebars/src/i18n.ts | 65 +++++++ .../src/images/thumbnail.png | Bin 0 -> 398917 bytes .../plugin-chart-handlebars/src/index.ts | 27 +++ .../src/plugin/buildQuery.ts | 31 ++++ .../src/plugin/controlPanel.tsx | 158 ++++++++++++++++++ .../src/plugin/controls/columns.tsx | 85 ++++++++++ .../src/plugin/controls/groupBy.tsx | 45 +++++ .../src/plugin/controls/handlebarTemplate.tsx | 77 +++++++++ .../src/plugin/controls/includeTime.ts | 34 ++++ .../src/plugin/controls/limits.ts | 38 +++++ .../src/plugin/controls/metrics.tsx | 103 ++++++++++++ .../src/plugin/controls/orderBy.tsx | 47 ++++++ .../src/plugin/controls/pagination.tsx | 57 +++++++ .../src/plugin/controls/queryMode.tsx | 42 +++++ .../src/plugin/controls/shared.ts | 61 +++++++ .../src/plugin/controls/style.tsx | 72 ++++++++ .../src/plugin/index.ts | 51 ++++++ .../src/plugin/transformProps.ts | 67 ++++++++ .../plugin-chart-handlebars/src/types.ts | 65 +++++++ .../test/index.test.ts | 33 ++++ .../test/plugin/buildQuery.test.ts | 37 ++++ .../test/plugin/transformProps.test.ts | 56 +++++++ .../plugin-chart-handlebars/tsconfig.json | 25 +++ .../types/external.d.ts | 22 +++ .../src/visualizations/presets/MainPreset.js | 2 + 34 files changed, 1758 insertions(+) create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/README.md create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/package.json create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/Handlebars.tsx create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/components/CodeEditor/CodeEditor.tsx create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/components/ControlHeader/controlHeader.tsx create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/components/Handlebars/HandlebarsViewer.tsx create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/consts.ts create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/i18n.ts create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/images/thumbnail.png create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/index.ts create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/plugin/buildQuery.ts create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controlPanel.tsx create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/columns.tsx create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/groupBy.tsx create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/handlebarTemplate.tsx create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/includeTime.ts create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/limits.ts create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/metrics.tsx create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/orderBy.tsx create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/pagination.tsx create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/queryMode.tsx create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/shared.ts create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/style.tsx create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/plugin/index.ts create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/plugin/transformProps.ts create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/src/types.ts create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/test/index.test.ts create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/test/plugin/buildQuery.test.ts create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/test/plugin/transformProps.test.ts create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/tsconfig.json create mode 100644 superset-frontend/plugins/plugin-chart-handlebars/types/external.d.ts diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 046e1536e87d5..9ca8a5ca29250 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -43,6 +43,7 @@ "@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-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-word-cloud": "file:./plugins/plugin-chart-word-cloud", @@ -21998,6 +21999,10 @@ "resolved": "plugins/plugin-chart-echarts", "link": true }, + "node_modules/@superset-ui/plugin-chart-handlebars": { + "resolved": "plugins/plugin-chart-handlebars", + "link": true + }, "node_modules/@superset-ui/plugin-chart-pivot-table": { "resolved": "plugins/plugin-chart-pivot-table", "link": true @@ -32492,6 +32497,11 @@ "node": ">= 4" } }, + "node_modules/emotion": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/emotion/-/emotion-11.0.0.tgz", + "integrity": "sha512-QW3CRqic3aRw1OBOcnvxaHEpCmxtlGwZ5tM9dV5rY3Rn+F41E8EgTPOqJ5VfsqQ5ZXHDs2zSDyUwGI0ZfC2+5A==" + }, "node_modules/emotion-rgba": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/emotion-rgba/-/emotion-rgba-0.0.9.tgz", @@ -60299,6 +60309,27 @@ "react": "^16.13.1" } }, + "plugins/plugin-chart-handlebars": { + "version": "0.0.0", + "license": "Apache-2.0", + "dependencies": { + "@superset-ui/chart-controls": "0.18.25", + "@superset-ui/core": "0.18.25", + "ace-builds": "^1.4.13", + "emotion": "^11.0.0", + "handlebars": "^4.7.7", + "react-ace": "^9.4.4" + }, + "devDependencies": { + "@types/jest": "^26.0.0", + "jest": "^26.0.1" + }, + "peerDependencies": { + "moment": "^2.26.0", + "react": "^16.13.1", + "react-dom": "^16.13.1" + } + }, "plugins/plugin-chart-pivot-table": { "name": "@superset-ui/plugin-chart-pivot-table", "version": "0.18.25", @@ -77699,6 +77730,19 @@ "moment": "^2.26.0" } }, + "@superset-ui/plugin-chart-handlebars": { + "version": "file:plugins/plugin-chart-handlebars", + "requires": { + "@superset-ui/chart-controls": "0.18.25", + "@superset-ui/core": "0.18.25", + "@types/jest": "^26.0.0", + "ace-builds": "^1.4.13", + "emotion": "^11.0.0", + "handlebars": "^4.7.7", + "jest": "^26.0.1", + "react-ace": "^9.4.4" + } + }, "@superset-ui/plugin-chart-pivot-table": { "version": "file:plugins/plugin-chart-pivot-table", "requires": { @@ -86171,6 +86215,11 @@ "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==" }, + "emotion": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/emotion/-/emotion-11.0.0.tgz", + "integrity": "sha512-QW3CRqic3aRw1OBOcnvxaHEpCmxtlGwZ5tM9dV5rY3Rn+F41E8EgTPOqJ5VfsqQ5ZXHDs2zSDyUwGI0ZfC2+5A==" + }, "emotion-rgba": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/emotion-rgba/-/emotion-rgba-0.0.9.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index c477a1d6e3e15..5cf75e7c44ff4 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -103,6 +103,7 @@ "@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-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-word-cloud": "file:./plugins/plugin-chart-word-cloud", diff --git a/superset-frontend/plugins/plugin-chart-handlebars/README.md b/superset-frontend/plugins/plugin-chart-handlebars/README.md new file mode 100644 index 0000000000000..5b5468cc053a4 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/README.md @@ -0,0 +1,74 @@ + + +## @superset-ui/plugin-chart-handlebars + +[![Version](https://img.shields.io/npm/v/@superset-ui/plugin-chart-handlebars.svg?style=flat-square)](https://www.npmjs.com/package/@superset-ui/plugin-chart-handlebars) + +This plugin renders the data using a handlebars template. + +### Usage + +Configure `key`, which can be any `string`, and register the plugin. This `key` will be used to +lookup this chart throughout the app. + +```js +import HandlebarsChartPlugin from '@superset-ui/plugin-chart-handlebars'; + +new HandlebarsChartPlugin().configure({ key: 'handlebars' }).register(); +``` + +Then use it via `SuperChart`. See +[storybook](https://apache-superset.github.io/superset-ui/?selectedKind=plugin-chart-handlebars) for +more details. + +```js + +``` + +### File structure generated + +``` +├── package.json +├── README.md +├── tsconfig.json +├── src +│   ├── Handlebars.tsx +│   ├── images +│   │   └── thumbnail.png +│   ├── index.ts +│   ├── plugin +│   │   ├── buildQuery.ts +│   │   ├── controlPanel.ts +│   │   ├── index.ts +│   │   └── transformProps.ts +│   └── types.ts +├── test +│   └── index.test.ts +└── types + └── external.d.ts +``` diff --git a/superset-frontend/plugins/plugin-chart-handlebars/package.json b/superset-frontend/plugins/plugin-chart-handlebars/package.json new file mode 100644 index 0000000000000..c83be8bfdd86c --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/package.json @@ -0,0 +1,45 @@ +{ + "name": "@superset-ui/plugin-chart-handlebars", + "version": "0.0.0", + "description": "Superset Chart - Write a handlebars template to render the data", + "sideEffects": false, + "main": "lib/index.js", + "module": "esm/index.js", + "files": [ + "esm", + "lib" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/apache-superset/superset-ui.git" + }, + "keywords": [ + "superset" + ], + "author": "Superset", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/apache-superset/superset-ui/issues" + }, + "homepage": "https://github.com/apache-superset/superset-ui#readme", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@superset-ui/chart-controls": "0.18.25", + "@superset-ui/core": "0.18.25", + "ace-builds": "^1.4.13", + "emotion": "^11.0.0", + "handlebars": "^4.7.7", + "react-ace": "^9.4.4" + }, + "peerDependencies": { + "moment": "^2.26.0", + "react": "^16.13.1", + "react-dom": "^16.13.1" + }, + "devDependencies": { + "@types/jest": "^26.0.0", + "jest": "^26.0.1" + } +} diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/Handlebars.tsx b/superset-frontend/plugins/plugin-chart-handlebars/src/Handlebars.tsx new file mode 100644 index 0000000000000..c14e925056be6 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/Handlebars.tsx @@ -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. + */ +import { styled } from '@superset-ui/core'; +import React, { createRef, useEffect } from 'react'; +import { HandlebarsViewer } from './components/Handlebars/HandlebarsViewer'; +import { HandlebarsProps, HandlebarsStylesProps } from './types'; + +// The following Styles component is a
element, which has been styled using Emotion +// For docs, visit https://emotion.sh/docs/styled + +// Theming variables are provided for your use via a ThemeProvider +// imported from @superset-ui/core. For variables available, please visit +// https://github.com/apache-superset/superset-ui/blob/master/packages/superset-ui-core/src/style/index.ts + +const Styles = styled.div` + padding: ${({ theme }) => theme.gridUnit * 4}px; + border-radius: ${({ theme }) => theme.gridUnit * 2}px; + height: ${({ height }) => height}; + width: ${({ width }) => width}; + overflow-y: scroll; +`; + +/** + * ******************* WHAT YOU CAN BUILD HERE ******************* + * In essence, a chart is given a few key ingredients to work with: + * * Data: provided via `props.data` + * * A DOM element + * * FormData (your controls!) provided as props by transformProps.ts + */ + +export default function Handlebars(props: HandlebarsProps) { + // height and width are the height and width of the DOM element as it exists in the dashboard. + // There is also a `data` prop, which is, of course, your DATA 🎉 + const { data, height, width, formData } = props; + const styleTemplateSource = formData.styleTemplate + ? `` + : ''; + const handlebarTemplateSource = formData.handlebarsTemplate + ? formData.handlebarsTemplate + : '{{data}}'; + const templateSource = `${handlebarTemplateSource}\n${styleTemplateSource} `; + + const rootElem = createRef(); + + // Often, you just want to get a hold of the DOM and go nuts. + // Here, you can do that with createRef, and the useEffect hook. + useEffect(() => { + // const root = rootElem.current as HTMLElement; + // console.log('Plugin element', root); + }); + + return ( + + + + ); +} diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/components/CodeEditor/CodeEditor.tsx b/superset-frontend/plugins/plugin-chart-handlebars/src/components/CodeEditor/CodeEditor.tsx new file mode 100644 index 0000000000000..5128fd8275388 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/components/CodeEditor/CodeEditor.tsx @@ -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. + */ + +import React, { FC } from 'react'; +import AceEditor, { IAceEditorProps } from 'react-ace'; + +// must go after AceEditor import +import 'ace-builds/src-min-noconflict/mode-handlebars'; +import 'ace-builds/src-min-noconflict/mode-css'; +import 'ace-builds/src-noconflict/theme-github'; +import 'ace-builds/src-noconflict/theme-monokai'; + +export type CodeEditorMode = 'handlebars' | 'css'; +export type CodeEditorTheme = 'light' | 'dark'; + +export interface CodeEditorProps extends IAceEditorProps { + mode?: CodeEditorMode; + theme?: CodeEditorTheme; + name?: string; +} + +export const CodeEditor: FC = ({ + mode, + theme, + name, + width, + height, + value, + ...rest +}: CodeEditorProps) => { + const m_name = name || Math.random().toString(36).substring(7); + const m_theme = theme === 'light' ? 'github' : 'monokai'; + const m_mode = mode || 'handlebars'; + const m_height = height || '300px'; + const m_width = width || '100%'; + + return ( +
+ +
+ ); +}; diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/components/ControlHeader/controlHeader.tsx b/superset-frontend/plugins/plugin-chart-handlebars/src/components/ControlHeader/controlHeader.tsx new file mode 100644 index 0000000000000..2dac822f8f2bb --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/components/ControlHeader/controlHeader.tsx @@ -0,0 +1,33 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { ReactNode } from 'react'; + +interface ControlHeaderProps { + children: ReactNode; +} + +export const ControlHeader = ({ + children, +}: ControlHeaderProps): JSX.Element => ( +
+
+ {children} +
+
+); diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/components/Handlebars/HandlebarsViewer.tsx b/superset-frontend/plugins/plugin-chart-handlebars/src/components/Handlebars/HandlebarsViewer.tsx new file mode 100644 index 0000000000000..6b3a69b0c731f --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/components/Handlebars/HandlebarsViewer.tsx @@ -0,0 +1,66 @@ +/** + * 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 { SafeMarkdown, styled } from '@superset-ui/core'; +import Handlebars from 'handlebars'; +import moment from 'moment'; +import React, { useMemo, useState } from 'react'; + +export interface HandlebarsViewerProps { + templateSource: string; + data: any; +} + +export const HandlebarsViewer = ({ + templateSource, + data, +}: HandlebarsViewerProps) => { + const [renderedTemplate, setRenderedTemplate] = useState(''); + const [error, setError] = useState(''); + + useMemo(() => { + try { + const template = Handlebars.compile(templateSource); + const result = template(data); + setRenderedTemplate(result); + setError(''); + } catch (error) { + setRenderedTemplate(''); + setError(error.message); + } + }, [templateSource, data]); + + const Error = styled.pre` + white-space: pre-wrap; + `; + + if (error) { + return {error}; + } + + if (renderedTemplate) { + return ; + } + return

Loading...

; +}; + +// usage: {{dateFormat my_date format="MMMM YYYY"}} +Handlebars.registerHelper('dateFormat', function (context, block) { + const f = block.hash.format || 'YYYY-MM-DD'; + return moment(context).format(f); +}); diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/consts.ts b/superset-frontend/plugins/plugin-chart-handlebars/src/consts.ts new file mode 100644 index 0000000000000..e6b215ede3e66 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/consts.ts @@ -0,0 +1,37 @@ +/** + * 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 { formatSelectOptions } from '@superset-ui/chart-controls'; +import { addLocaleData, t } from '@superset-ui/core'; +import i18n from './i18n'; + +addLocaleData(i18n); + +export const PAGE_SIZE_OPTIONS = formatSelectOptions([ + [0, t('page_size.all')], + 1, + 2, + 3, + 4, + 5, + 10, + 20, + 50, + 100, + 200, +]); diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/i18n.ts b/superset-frontend/plugins/plugin-chart-handlebars/src/i18n.ts new file mode 100644 index 0000000000000..5d015b5665975 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/i18n.ts @@ -0,0 +1,65 @@ +/** + * 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 { Locale } from '@superset-ui/core'; + +const en = { + 'Query Mode': [''], + Aggregate: [''], + 'Raw Records': [''], + 'Emit Filter Events': [''], + 'Show Cell Bars': [''], + 'page_size.show': ['Show'], + 'page_size.all': ['All'], + 'page_size.entries': ['entries'], + 'table.previous_page': ['Previous'], + 'table.next_page': ['Next'], + 'search.num_records': ['%s record', '%s records...'], +}; + +const translations: Partial> = { + en, + fr: { + 'Query Mode': [''], + Aggregate: [''], + 'Raw Records': [''], + 'Emit Filter Events': [''], + 'Show Cell Bars': [''], + 'page_size.show': ['Afficher'], + 'page_size.all': ['tous'], + 'page_size.entries': ['entrées'], + 'table.previous_page': ['Précédent'], + 'table.next_page': ['Suivante'], + 'search.num_records': ['%s enregistrement', '%s enregistrements...'], + }, + zh: { + 'Query Mode': ['查询模式'], + Aggregate: ['分组聚合'], + 'Raw Records': ['原始数据'], + 'Emit Filter Events': ['关联看板过滤器'], + 'Show Cell Bars': ['为指标添加条状图背景'], + 'page_size.show': ['每页显示'], + 'page_size.all': ['全部'], + 'page_size.entries': ['条'], + 'table.previous_page': ['上一页'], + 'table.next_page': ['下一页'], + 'search.num_records': ['%s条记录...'], + }, +}; + +export default translations; diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/images/thumbnail.png b/superset-frontend/plugins/plugin-chart-handlebars/src/images/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..342bc23206413b36c6b28711f0c836ceef9e8d61 GIT binary patch literal 398917 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 _W?>2t5j(V8WVNkbY~=ertfPZd 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
GT^O;-WcPEVeLH{LJ)BBzhE9_c_%&NImj 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𝔷JbZH@xzx{lUwkdO)F)Bzb>J|cL|wf9wmIi) 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

U+W*yvyz1xc=eQ#neOu zhxAxA4X0tjg%T(9^6V6b;%YMT^(t^MSkx4xpb3mRg??}OniJWUpE_`;8iOZ@0zEX5 zF^GV_#vb(7!#-?DfpN7>RJr?sn4Ga40>mnVt6`Jhwr*et) zYAQe0t7L9=vi&c1Q8x1fxVF}Iz0J%wJSzM--F#WM*}Ei@$G4nh-j|zX{O$aweWKef zzsGj4`AX&jO`px33HH1I?>8h3w!_hG8DYan(VxDcgHZutjJFg2SVB(y{oQ-aM;y`1 zh*sRrLx1Ru0>S2YX?D?RjY)>b$lqMb>TqUD$w7ravS3qBCTE=T=s{t>UIpl(`Xs3& zz5NZqM#c-&!{^q0ly-+KqEo>}aqi<4Y-$J<{e2{m|EA?Oqaoq4)i3gaJ&14n%X%&% zenM`nsHxdjMrT(>U3ie0lz7*lWLxXb%UKF|ddKZI$(r;PoBGeajZsl$eFV$ zVxY>VPnUpy*b&uONur^;yMiln{i(s~c9Gi&pBC$QHcybTQ|klqT8W0771 zaf0u;0(jloUrVHG z|LyvG1D9+El099JvI~v@dOeU@3th0W#?I;ozW)pZprSzsTrfJbZYsfr7}j2mz|UlG zmz?We@aapxc%lK_!tT|J3Y&ui1Gf6!#h0RC;;>|K)%#QVnS6Jq9=lg8$uGCCA!@}9 zHZnIY)R)~-)SL{1FaGU5F|4b1XN_(0$BUJav0A%{EWueOCCTi8v%fi$m0O`Rg;7r- z0x^8*BlG?(qWd1Cu$O)_Kk!La+RXpG}`DsX$z5Srjg=jGdD zrG;=D)H=AnfQl)qp4}yJDH;*s^u}?LSP7Em2p5=gC{ z6w$&{Vl-geR`<%E87VkmP>Tx{U_I7%)33Le7^EdjZ&sq_(t|yPV5!?P?_#iyMr^Ak zm?P?*p>aLT&ne3obkku$Mq?sD>h8c!*i+|lD0*%7!S&Ll+-wyYYf)A_8lYcp9ii6|_8AA$PMKsJ9qc^)hO{c5YPFTn-LOmy|82GD;<@17)Q&;i);@A)EV=LQ zwBvgKoUc@^QT4{)zbVOylsT@^q>iciGR;w&%C{ECwT1BXE5MgFCuMs7`Ob##yLHxj zyPOF(rpFtY2pESBSX?o`D?h|tkMp(HUZ9%zrZ^29=M5gYAbuQjo@e{QB{a;MA$1)^ zN%ZyJ^#qkmG0-*1N?phWgA|j|pjc`;=xS(mG5%p?jbKsyKc&ZGWuAvLE6lPpGCLAF z2M~zN^d$bD${E5A?lUo^ZR}8nkD53;9kFUA1U{}v(K`Jrvaz4>eDQKUzemPr@!9#g zQuWw;jgaNq?QG7cCAVzk-BQAJd#dSlvws&@y}t(7xJ2x`Xn!rbujN%?)^Gbe`=w%d zN!8gH2NCbSdfJvaS=vRC7^UGPTa<_xGbk)?q+6EAO;v~l4}I%#Mp(tQrAQSiIT-2)~T<(;$nlib7+Fgn5e*on3cIIb|lF$z;SOpI|`}IHzBE znsv38xv+*ad(3`WN9L9AU;WCuh zfp(ziXalMPHprwV^hLLGqW7QK;1kZMtbwPtXWkvLJZ_)SF(NDIigeBz-0g{bO~$-p z#*D5aw|tPg5Q47Hhv`Y1&N-1{cVeO1|e|NZ5_0}qxbwRDOj!@uyvmwXD< z=50H?Y4K86ZcW)u=6EjN$1GDmsgEV>_4nf+{IgDQd|#(J9_?g*sTgf|4(jQ1XSlJ# zsI`;=O%OQY5ipCkqESb^Dgu%BUbLAo_C)+=MwH%Q#IG{!jWRXfq|l*ThH(_h|BB&8 z<+B7Fb=AC1$C0D4$pX)gejjoZmr8*U@;rpPNWtizEK*`4l4+I7i0HcsS~1+rvve)D zx^N$pn~&j!Z0DD&6vONg^ElDbo&CeLhMRXsY)-sX_PEI^BfoUAcwSCQTC;9_dGO&w zEZ?ptqaZ<}|@ ziI0z#%UU+Yap263Sf?XL*RPMGR(WoWAIJg&IbHmG%%17x16T}fLQ+wzM=yg z{=S!Ej>f^Kp8;{}@};)cMUWx6;8T$UP)Xqtc{8GM31?{ZJI}OnN<#Pa~20710e!EPULL@!nA;RzG=~Bi4bi*r8I~`gnC$tRN(drWLxMV^3jI5 z`GN%Dk9I~|1Y(&|M8>85fatT&ZYv_%z3CL`_b3cInrgUFT+GPp`MbSK4}fmEYPj)n z;kRzf;BmWb({bVV?A)nkQ;(NN9(!Dy7C&ANJank++y8*x$NO{=q&6ZW9|nBXS-XgL~Zw#{UQ$Gur%Ej~!%cP^ct+kibX<1r-d!F#7@HM8`{Gyq z)$foxk4w=-{y%kx@Pqf>D{pE8;@34T`=i5$^=0p48+2}7zpiQPx6~ntHDmOHTqc2YV+~Htiu}c40mcn zCL_-Sh?4|bd7cDi9^e>=%$0%rH(Vw-_)Y*C(8_9$A!HFi(+#c^IqEoX(^nbj=^=++ z#!LO+;aN5e%qrg=SDk7)*6smoXeXQ)=n0iH-4PDILh^*XIa2Z9w8sPYd|O}Sg*-vv zdlMabZ2%*@;DkXIUZl!1VW5Q}o29bDBzJJX2?YTbEcAH zS#1yF1Zju*r7gz3Q{F48<|Fm7n^A2)qq5kx!;H^%6Jd-J>iA5;W8@~V#_u~zQXL(6 z=1UN>hxe*gdIr@$gMZJw0VbW0jyy)&3_j)|?0A0hA-(E`4L5+nO9PI4iE6lW5sVsm zjJ=b9-KamaD#lmUYcNwWcQbt7^5=tNtd zcb-G#*DH%<_!vAxU;SfwfRvF%Pdw{yDB+bEH$w%|_cVqh79lJhGLP`mB6>b6vT%^HACK++Ua7kG)v#ec(|S?kz2wVercU zfaNe@q>0WYon;T;8D!VTh|Y98kUP^v%+m4l-YYte0=msIeX@m zHz8a~)12uf;G*N8Dcf3e&k?0NwPcE~eUCr!q))ndOvi=u1#sK;?Pc?ptzKF+%c5B@ zbn?n+QEWcUNfj3_oYx5zA9(}fciw!xysh6EZ9aVA`~}M=wdiF?x<|cJ_bCT4sIsxe z^{s|!*$A15OoNM@R9wZobK)LLl^?@e*@?9Bp_88WB5H#(FNu@)ki%j6p2pcs48SlG!(`go->SvUJB@H!!V}`?=&t8?vOEqQ_ygT4l_qXYA{~PnefD^iC-TDpcGi)kbw{F!wHxHCYbyWD{S~kUddAl|<=EpmE z)27YQh8qK6TMmoizoGutbsY+DNlTJh9`(j+zbmi(_E%c2{L6CY%xUi}_o>^y48*lx zHdT{U_zw7hjr4FhfYxaVI??Bh<5g8ONP)$`bj& z&oa7s&A7=tT34b61kf+!J0i2mT31=h+q}|d)vnX{;z7OQQ86;qV3Ty5&~m_??+}Lt zcYI};M$!(a&d%u@SLA6~UU^0exIXK$eTH!vZd`N-(wIt)blpG((_b;%43>vlG_86V zxQJnr=gavx9Exw8&pzL0Chudezsq{5UAQg7&GohA+C59laC1sig5Uc6tKN|B$KU@K zeQjErhMN=vcUm++x^w5IKr(i%-B|YMG>iN9-(Md6!k5aUGTc0->DmVmJ)*CF`?Vj= z_)eQ*Aa_F@M9TEzqaT$cAAaa1Q6IeXW;uG~aQRqEqpn@MUT)pIT{igm^%%geQRiii z_O`Lu>JkGuJ*x(LT?f*US~6zb8~_Dw4hqe{;@=MPXb#{p_{+AD^b9l^ybU&Z;~F_@ zA@0d%7@ydP8^oV16!e2>PKG-Oj}B$Rl26VqN%&-U_gzN30Eh9BxnY+b*B3vmg?zDJw|12VdmGAjEw|X9J72f?I=@N= z8kS&e-MU4F*VyEgbk=Ek7z{*CY4EnfV)jm|$w4o{cRo&JS z0hT!4x~b&?3}R~8U|g|Ls){%5+J^Zx>)oC?si`;4M!U)M^4=S0fUJ>mh8Ya{?p%3B zTT_{+?J^6C!H#FTBm?2AK{~`u#a!Fxa5#4#Q?Pzq|Fz%>u$Vx9Sqi_o6I#igTejmT5Wy-&o(S{ExAAInh4gat>W1(L47}>LM)F1TTG{`lRHPHpA zAJLc(MjgSXGTb;0v}yqA3^;`Oud{WW=yJX2In;RlJj?YFsEcvUxXIaFV=KF?zo87Q z7;c6N#2t|#MKO$WSHmcv6~oQkie=iYhJpze-3 zxD5F2@I_voSvEX8Bf}W`*LYbKHnFb|}_H9olbHDC&_O42^#nFsHOitDiCF>yr#l{%d<;EHfuBQ3{9 zTK=+#;f9`X7<9ze&V0j5QgCe252;DnqOSczwj_@jF9<`YY&dW1-?Z5qjqj3yb(7nG z+ksA`*}3~(A3Mx!90r37Ou4UFvo7Sc=40V*ly7hg872P=L}eV6&fwW;-`BN-;_8(v znn8D|T+%E$?wtH~TN|8S)BaW0WYoDOKkYxu3x1Xf8*?Z)^cFWRbu^;XhT9Nylxkn5 zfIO3dEg>`&u+C*kP?635q>gbY3;zl^) z!80Pj>fjeTMR9UYAF03tes!6byeFLdC|JN-tcm`h>(l5ll)l}lxE^bAU()v&AybpV zTfB)l6AU*(593QuhYvXlW3YJeZ@Hp%hD$!cdEa<;O%Mtfoshqv2!4SZG#MZI!|fNj z)vw*K*tp_L8(zO|L)oF3uRFDz`9T?NSknCXm!2#S9eT9v-FKg6!){T#T3g$EwDC03 z)ojoEMt*n~Gc)eC?#yhat@6R-$l(vmTd%)XFx(v0{x-a)FKK2h%E^5#>6++o%~clW zo&8A}@Lac5k2hr`zOD~Hw)(RFB%3Il-< zbodg7yFhVblw%YJj&K7>akLe|s(v(y--GZ8erdpQamD?F4qUB`Q|yId$Q;c zvSPTIX_1fqXfspl`Qt^eV*Y&Q%0ZeW706s!%$Y9w+jAygGK6U3{6x9PnYu2+&1HSS zK738FF4KRV`Nyg?Rt7=Gc8u!A$c_~n1= zW;v_lF5i9Yt@4YX{%iT(fB#4Ax$=QdHu|J&-Lgs3nm5jv-#8HYy3YV9Iu4)d6pIb| zQomgWn=g88{g;Uoj@o4+G*J z8RHIxx%6q7#`*8_o)}Hn>%=!75iFb-7_IRvn=Kl^-J`uRI4O=3;~4a<75#2)YI=`08fTUba&_xA z8((S=rUy&|)O7L|&oc9?`3jx(7SE!*OIM&*C<)>*%wTk7*(3X7T+)xP^tAu0S1#G8 z!xB`CH(|t;eq!LL{NGakZ+nJ<{2D~wVup@%FoR3#K`C&l?MQ9Tx}bjtL|nxC3$xmO zxWqAr=)*X}D?Vj7}86_u&Jek!f+J426Y2`+j`*{ z1kf+@8eD~Cyu<}1j7=yvhQ^BKMO@$G0+0I+c!8RE& zeH-jC6E9k&;BNmBLas6%3jFX0CmgqF^g{d+hcLRts5Ooyfh~+_48gndHBA^4u3f+3 zO?}s?{U6ZMDQ4WT?+r_t4<1q@oMQPIG+ycdwQY-+xDapwnuLx4eXFDbKXYvO(uEd(nm)?Th!-Z+x?S=db>zeB%%Q zxIFXxi-|Vtdn+dpXL!_%o0wS{216TdBq;MX+_)*%_Ke`xu-|twq{N;!DZ9M!&?&_B z@4B236RHhE4om{4i^CYB_CG)`5b;)1iXXsrS%^<2t?;sSH0aO6bMOp(wJ&OQtQc-) zTHJ&0{FzcM9G>6w3&(EOoTM{W3^%hbwLt>(w|kL;;Kc}GxVgeosT-@y)$I?I?O*=0 zvh%U8lwA*fNqJi5PJJ3{d{@Vq!3~|#>`dPc!woN71|T1O@P7H(Pkvbb+yDD_?$n$- zabj5*Zs=I_9SCi|t1o!#wbq`E67SXWsRIu_==Jvd?mtlW>&Vgr4;=L7iM#YQpMe== zy%3H87y~$FFP%Jb+{cI>*H=A$CyyW3sTF6+1)U^y@%%Y=An8z&_F87xsGg}?dJHi@ zqVquABLg{@BA1HCm}A+feHuY?P!$81keM7<#efGKgg@?9We!`ZFno%5g7$MaGS}+B z>G^cf$~VK@Gp!cZ`bUto(ev`t*TW$fb9Ix^4#NqP%rKmEJHt&N^!=nJU(od(;=$Z{ z5aj_?JckW2>ZLeMp*l^{R!te^bkvJsfK}r$GJw&#ZqQ{Q`G*Pj+5@WSf*79kfyQ8g zud9&{jHMV-&>_UHT^E~_Zr45*{I+i2p*=0Mw*~hd_;=WF%F;!aNAa^!MEhcF)bb~c zHKF^WU9uMjGwFa~^k8NSh7wMCxuL-d%ahU&)IQ^!{dx$VjfcT_v{5q-G4`@A$F?2Y z%SH_>f#2dZ9kurx8kjO;k$m{8zGm9l5MyJ^EgN$%&@wyciVQvMamCMmec8Wo_H4Ou z;hdifV-SNd*M}Ye-4yyMj3V9~RShTG7H!CbnXbLM@rsf=It^Sff(AX((-5mT^bHYq zl{abV4`kBfbqO65nRfUG4L3a>X{q|&3ck=o8$5i)Z$R+Wwc`!gza%z6zNyk*o08uoSLgNnXXsWSN}5$))nvRgA^nQgOAM~HLc6u@p5Op~^E^5k*9lRr6rtbB6fnBK=9D||xm6FMm1l=R6twdJc` z)*1Ro?_K44rn0mES)CP&EN|Iu;$xAF#9#f!w`92aAIjIh_3iTXb1%$_UyGc)6PDrr zJr(aP>fgp1%MbDpGBGd1O>L8*wa5X2iF`?n2>PPHfHP2#2Ap z7CM1x_(@-^7;dI1qv@j2zw|`M;L>n&Ld&PGY~5eBKJo3cWId@cZxUOWwQX)F&tPHScU4;HqP*Q#t4*z$@yS zx|MRXeo^`uXWZ7w3j?Q+0Xi+h6f6d{(1M4H0hkOr4%D#YTO9lm<_k}5_B3sk#}_@L-u1}B}8Wg9N-ZS7!lPj5q(cb6UV7S z4A{WvpUcK4*n*w7$P8`7v%z>N6Gld3xgy4nUAuPqYdW)K?vbAX8K=$Q$9S`Ct2QW= zUST#LGji6r4X(8ulY^A8Xpd1pEN?icc0_wRaqL(aJJjZwjl*Wequfko&}Cg2FxVu; zbPf%^S?XVM&!XCD)!B3=nO+ zwejg(IgM_Rq4&JtmoHuREWDUi#HJ3)1a-(@m`bCEO8p2XV8` zGw#|nXm+(8ZRAPD!*r>CAb@W;`1v#{JUHh8SqOjDUky5sZC#j57ybe#o+I)5B^(RLA;syixbwB3M(4hbBh zqs^lyWSFtxCgeDE4TEv8(0yKpMIsn(^ou=jZ2ohap~Ez5Of=+yHerJ<3CSPtt(b|& z%(|V@1sHAE#F)M49)9#O?NRrDmo<0q<1}9C9+J}6F>KRO0@3CoUw$vE9W&#NHph}E zen&t0P|K7*(#Mn|`dFozHFeJrfBF}H zV*}1p&pg+n&&oeRepGi^pBYEp=F+H$qulwa&b6O^zlK`p$(qGg4?bZ`|~KgQjUSSlqRHPuZ)ld~BFV z2XXIxssnxb=f@r!{<^P0C&r$2I?ZM^%hl9dh%fLG8-+P7hOg~>Rln(F5m$VK_cfMH zXfVbg?6lU&QK&H{9(N5PnGI2;q*ZRPf`qLa%QqfGs1a|#3FxTm|%I2X480vO`jK*8g9{s!QMDn zOA@zgHVU(4*jRhpwjK5K7#U|2w?(JRaNm9pr^ztmhEv{XZ(D5miRFQ<-1c&4AF}40 z7tL(B?){=Lj2=0B*aI(?D`B|7_&UnXL{_u>p~uh68B#?LFk@(!_UE`)GX(bDw_kgH zZ7dr!n@^?D)J>+`!lLp$sFDHahKx?+6$AMd%^Hdm<}TS_!)bDtw0y&G&&(nIa=EG* zIpAHq824+}u6QX2@?&EzdPK7e|=7OW%Sc z&?_wQ-HsE_^&xpOuf9`|hGSAA$(St|i4WI7?s?Ld`2+f(q% z*#7SPE135_X6C;6(9S%SkiSv*t&9%GOJu9-bqn%oN0stteSv&-F%gZfkvxUrQJ_v2 zg${~M&!h0oLcDD3QNYFnC(b)E(zf10FTvNpfWzM>uxW}2bQomW&uWMC0?Q%y-WTs> z-pzZ|*3c*MUe^103rndsNq&*Hcn_}$A##`tym>D(YmJSe+ceS^3S_XdNm}b3d z7A)^&X4r6wBQy3e?69YtH$?Y4w3o|{`4aO`vo3qx(F2qvhU*t!{$~01cmA@x@Y2`H z6Hk75&bc0zwG4-hgbd-I&Tvz%p4Au1Q)T_JSIXKGzbR|a9WSe|>g3GnT-I&2c|Wky zb^W8O;@uc*&|GK=BXyju-xLZsadNuN*l<&gGm#aZS($+id8+JSxon*~5z99Bea5K^ z#9<7slZi2;?Id}2>)*i)tQc+v%Y!$6G~8kMVueY5#c(sf(nj&pBOQZF!_CQCtIFlg z`^x%5Uzg!VOQjxpwyfQ-x$DScyy0b>Mx47no1A!b zOoJ|F+-z_MVGKn!>m;*dO2_7=PHAGQH1B(sV{F>20jCF@`iia%6FHicb@dD`cW_Lp zX67)+WX1r?Gh(TNj5T`6!IwJWYdm{bWC!sAdtC74PDY#jv7Cc_E-q=nbxD4VJbVF! zAImJ(t*v!S9ZRQhGK>~xsBtsnM;+rTo?)a>=6L}97(4`ibS~*a`>*n`amPBMm+u^K zaEEV%?J+GKk?at+g&l^Qo=+PWyhg_jr6yM zHbB!r1ztDY@)Tdv> zKuVOAHtt&<*Z#ItdVehDO+UWbZ24NrRWH$DxU^dI}jGL7Cdl-1X?@$+=f zO;wj{vXWU|z5|9E$xp;fLrGsm9<`x#N5)=Wpm60EHwN3TUg5!ls2o#yATE&Q8$(U? zbI-VB1#DS6mFMT;S*XKMQHx$3D~6ky7CHTbaXMOjwCC~Ug5*~WH}fld>TisOo2%O6 z=94>X3Nvoj9elZLJ@i7k=g>1{{YJh(*TK2+7seY+50=a=mf_~S_F(zxkA6`8%m4Mi z>7<1>WVkt^4#7qZ!eV34af<$2E1j3KJFa4YqKzrkT9Q@=p$ss$>d4_;_iAa!&fPZX zV9eRHNkjKeOE4PuL^Y%Spvmil0oDZ4I*#dqEpHY2L`&axhV`R zcC};Z0S1E{8EV*w6Jt$pxM>F&iM$&unz$x=gJN?^=}vxyzs(GRDe0~%8a zpAK*$Cobtn?a>Q~4lr@_uhbh>sMPIuvHsRG2(LU&GwG^b(f5kXXf& zhY%CVn+63KYGO%L(eb_7s9XaC_KM=mI7zXxJ`{Y}}j{8{eBuijH8 zd5)PlmxBgb9KFrg^W&Py#*zmNq!>GL)(O0lcgmD$Req^k!O1ef4?J-)?$gF_!)zip zP~EflJ{fQBi~V_Igqp1*4GOp&{CHrj3buY?V8XyhIP8JNF~vuZd{jQxkIl_b96##W z0vL6!T)FC*0vLnPXBekH)$&X8Bc1A87OkI(9S$?-@fT}FC^`%?l#7D`!jFimFC2jm z2@?t|0VQ^nf9ROrZ^m8S7~3H9524-#O$@Fg1;v3=b=4>%b5AWa;ZC->af>&ZKhvKq zekO2uw9a+k0%qxN<#j63kmJ7o6Sp(m0I(dQH=<@o@lHNAFiR#)1}QSCKW035fG$H! zmzwZm)?cHOgfG!lTKafWX;Sw=haWy_p87#ABZ^L#!w|7$%Qnx(xmQaRcWOos%PeW< zxO^`=P%q4W<-Lr4VB>!dTR5-%X{234zPNU3w_x+oDB%={HzwE%&A6r|iOL zwyG}Y-0FVI^O|Y%^*{LI^5oOccscl7b3H5*sk5jnq{i~0x@%|LU`Wc32Y1+L{noXz z>cY{o=KKd`%?TN8PQ6*yUOrP+-?%b$wz{@cC5y1+!^e1!Iu6{bh#6nZx>7GnVNFDw z(xLBADxSHc=SZVWj;*>`Il>whHzo7s5=mXwB3>?89+^*`Aym}J=(`EDY>cxENgG8z zAob1=A>nVUh(6EA3P+3(9vU%y{nn%>;j`;_wU}#V&pZZ%SES)yw7NZ5eJh z?JH{^mf_~%7s^fOLl6w8=pagLDS)bUu&;UOqffxa=_IJ>fIC)44f+ z{;ZD!|NH;)x8+S8k@?{VANXt3IvFpVW}nndSdJZsatPg4I~yv020EPT;s_NYU)wfl zgH*ouc{YxWHtRhrV7-ky4D4EGdT&a~S8|E?=>q%}VLqP=C}LMjr;gQBSqL zs58YgK>Eyv8|qZKq`n0kUShDuy$}jM207*ho_}T({Kb?s)1?dqex$7{9?Oj52{!VR zwA^Em-ScbX@F)LacpIJxPK%#@I)?LcNuPJ}5iCdWrQ@c=MkSf_OD@2R1oBQOX*^hhPQTb_ z7-J&{h+(t|jF(>GDPI)(P)}B}_tHj09!0;1B);O{4hTgv{jY2Q*V_71nMBULIQOKHS zpGk(`lWid+6E4fgsrYmZIBi@fQffIT%TIS}R^anrdAWQ|hMOmzdb&LP$fI+9pp=1R zkOsDdQ+F6?0v<-Z^n+u=%}p6@E`C(joO@q}o7ZHxd8@3wa&`uW8&aiKkQVN|>j*}_ z${<9In5;bABmK}v(GAMTo=;^YTE(G9g0aAa0g;yQO=2v^dCUbnqCA6D{^jo6IG6cD zP*3?A!=tPiZf08Oy{?`q#lqsDDQLjsa#k)#>KGk$FwQcV@1v9V(CqW&G;4Z&6D`+O zeaCdF)TNDKxY_pbi)E*lO0Cn8n7laRr!1=T;*Pgq=_juLlFefTew&fx(ZXip$LMh3 z;<@shSAJ3c=^y^Cy!N}_>GC$Auog;ZhC^a)y$gdW^wb;*Pox52{AtOa;sIr^3m&>=0&nA@1&fF_@6D;#o)| zz8KD&`@om#Bs1eMjI^2$R(!b*>@ebh+fwTbDxJ~HqX$7T_=cxv)8OIQU3Zvk zXBLoX_^slTWb^51)U%qu(;z)gebKSh{PeY&I2?P$u&inCzP(-=wEuukVSDhPkMZRc zn7uM!Y}P<(llH0Epg{vie6P{Q>Ky-lH;gvq$@!jv;l}M2S)ZeGo2Gn+XWT5Z;RYiW zI_0M7hM5Gn^#%KOJ-LcyAg4~A)E++{$guvNHkAL!o1vf6?1J;>V%a9T97lb`DP7V- zG61jj$ABoG;r5x~SdI|7B4PbKW)gm;eQ?k_G5D>8ZKDc$C3Fj$dRohc0y0v>^@f{R zde!Hr)nApX_{BSJlpaf*ndq~`ZSU|Nm1c`GA3yRqoon^Rbhy*SyPYr~8{rsi=Zu*) zy46j<$Ong0l08Nbx;JZo#X!Kl9b zUKwucjGHe$`II($UTDKjl?x?~e1wdI@h@d6ueG&tbv?eBi` ztMa}7_Kz~${IOovnk8fM&pgE+=an|9y>ZvNmgIN*udIp8}Jx<^_Os_E49 z6fz^y0?$D%BOK7^$iRCv&Iq!RpqbLs37ns!!g?OmhwHGyA|G@%C#K>~=p?lrUy*yF zj;B05l%%sW8~hz+0HmOw&G;cN!-gAp#u!JQjt`w=EIIt=v%rj*Vtnb;O~1}n{=@+% zO>jK321A4fN}h!QjjEI-Q0ru%*}&2#8EM!bfjwu?KU>*M*aHEbqNJHMdt|8Dqa{## zwKVD;&A8!+@7xTSfmM9{mJt!Zes{z9d8RPjtX->9;U*i{4YdpM=lU5az{vGdG2A$R zs<#}3M!u*+?c<^2$C*iRN*k>n|1=@zJ^%nf07*naRQMyEu=RDRZ(y3trhPXE3T>=tSVf7C^P8;1~nQ)F<|NqG*Nbh z!Lcq2B_bT+&^eJ_&>$m55WLq}_7BMqbcqx9;K$XDc=I=6LTgmddmy3-Wd0hE^J-o0 zNb)&%N1ms1)b&C+w-XxvB(6=1TC|*@XGD`!0gJd~)6KpCp!}kn(s*N#>cDypKIk6b z(O=SDk{7~Z$Pq}m(4wnc&VnR=p*1e^z(1-c^bpb`44rH z)Z5zMh8Z_+mo--}lvSFEGo^FIaH0OVr(a%0}9d>3UV8ch{M%Xv_NH%dH zU*aYK;kkl@lp-$rLj?4{GUJ9K8^5FK;#D@bysQ{*W>fUpmS;n=P`GIR)CUX2b2fZP zzABs9kS!=~_PZ98=^`?t{^(tf!KLBm?9ETh<#jvCoxNWw8ywyuf6))^7b3Ad3gdGX0FwqFm&!eZ!S6_ z403a@tN{{(rtS;j=qIKuY|L7-PJ^G=i-;G0)=ArG_n4+C4&yLDVadQM_5z8{t-dcR z%s?sz9H3;Uvmfv%FPVlA;Dt)Ug}<*K;Ld{)0`9cQ%oHy21H)z1(A^V@w>a-}UZ~MI zo8m!@7lQ*`CnCB7XKs$m{@iefxC26M$TNOH%q_BaGW%yKyaaX6=8 zc?1%Bu!JEe20Tu~nEfROKap(>l*GydraF6rnKj5JdL@DQRsEbXQ>bNrxp@=g*!q4oj(+K|z^> z{$lBfW;96e_?;7q!|9?mLWiJ?!f=Cr@Rw!rp!RHN)mmdFF+$lo!AH z4Iiz}5$khx6YAgfN}WaBW<2$1<8rM-*S%n$DsOjWpjmzHy|U)?8)f~;*UQ?|@0L~C zn0S>oXrI!#&4E$tnJ`|`uwhPm!Xx(4axf+D8aXhV#;ikkcy^805}z||(omD*t_;s4 zoh6Nfo|tEyGo7t4S->kDCSU_CA%omW~Y+1Q4G3M4rUnun4Y;(Uf z@X5P-mW!XA0mIGO?d8ty$IAK#o+;Zk<7VTo{bj@U-Cd^K@z9_n4L5W^qNCdk?p+Ub zc>4-*N2LjO45#t1=`KglzVqhm<>x>7*Yf+{{<^&W`&WG$17CRAuf?7FIq;q)>9Iz; zHMvuuZ0OG0EIKArXUp{=Iikbp4!-VmDf*c+G?S5vaIX7)@NnHWrGV#e?QD0st5 zKK-ZRCg73xjHOczJW#3jH2CGdNt~?4DK1;I%!z$z?$aj2`_;kU|KLM9A!@I`+-}iE z-CHymSSJI`y7lY4hXS*4)`>1oUO_Gv*YTWF?QyeqgyCkOO*CzQoVWGWKFM-0+)%w# zGJBHUltG8c(Fvq~FrpKPnEs4U;A0bvr=`%)lA98j?{h$dvyo zIq|q?EB1AM$ANGC*yDwvnq^diS+9YEA3a)i9XvhbCgLImRJ?S_XF5^pGi^e=`pny9^~qPuy3_BJb!R^;tFB+w8n0;# zH>9b$s~6o>=YBP+E>S0{V?K1?98Qs-QBwb-j5^>*esbo_tPD3S3&kOOzQ=9ZU)@<) z#!o?YNG`_dRtz_@DSaBX8}@9776{kMVS#wfg^hJnRUmVrUkDm2&xK$#CLYL+dzOoz zzJ^`prd|NdxVf};Te-dSP+7nK%Vpcc&zFt&K3F#HysyhtelhYoZ@r-yeEEyHWRSS% z#70wSJE+;Y?HJ9_!!&xn+J5-nyXCjP{DqcG{nE>(PM!M1r!%mL?^;c*H~+CUz!^Tw z-9l^h4qXJw!cKP&W~dwLO7|{K2)5A}H5w0Jx*acE*QvlU7{VwQgPEvP5mjEvFL(ov zv?JW-17Ac1ZDcU6iA&yx9W*>eAGNd^`fz7*IRENO4ub~51N7MmYcV{ya5vG=#2-dA zoPloRNICa`@YiO-B8*?)!Y2kgl~Cz1po#tyozeKBnQW7ueRk|zH8F_ExV-b|`Q?)e zL_e&Tp>@5CEi8xHEJMv^ErH@lU6wj=)GnI}@ADDYaWWK!87r)l2nT~a$EovUAQ)fqLpO!4sQO7|Y2%+{D|8FVOJ=R>Lf;uenagnFdQe=9tZH3A zXthJVL>jz?gP$Y(Og02D8&M8u;1DGjNp;M7VEDRg$Gd~{9_6qPG(PgK z6dK4<@-lD^SssqOls-YzeF^vwCenXSFsVOZR zauNWI%GAc0n(;`RJb|Pe^5i@Mt4RM&Z|JG(GQOhUwrf-PM<4&9mPx(jW5Az&PKRCS z6uj-)RDQ0mt#@PwLrH7Aq4;f}QDu5|a(#D&{uKVL>!sYdR92sQy{zFV@G~Ejwdaq- zGAZ>lr*)weDw#cD1b{KAM+;ga`z3<7g^&#zbv*%#hw0nP-DLR$Bn7xmLXV4=PV$5&`kMCHbPfPY{9ZCb`B z$~-IM9@Rebku*b5L6*SLM&4n@K@YBp1PLIlyk~V6;8`}6}29J1-#lP~*QyFg9-zE(=*#@{z z^&DA_U0IU`gG+wWg*3zs?D-=@*Um-WoOr$^%x2RZla65q1I<+(lkEL&#a9M=7)k5|8Z>4FV7F*w&UDwQeAW4nED7}Yd7q1R^tv`!I18f6l>)iw5~4zz9y zEZ~$k&U&!=ff0v3Oq`4g+zK~kT%1NC)w?+-j|SH~+J|c6MAXiD9EggnwxA{9uWDpu zUeF(w`A{{TJ{&-<3_$S=z#0i%wwLr9wZbRH#o!s7)Qe%Lfe%cBAG$60fsc!V?b9G$ zMxKb&RX+Y}x*ho6nTNW$c1@=?slTvGMw%y{`f_>sn}2A-%~Q`jYs36pPQGjFF^V+m zN6CfZhPr6#-0vQRxdRXVsXP1FT)$Y>e4=B(kG~?r&4)S`{Nu7pOQ`gsn!2`Xn~h~r zHva`a@4fWRNqLXUvG>Gz4YV$; z*;1}-J6P7-_ocE$hMUcM50%Y(9_+H^4l9NmI-DNR-(ABEgFlYbJf$i1AHDycj5a?j zul(DO%e!yCCF9LU`g+ek66^Hti@goHj4nw}yksY4ZSs2OwH;Iqj=a3)?eGtE{)ua0 z_;H3&XAS`fJ^@dfFw`W92PdxQNYT88((4CBkw(&*ZU)0pEO@d$184DKu{Qg9d!{ zQ`AOdX^RmwNY>6s>^-G+$sn5zhc|E05xSaDvt3J}cI}D{h98j8=D~*_(bA~DohWkA4c;NAozXY?yyD_&^teOF6L=VYwM@ zvb@;L*h{E%X9*Q2Q!%@SBgT(wHV!9KF(ZeWIdRYOcX~2VaYcKvB z^amfOQE|+-rjhA%@o;P69N)-e;&UJE@_F0VpO_h!;k=K+&A9R3F_2u7F_yM*@18y0 z+vbn{>@UhwPd{58f8tBhTUy#G{XSclWuA0V?sdN*>Z;05;@NPcyrgliH{4t;pUH5u z_Smn=x+6a>Yc3osYc72v!;OqKER&ncx#;PQ(E@R`j=HK!Pm9xx;N8X*hv3PLG~C## zgIq-lF{Tnt>MP4?8wVr(#B#-OL#ZwSNA)(s zM2f|Lj5~dvkp@1FG9X!7mKUR-CrN`o)i|DglI+YHOy0+AxVensX7#3War6GNYTpxO z>%r&C=KCL$@#f*Klj4rCUd&S&Zc+?mSxuBg*GZja!O-ElO}=!p)S1(#$}fNZ)AExa z{)=Yb{JOmN_S;%QA;XOZh79o9^k$jNqM*}BoT$~A&C(AD1~bxs*@>?>5$m#SWM#nM z;kM}Ry^#mXT@@2EYNDY;9BntlG4N?=@W)IWv6H`z%elPE&44FqM%@RVV-&&? zfMw@U-K)9frU1u4IKLefG#rv|1M0jPm*WHvLNZXXz)2$wpu~w>#vmgHy5P31!xcQ^ zGR=Xb$3+%!20L{Yh}tK!YHr<@f%S$A3zCry8&;QXGAuB&hNVxtcJD5G_U`lk2KVSo zEU+Crc6#$+j`C*uJVu?AA4_AEHS!63DLTqxD#HzW38ONud$yrxF}${qEOQmEY0O{c zi^J~|xa7wWwpDxnEH}eVK1Y8?-gvf8Wx%;zuIiMjb28qXI&mzPQE4gF*)ylL2hJJo zk8{CGV^3>e9B|I-`0*P$k?N-Q(ZNxUam=_3ep-&ldrB`<4Uj@-d5;@GI5AJy`VH1e z)&uv93&V|r;*Xy0gI~@sMrJcAz9ci`v5+3=?(BOR(pFxbNL(4*+N{{Bid+MjD zx~H~>7Po6R0!Et|)Ncrx2l1jcZY5?wPZ*axLa$YN#U;Fwc=ks6C&YPRJ;-me*F^LM z^nUlde^9?g-0d*6NKt6%?S`Ro7qpJcRovOIY3pxWfzW=#&4 zGhx-3mP2K|s-F0(^);>G=I~F-x_7@@)?PbX)?B~9dR}$QhRb_IS-P5PT{#chpdTIM zk^0p1(SM^~242dSe4GZwzmE6L0e6n2Is*;;_G@Jm>?shR85#bfX&FFHWcVHzjS@d0llkAG&W`4!1G90Jj z27P&5FR;sHT{*vgXSs9l<7M4LFO+Q$K2>%+_(b=0la8jBNvT7e9YZ>#Ht0zH=!jrb zuqZYLiMFFVn}YJoST9vh9bsDiFMsya@}uv4xBToUKQ6ET?p5uT!B_e;+)#*1*JX!e zFo%>`?42+OkEXn86>kB);%Fd3x$@Vv^9;A;8}Y;lAKdDUQwNEFT7!!@yZ)jslg2Y{ zl1GgvuH`$(Cn@7ToaSKcV92=6^+fm}6e`!^REL6{GSKI&17}VVC$9)fE*Ei<!xA~Z|rpbF8#!z$NM-2|vz0;pC`U|{Cs#-k8>nLkJ46woYhK(D`0}nn} zzWxW_E`Res|L1b>kw-NHXP;$pwz5W<;_}}62MsrV|EWCV9Ty)JFzis4x?rt+$g+qs#FxrQod55y-FENPFD;!vnh63Tei5_&@CjcdjJc`aE824HYZ)E2XIF11*R;>grw3jv_dNPy+53eTG-!yIp$#+|{4=1p z!A5sp#$mXLFOBSXV^PcwvYrW?*}k;fqHw23nZ$7O{eSxZ$`AkLpUSUa`9){A$ATafa&F2rpO3O1F5l6Ehp4lpi-APcUDgfv*?v79PCNN?cU)sE zYU5_&kNiwFBWWfaW{-ocb(`03<$;5T)MX4nF*dr_ZxT33d8;a4uI_tFAFqL?`)7B~e&$HoXGI_ut!v@7?G}Gqz(T~bInq|pG^@l%rznndL z+I!=$2ikSbzG3EY(Ne6{ycdcKXH&OclR)8Wr2-tvr|oTG!j)nCcR@Y-ft z7&>pj31@;~w4p;UquZ!ww>PqCy{^6wdJicu&LH36^a*>WqSKc_5&c=;;h|3l=?C&i zk}jjUm(wu6#R&6WpuA~!%$|JY@yE)yzWpcVKm4cvtUb&g@C?kkQJM=iMQN|I#a#qcX;bziOUUCtL=9ruAis5E9QbFTx8~AK!7YcV+ z$t)D#>G+`ARtz`OmD1Gl$j&$oH%k7tUP@P`bk5#cT`p}oP_FHMs_cF056Xkj|FQQJ zpko{c5|-4&06z>kvBW07URn&@DGmb;4Pz#q(5VZE7ujb%RqCfd`a$`@KYv%trv5F8 z%O_%=sO9f$j3E9%%X<&65tV%mCR)Dc192T_c%WitbYi2uM6l8-5b41s;z=Wo zG6{3o@qq*1Pklr)%c9iLRCORmn>KIpUMqX{-sfdcEQi{&cdvgeiP~|`E}taAX(Bs! z?ed1hQ^^YHFDFM?&Sb_-KC|VxX;Y)*I_os+DF-fu|2-OR^0}|Ff$_!jS}uFqOJ>h# z85Of|Fqm;V)tOVL1lQ6kEvE%`W=sc57+u zV_$qi8x#LQ`P0An4_Y>Lzl}L_$!+Stpp7&rAHUytFR45`gN-r}?RcB!({xH(eP;5jrO-IZ!m%m^iD()U|AbliB%_yl9vB`BEIP-bmEa=Kv*> zPQwp4>CDD_5)$`JYY^JR&C_r*itpHAszZ8O^sU_A8j&G7+Y>tJLwIy92XLSf&crXv zbW&@2Bq|r3TDo#En4r%ip!lh?id-q&@*$mW;6NNHfyCEH^NhR%9=H)5Y4vdohS#lI zBg4#YEs5Gw4rx}+!;d~*4(WG5Q{?yFw@*u|_0b3Km7|BX zvGLI(<=i=)Ms@9$mi4ZZ{N-$x(1rfkfJ&4u3q!UmMq$Zkr{DT_R;%ASKgv)p4dJ01 zp-DX(^hVMJZ0pntfRB*%AAAyzJFqTaZA9bm^3lLLFZA#o!Mnp*P9DiVy3e|5M84f# zh@Zl>dbY!_G?uT1o;H`p>;U(?TR!9`@FHRJ#oO=&*1AY*Xk-VDW``wR;Jb$)4W054 zX$h%iw`Bb6QH6nFr#7G8|G)z}QR?aP!b@K(U;Ea#{f=OX>|9*xzo5-`P?Q33ekbN# zOs8lQ|H{Ge!a(yjY7m@9Ol3FF@W>0nKzkM7YF^)6bU`ZES<|J%4L?6 zO9!227;4^&ow=16H?t{mwI4Ug+0ZOBu3y#*&2$F*P%0~in;8_;G$@uK3UeB~yAX}O z!Ekd!Urw&v{Ir~3yQ`epda&$y{?E(7um4968u+@Loyxc`zTxKZ2k)1+UVqIS6tht= zUtrm|m=i~MgT*GrccFl0%rZLegXp}>`wD|4|s?(Z-3F0D+9xw7MIR7>O z3>u)J(@Pu2K;X2L7z0Q)+1<2hOWCF)sCQ@?)b<@a%J%I$e5CiTd-rPpng?{Ww|?5k zhM6`T>CI_Ut5(M*vB+0zyl9_nIr}NUnasFppWW@WX%EBgChKIya5E?wE|)qWW7z|< zKiT(;4T?``85O_dM?dy);wQbi@hNR=d`9mE?U!?zJ##MmsAB3%t-%Hz-W99)NvGLR zBEwDSH`*J@7o9n%&(omodL)ZR3w;ajupuY)Ecm@Hj%PCu(#S`3dJ!6yiw4CymGERZ zj+2aae3N*pdz1E5Xk&?^o#`hfH8S~ZGb1A_a99^ZlP+b8Fwo@J-g{j1Yo*n_)3sUJ zhK0U0zZ&l8`?wB zTqxTt`+D1b%h>S@Jos{d7V^JhxEU#kD>H6J7K?yVAl5CD#osNsla<8kJ~o$@?6GBF zffA++S%6z*-<{(w7=|p<~!xU*Z!xfPvc8q4n#a4lgx2v!{X?i zQV}7FbX2>}Y4tmD9+#6R*r4*m@(Uda{+nO?tbC{=!cQDOrj3hr)T0KU>pd<0ZkONW z{OP>79X8JxuW-F&-9Vyz@MhV7YJby!2zT(t5AW5y8Fz^Qq34*Cn(2!1^bU;7+`F;mA4&*K?5u58U#SgQ?%w-gLC_uao=n@&EYWl=i5Tg#$2HEM^C_wJWO zZQQikx?p6TM3z4{SC^Lz0n_bqllqb}8E#+0Z707JQ^D!ay8HJ!<7P5}N!i74fAPWv zpEh;w+*zGIrR7sHC-)f`w?56hw2@(KF;C(-tmFLtsI2X7u#06U@ z;M{UqSyfp+t19cxois0bIGoV5zD#3Ql#SWx992TL@REb=Zc8^gIJw*DI{9d2Xt)^f z%DxwS^jAK1fjWd-`jSN4yyp` z*SJ#uOdUXl-V0}&aZ|ztlhp8JT!GK)SRF@cSFHN?zSto=>+XgNi*WB_4R?>U0>ep2 zU(Catv5~wH*6K!P-1s~Kj9HNk<^&mkIOF=Pl{5HxJYvCQp1uCY8|j_zy_mIQL(a|Ilf`su=NX z@BdvI)>Vh$QLa0PNyBcs>RL=ioo{u5YY82Ah8HtOV0WYYT)(lss)08G8owaj(u(jk~Tt;fC%( z1z#~>3OD&zu(WLcEl!`{+XLN52jNuoRyH_m(zPWsZCZlJ+j2qR@vhJ4Vk|5PWC6^y|i%0qn zYoUEu@QN`9*%4kU#RRoZ>8^=Fn&rx|60oA*vd;C_vS?@-`1+#P;xr+yhX zgtOR$JNoA`4zDLUJ)sq_-qB`8xavS8y`n)l&#*5P7r=u{^ zR|o~pnfXw+Pr{VtMpRLDa_65DGq`F_SDJC_vNxDHZPWyMUR9>u^P2oB-IU3o7&k?I zs?xKS2J+tO-^%EFGj4#2c2S1d3N!HzD(=W1J;or@K=heQ97)>;WWGoVD1|6>vo2pDu0z8hi$-Dei7dF zh@Y_JpqzkLeuV)v!I&$YD`Ui2ImpoER{F2iZ1{@&zznhUx7?W2^@`gj$bClalS6=O zd}VmXsivJtom5Uw!!J)H9ozD7<}QRDr;UOY?h;S62mw}s48FuoqU3|S!5UP+;m*S1 zsTQyGg4JJ#8n-99_v%X2fkTJ0VBrIL4eDq*cH&gN4s}K{3y#?d98TH!)eutt=ckOm zYhc{ORhg)lp_i1ivb6Gx@`|0~9mY*br`60bXKvrVkuD+sMo)knSp4`wfBH(y(Pw(vmL5M$iw|z- zn$+iM^v3&X?B-`_^w#HM+~|5eLW_H;nv{ZY3+3la9LgBR9Q9v`!CPstLWpoA6|?!_ zpUu*u2#q--f0_!xagVuO>4!W14Zi{p#^+k?Fm5(nd{$qmLxWPpZI!q?0o{z7fQ^O{ z_N~$f6>ph8T0iL+lp^{a#?8j4Px%!S#f>3bduWFFCu)56h9}dF;k{|>{EyP^*Z($+ z?KzajcOOWj<2unY;>TX8ZX^0(^2q|OV%*5h89Mm!k&ZCCzV?)P2zH&HXt2Gg>r$VJ zdGp`?pZ{CerhcBTUAbzzt&>yExbZmqkA*zgw#h0%V8l2x$^jY3d8)fhIV`;yjFnn2 ztJB1YV!@zWTlu8FhQXh3T?^%y{FgW!wuv|5ZDJK(TVS+ZOKfh2rXJUFzg{j8XD|O} z;T{vduDHW*E%Ewf(dp03%5ijHP`kko1x*bO#!^;3x$GWUV|UO&BMyfJPox3=SZH`U zYM8Jyfu-(QICf&>k}YEOBeJblJq ziQ1(nUZ?en6vy5tCa3gGT79&@f2Kc1-!(FB`e|!*IjRg*Uepb~of9|xS2XJNyq*z% zG&^gIex7H4D2D&#OP7q9^VOGMNSN{G>B5(v+ly2PIY!ZEAk%47&(@EQYFy)VDgAe$ zqF>?k%`(PK3_SGJ-eFou!YuE*-7WfWVA=|ohz~zAcZ?V9aM+2j3uhN>=~l*9x`#RH zC-&7~Bg|&#WxQ*}NB>FqE*1T0m)}Z#Huz|lp`1~Y;~*|$)QH)@;$HnRb7K})=B1xV zPp^EHxFe3Z78G`#8V5JZ=OR{P+`dAd4WRlVE##w{J{4GD@H~(gHUa3H7BznCNk97! z|1tg5-~4+MN_0mtwS74b27|`S!C@NLq?E2jrE6*7K}s`M(#Wlk zb=u~=G;-&wGNAUiJir+kkfQA|gwLe0bR$itbox7K{KX%q$wOz- z#DNoOT(8B9=*kp|>J|(dyDF6#08Hj^uXTeq`$i{vHb|>;xT&4fuQciY?|=Va)4%=V zU(y9l!XIdt73EAM;y3FmKLEu9w?_auGxahh&2OM*w2Z_(A|iwprTN#B|27S{VXN{CC;@nZaiF-*c{`t)u6J;F1|V%8v&X16BXdn9mppM*#r*A=K!V%EHH{zcm{ zJ*3k%2qNaVJBwMuaXM$cN8jt5KmR7^xsq_=F?&%f_m%1!txwE-WaQpjebGFkj4GWv zLp;)h$;K;PyZ7wT6}Cg^@Udg*=&|F*bemFH?kJ}K?Zuc~x(e|`FWx*|)QeOSV1MWK z9XoY%>FY1;B+l1ged*VzZd^+bHGVu4qlZ_k<^>z{wv%OQ&vf-%1+?pcnJE(eY{|fh zGBAcb;pE^~uF4ZQ4plO30#}3&J;qYAgHKSL+8;xGL- z)7*}`7j93Yr!&m0WLH5~V%%6e1X6*UxyC8*m;G^ul4eW|>z7qtnio@Q)-{`-{a6|- zj*i52x#LD3AIg|EIZE7WpPatJFVY)fo4V)0-3U|!Yyz4M1`3R1>M!+t_ny7h55ctg ziy!}0di%TIONS&EZM}7)nfIE$>IRiE>`vck=cdci$I4)KGyaKVGB=Zk?tGC(uYVxf zHor^5_phblN4Lee(WZs+&{H+qS;Qj3Ttt zIxzEs0R(d|7(lG0EUd=~Uh~75Q$ED1uok`9p5q^D7|(I~NjgOm-vF{Q@Va1m_j=it z{T;?l7gcbhSD=j#N)bJs&7c%{k8e4%A>Uz@0peaB?eb+Gz`(QayK_kfImbOpX55r%*pB9y1lmMvjS1|U^!wy`c|XWPvORhB32Hgr zW-`VHtS81`a#)9FlIWi32HRY&QajPa<&m=y6?- zIwJj;gbyFn)u|e%7&k_eHUB_6+5%T?Z(O^g?`pbs?V3(&Ue~f26_?2TpxSOtIm|rtF4UpPG8dqRw2#{;(yyQ8mO}uf<2hW^6mtK11)%2sE z{B?T!d*9cI%~$Ld!W!q=OwD{c&CE^ov$7DnVA@!|ML$m4gqg7qT3enU-AThYB-`f7 z@AaD0uhNj@-3&dQQzk@nzJ8Q73X8NSwBD04KR#)IOHZ!jtFIz<`=r7Zuw6R^X$=~Xb%x%BdX*6udo z_b#|+9eu^LQ97PmMcTIk<7QTz{M(Bo>Cv83Y2m<2Y3l6v)AX6Q)9(ET(}Wgt42&8+ zG%0n)4HL*Xbp!2vLYc2nS$X)x7QdL#VqUL8*3DhL67t92{U*Kl+h3>OOHeU_D6i-! zJcBpFQk(H~GjTc@H`HS)lcl?9CcTC?JhQ!>4`5KCm*&z<0FDVghXIxE^TG-D}*cId*KEKzYg?e@J%<{WNs6>9PorPb72p6bf8LDS{mt_?2{KAe4+9^AjDXT$HMFF*TKuM~c&lbm1bO4UUP!M|l! zt+<+t;CrqYQd%hQ2fYmG^StlFUTujN3Ybv1Ra5#JYtdQ6 zJbd&pUHa;RPTRbne);o%vX`blc>j;Kn3|Xzx3tB=72MR#A_*3q`!{zdEp0u@Ao7LY zOZCcIm%GQ+j32z^PHe>E;g`eWAGpzbsS2hGfbz>rzDGu28H zdE#V(xRNp$V{DdCnt(A0YlSU$U@QL&6ZX(6#*NULn@u7;j&icfX&tHor_Ls{8wvwq zTqgx~?b)67=!y-(hL0XUp$Ycu=|%05@>0~nLxhn)NPG5fUX}YL!g;P0f4sZqck(fL@`#wKE zXIHL*+3HuP=sTki_E*(H{tYROJ{cxUsOW3kC$IvZzBE)YgTh|w;3@Gg^CMv^C`(6? zt`$I6^zwA99M_5n*5O(9o$Cl*1zD-T51?QQguT@1dug9dRk!8R-TI88-!A>aY<)m$ z{4lp=apZBJ!yR1Zc+te&PHXsvPTHMu6DLE$ht(Oxhy6v>)h%o)vt0KQ~@{|<2Th?N zX6(xQX-Kb3t*WTXYCFCdrhQuqh#U{aL|cY^)8C05hp+L(fu>Hzjo}t7xD{W6ag(n` zd9$XRl+e|RbJfgl0{~;9ZG&{IHwn3V46-Z+UKfl8y({C+iJM-Pi`KlgV`noZM8Cth zY0_k^Hp;JJxX2CD0BYnv0QfWd@gHNZ)H~&+be(h)J(1{n)^zQxXG0!O40@3d{ zn~-7{H=v&MvhfYpWsF%jmhhJ(#)XDLgxO=jO~*C)BpJI=@;dnz54oPi^f zfpX;730;jkA%@NIbol5|W8NH;AJ?M}9yp-a*d)73vTHi)01@t}WfY*y*2B0N;RKVK z2~+d7@_OL)0o;@;3ilnxO`BSqV~57j`u3gMl7VwuuTANb`R<)tCQI$=|bz2Oa$J)GD6hm}G3smGjanc5+jdUCLoJkY?aH*5((%x=!=2tLNnqvWD zKwWRbY+(xbpiKBzafU~smoVYH9tLg~p1}7kblSvPRh(ce!*$^SbLn21BP?#=9(wqp zH{Eg{Z%MAxU5*7+Snx*poX0Y*3NFJmJjg^lcm90( z>wo_r(+_|0SGr<*GVMQb(5{GIchF{JVaO8?9Qwq8<~+KH?l?bq=J?cy!8z3U{T~YsI&8PMaSCz(YWxIS?$3%8jLst;>b79vmhRt+t<)plLmGmn^DHq#Z8 zOOFv4>N1m0GiPATVGpyz07sB4co9pklPXx@kxh(&J!0A%Jb1vK@IL**xpd~-`E>I1 z3+cqEGrA^qK*!tnOIFoX8rLgvA<{#oTu@w(y64{>TAlQ_`#c zc^M3F!4z_E!&cx4*HVvosyIr|!fYvsI1BexjS91El=X?rQ$tTlIqPS+XUhe+^_{FN z5!LZT9c%bnQoKJe-NRg-1nLS8TrYS0*2lPsaRgphJo~Cpa>ikPDr`O;5 zR{H6```h%rzxa_|!$mGy)Hl+x7FFg4>Olir(-iy0H+B`LgP9DVwDJ}o|e zl+wf7Y54A!Y5dZ!#I*T1jo!H^`8L{g>{n5*soF-2jzG}J+9NF*W(sLUaaxBypg5I- zS8gUv#tpFa1p?4l`Q^2g*+~Xhr8tdaj2)2aL;EJE9mdUuOL2#Bvk?-~FmvPF2%2?; zjZS7=fj1!#olEX)HUZn(Q3UM=MgO&@czFoYz6}^RHVA4|xczu3UDub+|8*LD>8I)N zi*Ki0hfmppg;9e68YTg|$iXb28Zz;829EnjM#HVLGr?W2QX(15*(Lq({rA$Z^ep(# z|Ka~j7cYLDZr!|T!t*$VFs|2K;@GY=k)n=vF>Wk5%EL;N56gF+K(~`0;X+^LpC|Hd z_tL&y7&n$Ch_-_2mCqG|Y^s+{=%@?p!J5Mbj$SCh&7`nP5jMWk-`$#F1ZtDf5as8lV5XO!iA6NbPXqM5I}rf z&wL-2;9*|+pgr*7k+oOat;&}&+)-~52J@%(_ka~1{N-dPeO771tG09c z0mJ8U7~(z)piEDnL7oX1ZBm_a_gH{mpUT?yD)0u=V@$9fn{-rBR)#7C_{E8m%CRtP zJ>$m5F;BaZ(Ng2p%9%E7ls$a3pqB>srqicSrgy&gZhH5JKS|$u`#b67*Iu`cfDv7h zs_Sc!p+1bz)XGq>P^`_kffu8om05Gj2kgq`&HJsGBD+phjW|yWxMlj#C*wPb?ryP)EpTRaq1T+Z-fq3ijVp=%yy|j4x-E`ok@2B0z z&Zl7pXD#{|Kv)3Ur%}lm4k`q554$Wekhdp~gxjckC_$4~wF~;u`+rEk`PZKt6X#DK zewb$N-P0uFk*-boOCXVkvW_mF@KOM-3|`g<7w+gxaVvkTbfvG;#Ngv2@Mi!3KmbWZ zK~#K%HP67~;XEGuEnor*FgQn;8kdI)xZ1sR@o?%uwYZr{9NSEnwCiF1h)I1-3Y**|z7VaHn3#w%2`)4C6?&B2I~OXXj~ z)qS^R$i@L6rHXOF;T<;O2?Dh1R0Qp(-A6jQ#)rV)&`F;hEY2c*4VbO8)xs$oeH`$;T(~@3~8ol{p8oBu=J8d&^@2Z{w*NajnWIXb=icX#O#-{}8 zpTtN-RfoK021Y|OzBS_}`*BFdcrt1$T#b^d89)|7Y463j@zXfQ0MgpZm^vOr@n*YW ze9nKYke}l`P$uyWC>sN1+>m!`AiXoc!?@{98}$_Oj9z6WxM$%ZtXm_!h`R!WlGt=Eww zY3+b%?0D8;F~yE))3s*Yl8Nd#QkF=L`7oLtjl*u&V(+2mZ19)=yF^ zCQ2W`zB?Lq#RD*z^e@|s9a!8^=I`EEGJN5On~nPNwh5^3B6@tx_*{X9azYl3?c~eM zadmb-kDof7PU%_i*WdbfdiC`;64#>+N`B3-uH|sb0~s8~T$Oo8Z4Lz}kMhLLYK$8@ zc>(jr`WR0G(-!f8mSI(r2Ii z$@GgCzO+}U?&{=?of1$PK9LNzCxRyoDo#<-9;3g--OPE6Y1a+>Dwu3GhX{vT0TX`2 z)l}3o+}iGKC_w5u+4Nm*v-;ag*K(-Ay98t2Xx&PVwPUrfE@gg2pWX%2cIz4^0><$* z3 zv`)rVFk>t(#a{|St{68K`Wy7K!?@XSVGfpYvw9oYVce`%#LEWl)dFmiKOI@M2;L-| zYe7R_7~fjLZ6{buKR;4gbIV|U=OyQ6PRyG-yH2DB`_87xbAOShPrsG+96gi9CU@D6 zkxkx=aRXzfX4WuK^b0akzwkOLOT)#0H7ph2Pu_Rq+FC zZS`;~g)e|}u%2<%XxD7&!fEpvn}j*Kfbof0cN76r`nK_uer>Z&0^5$Vx8n?Z=9cI!}7<*2_?wpyApR zJD*&GVz={cX7As(#l2n8RC$p` z`(WB+#!aL<{D=>HhBGs6f&oCy!MxoWH=A9yWf=s$O1D9oxh``1)=iUtbM4CIbVH|Y zc!3JR#ayAfYl4k$+jZT0oceq?r}ES3I9+KR)wL&+T)c7`t!U_CEw zOKz60!Ve?i>)yHcH22&lYu7Fl?Ka%Qj9xj0r^i(|8%&Ql+Tp_h|RC5>0P>tdoaEB##`wx{_>~kop*nb-u~|QyTbf(^t}uH0#Uxv6D2JR=`##ZZtN!*>&6e(8-!~|4JJF>gQ?n+IwmA{`E9GcRwWw zEHGFw|)n6qX-{WxZsfVdThcA%dx6Ybg&$ zgs^a+ho!}I%+I-se2}p)KaW`=?ePsL{R3s(SiN33<2#I-mFdGVfYl(LjX!5AGEiOr@@8+|c?97^achPs4 z8Q^>}*vuA_S9jl7X4!pt^k~*z1o{1Mey!)h|2ci|hu`ao*N;qC@qz?Sj%IlSy#CQv z)LS2f@nLe?0i!bMns^c-7*?1=^~zV5ySZuN(~k<;mavYqzwp4uE`Nz*qvrNCIPm!d zs~(YofzWo+rALJQ;OP;xr?aI)b!08dcN*)o6V~xG-OOFM@e7zRx9v6w%@fTyb`1>m zw+^ETsO*c2PZFoekZ1E$%+txqNn^$w)vHiv&Yn%Lz4f-8u6g;@S9KjqFGKCxlP2~2 zH@lz6u;FCRc2neQvs70*+AQ4t;IX=(1x@>^6mn7K=jM#bhHR~P^4S;b^pD=>wc8;0 z3@2qT#$DKhfwP?%H<8wU=(Zl1L6>!zX7akAcKLY0g&5C0e2``(*Xa7SYw5DC?0)&# zC)zYXnDHmNIwyH+x9{2M8?I1|PmHO3ax%dgH$Lt#uGA+zEw9BKuB*PZ=@E6TOeZjY z%BJC(-PS(dM*S_Zt;KBVq5m%wJ!aQf&@~1W!5YT3oUm*JT)`6Ua0QUc%jn}9?9U8O~$DzfrqT*OGf0`@Q>hO%=pE1s$k$?=U@I+Tp8AoJkLLn!OHk~&Vfo;B=^;Mi!`V$k0xsx^hD1KMHStchejfwu|chCY?S^WM-{x<9r% zEgX4M%$x6~LvQ>fjqf{d3(Od_nb^fC8zx>c(Tj@v2-H&q4)Eq$}J@Jzz>VCR`<+(hpoK;S1Ort{0|&bUlt; zm#!5K#fh@tBL2!BKTES9>_6)^nP;Nx{?%utZu3ovAu3q{&ef8y+IstkyUB7-^8wz*QL%lY}i-)tb zvpPLRSS|Im)}yVOAY+Y7RzEcMGiHX0x>D-+1x-w+Efj9%FFei=!sd!$z{lKh>C(H~ zRm9gBqK~#{BM=E_DayI6dcn5guy=)T!$rGlyRT$l9j}i&p8RGWaC63gcMOJGKwWW! zTj(VQ+}iGPQ6FKyC8)c3;u`LyrOmjZcp?s~+d9dNohR>)Xv5(Ai!Z0|{@_RHEeRuj z?Tt6np(96i`tv|l7famzm?N%$^}LZsr9+M}29rA*E6oFc?;GPqseoJ5i&0NC4;;RK zDGlHLB8^}8mo#?$BRg#~^pw*!5z`8~S3Wfq!DNP3wXT6#Lr~`DGH?o}Mrr}G9kU^* z>?FN{$n9WAj{Mn6QEVQ9vG4=8Ey$&N*$5p_f*t5Won&l=f_yuSo6Vr)VCzF4Ww>+V zX0>9bQyGL(v{eT;p!lt498mT*HOrLYHp&OGZXTqubZ_t3v~=n_>F{^{bDB8(g0k$B z!{)Q6&a^3+Hw;KV@uZGm_UvsI;|AKtB!DYZH?Cbx|MtKCCH?AOf02Iot6!zBC4+#v zN!%f8qC8dOeo=y+Cr@Fx$z~Jb?m{uV#bldMp3mj*UGC=A=ZTw0QP;_v@QXN{CcV=V z@V#{Ku$b7cDkgeN-1<>InO+5!d6Z3d;YZIUHFEv7?7ox@(5vB#;2($7!Xz?^a1 zoTRbExwh`m7Z!wD^=Q}hq&?exHoXa6Ja|-E*oQj33$e=QmyC2jWY!Nsk=KfyJp(Z zS2{fxAJ3U!;*mRgN$TdudItQrdQIw!G_;_n!PoSflv1QR?aUG8gV0uSn7Asn37elY zc11!X2ikEyBgTdHzGk(QZmvIZ#<#hcw;tOzW86H^05YFO z(~XHkXmPx?u zX)Dr&n^iD?@vr^Ny|yZ!&g>{*JPrpc-NPI@a1Et+y8I$~i^(Pygp$oX2*becqk32P zh{OGg#D|CUmBJNZ12fjUnx4+>J=>!hr&%(TqK@W7ohUdhuT>RRuP<{UCClYAmQcv1dolI%f==j1IVZ4RYZ$C%f-x7R=#@tgL%=bPtfm~+ z$Gp{G2jO4Vr*{1Aw_4BB4&_Cs-`uBn`7g(5apkfuLzL+&&25;rA9nkb-c~XGG~GSD zq#G5%T(AAWO8^)_=e~XBJA9bUgOeus{#$QU+<1KiDZO*Q?+Tw`_#0{rfe2`MF;Gkc^w({@edfAO7+8>8lGDBrx)ZF>qM$ zlSZC2_npGJ(sfP0FSxK)_u@cZT@{2q=dL}(Fx$6~SOpL<6o+yS()(gNcn6?{snbrtNs*rdL^$gI<1-w=SLX^!|#%_0kQvrY&5X`fB}W6MAcHT;F~8 zaMn)WeD%c_>B8rqrZ4qz8s~4T9^YW{&r8nRCUV8moZ_E68I&k2ioerZ;ZA3*HEf1C#??V_iEw4(G!6*`A z(#VefR3GlnxXHT4*r5kk)6m_o)7aJDrLk*zP3pl7y(Bf0hO}8%aY9x~a9Q7lKiUP% z7?d$wFyNP!pL9@LK^Bd|SFpC3mKir@R$emisA9k%tA_tLO#=$>NL7p+>{d>+M*xI} za1NGJ@tkVp!RAQMY5F7C~OhtD`U$_%XhxfbOzF&zq)*sRr`WG>WP+1eun)7aWw-Mte$|UqiyW8vXiZb+e7f>tfb)VAR zt61??D0=sm6-YKb%W`kWwUV8MCCr;gV$RIW+)Fd}?xq>ZySaAts;*95)>w2gUHbZ~ z^tI&ST-Wv9JGXCJA3?r{P4*4)Z%VmrJ|CCzF_M#y|@afCe zqk84JVrnUI@JRwL%+TKCec>zxS$!R;DT4r|w*G|mqVanuIF=5|oUhusNo3>QOP*yX({<3>1nlHB4D zt{uirCjNHNtbz_OA*xE>48{#K%x!1O$w|C}iaRp9!?@XD+(cd2M4kR=R-ME{2`PRg zSvNx`-%b8|ts!#0pI*uvn5Yf{d@VZc$d&<|h9xbdCB=A<#;Hk$Ou+GSw} z^Vaoi_R7a^{`KeS{r7&CKK;{2I=+4@Jjt(IQP zmw+8t4ZTu+OV}`*Zs{p-U@Y7mXYIds6X|3?Af(fPjQaI5XtC9b38zmw>-4Ogh8o`C zuy-kQH{4Bs5ASYfh7SA#%U^*nj-`7Tb_0=FqsgQi4Pbo3`8i;&HgU>^t4{k59ukA* za5^S=H78D;)|IGN^ z$P?Aa1?+pBGIw%d+4VMH<7$~J`QZkvftExisO;gF0Zc1@!rx&_pWfyO1B9CgQzl*~ zm)FZR)m7!e>-_TkcHt?RG=vBk^q`C5>rR*To7v0T*K#+6kN;#*GnhJO1RlF|edj<)Y3F zDiNLiPctzqpSS^*`kos~Gw2rB1{rr`Z-;Ti8vFTHl;NQ2a1Y8coIKWm@=&sF?xg9o zcwDD#Ui+K0>*Q-`*RgYUqJZ5GXWWPhqRC+FFf&giJI1uZ-6o1YnN)ykB16;doxM!YT4J?HV4fJ3;VSpSvF&CP z!%PDvVQqhcve0&S7TJ2n+dd}4OL{3vG*r3o**&G*%Dw5xkt6o3>XBn7(h)IijvhO1 zFGd}b`w7XkIehr2u1)EcC^2lxda+vlD6;TOjdlLX6;sTvHsSS#niHg}5+z65T`e38 zr@?w%tGo(>(L#2o1MMZu4itS$5u`!C(tR_`qXGjx=c@oPZ?q_3 z@xI*{Hpe?8LYC!hC$^}7?_JW#{owc{>%GfKdqvZHuE|KsytAq4odL- zsne%SrVZDlUVHt`bY2?@d-v@#VZ?@Q1BKd^XWZ)kq|ST5hQD2r>R{X?2_;SovuRXJ zo6#%3)=N?!r;%G1Ji}zXU*(lw;o6K6ArP7&4rfD0NGrxngACbX*P(<*&Cp@RJF;+c zvvATy$KUCb z&V+bQf+U~VGt7Rs+tW&{bz01(bd_mZX6`7j5+?Ft_=P)Ez=R&|r5=8vFY_a9IA+?F z{$1uSJS7}xfboe_?SosNQPhDp(bN~?hDlF5PMZk1D|reP=FoAs&;kiJ^uTE5ikTJo zG||kAo0yO}eo6#p*N9}p4}EakFceu0-um z)6(sP%^(Tw8K`KGt(E4qsp%LVL0YGoT%jXBZiJ|AE8Wdo?h)9a{ik-LLh|qR&-Gjv zsgwF+*z@`c-D?$fJ@ndVVUhm?<*Tp#k>RXK?&v)hv;4_42Y(%>Z7m z_*V{YvNDcvUhY72Gm<@QKjqyIOvJjGI`o3MJ9~d7A;9?l4Bw38ug&=Nsjn{BX`IUv zY77HsMuLoC+;Q#7jxF)(B0L-m0=AuaJnT! z_ST5OnLWK>2I-?BSf$VDGj&DXUnxYikrD>CwlX+cnEki>OSdq$KrEmFH{)~0O~i#C zV-~QOVGJ|o?BBmXoj!9mz5Lp1>04sjy#2lJr^C9&%Q%G~d|)=Jn&|=?eTaT5IyM)@d~4^32^!!?V|P%I5tvdR3=w?p%~m;_Dy<{o0f@4bhA1^8|*; z5{q;5bF?7!HbMk^?n#u^uGC)4taLiVMNr75!5f_97d!vRxUt1IZ8rPm#kezWa##zV zBS3rJ6?qVSo@*6#V^En8`F0pL8znvs%No~45p5v+4&!EUjJpjKTO+8gLK-gH#`0JL z&a4<6_jesn_Yc3BreFGDI`G<0((vfGu7kuW0VdTxfwU_gHi>jQ^S?N@ZA>2J3^y81 zIa$P9+Bbs$SGBwO@dxjxU;XcYPXGEZ|DYYsTe>!N#{@(2oU5fm@s8+Pi|=N3bAU-# zd&j(;K1=^9ZPD=9sNN1;+YOHssCFz&tEHm=HKV7iJ6~sqZpiy2W zCd$NAF57^q+zVhUKiTP%Ljm8#xG7-*wo88&b)qYte%&}tz>9~M#I*V1vrlv?^kWld zeC3ieZ)Rs7NQkin8S5$)V-<{?P(EHcJw0Ds|Lqu~tbK@vne-7p+E3Wgd(o$tdl}Zu z#825;`7ZO_o({C>T#wh9K<~?%{aYJHKQJquw6XB3E_7*sd`)IenHCr|e4(g6yb^W# z%nRvNjZ1IoYSi1`eK)Cm9jx2SDkr$6%X*TccI z;R$er5)a?`TqkZMkoc;YHxF;85eX%Br0Ym2?W3%>mbf;aVMcs4C06dDcQ9o*l_OVV z(%{~dhhv`ZM!rkN4QA!t7&QtRi}42I2GrjqMZvFHpSO92cNjN)mEr1Fr6}jk>u1(p zYhq9geeD=F79wUq8^f~h(8e(yRNhH7aSyWm*G;)1AbBtv>c{lLZ@|W<8gS-x{o~=( z!F2z?xios_duj6A57NXw-GAPFPy-IVv68KUZ$x#G69=)F^3ymzp^FKimSZ+X`Z(!k zGL3(!+V;|~Uo$(Jyyn5ln?Jq(o;?%J1o^6ts^8YLt@AoEOtcf@I*LBRi&H+(cHuA6 z6M1RFl`!FFZbjBCY)5_r9zF1&2Ytie!nbG@er?_G2)2m64bt0AxIW|QjbwEn%U`ZT zZD^}mggu&U z>Tc-xZ$y#Sl^8eUV#x7on59o?xI~wph_3Ye5Tj7?&^v zcWaS#;J~4DSSPr+YKM^Psj2CWWTz1}%3+xvY0PDXzwLr&ySIyA+<3Vg^(nI|2Kte( zV0JrBRyGaTX5ZJqA~>7sl<-0VjGVxx&+BMy$Guivb)n0x4)N?vwlGyW-;)62+qbo$ zAi?^4_cShXW$MPYYl#y$7r$~r#y4-=P~Gr0V>A>oe9TgNkh#6_U@*|iiQyT6UbsOk zqgl|;Ee+V5^}w?dB_dqtJz?AF=?SMxYx%T{x*y#mDhjYj^w-d6zD4(fpO3y*bG@3G|7jHJoQ3B6=^K-Z(rq&M_x)aw#L z{K}i((k87+IrtVWb|rbfCZQ_uxH%pf37B;DOz4?5YYn#{!U6E^fM9^U#z2_9aW zldQtKU+A>WdwTx+lQeqkvoyT$NKb(CLfaaWXjMKp0)k=GFhkmGO=GB7Sb&rd0KU@h z@N&%UEc|f6&le`ushTw~Zalo=1LgCsED!o=lIQu%)eht48^XAu`bC=_L{+4dCH?`a z9mdTj5}ujq0L!u~CosrasLqY=PP4m@rl-f=OiL%E)?U(xrVA z{ zB6^+m_Xr>7^=v13Pc9qNVj;w!JFmtK8C6X3HZczBnD4NvOH?=fAQV)4CE zA&;Jd&G`+Qtc1ED@-(dCJ zWq8n^*q50gw9=^KfYEi*d6xAx{44w$Fj$(^pFDc>&|U+&cJ;EZw|r$!JI_lfy1xSE zClKiykV_J2!4C7LO9ft-HtdRB zPy7T@rk!Y_7GiWHPgdtLoq}FiT^>$NRdO1P(5_rtSi@^ z*Y)Cvvgs%MO0dC@=m!rl6|QKC9RULdy-T^fpH>N&`GqIiqr2K$^EjjQ38EU!zkp8oAMM*6(ZN) z>dEi?tas&4UgP=`*P*1$PFs@Q%n?`kNskja2A@ymV1BL@*=Hf~O;CUCj2nuvN8eKx z@Af}&Luwh*dXH;mem381@zNV?KI(oj?l5lpp;DhP+mLbN?Q|(Eq_Om9WFpP(JC`0D zel_iX?Wbvv zO*4nboCo~SdxgUv`UQOhZl!Q-zqUPabm?8NU2a`r*WzZAwlcs9gMh)V;18OB(Nw6! zYG$LIoK4TZz*SeJiXbsbM5Y9%YM4O6u;g0QnCisj#JHV&;E4I&{Rbs@_+Z+v=Ti?$ z$nde_$I~gv3g9IUp7lnK4W}>sX&P!UG%$h0%6BZtse6NF+%V6!v{8n#XbELH{plh*-wShTL{}EZ z?q5#xYBMUSv^0GNK$sG~n6&(I=18zamOA6G#u#t;FTqeA2`0ae2q zE+#-GFCv2Ura2)aj?kM}x5gIr-xjN)D+^fa0D_D;d2?CUroQ~_Q*APQZDwS*%|3XL z?#hobYeuqi9?U)zGjUG+6(`a-g(HE++JrzpNu0=uJXYj{{&7q>k4XX3m2>>;FPB9d z#{7HuwPCTZ6}}6{N^Z~<75YlBU49WR^e&ii>w>|}{&G43*7V%28g7N9#AkRqaFj3+ z)L}ED!%l@FG~Wg0PwM*aF3D0mb5`=!G)A30|Ds+rd_`Ay-_j<-8J)m6Xd4UEm(5a< zc2BFQLB56D-Lnprpq0LoVdD&9@{;poeU6yK$hNunwXR6LpC&H;Dh=PimWChQb*4ZE zomR|?cPkl$MxZnqqeL~Gr@k$<0Yu83k>gAp>?pZa%o)ODMPB7@Ya=<;Fd2{f#5hH| z+>eAKoVRjfd z{ZgtAxNXR|iBvyP$Gf0K?A@s&>DI1OY5&{*DeZaX$0qA$h*vMT8ldzuDYMBqx-D44 zO}dc9q-~iV5oH*xz-)$!$$`J*dM`2rWd_(FmrXVas(1zl$u)g*@9 z@<^*M3_U&e+elL6qv0q^SK7@Dyhe$3h-}YU?uEB2RNz}nm^PkBZ|KmhRK46wn10M{ zyoBj;3wI{KE2%EoGyxA@CWLL}ZjLvy>665KEs8}HlQK=@ROL14olblA?oP*b9qRbW zlg6YucH)$TI35!7=Aalk`}0dtybv|5$P?Bz<^sA&oqpOS9K6q}f}SwSbj8gNfZ~@0mB! zu0to&)ULhiV@507l5rDh zsdTFYv>SaY9+N*|W^YTr^Zh6->dM0SjSteKgoDnD!M32q$l|`UX>s>)-67nYmL~V6 zG`Ua5ImXoCsSgz$>?*4AALWAXV+DO;txErJ4Op{BADJx zGE9PLQ!x*rfsuoD&jLNuCFqa|6k|z)Bo+>z3aIG~j__;vxP|g*t1h=T%tq}|r`Llg z!iEkG`muf#VXAPgAl4Vsu$C!z%iX8>Ht;Ni$DzjKeK_Nn+ARIKKFm+WKsa*ra5{DB zM0)ev@1)nb7Nt#wle#K(?9^$EYx{IneB9b(XB6vnjI=u)ye}bd;ZEA^)TJCvC%y6y zrcI1Vo)?|AnZKXXgKKF-Pk@hI`yh>9eNRlAdwTkmD^eg{XF>@^^`wr9R>pAQ&QysI z-0H)eHA-Caw<<08T<^*MafPVO*n`Y9dCv14%TC+J3Db{IDsrCHKs zcZR0YjiFs>>dpTnO}+f%G`9Ch8lBp!t0TH5U~(O(ikil0psTJnV27!rtm5YWR%tbv z&vyB>Cv!DiL8A>AxzfOH=8WVDT)A{Hz5m|t(}#S2{6o4XnF6PdJVWKjZFwdm)%4cBfaAKy#U3wP4(iyx;uS1+V{4|TFbr#KG3{^PXo z^s8ylp%W^k5j#akdo-pE{fx}bekCVXTGZ*#YoE25j7A$|+>i!7r-7GE;2p$OsWKfw zjc|(vl%-E)YA0>fPAr}Cv+1c$L`_`y$29rL-zz`5E~RpLa^S`EbkFJZRPxa5^v$j# zI(4&KpXBjKko^+E?ClC!2-9_XSU(4CNQ8dqVXeH(O%D8L+5y97N^Ypq&+LU8c2k12 z6FgyGK@WT@fIL$#qq!EcyehRYk6>aKPCTm@sbJiE_2n1of=-MgukEtNv76U#h^ccw zJ(M70Yrj=_Wkxh@Qp(zijkhcuo8wrO=Zd%%#C~$g)kh_jb+KlP&-gfYxFA1LQxyJ8t*5+`h4 zef@P^jrxo9=G*V2LkEwfJ$mkYVq(%xW&yuZ6=@AU@VR|C2(;DIY06ox|8*R8l8L7&rZpiMIcY2g9DpP~7Tn>i(-I;4|{Dyl33r2R@^Swg9ar zRn<7K1<|c1iW%(|+*#7@=?gzd!xB_HcI>q@cHp#5#ZT##DV~qiw2o}(huu`$b+*YO z(>?rf$KOw~U=MgJJ|lsd47N;TOvp;NwVIjSa1DVS%r8FIcWLDbrMI5 z#k+U)RO>z6316^RKfrH`MB6c5KHUDwHO*Jc+vRz=5<+<^u;0N!akAbo|8WbX>cX2y#4pn*DjiN zxz$*iP`V8*=;3ZPEp0ax`U0vSOb+V~5eIq&3w(tl;N72X;H5<|ZRQ{7n@PiScheBN zsgGyV#KPS)CC1I2>lf3V8`skH2anU^>Er3}JO3daI{$V$c>Jsy>KKW|zhCN;)XVtUKez7gJA-adS6~fByfbsXzT+T0CjjTlM1U z?vrUzCo&f&_DbKM($oQ&_v`e{{q;3Y;e8Lfmdms?D)JM0;B~vxjiR75aEoyQ-B0#0CK!dy?&G$P-6l)x@xwHH^G|6+uSkta zDDlY5r8FYOvF_4>FnE*ta3Gt?PtDM#Kt%o> zart7DnQ>Nd{GD;*X0Z9@p#YYnZ$|b!*vP~4&bWzkKo9d)(_Zn~39o_0Zwcrt*E@`x ze#k@zQ1>MLgxGW#>v+6>+H~A~A*53sWc+<4auuNGfN`Te@bpOE{N5MRkYASpSaq#aZIaY@QK?BOqvc9T_MW5WAb6W+;5$*|cqr6c91OcucTmtU2BUe}^tFs98O$*q~5n$kC|iSO<- zF*zw_Pc4A9jCZTep3i39S58BoObe(and89?T#nC7vioEkSHgP0h!-~;V?vI<Eq5XM3<9&wNbN}M8)7HBClko3Hw>+%ExEa?eE$bt! z&&y0}^&OGMGM=EFF=~X@@T+dd$s3-34#o}jjVpOa^s3Y`EySJ$xJ*$N0~VT zhjhZcF}WG@57Sf0mYBHkKXu~fzosF|O}pYt!;?ByqXi+SYjjdAjZUPelZVpNX$iI8 zeJm~RI-ZvHp7zr?V^jI`P0hT4KHfJ*c}1D#GSmoR{stF*-XBBbP|=^Up@b{Hcry;Q zm4{pI-UV02(e~d8J6EANh4W}`-h^a7oV}lBb$yB}RM%nVT)wQ+IG?AB+Jv|wVf?(P z$T$W({Tx@QxJtzdK^THIF59L`_xMGACXL?^rV>rGd-S!cno(ojtuc7%BN*f6=2bT|j!jQbq{D{~idpkYdgHCP_00G8HIALr zhQq0}N7tedns0NNO*mx}>5n{x9o_RMio%e|c_qg&Mi|pZ4yJ274jkV1WTuV6aT*f_ z|H!3ZrO_+DwQEu%v$txdjYmc!U%R5&(=pg77}&kcOIDei054U&7-jIy$m68UGG+_{ zjQQ6on~HH`$iU}#t1$T_jz#vo|3IE^0^??ts_{&ViF%Xk$TN*+CFHbY%CZl&!?@{# zNH#mk9ZEmo&jM!Pj2rsBt(^;OJ{g|_#*K#9^h8XDM-vCq+@2F@>8#}3ocwm0Ja{^d zOzK#?U6=C7cra{YVMRTSD-C`cheC_Vp_9|+*6!9Oa5d3td#usUB!NjVN6^H^43fF0PS=N7!MOR z*Uharng(!J_y#}^i>RH`+XG+|okbm^PoRuRUD!+A{A|Eld9(n|C$;FBBWN;f3r#U* z&_nTD{fKrX?Ug6(IPyIA*!WZ$o0!r`o87t+wKttOc}mQhGrAV_QaUNYjj>Rk5YvXI zykXXa;Lp{{IO2WQy63+ajhrTB8i1^5YaR}x-|UXbmBGpA6YUHKk&WgS!os){{?0(N zG-*OUq$^PAiB4qbMUW*H_%Ln6oEef}+YvEuMju>GBXhUKw3#tKu1w88UP|}po~8?T zo~HZLXVSrU|1KSU_4{JnyiDVB#*NxnFmG(3uCk53g{J4%V!Yu{PU~$OnGBZ46`uoF zT?)ocJOys)TPNe@i5NH3UFw=MZVrfXbG)jLoADfYU2p1gHQrh-TbOOpmMDK0b}Avt zI&ri3@J<^4>L1e7XMZoo&3%=rt}SRWzJ&Y~7Ug^@tHrTBI(4%zEh4yh`iK}fC(O*L zoTc$yV%~_+qZ2qorK2M|N z2eUjd#~ezdg!&e@6F$a`YIDWHE7a_bG$aQ97>paqwi%Uh;-NX6)|9^~N9^W)ewS5c zci?(c2n=qb!JkhNalI)oo{br7fXpv4jHSu*ZM7SCDO+$01e!ZN2$C_#-5EU&Tb|0X zz~6))vi7_)ZYX9iqQR7i;@)B0Y&Pk0yWMQGedDwE`5BzrVccv2?QLec33#>u#Ut&g z&Ple-laueJr4w(b5nY)Y*?q)L-MFlqnEYw6Y?DA1Rz7&eWH1(5KFIk58kn%Rvtg4y zpJlhbHf-mm4qll0;C?*{wsXm` zb3avt(8t&nb7oHe6aC2BMkZ8SaXM$igl-`ycjQ`LwrI48PTQly&aPuj`s~?U7BzY% zl=4DR*Cr>2bz){R?bgoY0i9qte3&axNBq;#awfQ3M`3|{^u!6jF11%D$+Y<1&I%g! zqLaJlNp#Kx^UD&xhN&mmP|fGY23$?Jf#nZcGm|lU#^+`x+e(PzPnbwJ?AN-%`$ZJ1H$4~3T zjTpo8Vz~M>DfKNXn`Mj};h>xFwnV?;pCMo84|)5hGH%ElW$8>C%H7K~7&p~5DJ`&Q zTZ<~gr`q+N{Q95L)aU;#Gj8-$Z0*t2) zry) z>({PIVDW9c4t!Ue51c4PFh3g+jB~d%&fUIqOV_CA zAHB`N)Mi{8)d#&t#4z*|g??gewR}e#L+`2NMcEp`djf4AkCn!zep2ZS3EEb_ihgzZ z3X#6nPKPq4J=TVkIBwK^%8Qo`5t4u8s9%dZd+tTOZpcZ|Gj?M1oHiiFH}RS8uADR( zH^rC$aPCX2|D+Ga?#|WtPXFS4j_c&r;U%zMPk)a{2>STt-=@)9pX+L^m^PfkK?NeW z_4gYMz6|djfTh(S%Tb2_06+jqL_t(=HZai(z>HQepsP=IfLBG-I5DHLFc{@cnX3F6 z@@o{Y!RUkzCwt6~^hcjUSV8PCZW_ejK9=Rpa%haBE+{9QB`qq-v2ckj6O7KSjXLGcb`i02hOL(qi?3Mqc3R=e%4=_(m+)+ zG-A^1lWKF9zfJa3dCN(kjjPH(6BEo{xjj)aDGa^Or1k~I#Fyt*5nO!d_H7+IznH$h z@J0IMPajDTvM;sF-JRv`6S!<`XoRWBU*mR$Su`LjlJ6Ri z>7}sUybiT*uQ6(li7|7E6E!+vbL_-Py{@reN6Gg}KESlTT_y)$QrDpn;8@bSk%$3U zx@c9ql{BGdW!gfkFy}NI{UZJH1j1?Rh2rMY-Eq}$*$!@wy6ZEU18N(KIt{QSxdcNz z-}>m5u0q|==^Du;(9S8RZ-&IA;e-bIFw7l<6cgT3nbM<$r8G0Ql)jo7N)L9OO$WdK zcj?G0I&mY$%@T|oofa_0jTW1(tBkGgX9lV;(WI>j0j2o{9 zcEU=F5L+~w9#^H-$hhGcEQ|FKU7gamD8>xm(&$ur%1InCZ5BC&!xbuo8c*pt@ew^E zE?CN%O#sFV+VzsY=;Ju?=;iC>OwoEfEf_b{3Ar1)#VbS=CaOZ<^RVt3H6?UI67?to z7J3=BaNbg*oxqXoC*;~Z)HpUTc{rRV#mwo^OLhw9vaVEJ(b#s$WaHeuJ0pKxd{+5; zXG(wD>T~f!W!jrjgz3t2&{Dn@r2P|NYPdGU_OcsYa+171%(PfTGHV>(uAoiO0W|I( zcpb)z1GBeHV=6}dHQGuZ+XPp8r|ksZ3B3@-Gv8;KfA#mIl76;~!Ih^6jFwdh#`XR{ko)4?Q11tbY_>H?N1wE%v$~|8!4~K0nbFAdRFmqNp@}pr83vDzyqU&Ne<$sK@!d2$ zxl7@jj2r9>TtA;BF+WVDa+qgFAmj+#(k|u2uP>xOeei)^@AxEr zE!hF=V$Mh|&g|?1O^_ejldT95<=Pbs9L6Woj$+r6#YL$+-7F*AeX~yI#KgxJIL$cA z_y=ib!qVKCTRk;FA0T})RFD49o3Hj`^GXw19UW(fani0qafFHtDVzbFGX7h9Za1Rs_F3P706X9(_ z>G<8x>w0C@^`<2~%WBLRO}Ll1HiO{dhu1aP=93JY%(xjs#tq*J>VZxI+|>b`OS7Zt z;ofuU;CKFu7&q@a<39@VMMqbq^c48w!`uCxxLJX)T%&p-Va8AQp3-+F zE$u#P@^9=p@v&WL2_eQh**PqkIb5OQDpmCTWnWj9wU04PJB%CEcykovN9h4aC^y&P zaCex12>3co?c;tWzS3{dX3Fo0m^pWJg$l;al}i`JwE0^4r7Q#Inr&1()hVh4onk|1 zKOgen7}ppF7h$MEK8^KI2GaR8VW&qs@7S%qRq+@!qb4+~;n+V8OL*J`S22R=_gYC?@7ZWmV&b|1uUGGJx{zBArD~A8D#*Wc1e=hkpze%GH z@1|jxIi+&MrAvQitGfKaNI{2*0z*k=aB7U9`nt>;9HgX~Y#N#YQ@9o4Zk)6MG%vWz z(=^Q!I|4!xM*&-%%5mT4?>A_K{=BOl#toIz>S#Yj*`JIXFnK%d2j}L)3^ML2?<8Vy zYKL*NNwl}Rq@TeS=jxf!41~UHUe|*jXaas`*9&Rv?7QjkTYr;=r*#BgJEly~s-01r z(7~YbU2-NbK7mtK3qLYmyPK^_3+|}U!=r5v_x0*bC>}kWP4^|(@#ak(E7!!ASEx9K zj?hO=;~)s~mi+GAxucgpW;Nk`oStaq1Fr@FkQ_B19ra`8IoXz+GOhH3-V^$SxS3w3 zPt&coHSze!Sh@uyZe8x-zg9gaZ6)4tTMZX*-HM4Eeogxh{bHuj5Yvsd6DFL?BV0-ROE>a zQuU^fr>ge%iElfta;->c)D!^;6LbXL>{&OvJ32kHyHBh=95@|Ewi`Ys-0n{B^hnp8 z9%`a3xi!NuX_$0N*l?PcJ92F#-)8vneUoG3yQR%?HXOxI7vtuz7&k{w>8h0a4P;aK z;?bET#tfD_ZuA)DC!^I-I?_#O1JGb4#trh4U>L%H4yJ-JlXNnXbYL!3(9{+$Tq^^= z)on3GWZya&H@*W-#VHv#l&>!!)!x+ZEN!GqA6KQ8jB)eNt6!BWGu<*T4NvIwjTkf& zyY0m(WZo?4^{GX>N~Nn)yPT0Tq?0&{lHtKMDx+mJc2M3{yk6G1oYmt|;UI5I7WB_z;4mlPdGK)z|mG+Zf%%nZI3#|$s1luye}cf=m;~OnbFD8Te?og zW`tf+FxH^U#Q&wS$^sep8h&*(Kc%cH{sroa=7J8o(Ny&-BA{8P3`L`S9~?D8N2`x zIJ|rJ?sQbb$$2G;6QamsJ9+xFu1GPq9ka1*pRWHR%Z81LXQ3kfp+_8U&*Os}u~%QV z14|!*yY(yRxnHTrcZCzve{oJ%Y43iX#%_I_My~3r)XmQ{esDrlr)^5*gIwvdy4i}E zAFtD1CuLT8%KGVua4S@R(B8#hF<*6>*8DRw#|{IT19e_?z-ZxhtCMjfB*Cm9rp76q zR`f+mo)07Ghpx|iKA`pv<7Q1|xx={Wr-{-pbW9%l3A$M@JB*u6qGXh%_w}2E^x2@H zKh}~XJ=6sJ=GcKWditGo=&ip^L;JOZD)|L2Xp*DudBUtZ%7kk~x=;F~3kMS!-z}F7 zKmH6xC<;3Ggr{)GVatiorsPzEjlH0v-AZ;Jxkkmu%N>_5eyyuhpVri?V3$; zXLSN+ZjRG8x<;kX@}Z>qxYZoF$U)>!*~Dg_o3V#~bFt?>@oNFY(o^?%`h)F>r0|Fd zSvheIw;HUO-`c%4K^I1J)0x2Ob2n%v_W;F!hq3^DJSVC{S`d$FCz9(EI^QHl*@z~% z2xmN~N$p8J*?l%0I()>QMLnrq$zvzC(e0`!VTbWT7$$3`# zavJuI30P={vR^lv*=d;lq#3izAOUu|MqEHu@P%J2+9Mu^7c3KRCeQj7spEXwAsrUe zX6V7SG;;4NU5Sz)Nlw~`X|se-;(QHi2;l&$oV1B#b}?=ay)34%u1e8wJb65C{fIG1 zkjG(tN-QAhOZ-$o^cS1a2B5);j2kVM#yN3QGHz5yX(xQ17H`)T{lO`kg4gg5gmFU~ zq+Xy?A99+c!yvWvMY_<{CoW1j@si{dO@6&G#tlq3Jz%WD(l&Pfc$Mm@u1`JPbu2y6 z^WsaoMwN6D$DSDHIdL`w^jg(WK7~V_v3@~6QO=>qm_nZsZcz^OB+;L^n|Cfg_Bedh z$AAmF>mFazE&JrObop(yo0TWb09{{v{8*n((LB|MKx3W_zjybZ7&bRFwq4ey#6^>Z z!v+Q8+AT42<`+C1r*atWhjqon8R&k>6lR~%&pYNSdo|62_Ot;X&!FepR5L)7<=SvK zwAFPgdZF6*x?l=$8Jppoe%^-sBn>_e3r>s?(&8us%A?AqHpA?SAY+vJkiC0%r-Ryr zICJ)_Uxzw(-p036x+1k#n-9~x6vf6vel3jt@L8x{bcr#>vPd3f@cd;x)7!_D%)F6X z^exV~kvo-1^3awLT8~_tYrhu*=dxZOlzcReGtTrcat1Lo?!t9tV^#SGTv0sZk=>lg z1ZcwFEDgK}Gd6$Y)X0R@`82W+j{c#GapR|ae7z+4@Oo4vMMl^`)ybbmjL)Hkd{I8j z$@lzaW`}XJrm}oK7&lbIXyc)8Wz{6kszch!i0im^7&kR7He`yj97H`@N5bio7gYCV zB!qJ4@VPXtQ#VUTUr$dCzN#k=^je7I;P~RjC%QJ7W-+5Mem#n+Tli%&4a(iUYCBo% zw~@*S+i=_jOH4lM$;F~h*4(!pOFMFY<&uOM=iN!2#zE*KyOf;F;p4;(@&)GRT%cf? zB!8Bb#ii(=-AjZT$Kp=eFI2-)xqF`bpPT`|Sqi$8ib)$PCgqrY(%OW>y;R`w37ZzJ zEC{neLSR@UJk^e)DJ6YWEAg?hku)j6!t5qaPEMuWIwixTb!vLIU321zWOf@lCVogK zZjMUGFlLxFhYstsjdmmL30zHtMe(Z6Mg7@K8hOIk_8avr(i8S37AAGfgIo{4s!Yre z*YHR8&t37Md;oNqu*+7t2jhmiAIcbOV$=*hx+B>&cQwJ*X&POJ8q)6QFv5rRjn3+& zC^2n@AM2D&2pc9YVhueIuAqNY7&k1sIC&R!t1Me+OF3BoQZR0yv%Pqyt19?y#j8?L zM=1Bw4C999$t{hvQNGP&+}NZ^4LtfM>AXs{G`8CqH9RH0IJrM1eNU$lWPCtZsSc&2 z&*b57l}ZY_I+G@tckWiXXPBTrx*+1eSw0ySPluO7p)4dn*+0DU{<~^=gfpP<9;hB= zYI@W=>Y2)NZf;Iv+Kk4mn=Z6ir)@ZW!^xXF5@7t`{sYN&<7)5&lbiEc8yMI*y~B0k zM~~(*gRhyza*V^w80Yee)h@RZG0ILqquYYu%TsndX}fW~$n$|)M5GbW6@{mE~?M%BV#Va*LR z3S-kCnZXz*Xsnwl?K}q##1j1!bx5%J+3(ELbUf?GlBH$4{g4j``c24>7uVn@3>ot^ zx`8EP4Q2WKFm9lx_XUHYE4>8q4@m7WZZ?VVR)+>#mTS!kdvWvJ5#D_`jUGCeo*esD znm_)wc1n+=q3MHKyo}l8f(jZ}9GGB~JEFn3z)ho?AerpNXHMCT)YTO|CS0MfHSKGG z=lX&@y?U2p{2EB~N_3ytTHl#Q^!FzD(!} z)U;&OAUrs}LwXJBkgh)+Jakyc#1Gk>eBeV(egs*)zRM<|$v~Z792&7`SV3 zfLmFQZK6z^5GyNpV{#ZSc-MAl8J>W+Uk%KrCjKR!+U)KW#-bQ$EZSudyxO+p4kSI} zJ#t?=pbxI-N|Xde>eK~{n<2>_7=H3dSD_?~*qAm5kfc{wpL!OI8&XozcC9IcMqMXv z9;y%ERGvRAU!S<4KVv~P%%Unc>geMPS1h184S`voF-V}j%*YrazHc_;rmGaxBOt_B zCvO%<^~#hO{|SM{2r}l|b5h?4Ju5C(*W&gW_L>L2ui)Te3e_7^UUn3J7tWyu0j+hYYg^)BE! zmKltiqI~d<`pN9Xt19JPR@y*dAoBqk2g$f0@04Yq>i=i&&6gWVk~B{T$q2@k8IgC^(bd&cHRta3%t1I(W*va=#9b97Jlbax$jM_zFU3GDa#)YRP^1V{h`=m5iwbhsUAYKNPfs;jA~ zje?sMJIY|p7dh*!lWz;Kqu^!>od}A2hBcko66#%{@jDH2RM=&&;Kir^x_S1?f7?tx z{9SYOfo$KLO6x|#8{8tnO)}Q`VUtX6Xym{s7iL*~0x8XOBP>4wv)fLC*$9RM)${6O zOH9cRKg3B?fA=^4z4@E}`d`f4KKt~swd10Ow6h- z`f#^xU8OF}dt?b>Ft&hQhBqKhTd01vmQ0eJddg#zXIDt3qy-isO_+lP(*d z^BZB~zxd$CE?94tew+HxN|Jp zGvg#Gj2RbUPCj#3BKr$ozS%OIX)e`trcYl2F5vY`wVW4toWtQI)Jtolizn+dA3tO` z<_Eq1bK{RNhK*n9h;dF@WScOyZ+?{jOBpx*(;xq-d8%W@(bVB&DukEVo`No#CKz8C z+l+7CpAtvL zv3$1q{^~csZ9bPR8-zHVa)@z#Xr*bS@FAGowa|v<<@l(ZN*wb$xAsZOle8*@Hp$cY z*_Y=}-sBmKn=zq9ff+&@4A_73-Y$%1tK2Bfm#|gJ;TGqr632v4ZmS}L(mH``4jiVc{#RkuC6x^)ylyY)fC(pKERW{R@eui;w zdntOTI&7omZijvju?=D@SS@|?_!l}N{5Q?pPyTgt{_($PPM-X-IezfOOl>eSFt{)t zy7Q;D!i1y%2N+y@1mOoJ;7H0m2J%m&cVWR49Ch3-{V%jN>PKyr`tl3e3ef2t&z^-L zNDM#1`2H+=0<31f5+e-kb5=FATA8byuU_dS57{7?XKFDBIo}lbfqvX)>Nkj^*rDtR z8ejS=`f<~CC)1XU+B_cS^C`xG@l=jBEaT1Lmw4{j13A5S*M@>I7X0AhLutq8C~h&R z_k2CD%^K3)d;dLejbbl9D~)K>Jk~ZTwm)&PW$ILF=GeU1Hy3@^uT>Y<@3$H%X=OeG zo+QxG)iBLG^N5J}3@3k~Sp6px4JgidowwisjIaecy6Yp0BfB{j3Inu0(wk$wUp>`@ z0b<#&(ed7dC(^R9of{Q2KSrap&3;!E+%Sjaao}N~m~qcM5@RgpfbJc*&W8?tv`BFC zQtvU^F^p$yOkh8$6F~Mqt|&j}rF^ay+SGy@eaN8cv}=uh_sZ!H>GE|K5Vi_*B#*Ma1*`P7nI1h9}mF(e6*rF>Tj|heP zJLw7$ra9I8$msA@#W9%q!!fSyD{)DON^GPDXuy|znK$7gTmc$7esWAyQG4NKj3AUm zc!Tz+X_dDBvMm@5(`PnxjOJ<3GvF|U%=MB>urFVraSGj&R*%9z3l>{D%N+fU4ZQ3| zyncQQ=wRZ|Qx$yIx7{;Jh}G_DuIEA~NxcCJGeO%f2yT=wwgC0BU_0%O?5JUT?V;!! zbnD?G31Ogj2xAIu3oX*e7T$dL;m6H~+6Ki|D7N}S=MW0tG;QPAZ-R@zH#wUu3)<8S|abMmbW5`Xu%%`t);wn!~{U+?;pmNU2<#*r7Af$vmUw`$b{5r)$Mk1d-)2gM7Kz3!`IhpvBsxGGU zp<>O);!GcU92;s!zhZAl&jH&4n>y%iez1Xm5!HZ+IXeqRt}f@T;yCSTk>kWI+5`iO zotlrNJ%de}4~rcdgfMJ<`oR18Wn5TWp%C2M6Z3l_gQR>EBhW*;fsf^G6|>utvZ&ml z;&Rn-QTg=WqJ9N7`5iNg0%N@|pn0az=fOk*8+{GWXPAN)W-^&(aNbYj_^XeqYCFTb%}oMnyEV`=V4TatV2*iy8No~8Yqgb$lK>-VV_95jjc=xHJ?cZ4=*71d6! zhE9Rv{jFmgEkfLF9zDEo4N+*^C(@{4doS0=qIcXs`Bd9|KmN$udQWA$4I{&7;iMoK zfz1$OBQN5)zI(j(f}_XmkS4;z1Y3Jd$}@dV^h?r+?er`SbtXoIL&e=2(jZ z6KzYs(4nQwLgX^E-aRG&J>wW3+O*c@5A7Mv6ON1aw28%|1HYV#-8~IS8mC4>3Wbk< zY1PKO~-DnR3fcv33Ka*iApJR2fT5RwPN5RcH zPfIVnxs7%5Y#DYG+-#vk^Eq-qGzVeS$2Hfh5B{=w^Wi^h8f}|uKK{LoN`5F~#aa#3 z$5zhAPG_slY;rv6~Mi7qt5BBE<1m#rou=xm9C!9|dR zJsfNV{KN%ws};=eWdsuDmetIk#Pp(t^X4_`=3fNBdP+&uf&S(4=}f4B>#`t8 zd5$b#3A;uJR$|2vHSv??u2vvnO7A_m@0CbY#938DV8hBI!kkAk%*avQ@5@jl+obN? zz2g2QY`CyR8{2LoEo=JFvk>i<8 zqH0bh#L;%Dq&v)Q7Sxo18gwUU3*{!Qv8G3PWs}Z0sC@h-Y&YJ);b^CZzeKcnBRzi; zZ-)2n9&-jCoiUFvm&CTJvb|3<30maS7e(iuJr5xc$Bq9i+cs#OEc6d+3zMn3Yoqz zE(DzUlI{>jw&5`J1~|uwE~3#XTWQC#YjccE8)@92(P>Q^!3r>o@86@Ke%xgt#zNx4 z&nt&y`61SbMOgdC}i02k`E;1kOl<1E2+x3|@%0l)~NXeC~)aU*!xg_6&? z6MvvOoL}lqpC4-dv!|(lvTzx9!}n2ev+EA$g{xn>U6(hH-{lWk0X5$SxaKQ%gJHg1 zhwXyxG?q3%9S+!?Kh$X;?|<5yeg3~T=dyb<`$$3@skvhd2L>fR#PzsDOwFWl*Okm{ z#?uSIykuI2OJ0caoJ$;Y(SrnGS`gs8diBz3;~0W`{_}HhpNg$g3QK77l9M~6vBPRR z%<9`WZ>6n+0Z18zR2mF2{lp#k=ZfQmkLbG)q(Cdl%kktt{P?q(-I`~)tOIH|7|90@ zPQ`qR2|d-x71nGB!4Q25lX+KKFQ>9AgJD(}Rqk9E1wOs^zyg<3XFB78HehUNu~ZUe!;_ZW7X-z&oMUyNh2X(O1N}{ zKb`_8uma~i0hc8OiqPc9y718z1qw>GYcM)I`S~jy=l!LQ_0|@sm)|wVvS-6}@|x45 z%2mo<<+k8;TqRozvn|05y&m&MpyoZ<)X{&V#?63Rr7nAL+Cqg5ob%HU8lOa^1p>BF zoonmVRL6>A%$Sp@nmhM2SE7xGmX?IkWjrLUXzyj*FphKlBn|3dGOa(xF6OB#-iIY6 z@zqd==+F1#mFU-VZHC9QiS8#&N8=MgM|9p*V3 zPSzHzm$IY730MelLZ}mS9e!{)|4Rf&n$wsMH6gAu5AhtJbr0@`um#!#9+l!}gkT05 z2Yq7O6B?h^`V<_RG7lbVYp>#c${}=2+kT;&ca@HxH0-dk)fYb}AhJ+#!ZF^WrTgZh z4{3k&GyOn!xJTT-gmsnl=&OL`xmi$C!8t4mP6++Zbl1`H-%FztW5n3Ck^kf;Eflal z3L15pi$X7h=+ffFrD9Qezn~=&F_?vcSaKwzTVu*$eOliB4Sp2A;vkl>m!d|_U zmJQpkzW-L+sHAqz3g#Qx!C}=8W)@)%r+RP%xmW$9%|gp4i&ab1*1wZRkC|O*-}po2 zingc0+7oHTAT)u|v>-*RhId$1R2ew>kX1*7G=>pliU5aWx;e!unbW({lwph0d+&cB zJQCiB@r_l*Sgk~OM_H^?w#FO9#Ga^}Xj6P(xzIiSNv3sC75iy0y8-2!0^B`5A|iO5 z5)Y*FE9_Ox$Z8xRym9U>kAh&V@u7X?=gLYo+F3FmNIN1tvg zxM}C8sxH*SI5jW$qsH0<-5fD)m0IIT@fD$faSlYO_|1BNn~{1gdqwCzGd#R zv}JjQisz#oO;Ko@HDu6|!4KVIdsPWrb4GEmC2-+_&b@jiBgoj%5#5SK3JGq65BoTr zY=y=u{xi`s^5aN{o#ZN1Pq(KP58cjzYf6#RHh6;0Wz z>ba|Q)0jEr85eZNgvB(7cwd4yOhuarCtdQKmaxP_hxDv$(?;5zjjoe#|7#c{*6C5l zKYuBW&zH?)D*H!*U+PG^Bza*z9$@C-U2~WSc^r>$hBufm^aPZz-P%+TxLyexaO88A zQfF(`q@X5+F&Vz>RH=k5f}DlzIHbr?aI>|ib^8T3bQSN|P>!+(FMr4isQIJD&2~l~ zkMY0-H*~~IjLY=F`^~uw6ukb7RFa& zOh5ihzry*UmSi`uQiIMV!`;Q~pARP2D~Jegbp0ry4MvZD7W2xgBEl22a-RLHEmyLg z!zmxJT`7bre5Au5!|Zb62Sy>SsUy|zt3F)Wng#Q9N5^m9W2I25gQ$ALoSsO?mMeqw zBP!*rDCV)GtSkb@g)v6*S#TpJ_Mx^u-Iq{@70W~V5Lsn(n29Y++g0#pbj9Vt)1-_1 zdQXg_V~R-+v?Z>TK~`XsL5_TZ4UoVWd5DF9W=u41Wk~qVbK9@sKGV_O)~-Rz2E&kC z&!u&vBfYofcvm-ZR|#&|I>^UX%o!=T>E?=@3-X?IxN7_-+@ibtT<<0ZSUD-~t1tiD z{OJ$>WGw>TS2W1j(znRQ4P$;;**u@v0wMv9@TYKhXu*xg7j@HxKkLc-G}Eg1Oh>`r z`@?@~?*ILN)CZTg+3865eR3EwM*HUcfs7b)gt&|u&mMfFty9vx@zLV%HPgF~!jQ29 zJ+`f;=ae)e@6o29JB%kTm^^Nt%gAG(F3u1IuI>|$gA4g;h`!(W6_xjX>fwjj>YKC< z!42E0p2>)R3SSs=FJC^loj9~hdG`?95Rda#WN=vUBjJq%H)yryc4M~C`axaMRk_U< zT83Z-4I1cK?JT%aytQVuy$M?8cm9+uQTHXdd1MWmhgzhtP)7oqLvTqi=Id$_rT5f^hD654X1vl>Vz4_528#Qj!3G~AnZgq{DM9O;*??T4h+|?&k4;K z7+Tw!QP|ovde(O&B(kl5qB&!ut@v%tFQ1%idr&{gsIF3JV7t*6IUJ)Ok|Mpt7g*yb zm%_mEV;^C|I4XoTdQJgn$nY1uEo{kW?V1<47 z5Ck`TZB38w>v(W!*KqQbG;p{$m5MD?=Q>vW{Jmc{jZUGO-FqxT5?gl{(Hp1{W4NO| zjI9Wh;J-4hbuis%3R)AiDBlI+>p+Z~a;|cLtCW2e@Sdkz7?@*U+879N(w+_Pn#ZEv ziK(=8ymeT2@(^!V<~Qu)wAwt@T#dX(w#{~RC;0j4fm-vkY+X7My{a9?&!LNvXD)l0XY%uMaGrh>ZyHK}|#*jo0CurOYy(xW8I?tiSX@A6FbQqxx z&nnZ2Yh*)hB06;Z^?zwjzWmP;*sx$A`)MpF$ga^!PIxzRezihC!+REj6SWcfi{E9i z2JYAz#rvv6MFHdYwg_S(L)60g1!KXBfG!ovAfzc}xlc16C6gA#2jWBjXcpc)jBQh&{;OvC z@xRnj;s0DKo{yS_<6FfTb%Gm+W(Ws38Z^=(uE!WVEkDp0_7Ki+*>%Y@*WH*n>bU*! zCo=>wFU$`gO@#@fm1daufFiDKalZv-a0?7fRA<1q4_AkBg^Z8PSb>gb68%*}@+{&q z-kfmAhwx3$j&z^oqClSLqR1M_!TFO3U0(4Ref}+sYz<313>$dhYJ3Zy<03xN4M%2q z{EU5%w3=b=@|1j1HD@c-gp;2**844&7-KQaN3vsc{Nq1_0Z7@dIYR5^OqvoJN6Hk} z4!D$cAN#t38w5Bp-s*7~!W`n$Efbz(`ek?exfa}f{Uw5%KibX|8gOC5Ngs4YV3UHI zFreMlt1bha#;g8s1vk9wGO&ua9dT&6J&-2A6CDlw$!DK8pMCy|c6(xtYaM@zhMj2>_i$C z67YB{)v>nlie7~fC!QViZadd`ZX-s{bPJdWdtM~E8h7?9?7uP`Gd*ySH*n-L32a@c z_PLA-f1P$ne#Rlr?*Q)??}Ofj9FNR>aYbAT0udl`MfVHYKXM@y{C@Z|J224*{^haiHo#ICpO?BoX;uo z^0*?7`GTv!YcXJka7GIT$J&;A^7a4MoP6>BYFn-ZInuzHXwI{Agu!0BixLkBF$;FX;|+PjA0AjHZ8SsZi}Xk zdqqDyC;H~l9c_4*1vh?%>p#oG(zs!eG6D1^0U~eMh3T|k33Ul!LS^DE9K5|lU!v_z3w0O`Y7zH=<)~2ouxR`{DWX4hAvW0X0;A3mv zOm&~iVE**}6A5s%t?ChmjCBeX+o&p`bI^>SGeKj5&JeFpHPamYxOw{Ga4)`l5y%qo zU`^MjR~ei4dHflF$amg{+Bq~A7wOE0HRQZsiSh4eGM{>RxO)QgaPes8(iK_9*k8uL zeExk1C;edj;OaidkI*Guz#6KfOC?X@0Xp+#9CRrKY>}p~-q$*<5F2Vb>igt7wnuUQ zhY)aRzVid4s8!sx1ve_BYUe6N1`NU+-gN4Va4`C?-Qv`0(=a}jrp~FgaER!%d^B!~ zNr{AAy&eYwISOvJ_MmdCZ4Ke7h~fob6}ks^c!C?MA3F+q4Y|<(6LZWfH(IOb1r*f*YeL{$yT#j|3F~!%H~@zVsWnU{*RDMmF~oFmsV(@SW%Zn9QqO z2h+i3HZ!g*g7~;_Tn8%Ftps$#AL$W?-;A^F#DI4pj2NQKH$%HdtCe1{v>$`RKWSz2 zTdAOnQU37{-V$~6@&|2!dgbj-K~&v1$0hHXi&WQcrd!A*XI9pqN2rK3zAU})k(r_9ho4&u zXZlEnj30iYwFDVD#(;h~iK=hNxT7^S-J#fy{*V%mI9~w(rE)^jD~I$`Vhg>>yv`H> z{jNf%R)J{|j-FfF1I#lx#+BzB{AD7}G{>9%0NK%_J`IZeqyt}T+jRbbv9^tb&S-w3 z^~nOlv5xva{`PMru=yV{g3n^Y_Y}6ebG1wqSI?7>`Eo@sC^qqs;GWTqHc|gr^mCT- zHX2_3ux%p%=O-Mw35N!aAe0dtq;y(11)CE1L+gj~h)vBKBO}|PJGY%Ssv-6taD%c9 zL$yQYdXx-Uei_VbKg3aRvv!v$gSmlCGHxjk^U{u2Z?CK={Bg{eSAy;}0Bz{2kL-1I zmsT#vM0qCX_a8TB4?k?qe*J&SXz^b+XZOV{%V6>G-TUH`wSQbHr?yjJmE^+oYFxWJ z+BjA~%5X2t2uw+>okM;4Fr@->6(wIXG;>K~@Ez!ZWL{TkYB0P(P5FgUi>srs2~)3P zf;-})lq@sv=fUwgG2#-QRm@a8y|B*4`ziCdM{?lf5)LedBt<^n+ z`J2r|)Fd#m-5S14kkYtUw1pBOitO2(owGeuvRZ+%ML%ra02Qa0^$IP745d|^HE8G; zY2w(ga#K?xzl9uDJn8le{?=*USo#%xa?rmF50tA0bc1VS(FdLn*6)N-aI=1&X*OVX z*d*hY@=*LJxYS>~wlMw&Q{RzR^ykiwg= zeG|;9enX&BYzr8tev(o4y$ur|kHNR{6EMUxGvNY=_<6S|ZXRX~A4U%ZQ@XgW%hDFb z!vg2f3ECEnZ(vTpp)9DWaReO%Cg6YyKR)PWhHHPu0i!=@WcX-r9p!B!!!KkzfGtt4 zb+or^*G!~gbM#VLH(Kdz5bkL8b1k<+!A;g<9Tz2@zM>vH{+(`r?(=XQLy&Pk#8HT13!s-%tNRTA_G7+vBcjiS>5M?HeV227^S->>trH;-!!58DGU zZCtB5vsnw^&EFOzfwRLH_>(~khBs3|Md`6>NnP**i$CfW_>>O@nKWPQsqeN$%{ci< znl>04KKl93!5p(SN`jgRhKLc=oNGH2mkZ1qta#_$;M$?%6ll}6Wq8? zXbmDfqHg70x7W|L65J5BphTzWa6*oc1J?%*^ViJ?Zu&x-wm$EI#!WsL(VE$@j1>E> z7)hSfd5B{0#;K(M<-G`!0(!dd%9#;FVA7(WpgZMW=#U~K zUgafS@~cLCiQ~m1EnUJ94wy{IdmlbyFS}>mE`u4<4xZ+uL4fY0FLH&} zOyDt;=?-a0gMyZkuNC4LCg!cyFtoNuO+~AYzW)bZf7jgkN`{CfxH)>RMTR#ogEpBc zMY${g&R2Nlj0Lc~Q^jtu-Z3?#d(BqBc7=@_Y~JvXHVuLq(3fwZR&WE~i@-;6Eln5t z5iI!>eQVq(xY^pH%yG0egzF+=T=L#+;6{y`4LVITh9-qgGHxjk#gBrU?Q|y3@iq!> zJVsgRl$!dTdphOg7t*@<3kh$2-%O=-bN(=lL{6lAlbSaS>Q-o@J~0ETIPS5!&bX`N zBTPPu%X@hB%cNBDE_+y`;AUCR9A11fxr`B9FtUr=kxcG9-AY=P>4C?;=D!kW@R@>^ zr;T%&Q7&=*GYB8eReZ^lFb!Lwq)F4f`qB3Z#vXNqH^+J(qiyr-tLFHn3=msrgY6n| zhQ2thV{!W~jg9W%b=kQqkD^!3Aqj3?ym;;tpzhv1ZSLwQ;G?2tlR}$OaMOFV`yJqG zs$857Gt(9-juW5h`0%L=7oR_p4IB&^%N`EfdK>O&;K)vn^C6(qfLf=Xh2-(&NC{_vAhE=UuqK2yjw|UbFj(k=-L$taMWZC3;mGf&f9LDu`+wBp!q-0T zd#%%>T)XPWb*aLuc-!{};LW+#)~Ua0-hTAYo0%Bp#zrDT^Cph>EUVZsu^wkIv%w7e zxK_u<;5UP+a?G@r_(;jZ14l61{gf7>!k2O5{qTAq8CdT1M@-$4WYIdtRH-noHBdw; znpl{!NCc`b!>LZw4>$@Q;1kyVOp~C+`K95&R;Z~sL++@RBak`%`3q~;OrCuq;f)Ls zBe>y6Z=De3OJS>^Be23tn1jv-_0--wn?d_rPq zTBVSrcZXtW+?#UQli@nqtPLDeJ z@gJmRqpeXgj(_y*sp!-5tion@SIZS$J-^?Rcz?2;YKq^d2uC1Jio)N~&Ca$B@tKJ` zE^wO6Dk|xhx+7B2qVapk+sy(B{TDB7X!+2inj7ZQTuh)i?B7x2W^2!H6x?jBD_2Cc zn0~I9c?)1c&9{JPLln$yqu^%4&YR1|oPWy&HxR=aTcgkFviKmc6Yic1u)5|_TW^8UyG!)?&i!N1USem2?5LfDwkzk1vjSe zwg-}dUB8rIOwD%{=8Ic0xWTvuR>zeB@Z;LJf*`{rhuML;PKHB4+sb$A8paCq*fatx^KgiBf_mpEYbc=Cf4u`Zr#It6zg_ zms7u%hbXu?Jw@Z@G&BOGb;GAlA?WGp&Wj2NRyI+4Z>+kKNpB>fR}DRIyp{Cvpa@0uH0I}rv3=U?FQS8;f= z3drU+OU-M9v#!38&^1BU7YPfi2RLbN@vH6GpGfw~QJ=-$eQJjkZ-mev`4xJ3^aU<0iUo4vsPf zmt9F-IyP`PsjU@IPHtoHWX@is^Y>%AP?XVyN8frq0JFG?TMpOdZ&B$n=9452=GFHW zG{a75i_bCw4{0!9MqnI7I7a{kPGS_UFAzFg#*-02j85V3ao#%8dnT=#=FLwM+&t4Z zsHb9z|LiSMN6)`1$9ZpH;IVDV8lLQ^yswdU9nkt+J50gNy>_dVw@0Z#mkDkd0R9-N z?A+MK4aSLWkAAHz+^4Vw2r=S0#dHCp2bN~K>b-K3)n`1E}V>LZA zzPb-I)z9{A-hor4vLjZyi$+PKW~wby=MO*C7OGFQed=Q$Cq9EdY3me5jb{jQ*hY2t zQAJ;p*05CqdSfcnrDyrX=jAwnW6p{^igey8uD~Y>Hrquc002M$NklpCi3+O#sN3&WWR1ULSb zzu>IlD5DLhCnBYzM(&a?d@V%0WHMJ1=^?nmPoD-xz{e?h>bKd^B?)+-sK7Sz=Qvs^G1uy<5USwB%oo{5@W+hKm92T4`ajT zOd2=Xu0hL2HU!KJr=E5qx`{h4!^Ib3>m~DS-U-1?>ABc$AmKRd*q znLiNJeDJ|X&Bq9CKKpzW+-!Z+t&31}s%YLcIwHKmps_SUIf3f!!_S(t_kPu!zyBN2 zt6zB00qjW!u-BG;(-==iqF)X~JxFi0M1f`E8UXbKcGGo`;Kt1#vJWUbubXcFw#&Okf*WQ=6TF3EgB49B z$k5~L-Xp7^pMCUK-aa*zYC5(B8Xem@V1My<)u54bfpI=RzAJsj7dVbQpc>lM}hyU(B zG#~x`-!~us`gbaL(!716Eo7P>jx{F^eX3O2xH&z&TH_|iarR!03yvAD1vgU(Y+k(z z!42(1aD&Fp$DfXZo9w&c@1c3)FF!paPMMmWJZR4Deb}5mlo4YIZ>G}7nLU&?&WQv& zx+Zt-OJh`;Qa+U`wonBvf*u7;O4`&4ZVJ6|I-@^n5%9c>C7=DJQ~EGnCTA`A>;5yQ zUVJZ5gSwtE$3=A1dpUeB^B7p#134Gu98vf2#Z2A&Z~6kIiht1s)W>%x*fDg`3{+mWMTuTnD^!{`N7}Y~jNnFwh>xC$cD>T#11BDO>#p)fWiD2K zotUWPIyrcS=w-2A?e=0bpw5U#T8RAWhj$7VT+FF{N`A)!LD-3g z3*6<6RJ6kd*3iJRuWOuKKgrw^MMF!eu%p z$1h|0GQkbTjvqXH*gScH;O5g&<7VSgbpsitEy^iWXx^ON7roNfDPKB)YW6?|jJ3Ur zCrz!Cuj|vgv~%^rnUanLc5*Cub|@>HylbmX124s78y6C|)ee`fc+%|` z{H<}crN>ttXv=6vUIR5@Fio3muiMYh=57?+Y~FX8A($nugW#HZDSi~(T(c8P_IFZn z!{8N3J=Kw{GFm)E(?%br*RsTS;PwUnxyU8KbzqmE*!$>Y9#^So!e+nJ;bbns zQ%{+hA9O>c+aBJDPW_$FL??c{qP_=MS^2CO%YXkfpT}dq*S5{>uePVvg*D>b0pj(cs=v2bxe3*^Sye-~3k0K)& zP~@%W!g*YY^Q!x%`L4pd59xLPtXiMUll?;2*8&8FCB33skgmkxEScYMfka;Csd2Y{ z66K&_-omSU&@a&~j`?OQ?=#s~6WzM=^zW@{bA;B7?6D!VnMg}e8KR(V{2G-jygIfR z@D)5rh}7k}(WVrLDvhL~DMQb6uH=z~hFIhgJQttZCV?3CY+8Yhzi;$T@RBHAz9>pQ zzc*dcAfZ6r1cLynF0w29Fn!rkhgtis*0dw9F7`%!qu_@3WGF}3LzYjwnJ9*=1I3Sm zo9&FicT#ZE?>e+^W;!hh*XLmT&14g*EBOn2d`YstF z6JQh7N^qn1#DCSh(udD_1RlRuCc+udI4@~k)Y`tpv3AA-%8-oWb*mgqYA{L3|G=9? zd|*!)CP5yT%U04w7~*t6v0=){&fRf%iF0hkIgN0{0dCESvf^ZTPKiSJ0@Evj%+Z;) zMX~>1jILG8S+#ujM+t7UTFGzL(2@vc*3&8T0bxi{|^MPn)m5{G$2uAOEOu90xwXRf@7G+jr_$uB?y82sl^KxRK!I{l`yi zocOcRIPoTr@5b}!(RNOPoSC*qoj;P$Mp~n1T5y<3h-2Xm+o-hQFw+*QnYL0*v;g7L zsH(9nuU*iG&?F_o;WD@Rbu^%SN^w1N`d_BNds6B%SQA|CmVV@F9I6b zTjQwj;~!;w_}jmiO>V=&ie`pzOf8iEhd2$lJlHESW#!+;K!BxM3utVY3+jVwlkW zc0RP3$~G2%->8hpgW#sp>M7qo0ju#24=>0TW^wU?IY38S3jcF}wePCzaiidX&t??d z3`rX(d&u$`iaBZFAHu2qqu^#s9X|?g)R7{PXx2P2k4kPRqFBMQR8NdLu@Cgm@5`K_GRd3 z@Tu&wS^LIYsHA~&?}<*QDqE-?#R*lj`%k1nD%)*k?9}*j0V3&yw_2HWnSwb_Qmf4G z*Ao~aavTVub=>M-2TDxbBGohx;2PRV?N8PxBbW;Vyh4;dS1pR)5{QwshJ;ZOVrU@gf(n~(q$u#HB%Y*59T*tkGyZG;;Ra75WpTwaO1n?1@qgy zpSpyg44;1^KGFhT4~zsk zkQqTto}NXc4v{|oW)JY*LtQ!-{RX|MqYY)S4%T$3d!3*|l5IhLfW}yIcUUE6$?778hB^x(sPbc}@(wzFkyoYCl z=DJct|4{E3bVJwV`sKSVwCDP$HpNhnk4jk|DBa!hUDqqjpB-&5Y`{dMsz#oYmT?!t z1woWU{^hRz;7dQyFkj2UnMQgrX&r2)Wr>%CGi_BoiF1`ZxN9nDdZ{1Z=MV%eZO#N3 zQT)2c%?TwGZFY=7{8Qr~4`Kfmq`p;ggbh>DDa#D7R~yB=Y8%vyJ^t+V=VT}ZGi--K z!v;;8SGwzTsEM?1CO#pmXxBtRAkP{e?!7(>r&u^7N zaKk*q$F|>Wy@L+N%ifNuT5yxXoAa}`?Wt0uapG$ar=25M{S}5Fl$Qk>CSlBsExyvc zIlm{{Z8BawmCZKn;UK(ev;Z+9EKMEkyB)EOO0+EJd+zfWjV(hg(lq)4W&xe@!0h5% znpWW;HSm>Gn!t!Id0ieqm19ODPX$s^!F4gV%4%=IFz;sjat@wP%e)D@D4c2Jy)K@_ zzuH}tWx?PeDrbFq1Al-aY6#cC1Z2Qe_zK?4>vloO6p?;oQbfWqzhr$3Lz*sY*~rFP zBjdw9$?&C4G}P9pclc5IP3ds1FH zrMTGAM5+sjHt;SgCcahFCq>Q^VIY~qfiK&jB~+KibrYi4(g@#1^$HB$*}rl_533)S@f*h1BydX5H; zS3B8W5g%0y%wP=VD@5O6$CqKNc>V;yFSi!fbykFjR>GBr0GD}6l=IKWk(OmATz${9 zyeH#ThiiY05743%X@@2{yhZ2%{N7G^Zfa932t6B7Tlbk#?3itEOESPt>`iG|Qep)uK-G>E!Ik1+Ih*0YUr<(l}ga=j&jj9zX=X z*tQ$xMO;W9C@XLWE`&5;TvzEbC_Ixe#y-^QXx)v` zFE^8lXDe5{dnoWjM()Mm>u`iLr0|;s5aJywagcd=LpVd45v$)2d=-B|4@-cng){Mf z1P+R*(-L;@k8{_03qN$(ZP+43SMV%p&yY^#d%O)*50~poT&ov07u;mA_ zuN0Q{%{?8-s-wdjjt>{i1l4noNtn#MAhPL?0UIES<4*y?!7LIW*0&PZZ>VuqQEk3n zT;i)DEARYvk1AW>%j!mU;itQX5G?p+Y$Cv4p@kZ@}4h6kp-N#Pp7in5a z)6ID*9Ha#O3YbJgy<2|^Q^y26zo4sebSa8=x^T^vS;OpyKj=l)CFo0r;_i0J>zy}O ze$)m&h7$B=-X$G_SP2p!gCs1Ws6)YH5XWAiYoT!iJ<=is+j@Okl(a=zVE9QV9sc-_ zGCC~d!_u;uNc(1@bZgkS0KRH(z-5CrEy_dBqe<=O&S&rz>MmT6@Z?uRbBD1{f716Z z*y&5tEEp1a*+R8x6Za|~&+E!DzE?F~UKxf7?HS6c>>Ni!Re`a*jXpSB!3{kh?{aq^ z>S?spdYB4~f}1V&`>1i#@60mU#Te5Ihww%kIOmT(w&o4nrx4s=1ahivQ_}|@$_~!Q zv3luMOKHrA=?#7{7{_2*_1acUq~r8Ln<>)|e|GZ6>lJDB%WhbJ@Ng@?CD_F{q2M!2 z{5ZdUE~ZpK#oOV$n6}Lr=Jl&wFqkp_Uk(q$li0%Vyct#B1qf)($R>pWhf@nK{DyP9 zGevslFL0m_I3mac4+0}b3Td1SB_WluU|L!?%iwd-aWOTCJhs(#!b#6F|Tx;!`So36!vX& z*}l!FakJs5x!G*Y6+3g#C28Vd%y@b?woXkkWX$4&?oHV`b?#%v6&76seu6+p!^_l3 zL@6mp&<3{ZDW)Y9ks4z%@?>7x1y~2`m?UF!@#HNTxZ$Vd@U$_GCKSLK^gUr+mtM-^ zK*BFhy?*Rv&o&-=5{Q;mn092z*~Fwl9aKVe_oP2yoG&+Mntvlr0CgN#$P;;$LVCG~ z&GIwO_Sg{EK(C-(EIOcRQ|z(XCL4YO8}!@|Vg(aNQ?Yo<~= zKbOGf{E3bcfAZUA`oTZ*ajagcWZRTE!9rZ+4`#N{e6|d3rk966E>0ofui{qiby~$` zUIb>DN!8j{X-n4Vhl(t8PT2W zGDL22b{-p`PEdA}mRs1dq0h445cxP!V^eW-2jpFX83uU@U;J&OTs^@J1-PteM`}?x zuc9*VimlskFq&BR_kP#*^4js}-O{rh1vgjr^a=o@;Ktg> zEA-UauH)x$vJ_F)yorU;k+lc^Y>-E_ zV2A@j-~LU))l6@c8Tl!t)WIi0ok;*AwQyZCj5MMm&Z$pluXVlA3M7Vxziv)`*8YFl z0cc=;xz5GtwzhAC4`$h&U2tD$6xXahiydVsc~oY9J-9|-(BZ^6d`x( z6%TdU>|N8FiX?8~sV-0!6+jsALCb)T43xmhNL~cd zqJ!y^w?j!|6fGO*n&{g^r$u=o;-@cUmrWWr*lA-cFtp5q3*iH2TMx>-0q62IESEu% zb6mEvo#^yD7e|iARPraF>5nF-v(zoqo3Q-9zBy>$=J=~i4~Re z2V!m?Jl0mJC$@ugF4c1zGuGBAj$xf@TUDcD#xZ0(5yNc44WWk#^u=nrJKxnz!X6;7 zs0EhxaPfm{J$M5yqY_5|#%~>$DC%_k3!V${^AWDnQY-;2;u{RfeCEr;W>qf97_7}4 z4$8U^g{DgtX_z`fX{8))V8}bUl7US>cSD(p1vkrOfiGY&GDujLg)^_iUd=0={)Fnd zPKTPjc^29<2yQsWTWQA}kqVQG3b|J%UC{WmtOpo&R}8&HS@%_N!*SiOq+#>@)2B@u zCmuC!{-bSVOms}}ZF6PHxcO$4scCV+dZAcckP+jF1U2Up;GAZNr7yC6@RCrTPiPB;NHrSwPN3TWKpk-{8daf-|Z&;*YOH`B! zp21v?3D}?(^DlbRZKZx**XHDa7cAg~WOcvPp6AI%%N zRyaf0uqx{^tsLjN1wS<31M(KMg(kQ?@o+W#*Bju>3DZLB&jM%b&DHcl+2 z*m~2Yz`6E~!Kw#x;N`tay3D(;f}1l0H!mc(`To1++poUzap0ri=5`8haz43iobDto zr=8t9i` z^05^5LIN}df^&&mG)|mnL4pMSkl|ub+>nBVH5$2+Qvcu}= zgfD4I>Kr;X8oT5}qlN*j+fu=e((^;x+o=qz{3*CuWCy1!?67@mtNWMxp*y!ljTpKu zDnG~5U|qWIJGi8xqsGmWj#(>uV8IRZXsQdYRqt&BGiQtEIe14q%oQDcOWFn`vGe3f z`*!YvNYC!c0OWhJf1~YF=kNWptW?SfzY^_N5{2#K=TL?vanUE zt8g%x5nn~SHHL>V5O}~vBb~bkEEtl23y|u!iwu|!qVrYYTlAW)bw4fUU_RSm>q$`G z+gLm$Mz>^giRt((nA8QifLDAnyT(-F@Z+4G_{Escw54fQ7}g{1Vp<)R0pX)FX$hc0 z4wEZGjmKQt7B!J(&E$>JI6Z1~*Xw~ClzCqP+ujOpWPj%6%a_d$*AU#)?|qKtMK*3Q zc+3YGr&2w5_^^5Mdft;F|iZMlsIR2yQR!y(}+CL%HydRSlI(;V0y*K`cx`T z@sLqtRx7cGGt-f*!2nAe$94xakUXX#%yEG|{m}eHjHlC;k#sxq`mcn!l|+NMj7W<) zJu{ZFGFt_gQQN);AJnHTHA6XHUh++IIa(q~e4lX*3^mi5IEe#a+~+Vl&6o;;a+7NK z455rZGJWCa6sT8H6Ia^tnfCiju)}sK+pOUv4PC56O1dK?Q1sDlwFel!-4I@g`nC#g zCO*9`8_#$S;mAe7&6x}yhu{WHzfo|raF}d0lDTCN7n9)jistxvG;xYhhI=Ce{m`}M z=&m(w&LzZQn=nEhjvQ|g>OkkvHkC#WE)DL_)3Bs(hJog^gv{Ce6LCS~JlZRmme%#+ zN?!1FXewNFE6ce3G5ahV&XC)v1#t^xoQTQUM#hCPrd%BFv zF!zjWG0I-eOeY;8oH>%V=n*tc+n<^j64(%rFz2o4o9>e$)Nxz3^kG)lZEOBb>&95p ze}4G-J0p5qRU?F9?fUDSxEMzfU$=nxcou;V++5_)7V8xZ54VCF+u9*}!Dj(bgtM-7 z$p_`9NsQTO+Cf$QLS^fraUt;rBHWD{WBbsTo4$i{-LxIS4M=$(hj3YL-Uq3J3Lmok z(q>O!L)L?`N5RbyBk<}n8KSIcaD*d-F2@OzS?veYvhdZVT!hev(F~)HMQvOht5zOQ zWc*PYH!!r?N;Q4-nY40#VJ6oMu2jx#1k!o#SoK`XmS&j4fMlFhb&;v1ZNbpeh~!-z z*EXuFVx|*Alg(_n?aJ%Oa>p-c{vy0v|K%~5w=@H~fWlOaXBM7N3z7`tbS2iS{F!&2 zih;w-t26+-^$Bel?98yAUuxoL$IMuTBtF%@+m%PHHcEJNjIEm2(x&0qZrS8Gno3}k z@E8_GCt6w#Zm3U(>S{Ckw$uap?&f^~+S@9)iPNTn0L6!xwmN9sEf(Cov33jEeD_C< zn+2m}yHU&LsO^?Ahpn9;=i;vuz^E~o#+&FA+oEi@O|*_<#1Y<1wVlc~-cDJd z_%O6hp=}5lTY9Fv2y?u>iY>!IE5XOuyh!7ye8f|YaDvznO4VVhB{9ED6fx! z&=T;u+>3)ehK0{xk*%Pl&RPMtk1#zIoZs>4Yb@S*%IX70d{q{-2)i@TCKeE&aSG2& z=YrNX7#S8Fo1j%AW5dTOxOpXQ8>Jnwt=F_|8h2==8(LLxpS-SHzh73a9uJ>EAl{L# zZMQlZf%v)PDRUtPxH4c`Smy_aF8p$#A)`R~W?-we1ujZ!g)zc~tHZTF(^^4|z)5ez z&V7JC`MfrKWP8=(eGuG~aWrVZlD1mA6-ETNMT$=O&#H+PDDMIhoK!%y04{ZLha=q>mQhrq+SF&9Y5T z&go`k=NZpS3hFVbJkJZTXdQ&+K_>O`WTfsG8J@w=ijkdhS_Z-w*{GSYvdD=UoD8Kn z_VG6`uCh~OW5Qa2^zq$ORw3CQB^dA|C+kX;nsH0P;ST$=YSz2yrEiz??Uhhr(kIg1Il@jH+B|F>UO?lhb<(r?PSFu=E7dm8X_2vwbFdeRn8aBpwfXDF zHT{f-`Ny#;txI}!aZK@n-eGka`sv3-dKl0zl@{$fD)g-pZELW%W@Gt2^)zc0^Rj~MW|U_yrZpDVp6BxMpabKu>~N;7aw4vc7q<;fOBQyh*~bY9?T_r-<=Oh4YCe$Wk@0-Wd3p>CH7K~149VAXxwqQKd2xAx%78k{eBU_sg&Y(?W`!rG! zXA2Z6;Z_Y7Q;XmR=9S~R@p~(jnBf_#klL$J|$Xqt}RrvVyqu+Q*X6G za3h*G#n#(h=$y6)i?&Tf3un+hT@A*N1>dJ}<%NdULH9(Ie3|Z%20iS*^hvkoqT9yK z3u`XWKB2o1r`*f%wTR`(aS-&a|3H$~0iU#~&KvRbF%>K{Mwe+^;!uHT*v->8Y)By~ zKug9NGtf4+^-4pud7=AD(YUv=Ujyy)B89f}%1#>w^f^H)?YG$ge`=Pvf)rdX!u#lW z2>q^Kua-P$hEyTxp!2gQ4%EaWWK@__xQLD)g(+7Nj4?ms%&X{R5khc7SU4^-JPaNL zH`bnDM1s78@!l5aX6fg)w2ARCWLxM;kC_1n$aUYrxvGzXo4F3zILK_}*=%DB*Us2} zfL|Nah6w0`+akE33)~)lZi!P7t|;fLBrCA$FJ(a5zHm_b`<0H6-#(?E?R~?oSQqX zOj}xa)GVTL*`Q^#j&x{O(6xkKbmyadGrHFw6N_mbB?7mI4lb%q(z+gvD+RF#F~jKi zWi$@M!ZMO?gZa8Q&veS+b7-72NU>ML;zLR)7F8hQAg6~g*4(*S=?(7X*;~+}Hd~tp zG}Xhkp-TnTAppX?&E= z7ugklcs{hZZdH5q2!?DA-8l+wmb?cG7{n!zyNPBFh;Tef$37UI-d0zsJ-snfCbTv#Cs*3-G~negVOInHO*jNC7&S%{M;EH)Q?^aP z%%YLQ3Z|J~t!lEeiPny`Q=yF$CsipAb_B2;P~e@n2y|GlnSWP^3nq6_12<=0=do4% zPMZ`=Yd!!7O`9Y?%t{r}C3QZY7clJKXYV~)FdR!NflO%8XzNpnJCat7Pkh2yFx#I} zb4DMsleY+PFd!@j_8c{G`Mm-OKBa*#b)37>H}~cEx~Xxsc})*9XZCB})r#97xH*=Q zSPTp2M=65aRczc$^-;&MhM{i=ZBjxp*9#YdG-00wH`&kquD;NWOCt&c2#SffgUQFZQam5Ssr6DKl>j6c zAB5?527wTkQ=j>Hmw~tPQQtz-YTQt^@iCs1P*6t!2mX6o1ULEkc^pGNAC)}{ZkD|h zqu|Dq*|P4riTL&d{3Z&!tPI8ibM>J++Hl(iH??{yR{70!6UZA z=mIBNIdPRT>Orh1eC=V7rFB{S&E&A6s~%qrJ6QHnHFbZd6RMF3V_8 ze-00WLTQWYS=V_@Kixev{aCZg2L(?5=8%^6-~~4l+ME=@4bLG2Hj~u^HyYmvZ(d0t zd8RFWqj6$-a5J}Xy4noYc5>}tYT8c4mSNFL$=0bYK}hOJ z1b6`bvm{4E2bGB5pjA)~h0y|CGI-FK341h=FK7+)2b?9Y&>-j>iwr)=P+OlGZSS>F zVJ$iwVW$QS8q>X>o8vcZg?iDaaT4Nq%dcr$YNTelmo}|y;9d)E)Rr}xbD_mNV)z>i zMtJ%(HW9-2K(yH!HQxzK(mc+T!)O>sTqQqMOiDHXh>(?(d3uHK@(YsYUXu@YyKElg|H3FVR;Vd>^9?nBdvBPaGOH+`)v@ILTf76;s>jt@BmDKB5XY<~FuyXM=kzG}Yw^Pd!cVMD^F_wF@!@191zis0r5 z;Y}%@dkAo%4j~8)+fMbVQa)vB6x?k3m^627oOSyd=j$>b-hPSO#|X`6-yy$XDuWMS z-YreN{6L>1kg>)n+Bs<16uM&@InuV7X|aNZikWE^!kZB6SnD+Dn-@OHJ8C%5H^WKL z!@IkLet{EO$w^pj+d|mna<1%+pkKB(BjE_A63(=+U^_DwmRv+Y`plXy&^ClA(7c+? z;TH}xXV55xzLf=v(7Fklb|iXtOgxJcUYLN^s{HZ+)zUbJH=u3#LAuXluC(=HFRvKG z)b6p#K$PDvf8AgJaEjv}{*d7XcrF5Bvxa}zi8d{=9en#&Zy*`@FF_Z~2qHZ5s^y zs~pU%m`^dBtPo-V5qmR*FX?3t0T0H01)~+jW}3nqfm!%dwk}y{gPj_fVi^fW-CKTN z>DNawLZTyJVSHss*uo=y#18Iwj|VKcVVe}TX;Qe9PwXnE+A;JHcb%SskTr}rBUkbQcA-i zRGC8ipl_ySh1QX7;SGWz7Ausl#f->{Z8*U#a4b%MZz7t(r0v5puek7DTuzXaQ> z#VEMhUS9^LJ^0YS@KL_)HeQAAwg_%$Cj?{>- z0@tOF(8)P__QkxKD9~S|fvemVzL2JL$F(p^%3f5TUB=OMdF`^C5qEQ_f}7LRdksPx z3vBXUY580#xY4_m!khC^aC7Bb;A!MasDS`-%F6pd)wuCud$cS{5Bes74q7qLKb-F! z-DAdw7TOjZLLUS%(!x2PNpORG9BHVs;NfjsfuZ*s_H>|!am{2)&I=$ts6~NzKo-J? z-g&XYf*Z77BurW0usy?er=xSxM;14rchEhyMTyomGZr17Z{;eqFXAW0H7Q zzs13l=(dP*L04=Z<@#NtavnxOjLjFi@7UfJ3x5CttUMPBG@@nI=& z@$)Hol83AXH>lnkmS;C7xPfz-!5>B$w@z@A&+QtIZu3F2gPgR3M>U`uTyT@mbjy9P zJ;6=9=UcAc8t7s!2qW$HK7Pu)Rjnj@z_lO6|$j z4u33ba%l;x(wOW*c%pXKX#|saC($KreA(U1REn^}kE_GAKT^A25oU1(Ay^=9=I?tu z_$z#H6&O+z=xkj5Bx#8sGr+7_hnYJja}Vi8mu4jb_o3}6q7G_ zKZWbQsTRiP)C$9w1V$;I1idS2y95p3egAK0x) zz2P1}6%cG3dz$LrCC!YD7+T*e_(S*7-FY2)TlTd62PL@S^e3&jW8dcP-Mb3~HwbJH z;w*A0xM7J06Bbm{W#Q zuxQcN%;SuQ_;XtXHy$r~0P$D`eG-F@3FfRF^us8)+4iuSpY*nke+7JdA-JKQo`hGZ z@VK9-xptzu}#FDQ|n-2>} zuH-X=*uh3xNhf+8mzDMXij0(-%qg`s2eCYlK^fm>fbCfhyeNeZgrTL~SJW^1E;*Hg z%ntznx}8$;@AHlR$ZMbLd7yPWM8VCev~AKj@w~=OEx1Wz#XOoYP>kkH*tZ!4Hx~zN zJB?gS8a}3;+G_vsH9PDYSZlTr9r&7gAx!pl-s{$SUKYJ;X=RcN3ktCMFLD{gBVRP(i@W&kvr+K$5FCGWh%AZRxey zlsQ<0FQ7&kqjd9}!qsVSD^eh&M_Pe(RVbi@m7R3siD;*_{8JqlDTnoYn*=u=2g7&& zD7abW8Sq|n4e5rigUcVb6Fm71`3$n`QE-zzv7dJh)0zM$s}frgRF3ABGlL}p z?uoJBS-+8pBtqyhX|PyVGy5U%hK4_5Hy$8TuEC>mA`82A>!SW^nNP;pkOQXGet zIgJl^U=?;gSUdPi1UEJB2cAs@H!9**xKVJk^~acFck75(MYyeRn7Q&sjhnd+*-B6o zRm@gf!L~jUCQ4>a*JJ(cTg!2q1UGcbJ_`Ugd1#E2bu?}?py&d|l|KSZe`Yn}ZV2EE?s^^PA4~i|~zc>-9kP|M(2LemjIdaUC~&Xy!eV=+bQA@qaKS?e_$6 z*4PqvX5O*DA;l5s1mOc2BU=v#Si$4`zyf1@qy$fhHZ}>V)zg+Geh|#?nX#xxKz>K! zKVU~2a?-vn?LlBSq*Fh7EDhD4F4vXVNiU3oo1Jv4kPiK^==pD`QCm9owg_%=jHK`( z!-sxnj6i%>aF$#PG~ruR0N!%{Yv>>s@ zvmkjdqVz0A&70~;&>wkK(WCeJJ#a9Bn=k&{ym~3)#5&@4)VR@UJ1e+Nr^pph-wcT7 z`I{+fEM-X#Rs5vbSdtF)AT>*@On_czn_VDI7fp%)LVZw`*l;SS}mURR-@x@IQ z%go75D>Cen?p9JrcXX5sVQ+;wFb9IAPQ)<*7F5o(?$6Ng;wtr}t-LRz>w3V?UqSAp z+gcCotKbI1!7pFFXnuJ5UGvS?UpHTU`9<^U#f#>RPL+D61vjsMZW@kLzOAmF+iU-A zZSOv}i~i4RpX+(kb-S&C8`D<(AxlwSlPIuN-qT+Jg)P%P7 zldTQ&j3KDHGT-9&FIH*-Mk^^WLUQds>7L+*l^z5)P_%M!+tdyHF0N8<-jTdkfqz$E zvQM~=ZYw>ouYwzn>qc<%{nMw-x8Hmv!OfS=O9VG>UT4cH}kiqwv@oa%{LCc2|MhDY#+KFxd?0h8EnY z?f%$~xpBCjp!~dstHpLoc02UL{Wdw8)UKweVL9K>PWgK)Atsg(b42Q8MMXdmV{7aP z-X{tuZ>zy`3=)AuoZ7xatHv0jkT!wXbOkuyWhd#U21(FW?g-xY-BHE7_Pwq*S-d= zF2?MWLl~=@eE364vu)*j#k`)M7{V3(i{A^ujesqMAzE<-hGJ82a|Dh8VD(8)>xfZsv#W=@2Z9?n%@gCU8o2^q-k-cy0N;BMw?#kPZe|x_t~n7Ng5ZXxGq&=It{8u_ z2Xgf-d;F)wJcLBgV8kOVqP{!=Kf*bP} z@M~%yZOrc#kA;4tTRzMKrJu&=aQ5CXnd9dK5JDNsDSRUE3${Tee`sD3keVmGSTtA; z*U_Hr|HExXS97_&S$%$4j2VDJL=f@u-C-M1UaOm3T5&eq#Cq8 zuR$uXgF!ekFT=lHrDj5cZ83h!JJD+Em*J6k<_KF_ZSHVqfocP;s zFiyN98#mIl!EVl|adX8$xlVF5RU6HG_&T`9yxZvko=INY!FYSH&@YD|(DHkwAK%yP zelu5OtO#m+`OYDl9IBR*l_v!*y;q1D+dHfWA;X`>IXoq3i zp4SeGxlz=&U2sEZGVh>4!@o3M>{pyuL`a61$~jzMbG5_?7k(h*M?wcTbU8=VuC^e& z;DWe$3`LnnT?o7V>-@~)StsvJ!j6KQ%X@q5pUeB_lGJ?_+#pDKspG$Y`2M@*yHRj+ z*v5%9W$%)4y<~WgNvre#&*yS7yb6}v3v(#_vnd~xPp*8Q*od&%ECvOQU*-_vKzw1z zq(UwKQoc@TmhzCv%FV+VH+-USBlJn3O%&MUMTt6SM>mgxo2@>=-gwz6<`t3gOnE1F z>~62%CMSlx@3UK1E(KKjX>rktMH9LM~YbSq?Gv9iO&*X9Ek#M{}a7^UYO|C-Kt_}O#k zda8~GP}MZ~f!i|*Zmu(!+86vfNVbr7AL@}4;})8;5eg>FAvJEY(mab0 z)Mw0L-#xbI+{120U3&{|6cNIhZrreH5(esu=8a%-Oc|DL&=^ZinE(b+Ea@fQx-4bz zg5Q}e+tGz58XJ>^t2eL@PE?j)1`zCQ>?q6}$i^6ME~D#8eQ&PFgDPq4pN$^ZD7YB~ zHyaI{MVXReUz9e+?OzY%^STBJ-M>a{u||j1KkEu^lK*i#^^@NxJv_;C#R6J)9Ju=V zif@U~#$nq#yjsziQJJ*>rpG&ivfvCpGX~}&9(0ROd0150_tDg0>$0uVVfFW*J)_{} zx(_rzHu8R5e4FI=B(+J#E#}FIaf>L|LRBWswesu&m}fBxZg$bL+rwGj)s~Q==^|&M z+dC1U5ag032r0!#y|qW-mFS6FKSmSGK!|H%hUeEZ7^V9}0)@NDU#a}FBEz@=2Ulh) zI5!gFWM1Eeb6*|x%WGfjIAp!DZ}$B;4%tTw%6^vxH`A$(u+=f(uU=u}=IwZ@)PnJG zWfXI8?dHRE+UVb}9x(k>mBweb__-WP|M>aoXOrjH(#s&Ah-X^ft?d=174*5z6-#We zBow-=;6Ps1?21sP&mUe8%G7*j<)*9pkyeKfsif`+`#~yc|eh#?}^W8fNZlXu> z+0OURmczT*DwUPw9gg=nV{Z07bosP)6x_r!=UI$`o9K&u{?&781vj;g8y+4)6l=Xy zf*VeJf|SoBxFId6Q4*#0{l6%-8plKNeR%DWUAQLccSpF<45U%!1c`c6B zvfMb7>qtFsyyU|uaO|Irp3`9pZtgdC@7}ebg(JL=j*so1NUD3JeU{u&vFA#MkOr0e z>GWK}8eOuX6M~!b=Jo4W&D*!5;3oTOtvmDWCUfpug^a*E>4AK90ej9JKM7NJZ` z;{}$Ya6&l>ZlWvp?zdw7hpd~&R*sh;Dni-34?A|F;AY1iteK{r-*#QpW%y$Py$oW< zY0TrJ;AY32yan`yID)BXSZ)zP6^yq*CGod_MZ^|?PvyF;QpDGj8U$T&WwJAaz!(X; zbS=XW6Ja6#r3}Z_sH*bT^6ts|d1;;Lw%hdXT-w+T636~otJ4liaP#Wr3kz88-aYLJ zZjMh*B(O>04b2as%_z88Ybaa|?78`Buo2<_dSFjJuJ*R44xxVvue4DghBPETETNf% z+xNR>rh?ZZ(V95G+NB5B0>ekDu2#5H`mhzsz&G-*%ipuk3Z7~a@TvvEe$}GRQK7^9 zr9JMq!>s>OMa`1HUNlDm-KQ ze)T{;!v$n>zbZOVZ4Rk_{0y_gYxY+PZv0Xw(G;#)h+z5|d>Ow?0C|9fStZh4e87ZX zf}28jK}Fm=FGx6f#soGD^K>n^k>+*~^QpPRb`h}PkMaa^sLp+-%4eq@YCE#t*`D&; zGy2BwD!(0HH(q!t(0xHgc3*)}aI^b9j(I3&wwow#K^dMTbMjk|eq&KNFW*>!J1&Sm zO##l1i@H(tYFA5qMOag7CMU->jTIF+(v!&c$65$nn9ahz&1J1ve6(ao*h^lx19K4> z*4-S#Ww@85)p(YG>`nA-dv~_ov19+N)T0*!H~;(p)qM23|D*Z%*S}N#N%Qv2Yi~nh zOVaT%!V%pi5Mi3j?MU9Pq<5#*a3O9}hmeKsObBXz{NZWy&DUQwUw!d~j|We|4O%xT zxN-S&f}5jKSL$HFjhN#U+;G(RxK-+srvtNj0}SUSH5ik2)dNGXhVQCf+roQD{p0ua z8ZRpB8a$7+q&aq*#%oJy(UP8|9K7PE&IAI~i3Q{GRB6UVRO!PDd?6arPQQeQtOYmt z=bLA`gWv~5G~j?;S(H5B7WAvmGvV0}?NpEaHW{oc3{d*x2Hfoy+|Ysfp6C4rI(1Nsj9krOw$~WdS=b|VP(_rgGWY-cdpJ(uTr$rEI0zsSnE>MA;oJd@ychUv3j(BcvH5TeMVT_GD*QbgQkkHFrXYf=0%@Zv z5tqAUo~NZbZd&>6Xk1M0xS?-v6qfI}L6H3LV;;GYSPwL_I&MNJ^uv;FKF!b6gX_R} z8!Ahzc{hzajTM@B0ZmSlR>mspUOrc^-c+kCRA)73Pr;YLV7xmtKWyTrto{-egU4-Q z5P-ipl6I<|pTufty&Iu#Nxz-*d+D?q$f)(cJdKl4pjOAtGkrNRcIEAC%TdPeiu4Q# z(AV5pt>JMAv~u(L7?u9*Uijlp+lCuU^rAf^x`vnJK&vdBVSDko5_VQ7({5J{MMCAY zik`IV3|l#FXpuZShg6>rTs#J&vc-heaWhU=R^w@$22Q}b7{?8~dZIXi=`lfCpkZRi zjm4`Q-7*J{DecE`(-%v+`7}SA78cBsW<#zhSpGG5c6ZfS`94;r>a46hFz|T)gtYx# zEcFzDDc|9cc0N!hT4>X+vM36_Akc-`dz(5dX8O*maeLqt2iJ_>o{IKctmU)HX|KPQ z=(x!pIK1b^LGwVpCrOqFLeVTTcCSB)RF!pDP6W;@Y9F57d; z2*fs=7xsh)+e`E)#-+5+Rl(_qF5uoFDc7mnw+uwY zlrn$@g~=b8LIz87+|1#o-@SjviQ$UF1;uGci6YADo`ts3Eoor$9XEiXys3u6MLzp)?MDu=Xm)wkWmc5<@0(5hf8zZ;I#1}rwvyJ z%>}jNCf;>(@AusB8Sq6qZerR!Vh{;E+lS+vIDNgn2(rb=9XBb97WyQG za_!Z;=rnQPUdzT_{p3fJQmmwClB|`kDmV@6PdRR+$h%b*D{~wI-kY^LzlKsy*q3YQB=DRZH+-Qk zUr@|$8(vU6OUKQpcU}J-j9feS9Ynfj&Q1d9uvy2$@vcKQDWAL^hn~I!hs`B)TK}aI zgbk1sqd_=%)G3@PDoMMJMK}H_vtxyNiK8Y$dDu#c$2J|wfOTD3tkSmGK<6yY66hhEyI00iIuG(~cW`RqBtAeO2nf z%ZVS`af9QA$2;w``4V52nw{h3!@q4dPdRR$-!NV|-jx62(gwiHre=?z#+FfZf@DzOhl{WCM3SJn@;+6 zPB;BejeGH9Wm^3DtF(UXO(H9T6`5AVb^WY^ zuNJ&|sX|YLHSPd+eu>5<+U|^zEW2hRol_q!?KhPDH}o~f8}eGire(*?dpU0Y@P~id z{7*gy{{8pF?&+LkQ%517(39;gRz+7vgK88WO2S#{E&b`IB}cYmJ}?%LuWn>cfe&& zKQOA6z?$ahb;ju@yweqIzL=2xR6G(jUel5t4&GubGiMa5S48u^~&UngLaqs}IN0zO7e z2?4hrq4w|x-05$mazl=Sz%9qO>ktyaGs)v=+tXEc-P>gy!1F9FJ23$HlW4Y2oc#!X z-;xb;NzNf9)4VTi!*N@+mID!6j&8kIjU5e=Nf{McXJ!-~HltSjuPZ)l^tAx-lXcR< zzBGl8vDq$0&AM#ov(X`E<7Le8&Xy0%b&;5`&W7UbB4!qL0nF-XDPt$>6o8A>bv~d; z)V9s5veURpT|=d<^NYqOis!;?Nnf}Q5amlTY$ECie_RC3jm9U$B^0Mz4JmZu;8?gT zm{rDg67yWw2t)oSW}bUS{(}t#t?KiXMPe&UqQ&8L=WlFDK;|6&{X}YN)5MN*RIIUM zO9G(w?`H9w@X1D z1*eDkfU)Nl&VBE6HT1MMFy(_ot%fd6M{#TZ8d_I_Yq|9ca-buAxc@IvDnyWRllkdF zK-*`7w*VJ{&7Yg_7hbR2qF`L*_H}+Brr(wU>2YYbg|~R8oC28mW}{?&$W;?4C8T^D zE1%{;)xr%{5?1DsJ8a`GaWkZS>bId zb{}d$Z}t5Ou62}zAKD?|0qNgnN&&`k>`MQxAGb(@cReMEcic7gq%2SmyD}d=eI)5$ zR`>i}z>ix^KkH z`QNt@mVPf!K))CC|CtMaPz5;*Qk9EKmekPy6>x4Y|E82k^o4V(LMxF z!S}cHKk`kIi_&uH4%%WnZ{y8my4~2u2%|0jgoLw+bO)AuZX6#By*J*)W&d*81{(DS zGpkvpnWR6tQBw-d#e}^PJ-$G789Ix-UJc1gdCkm<7l$i%m|9Dy8>M*@208z=YB#C z?~bO4dl>J*A#vGtyiQwP4fBW*(1Y(?60%%$AP=*-G#Wea5bEG7y5N#4(dW?~y40*a zQKy^>Dchk^O1UYvS#ypovF^O!RTeghzGFID!GjGk8OPEu#PFjP@pKRGX2bsHy z%zji@vrYbI+QZ4Qn+g)T_iF#+DG;=hw;V@|FhYWRHv_ZRi5X^_k%7ds?LEzAVV+>e zfCwx|!%aJEGws?Rk9-~**GJi0w{b)(2x2f1U;GBn3*N4Bk`zGBQTg^$=qs0UcpM3F z9G4y;b#Q<1DCdEL=?AK-IB*LWgC@Hemhj8DhuhC}LE^DkMp1JzfSt?3q(v{QU(ek`2-2*i(}>hyW(!>WiYUdLFF?^y}}f+OByN+m_C@Bia~eLCR8}A}GcRGJX8m zgXoHOTjPiW9{EKSpF(o7(0^?QkxPni8K|t1lHq9l9h-skVvQrtVaV&_P947yUm-&J zwr0*Ent48OL$y>o0cKnw9Sy^cmm>4 zGu2sY=sj6l$Cly)%}#;;1~H&1>&hn|KJdk36Xa^@S+{t|{9U!MANEE-*t!QO<~wfB z%E{FYk7@5;hJ%j3{;e_HqIi{6qt%nuT*Y#At6;66C5G+{Idxl(%J*u7rG-=Z^+XH{$CSYB1XCK1Xx))X)(Kl|>`M8qy{<^DO+F zp?=IVW1`EhphS<9Rp;bfPD%4(F0YF^`7%cWAgdEN2-SUNjDfj5`X?L~quw?8B|%!_0vO&FU8htqO_>+*T=L>qqR57g-g&7j|vtlagfiy~1wJnVPv zH!#{&+f!e$dSD9V@UP3p>%*@Ax6|_cua3~K-AR&rcHb^bzG!so8;h<$GJTNMn4LVK zco&A?3#*v>d$Qy{U3o+%0zbzsC?S-HZd zC$D^^@50Az4E5dFAXSTb@@(^xWY(1X21?ZN$k} zvNcb16Amq5r4P+iv0i1={(SE|z7jl~;Fm?0n>L_vtHz|cV!jwwpfIf_fmcO~vP+Wk z5(dr~51G@&gSpXg?kaCE<_m+21lkRX5YBkGIhay5QE~?w_qntgX>()=9yp{3(41<5 zq*?9!=Q9ufKDQ$C^4TrhY_0bwIwc)u!o{oxy-|lKC&#q!-WtWF(5745+ zuC?T-A%}hpN&hRWyAc<8A{*j(uZg;ORq=IiMTvgC8t7)UO$n!P%xTouw{{w8`5g|e zX&m+y6`a*H=Ngy+(Df+t+PCoWq(`ht35fiPZtKl9`ZWTKH;>q_#;LS~&8>QCiur3^ zjpDJ}G=lu@Rv7e>>12b@j7ObJq3Nmms!ew#!3a-q0|)n83-buD53TVx1aT5=F7)KU z&9c6BSq{*;B4ny?AbPqk{WNx^{Y)^VK=>^;cA@(YQBMbVeQ$LQC}s_Pz%^*Sai_Qm z*6|BU@93yhPac{#&F{%?(>){wg4uhMeo;lpPq$v#Yqk>Q7Eq%YvFT+{$V@r!xPCiU z3dt?4Z)1Z^>AYw@Dg(?N)Wb(o0E_jmFQ!yhd8@wltgW!k?~=oqd!wehBv{*j8NM7O z*KnslGZEvxE2@ic5lo+r@olfoTg{}H54KX~!cRMHj&)CSkp9bzg7}_-Tl!9$dv2J( z7a-*biGb_r6%GOvErISj@}R|sOmVp5GNCe?z)MHU&OhHKzT>Y`3OxD1_WO1)aK2`= zchPKpp6@q7otX)d!W9HwzKZ&+7&oTuUA8sl)|bz*4({%~AY6;NTxRsw-l5$uJ)8;x z;dmXtE{DXFoT+bx6}6k(Q05%ccjl1am7L<-EyN5?-)3kLOwWUoB{IAn#_I)u&vVw4 z`5h|HxQ6M$`C8A90?O#ic!3{D0mLj2`XR0vOP;d6vN;doexb6RSV^7MtBzg4-GA4r zzuXm@EWVgjqGDZ>P101yWuN)lnQ|JC0-!a<^OxahQ)lFYyu*Dr0s|O$@|i43D zu#d2!*REtXX_S??+C(raYgb9yQZF7Ocz9$aLlBMb__AkPf@ugB18?T|&UNRMTZlk<0`FMqACFdLHRS-PyboK@WY2i;=Zb}JT>&$F7f)xW3$x&O zE3cMQL#!17{9FB*YenWzDGJGhxKA8M&JcBMssmp-tdoU;GoFeAgq0F?#dF>`+Mjq3GCJ_apB z9yU3jiH<*QfKX#3EhhBR+m`UtdZP_&zV#oZOHRZv`@#K1rOY|KSU4KqW89j1<6=i^z zs`_EX9A^axM!;=G8^q@?iN=f>fAefVv>uLztzWnL1PIP@%e2b$qeE-X2bYARuJFAw z4}s=m<9*)DUZZ($qyQn;0$S)jK7Pqk<-Gm%p;7^ zrZb+d7W~ZDe8}+`&DF_MJHYkyo$_T71iev*Z(KP%|8n23@P+-k?Q6>@z8+}*b|aEk zc3Nycbwi$95=otd{U~Eq3K=1kA*$Fd%*q`f);aY!NEnI3KJS5)tvCRG$P~0f(Kf@- zO#zRMogulT0sy3z;|`~bJf7x8(zvw`!_4wrku*W-?@Lw!!R$>JDPT13hW$R{I`%3Q zzL2|H04RM9AJ3IrMrJQjk3_uHU*wG{gDPguH!}TQrYw#hsL4WSArd@{z@h^VYjqtd z5au@ZK!no|f{=Qxr=OPgixT2gYl*y~{zBLyr1IDpVpN?wjUsqdkf7?_Odc!myya`3 zLs>}{CA^)=E@amJug5%zJ>mfgA{W@(`D=ikWQIBQx{~GT6a3*k#80)aR@SwB?I9#tcyQqW9Y{p-jdKH(BtsZFMua%%f7vDg%ki;fL3QY zQG27Na)Lnhm_z$LWo5nEu4hAT!RTMEZMfFT^cT)4UfO6Vd7P7c!UrsRIa&eJpm~2S zp|T2g(PqJj28z<)UFQfc#1xkWoyK^6AUhwfGm;}3DUthwC7mi>S2{&TL7Hv&CGwh) zUNTq8!Rdcog2D^oRPdKSieydXZKjkOam&Y-3@d{+?J0+OJyQq|shZw7C^A0ha;>`O zL0YcBI_Ah(A;A96xQk~0OlM6VheEcik3PJ_lWDhWg*b89;t?>Xoa}#dxSMlZO}GWG z?&io6$G4y$B107C;nR&n6K&LNPuwDCP;!XFot<KIyi&u zfpOUuncD<^>UIT?y-<;c6)oETmuTJL18V$R5vrXu?O{?~J=PVff>7HlbIkj=T*e4i zTDY)PSf$6d#NNHse$dXnCB_A|VVDbe?u4$4|BmVeucub|v}VE}ImL_l#u4L(75xa~ zzlPwdiyg$AjO%O^%eHFofku*)p%H6i%R(p}#0`{)H<{jEry>V1m|gC83arKvxBanj z!|{j*%)z0_k#>G8L$%9WKOb`vJ^h-gLmlD~@x+MA5>~e#f-D89D=BGAqx>5=&^9v! zUTG9AXs(j-P?V(i4^=W8X0UE-H54DHUx0e)cVNq#T917|AlF?+gijz~K>V1=Imbs) zCtbO(`#rpm;}fbc>DSk9qPwqy+N|0EN(N z2@qO2-1h7YhCYM3P(Qc{=w=91Zg*yH>DjEvt!Qg%sbV!a3~;Q=9IZPPoSpXty8+N* zh}|s8$|(ib6AAMfn1maX@oNdMU^mzcAN&FAJfwU_dPoU{=6YbXV3V?I2Qjl?cJ~*a z-x`8I6qdY+^7G^rImSFiwgWw~t4v`*BC4(7qA2QIs%y77U{>s}+2iu~VO1@2lpcf3 zC{j*qS%#CDwwrOR@QC-SR(4J2=3}$4Y~^$~Uuk>)nW6b!}HIMj~Y! z&;P9#CwvTrGZdlPIyCZ(C_E9SvDzPV(1a9UVU2jX)$AW-`45qsNpU=VM?@k6aU4P7 zh0#xhA6F#*{xzLuINR0CMvHdFBc-Ua{zCS>T}u&AiE&Z0F@rHy|3rkn8`dTX-WPOC z`lI!n*)!W906Zq3l@K?ga(}sl#+2WFm4GxT1~#Mrlx-@`P*|Rkdc>Vv|7(HsjGbb> zJz6V2h5E}1H<=kvWOVo+-}p;Q#nH6u9VWgPyEi>5;RG_DcEQI_QEgfafkM@q4;~)q|hH8WesFH z=nw$9C~|BMg8>;yCOcUwEX_3kmB;nX&c|X)h0so6C7E@*kBwnAu1>O!?<%6 z{XT*tI98C9i@X{+UCw}j&=}!Ow_H2AS95Di5ExS<)az)i3_9lP{rYX%OJcBPQgzWM`Kn5;$%J}~vXIwGbD_k@|;|z!onLOIpVCo>n_{ux*sgf?@(ccTF0DW%g=>Ia4mcs*j?9oZIP9k!;s58c&>0E%UjKR3@tzD*~{= zn^feMf;C1r>3kfuZaKGvS6~Mofy~<)QW>ZtbhZ}a+=)C#P+1hr7hdIdUa?tr$&9*S zJF_04JleAUBzEX)qe`DHw7H>in&eh7jzr_*{45-W;`O3zraCKqDX%1FHE!DNXv%Y$ z)>fX0JQ?ZHH}V){t?%PM72;}?ce$1x>$IJMO3y&#du_WnKZEH6f^RoDh}=8;idno8 z03*>cqJj~(^ScEwCAC@6{6qr(gk->I8#M0>A@lr36!}S_G4-CxpfVd_sCSxXpBB3J#% zRV6&Prkf&^@+N<6L-vkcaGY)ywtN|@IkEYU8D;Jc9l?xoHH?NJp4f~ojrVA*rICbj zVz~Vw0n9xLTcf(}bAV%s>OnIt8iXX9F?h;F&VP_}r=1HGQ^P`!>I&(^`z5l>Bct>t zSF#vbCJx~?5lBky3AErC^y_{6P=CCUIQ-vJZltwBg)JIZLIhh9_ocGYDxcwd`(^X@ zNqa|RA<9aCP^;T*r*+Im@P@A@Nn3TYiThfMhe~5?bdE1CNk#o?^ilcWV;_dWsqf*N zobPz#`Oh<5CEJKy&`-K4#%<%{zHuIz%%S}+e9d`7lq&gmG7f4GRhWZd9Tm?Vwcc-2 zJIdg3D-u9jl#QJPvx>*=7|iOj!mfhfAKZI%V?#p2sANgf4*wWNE805RcGONfOazA# zQcn*)Gd^Ewt`2s}bLyno&`rs{Hwu%W72A1V)t(*La1RZNWY+siUyq8|+9oCyoqiYk z3;cE^*-6f9W$=pRZEiW_@Z#`3`&V}Q^V-;tbhi8Kp7{tB+(0bQoDf{!V!w!`$0G-Z zkVB8(QxW9u@b}Slc76a9NBR9~x|vY5fOewOW;#DzRGGrI95WpSt(RzaZ6#LFv1_3# zb}`uW(z&8Iq&&JMhXeXpMQQv`o4<`X&xNn4MH1?JP&XHXHRzHs4YZ95Nk7K=5*_ zY>tsuOeOZ_tJ|OUe@6>HnZ5nq$V!(k@%ns#9Pxc@{&o=w@ zZEyU}4*MIVY6oVyg(^=P8`|!Y`(KpgdOhgdi+to4eVU&k!Pt+luLZ^D{YbHqEzt? zB~Bm!c?#zY`X9N2C!-`RKPVsUmDZIhja_shv_%EH9N`IwLV2ei>* zvOX-!$oEhHX7iGwVNznd6!p}=c<40=r7Q=@deC>W+V)`Hy4&bgZ0dKf<~qz=`ZQB$ z#f~dPOS#3d_^x0LQh=3Kvz(98mu$$Yw4-?&Vrj1*h+{v~ws_CF>`*%+;f%N@voj^X?~NF&(O)=|2026 zGloTsbh%xZm_AL^7b;5d6IpSHNLa}H$lKKz=sXtQxa}m2`L0F}?(m!bsRYSyKGL8( z&MszG3vlfC0D;MU+4)G}cp@pFr4UmDx1vmKDeIX@nH3zlBcdH6aHP1q#ErL?yyb&! zg566SWLg>tV8(6P z$#h4|BO=Qsv2pJ1Q=kslDxxl$S=W4?1%|e@SN+S6>fh?4&6qV*GCNktiY6ZL35VQ^ zbXt=jnuCNAt5gp<)~r(ZHiMBdz&E-_qM|_klu+-G-jk@i42PgNt7m~1t8)7_&<3?> zwU9Dl#sXg%dRZ~7xTLu9VRH1)E#?QbNDEy4zGWH(+G0#D@;T0cfrkVRlLO-zSRR}a zSoHLjku&QidkHai{|JQzB_V(L7cXVDGYOxV#ke&4&#mCgiI10Po}uS|M(z}Rx*{(U z!zcciHm9#-+-bqK?7y6GK(r@K2r^Ty+p}&pxe25Z7lna=APpD(=4?K(m*t*+ce=Nd zC3P0sW_mv0hB@15&rc@fXAaflAb8+;^#+gYs20RZ=_}9(2U^IvnqGwaNh4TFqZ^nrNuP<@6 z?w>`c(10C97aPwSgXPC%9@Q!=5&A9hU3Ua+lhCGStH-dHk>{FSXO=V>#?_w;Y_jya zOPzq8ZSQ{WjGG%V=62?W_Rol(kuMrjMjUbs8%D>f?7@uhgVNi&N>5euWBwqqvc%A5rzKdb zgh<@$Y@fv0MUhglD(nSi+U%%?8sQN>>ZGl%s2No%_~UyJLG`!lthFS<%jQVg${B_FsT1}3jll8t}Ph?;r#yn)QpR~7f3;HIsNzr z$~b8fGv)asmsB4yU)f*4Pu~fYGP~q$6MbcE5~lH`z90`J6iihc)8&^!O~SP6GvR7T zDh(vDWE=^1!5LS`$u$g*MLRESY#wx;B2Z~K3S#+WGgADA63)+H9bAJF+%O|5!oy|M zSXX|@HV?{)gp%iqyjclZZDP=K0k!5ehN|4YE5!3dJ&6u<%XYqxee@_f_Qi1>3#pq@ zl{N2E&a60QBMvIk!RI*-*=9E&VQpm<#d|a;;V_c`KiXa3u15{dP~;~JNi$AiW=fIM zDu)N3L>;eM7t^;**KUZ0Q3emcArfPP;zOZ&lc6&=jO#x`B&1N+dK>WM+49*@pUTzY z=UYu=_Ft|R6_CtdMiJ?vKeyY**P$nZids0nsZ1N9TTdpv!P)fL!D&!`(HD1wu9%2Ddj88ctm+ypEB;`m zq)23Izd09hJG>&1sO5)odEIMteND_FW^_(|l!SH7+c^_%fzNK~MVZxPZgdykxiJ~m z3(hQ_D?O?YcWxV9iIBtpSZC=$e@!jdyz<1HEg45=sVzBjOA+h#?2oxg>2d8`l-*B) zw4PUQ_h|&q5yJDU5?R+&N2{>tOn+QN^983+!~$7Brxd!r>wN66T+hdINowW?DbsSN@ho>F>~EPT7L|3|x zmi*eTiW^6S=TsTODvZkcRBS#wf_^S}LmM7jff8(o&`* zj7abS8AC!m)WuSHV~ua#tl2-5&k(Ynk8tp~V5?yW%BNANZ>o8+1c$o?*4d{7zixQ6Md#75S{od!^2K*i|k$%858TeR^J z6u@*Qfw{{b!{U#6jY8yyqN^-TjsLpHY?F{N?Ln$9)UW z%Nn%lOr42`3p&<=>O)^IO0mnGGBo9kaSkurv}-I3Idz7IXsJ3>k9$u9x) zaoJbB+kViG_`D2qF{A5LR~*mY!840j(mSH}HD zGg_!XW`y&L%WHQM4bB53t7Q;G*!@^hqX_Krb}wb_@M$SuUcW7&JVVe5iV{@48+*fX zPe7MR0>ybuO_>Yv?aG7yZ-UK8Bo65;kH@tLQE|1Hevap!hlz* zWrHhknRE(@s6&+;QwcgooaKjI1E!?LWZ(;z6qlo{8;?r=9M1W=`BK0AoSgekDRPw2 zec1v5wlVcQheS4y0wD*lXX1H;x^8=yJ2}0wIQ*tL5LH19X{qA(d%Q8PtNh>#4(H_` z7OlSZ6*h&9y9N}eZXTrG3P|+=DXz6N-jeu;QbXnFMHJwJk`f(O79>gaj21|^kp;eh)VWX#v!cv~fyx5$%Vd!gG{%0lTt3LC`@k`BJ(^YQ-11o!nPiTC2%{o%(Zp^``HqV!z;vIrl5OT`x#C|lq`&|B8{I;y z8z1A-Lzn<6OKnre9?c=BC@O|>hR?0%L86*3@KV7g zJ*4*G?%kW5gfyCs>T}2hIZP-ymJq(Kg(dR|e5wa2)|_WXsw`FX;e#e--Q4tVkZUhg{T1LE_%0Q(moIXb z%MY!^*ZTR@&n9~BNesX;b}mvcp$NCMpi6(#XZMb@_~s*vBa`iwOSK_o+{0rt{gsCX zilGIi&W4}^nf?!V1)Jn*BIOS`J0Td%qyHz7K*lSyB#uh>80%{{X=M5$TrhhF& z=V8V1sn$0Be=mSk?~jl5*X>i;sUZu48gH~1a2u@ikjf8)^V>=@Y8HWTEKoW-1vEaC za<|8DZ9c$#YlO;_gJ+TCKM%)Z{^uo$EVC$nykvo2%S8d$a#!ZDM?NQU!cAGEST?B! zj&1#HzF%>nom3hjms!z&^8p--nx(C7<*^}}@9fT`qr=&0UPYj(Z-wA|v9Cf*YSflz z%l@UvU)zey(i+RcIv>~S?a%zbLt_yj@B1G})nsSjd#_W#P5%4zWd8fXd&AR#@oTBz zWx2|id!I1;uSPx0+M66+jB^ABQmL$LHz`=&>4-*t-tPxLjHHDo1c80v$=F<;0jRDM z_uYUWxN}fd*Sq!ldKB3Dbl|z|*z>Yj2iSf+x6ZEXd45*c;{NO`(cIE=xvQj1b*D2< z0#4iyxn=WU_n*Drg~Vq^{1tK7dK(Pf_Ph5C?CyaaHsq~D^Or#c{^@kLIQSvY;Cshx zueGsU`;wVC*!EED==9T{Trg@aZ7QsiRddhc)xX6zm*Wmr#DKj};ahWqG$ONImB0mj z`!R)xts2{L*v#SBmZ{0*)`7EDh@xk1htp3eBBLHru&uWlSt7HE=8ksiStnOq&;O|> zIqfnE%)SbK@G;1M_StOP=TJ3+P5!`d*R;JhwQaX>I2_#XZ$BXx!d8kxryH<^Gp6-3 zY2EBXT2fRN(u#?WqTUr5@Czhbw6!X*fWUE2Q!OTC(gQ5lda2X{ZKEFz9yDpTRSItC z>Dyoxg?DWI>91H$9vgmo&~pHaE z?lhhLqhDq+Zv?gmPYfVQA7CGD(UF;Jv+sIqm*)fu>K9sAWYO-k^#80kgt8n`1n#N! zsu%u~pn_?bE9%ozgS|JaVgh0XgO-GRQN;@CR%f!dat$2z}la zq0xWkb)AsjG3o4FY*ponZE5fHasC4-z|BtCN5h>R+HFhRZbd>6y>0j(3 ztH60-!U(M=&VAxF$F3Xfx|T4~=;`c;I;-)S%yeFY<_`5KoYxKJG885pCZb7R>|cY1 zUy5v&weSn=kiUaRFlXVmS5;Wc$co8wBvWabV(Zjp_|rfBDo(CTFV5_u{X>W0otsku zwHduGpI!sqO^^Wi9K947J$Dx3h@qSKa>2Gl3SfB`LRVVN^VO$)CY($A*Bsiz!6rbX zlI~&MO_1y&mz&WV0*V-<-s;z0YazR@T;Xl}T5oM}6bg~He75$a==VVWCKsNFeLc>% z8mBJYGmZ>tw#*)Xv=J*D@_jH!nRr1pMv6O|gA*#{a<|)JHbcBv2u*o_rYu=fth*)I zQ58`^C`A=j!v`#oa~UH(%o>v01I2%pn=b~O-bB>PIGtFbxy8#jM5o~JG4HkA*v02i z8R@QZ@M%hW699%QFcyQ@Mubopn9&RyPHTRk3yd57V#SrBCrkK-3`I*Fvh)Ng|np!k7h!o35-~T&H z%cdczJ)70*^;PrRVMod$6X%I!OJXsn>e9j3GL5$L_nBn{Om6j$&&d&8yy9!FG{sPT zV5iwFDd7Efx_A5a^5qKfWvh3YN-6(873^wPz`wFC&!dCGVFl6v3=B^WBC`qnXJHqb zM`13`(G^pit9V_{4IOq@{zV@Oh zNF0l?ge$HYYPdofc)Z9l5uxa0b$fdQ2K#F*fkBqasFE$QQkD)y9S)zey)-d!!_dL7 zdg?(U76e6l*w+2YPxc`-ZuH8^u`eCpzfNPP14HQ+I+zCE@Y_SbJbfTptz(HAMRziw+e!oIYDbfR8zjSI`hu} z*-$+YtpzUrQkr!IVTJU>nPM7N5Pnz|#KDLgFXS+cq>2-UL@4n&Xr%|W5+ zFT*B4{-mGem*}C?cdBhr@y#cHatQsIytlza|zyM0Jc$X;N{;cNN zFoJV++2{)VTMO-2pp`r{Pd^kNAhM(}c-7HNkNLLSODkZHeic}XV43w{WPuk1*3%3~ zrzKc$4qa}NTWzZq`CXALeAXX8Lu-=9v|0V`9N8_6ggjbh;^i>Nph&cZAmq z2_B+)D|Z)~78WWFNEG)V*Sr|3wl9KJxF0lE@=pv?UpP>ln>bl`KcQm%$GYrCk2CI3iQBuH}b}21!^!n57xUGvg zdYQxG<&Eo$fZ=$&yFMdd$0x}W{=V$CK}0pHuIw^C1qU5m_Vm=B zxvpg^xVhbhw-J60>wNXXDbRPYr_5FRw(J8~EosX=HXfPO6Obq2a5id5m&QJHAi)yd z@aGlwo+a~hUL#B%Vvywrb*g~?dBBCOSu19Y+_E%<*8JsSge&|BFtjmHB;dC_zjNu# zDLb|iC8sPT1fvzhBpL)@fI2~LwhD)J-vfG6-pudE`Q&AO&@ClJsF@$3go7{blCCXD z1r-rs9fZa;Q3?9JVlH<$7^>|U^1tI$>1D~aE2NsP8K)K%2Ge~pRqwEtMIOY%Sb^Q~ zk%FP|^6!RIVfrQ^`Fpqgb9PqDEAat)-Y1>%+%`6L3s`#N-55&=$b7+*5g1>1 z^F`h*tmn0j{kxz3ioARLBk@83R-6@jvpGFP*qIM=#UdQ93zZd`tY`^qx!>N4FD%)o zCRtT`j(xqn=ybG6kO-=+tH|Ma#OeAZ<82fO2bYbl0B6S+pmDx5}5rKx9B(Xv2 zk^l5d|8Y3#{E95tzv5-Jz)CoVAYQCRou9Fo0nSFKj0e0%+$Ukh(sy5^BgD_$VAD2X zV9xn~c0xjU?h~@NR2l<=KXJWWMEKMIrHvbD4%3so#tqmg&IyQ#(D#w0M@jAW#%T|I zl)k3D3OY7BK`YtzdGvg@rBL|tUijk#NVuVJ7ec0?kijZVGgLcAP@(8g;=ZD^Y-#WJ z6H{wSCf)2`yNVsNDN*D{4vs7E6zlq}YXNFT0(Fe)3&D?+H z`e8-Q`jqHJ67}movnh_%4%tkw9`(gGfV(1#ts`h;n}4&ndNr_*z@Xs?P{4o7gT{TD z+`g_c=uV&s@QD6M6^D0gQz)Ra9Xng_=dpM>R4J#qN$X-y?ANoTKj*h@M4cH|6&rHz zh`UOYA|~KroK>p>X5tFsTF9HitlDZI!6#blPcV<8z#X0Kj|0wnj!hR0L#*w@ znW4jxS(q&tX!vtbDG=+42yIKw3Tvf^TkZv_2>0RLwXMsqM!vcwubB=kxCiD$r%`Z>mYxG#CQ)E};6g~P{rG_4R1W}~CEmR-v&_AGZ zpnaWZE}q2s6UOyN><{@@P4@Y&(syOzvosoL2~d&PL`%Z!jV7ZU4F$(asHMBgZuocp z@KUxgo!V0ey_?GYJ8uw#kvQJ_6CA{P0fKshz8-LGya#9@hbDt9A$SOry zD}?<%KZ6D7!eurQMn3QZ2PicoI1&%8e&#LYl%v-2c|L+GOetwQT}E;=4y}g`DSX}5 z=|^y$F*wXc9dqTSnBaaoeDUB*Bu}Oy0qn#x-{N9ZtEcDo&lW#%a!)SE6Vi$uvf-S$ z+aNRdg9gpj`TmF%Mq$UMcJ79SIH&TDvW2kPN&6aET#(F_=#+J+c#qbBq z9RJ69G0QKH-M-9xySS#-pVS6vu@<`5{SuWz3j>&Lz}eY-v($H;CVi4u#G z#a_c#9*S@81}iFeE*m9X`t5|)>IWGD?41BH8O2A9AI0`xGMhQI1d0%2n1?}4RM;X! zr9BFeGVKY~E3IT5j_CA-&_rxY)8mvy>UJX^L8m|Z$jQe>?!WRU7Dx*MCDy@3yxG%P znj#9wJwD#Qye%OLg#5GE!?HzL%*@29oNkILa8Um-NA-_kiM<+o`-a5W+bv#tRq@q{ zn?%xyeNY^08|1pwXI9JDbJ}!T(|4EFr~X3P6W;Zm$*0E}9;w$6JN1dfp=Auep#F0` zAsil$_O_*Vr-?_P^TISZBebX~@qCNH%cjX=15;agFzZ0|r}EW~28Zlq3J z?#G)R=$Q3#2(&kmS&9*fV;X=C=ZSmuv`4mI6EP1igrNiX%g`0ymhd!iiXJpb(v;x; zpel)j)5E?(tqyiwML3cf)LDT-bbxfGa@G*b*uNl;4fbI8Uj~AfU?tn_(A3ZDI3+O# zVj8(RVpS%(rpXfEtHJLWS}TaI8=|PeI~@fk{89O(H;c0PHUBZ>^Jv34=67qK&n5F1 zw_%Nwxzb_&<$@=4PZF;{)yZ|;Nq5+l*?s2zr8uA~JLLWD;gUI{=O6Wk4<8&PzXg>2 zSZ?0I3{pZm_b2BgtQ+sv7=am2sS)#*`PeFxzSXltZUYyEQYl)oCXj)zdMA(#owZqd zo&50wvasaW(wA>@jpPuFhvRDC@rzlEOnclSFOHZtJ;wydaJ|{8S4edtPRLD;zumSvoFhuzSTbnqA6_tzIFVDx_B1i0G%SHULgzt-jd;&JzUpM3#Q)BehMe**ze zrnBYW&$qAp{ksQD|8?C_4C~Y|V1joqH77#W!KgY>O`e}&m##XrCoijS$2LASj)_P2 zUqjyLDEezE>;1Zf;2Isf;VUzV*Fa;wjwr;8`knKlz<|C;?#cjak{PqNX2#D^mp?}NnH+k0jLm!%d0P$ zyRAV{vN*#y!;DAl7!L5shRCs&c9gs1e1oB_?T<+=QVQ^HAbIv||16A8uNqOryIFQy z(bjGibVmlB{PJdbH{F{IIRv~nd%y4gJ1ox+AX)+4%^yNO`lEM2C*)q&Mb=+$JFj@R z|D7&pI|V#&1ya7wU0UK7c*5Z!{FV1)lV`O|N&Y+h@rKQXI`OU1`|%NSxcd(y``7#R zHo*Abrm@rI*VlEcyOT}t{TxV)sh<8IBP?|SBhc1-;Psdr*VtpD;ONJi)b!P^au~_Q zTKFh4yt!J6A=k(5a##mz6~bIweUZ~PEM>Cc=&({!C|rprQYQ6+nO`UgLU30m;n{8) z#P*2O_6h?>AMiKc&`(}_Om)4}H^QP|c{hZ7?DOD9`RhwX>ZHeNO=CYb4}!m+u-&B2 zI9GHV=9m%RVQu`7DH`rnp^q7DBTDPdEk3tnks*R0!gsh%6^a&{KNTd4g5u<^Tg^8{ zz&{4UiJUV!xu)+t3R+F35VT^ipNW=&J%;nI1-qVYhAL8{a!SdQnxTy7C&1%n`-txE zRVhdL#y?cmD;pC+Cra|Nq*P~GA%~lX7$Q*a_p0ZL1duNhNVWu`l9`E!|H8X-Pzo{j zzneT#g{wQj*pmCp@g#PNG#!ZV%HPJ}*zDvinBYNH%!R=IpdJP!%^P zD+PD1TzH_S{>dXP^RW7C!bJ*bqI^X)o22r~Q?s_1u^Lf&cprVu3k@I#uR&+u<p1|Kct^0ITrI(7^lw(#<6q(X z*Mu31J6eLy|<_GhS8MXynNyi%(H>Am_SOZEGd{y(;8B{LWJlZ*I0n)P~7B8lb z!i|`ByfqR2SNNy8Z7Oh5@{tRNh{1dbBeU{BUFP&FzwT76f=()agXqt_nS$j$%di~$ zdlVPnbi|rs6W2{0Ch;>~aGO8AnOn^Q+>QmWL}anJXtUJJtZop8xF%xk7#yP5%`}0^ z{82L_)mAmO>rdZueA>kuU4Bk82M@nWJ|(BaaoeXNiFe2a@ln|!s^GcgN_)u8i%{i$ zKW<98*oHC;ids3fIc1OSVR?dQ&j;<$5VkvY_-b(V!ZyAS@HElJWcKzBI4-QF{T#FW z0o7bXp;t;o{nsX8GFCzIIvI&_cfT*?U|dk8Dzj*+ogvX^Mw(1eQWe)r&*2qtTH8b~ z8b?$VjH{7Jokr#R`j5$$*uW9Qjz{2)*!n}6M@t;HF*x3OJz^2SfOi}>!8V?Z09y^3i5io%dE^twKtwZ#uP&v!OoyKh(zJ3{M@Qk%c%3fO10U|JT( z?)sd@5P3*#pY~A*gJ-$?-01YOIQBo|NSW8wbqtBd5D9GN;4;Dq?bG+9`<<#6O8jt5 zCt)7Is5HJgb(r<>C5Xw>$OuZ_L^}z;o-~ML7EuHI@dtmr0o{9roM1esCnTifQtXX;hlCfN z>Zt0X8F!WiTs<2v)anF|p)^?j{!HzoqMJ_@TW*2hsrzJI{84-O86?lREpWm2DPD`{ zv&ovH@nL=tt$iwVgmQo0SNwhCxdtCvoS21*9a~*98m4z%{e!6fm_o$013~(kuwWs5 z1w@@TPx`M0(6A>hM&v|tbJQ%w47`m+Sl);A)ID~66aUknT&tC@WTvv|zf35*%l0s; zMY8E(R^h$-J~yVnqK2nxJAqyOUCYD`1;5`qi3zvoNkunIx-M`=`=V z(9S^QsDx>lUF<>q#4mZoZu73h1A;+6mhvA1&f)|9#sGwV^uq@^6sG>(_jn_ErmrM= z;}P{Piwucx(MJ4hj#zbuVy@NFFy5Nl_y*cq-_k-+zFE*@GdZQZjivb3wiv2g@}h>M z^+ItZflHc92^0TL{v2QOAva4I-}&;!4QRh}p%g(V)BgdTlKZ!*nfDL$#Jm_l_DSeL z(~&S1f&(}XgsH`2pH+jr%#v*MwL5B>Zi6?dO=|Y<-*JJuq!^MAA2E!ju1+9fD{ZW*|Ek_nyjiM<~x9J0O`8L!e#Da`{Ls%vY{10LQ_a|B|i;_7H zw1ik(ea=Kkp7s~t1p#qaUm$gLmzND{vr(VU53o#_lN%A_t^f@@n!kgljaL#hH#MANLI@i^4`k@w(qTRepkoA1a1JcEx05hJc?a;{uaV1CUF z{Jb3PwMuQE>4F#o)y_q#e}YF;kc9P=!{bl{aU++%koqaSX>JoZ%dbD4ieAu4js)77 z(bd+k+=d+#{XISD;`iVY8{(&ZSUefVTDMxNY3bH40|b-j2l2x7-<&2 zy=Nv&JGJ86Z`k#3Q>4{|xtZg*vWJcf_N7bj7B^=;0-&_Wo-6VJ-Yz!DEADze2u8PW zv`?+!_pJc>iz%NSI;^v5dMRwel&OVw0knS;ITkengEWRN?W(`Esc180uK(Kk?DKO7 z>*vbRd=jqCN?BoiTF`uMwvt53T?J0n?W#&EW3iK9C^8Y3%`tOD4qa_@u?4qZ_s!;RNkd0WNS!ba}K;h%!QY9iP9E3PJo;3UrwKPAF#o1q=+ZpdTcUoW3+9_ z>DqLY+N^r}4@JrToURksh^T3A;-u3V98__Q_as&97iqk07qIh~L767V;$_qCj`;gW z^=ip;(NQIfXWJF>TR--q@m3`11I8~a?vIE_ImIG;Yhw#cfrJ(s*Q|Q| zZC%_uKR2OD*2fa5J8m%*uDeVCy6xP8SVT1%vNo0gu{=)1kC5GsYa$5=c6Gi5cOag_??u;mh$8AK&A z-Uqkp43pY`QtGE3+41qSG?Y%4wjs)&hl>m@2#=6~0X&FR=jE&5*n)9L6EQ_I_D??+ zQ6#?VV??pt==qmER9su?A?3Svxvt9=*PgjAWSS~*HM?ac^K9!f{V*{9dnI+ zjK;OkKK_(;q0(m@^4TfVN9E#<{7`XeB9EIFS0;>piVq-{Y6KQj$BX%Fah4r^Pv0RV zdJB{qRN}n6NWWC;c3@CPf1kx!)StMKap#<&XUkM~Tjx?~(pjlPIHZK9f@=GsS$0hv zyyedznqUF#6(I{h%&nRiO%oNaasZk8?ISyv60RR0_yPQU|A$up5v4z_{mDoI38MqN zaF{|_^c`cJqJP={=#*`aXoFD2S)w3gK{wQZ41FY_ zI(a~IbY^H{9)(b@=8p$oVx(i=xi4kUqJap;`y@e7u-7pC^VnF2!b+WyAV{~$Bp7;* z$>PO_cs)7M6hA!8FjT#76ir@ykeDyfq;IZ8nK=&wT9Pc~mHLGb`Db1~R^4asF{ia) zIx5y6y)Yx5C5f;(27+9an?X#0Um$r;opC{%S3M#-r+utL(6-~kMcbTR=8S;IO$?Fl ziu)$AV2ip9Z;Dt2S;m$U8gIz~&s4f>cE~P{u%GSbmIrPTi5Vy8==@7 z-^cqGVy1etE|%t-2X)#?Sc}8uhz`~ec`o+i5KQ#uf4PTM?@I^iiT^a^vx4!WA>>s; z3sg&Jjca{eY>;l(sBHR>pR7%Zkw?`-BkF@ogFYH2LK=WcZipkM6{*+R&UCFfzdiwu zIS)B-ea8r0Z||t(UETAf9a(bs{4d+K<-LBkE{cz*+pn@{S0fa8abi**rugHUTDNIm zv<`7@gzvI>zd39c6^4L{JKD1O=Gr^i=oCOGY_vzkLtWQtc#wc`=hd)kMN4kC4KbZY zFcy;qhwmg>Yp&G0gy>lQ@0h(Fc2vW|zsAeR91$skZyfX|P>Ejm7~MAAIW0lEog~e^ zkU67qTXSpF&DrExHB1fkS9=(rVz%&`+zEsX#cL>(OrepnRv5l zPL_~MI5tUN%jIwFN2{KKPQVpH+T;gjX8()crJ)7D2Ay zT-ld^bDRC09P>w@ED3V=(?y$M;9@gd1l_yEJ|%Wzx$0foP1kft_&GcPR{$Jb2S(F! z&UdJC&RawZS8vV%8M{@XGZ&WHoO9;^MU$dpwaCHZG=%9$SS(ahXr9XgE(K5je9lKd zNuN>@FX3gYqCG7k%qm;Be$u9|4)pEc>pEkITE&0PU0$MQZZ%=xU=HT|P$<#mTfh=Z z^37y`D~(6VeQ*${UiSD=0Q%LVO(4mSIs$%jnJhVUx&B5D=;A-t1?<{9w-mHf^Q`j} zri)SHj4-wHK`stf)OAaZ6i!u>5{f*Oc^IRk9J2|VwI+?N8A)q1oFpsQW%Yp?nm+Uba!>@JOYYW{~i<&!4c-t39Fq!RFa7&S^lC^!|3p z>3fHAbCN4V5GxPgjdPq00zY8_1@GTa{JDp&uPq>JNK);+LVj&+jPLhK+sLuJ_zFW= zMwaEh3e<1R?02Oqx4+a6Vf_OYtlLpV_3&|DC#dl32@of%fsLv++W;!w`9f%i zFH`t-vQk0v#s(yRa$G%KefkB@M;klpDpx%N# z+#R-yBc-ctq;SjTloM4Cv7vX*vqQj7r zvEV56kQ;gz@~e@N{w41LmFn*mP0`E|99h`&TKKT&z@6?)AZGxo^P*S(`DgKzNIlIE z-;^T;2~y7Zl^{1&mSc$#gaVrrp}*!>$hq*T#3&u^YlT8LA(~cvh|D-uHQ(2Tn)A>7 zC;h)r64IA~S2A`4M7Y!yDBKldGJ$anr#{?dhfN5esR@l*9uP5aR~mk(0*r5|mtmQD z8~@{u+_{X@3!|{W3Fw_%h|b#lqWjvcI_YNCn9Z)s|9XVXU{l zPRF`WulTA-;{STO^EuC7l~b*PWev!f3LmPsl&^o_wIHGzoVm=rmm4AjhufRHsrcHt zifFesEf^$bmk~QUvD$6f2_lpfc?R>tI!|~naQOyq$QI9UD`n<{Rop+BM9oSt#Sm#C z>2g3N;^h@N6P<0e?RERb)>p9G5hl!N+Ki;xSTpNPGT9uAt6e62g$@f(63bn7Y``3G z%*IxRb9rJPDytm)SsrfoQ{BK$$J0WCK-W}_>HMAu1xkPunL3PZH!gn^$ywsC`(SDt0pEBJU?FVK)mlpQ*y{NWlO@l zmO}?gGrpO!*4QcW`lNRh=5uT-q1WpEA$2wBMM{5maq6PCA9j>jfV zh(LwYuxt9dp8(8hib(n|Poo8nsL%Zn@_D`OEc#SJgXfAVguwO-$A-+{ zW#g2F`i0Jyz~YqCH32(%_@*ob&kI>ym(wxZL2xm=nI@W^weR^8^4c1Wr(H~D?2F4^ zVEJ5yF51dHt4VnOz5pqi_D>bqd|!drSKG+i+X+EcrLFW%K$zc=3d=yxl`!GhZcjYX zW8a;6z8jQP{0kEJ9hkuY#Fk$Ih_jyt!gJ`>dn;wgn0omPk;(ZsV0Qay`|As2j5HJ& zxW+BINpa}{IZ*nxS1#Vq<5<;DfG0;!=0Dku=3=)!eEfkDI<7}Q$_)gUxUCB% zfr?)(l>hQyKPZ(4BOf2u^?PT7oZ1AUvF?P61{kMsyO;RIo&E(pSah-Tkt%I>pRdhj(PN^n=x2Czjz$6^8aGZNWq5Lc2Hu=FuuM2lvFp zye{^n(Jt1`#?dqCO|m5lD1kkCc9OYy4<=#6jW7O32}G^itPMU zX{yLbs=0v4#X)J;2sxAfE$Bc)B_=6~PB+m4U8i&+n*;MrP!*DI{H2byVGF{hH%dFa z)tZZ{20ln)UgKDL>~&^LW!&eTnB)jTBr;_66I>!)|nf_^7wvUy&8IZ_=s(w{qHObdNar1Dg;M38O$w6T_mAYl~3s0`o z5r_W7R)pZKr0!7JA86~zu9kb(vJXl2)A2I+YISlaG#p0=<@*J-AH>yxl5H(xglw<9 zIWxF~BeeG4BQ9Mu-bhsJ&n8|YB4B^sh@Zj+t)+cx0Bc8e{l7TpwKja;Q6l!4xh%Oa z!kR*?+x-;&LO&1+=q7Kni7e5kc?hIi$Af zelb?Z3TA9a<5$L`_45@5sK~vzUx4~h@gC~Ut+1{Y9O5P{tLD5DZ9!0)en3?VZo1`O zwh^sWT*1d1a5-5-7v9X_kwlx9<(cVsgw7>}6L?0v<3jz6M*j~vT~Tj@fvVbl!fzaa?44dL!%LQI1&-xXE+fc|t|o!Z?H z<_M~$Lb90xYRYeAg#vvVUUrHLu-{Vg9069#DM7n?&ON*rOjSKkS^1v0H0zOb+2U2l zM15d_=ho0fxM=NFTl!p^E_OM8GM$R#)J(WNs>WT_LR{A`z2&bRgxOuX$P5>4EXf=i zZF?OzS+mW8ifbo^%M7c@AQQ9$T}R?YHq=YwlXKq=3Szd>RRpWhmZBBQYUkC10F|{d ztRQ?Y?BG`&*PkGEKV}NeJ^{z-;BJ_0)SKB1@JIn0_z!w_>+~HyyHcy&v%@cb+B+|N ztJzH;=q>Lu2Ta^lju3A-$f2a6Tbf6GS?Klri*8%PvHPUYzo`4p% zVxYK374LyvkM5FCP!2Uw6L*81oMFWTSgD zuE}1&Y(Omux|?{o@JQL(yD($R{%|}h+S=T#3GXv|75I}=YA z_!C$ru@a>e6I8C0x;7@B(_At=%+bG6fJ*p-5Q$%%ftPP8>8sGV+jSep*`XLz8g6Ul z@AAaU>l`{^s77eZnYqAxjS~nN+4;G>=DE^?tzDZn1St423*a3a?7g<@m zuZ8r*?8b&Ae&eCW_~m*jMiR$}k#i1|CO*&zwDZ|46k$GFXy2KSihM6O5EzG9_w+&i z4FRp4;oB6TDn!~bs*W%}b_wrDgJUJy1jQPZV7tj?36+>|vl9#o-pfWH5<5$*V=NB` zQdbTxQAfcgbsxV#Z$55?!a*-^@PB7ly|#Kbu_2fg*CI#KPopYy`DGV#Z7_rwPhH52 z$!z74gjXvi(at~g!%n*TxDA2QMsw-uPa8uUM_{q zyJ*u$Az4wofdv6OZZ;;KFonJ?=x{&0<0=HIQJ`W6EH$vK5Q6UoUT`e8kiO26xf^o_Mu^1zDm z3t}_q!`plG09M%ZLgmJPwo~!D>q=br-hyKRX_Q1laYf9qXLh~P~Md=^tE3-?04$_k|7m}S6e?M%Ba0=3NLvKb)Suy<5pCA;6is8ElztYA$ znMA(y$!O)^l4dv)A&uN9eLDTx5gvV*YyFA57~Z}mOk&=xiSP$KB3RL@mi7Y@-Y%+R zK<3l({M-r{@X&VNtnRjIe|JA0nTu|CKP;`_{Z;M3=f~KzcG^80eWz~U*Sf@`W%`Iz zf4REqJ;@dvdN{p}6}2PX{;2c3`};rb#)p^l-&4iR&fIO9j)(OIm@~_zJDZ82Z9kp~ z$x>}rN$!cP3_c~_msVhau>$(LE0s<6$uqBiIiA-d8Og~#BRv!?F*kXIuziwM4nu^9 zy+vz_KQS*ieVK2%)A$ryoog0uR*1JnkQs@vU_NE%0>eSOLcc7@cB8D|XCaiy&(vg+ zqWfDFLc6@NG8Q&=LO0-1$GLzg+CQ~G(=iNTkZ@!{la>?mzdg{#h(8q@9XIll$%e?(9MRyiQjV~{z~yR&mo)*F-F2!+iYtFfN8V2 zeZ@K0XpW?Z@w&3oymo-z_2~HbW8~EDr=?mRfGGI@AQaq>K;m%mlEn*b5oIbaNx_jR z^|Q30Xd~L0hO6`DL9eD0NrnOK;~Yw@4J4@V|6I;J7+Qwxw6aB>pp54-U)pCAdA`Py zYBqso+lMh0ne?@xLgjmzQb+bu$ewH*{)@ugU=_eG+@U2bj1{m;L-uJLC~6CWAgjKf z$7H#;W$cxC;kV->qM#ic6|pu&rqMOlNz_n;GUjH7oc_a0`*Ro=l3P?HucR;(GJKP7 zbbL5XG|w9K!vA%j8l#|^g3hMmxFU&$R9Z)H0U~K7fl(_iQ(#e9CTvM7*sq{U#?1q(nhDtjs`QW(y{Emfev^roIhWA~d;)y7?SmuJ z8RM3X|J2Ii+?xJ)qrL)j3w-xDp8I|SP14SLy4&XK*$VKmjTk#VPPsbQ@&URS^O;Q? zWKC3oXbQBMQUQM=-6#dBgG*%ML)Jn~1TmeGNo73Y=n|N^QpJ9iSx>l1S7F;vHr4-7 z&Ti#;k*v=)t6B0=%Fqz!5D|QY7-iQAYd;-u4$cQi;IYNC>W(8f#bI^zvul9x239zD z5@wb?aD@3~V&i=myeG;jDAIE>cm!7)T)pL3sJ1J=wg0eEV^kl#z{hvcp0yyu{O zUOmpe((jb0P@0P_ere5Djp2&; z?lVJyaQ~Fo(7a=Gu6@5JJDLSzc`vr(_54H0MTUz~4C!5*n>*COK~HOXh~wt>UhpUFKvvz$j8w?kuP1;Du&W2-K0djW@hA*Pcpe(%hJ1%J+V5 zTNvpj5u~0uFlTl+P<_(_Ssj^ll^mO#C_&_=f?+YNa_%@H_-3TmF_{S0Rb<&DSao^2d$bOsxryu5&nJA(vP&Xek*`0Dzvjx{fkW`WafU!`1$yvG|a zlOO3&$!{E8#}N7R88}p=#M47_!w42Rz|?b=aaSfon6>-E z3Yc!lluEG){~&UfQkn4g$nJp@of&W}rC6w!aG{b>P5)%rF*f|{dTbp-{(s@ ziCDlblzcN0Eaq&cSn}gsu~?q!ud9AX+z!oa)&88~!-BM5buy#nFkuvJdN`-^#Zd+A za2^et?bF%mSf`;gBgDKM_s+Z7qo7rThgJN}YL)BE92E#qpr>yPk;zz_jiF78glr7k z!a{&SLSCEp*A}T|z`AClvHC48{FBb-9L?r&oXTyvb&SD$I4TT2mcc1D@ga(W+3KMO zD~s*=4dV6)Nv) zk|kHrw2qJ^L-1-JD}rJRbforH1z`R`1AY?)w4X#FQhR-T?X_rmMJc~9>Lw1?RBIsQrfZN;9e_f1ki=&O!Qr0{m0 zLe8UI2mfn%_<8>@n0?P{!=k;wCdY#cX<}>6*{t+Pi$)xQ*++_x@s#Y;q}Uuf2+xi zB@^XC$pId?OOMRYkDRt{Lde2&ZeixcU*9-rk=!=F&1`d+Whea41g+AKeb10y@iYM|j(5Nroz@cue(KMegG;vmi6$I2c1f%xm$%;y;TOVyXQpDWuqQ)6oX$cSq%ZAOJz4MF+_22Z^?+fF-?MAo!$vOWoGMw-s$F1Tteca5*dPIr4ie& z19#;(b}+JdNjIK_xMcPtPF7#OlpH%I*sHxXfnnt;5T+BAl6!FS16!fds;}oTSr{x9 z79;!$YLz)?!xQyYgbcC1D@>oj9Hf2ek+r{eL&-9w#(Do6%|q~OSbM|AZA(VI{-xt$ zt{J1$YtJLzkbw#ogiI0L>%*e>T%#dKGVFDkwiV z!iN7jjRYbxh>cEgXsCr8W^ zLEcHz-yOXaj2o*J%xDwH8sBJwgY}8HU5}vl_a{Q;$~O{_gt7|nNx7E&IP89gL=+|! zwf`F-3dwPsTpieC+n8=|4wf`WceodmJtb`5xF&wN&Xyd#fD5|#=C6X=+@PTMNkh7D zIr-RVk?>oB7tQ#vnk;Lmi8K8b^3m~0o97)YXv+Gk4mi}$7T7C5a3n#X0iY-#&R7{0 zOzIu4V#7PHj}J=V3As;uP(Jol0=aZVw=@|{;xnz=qE|6g znu%HIbJrdXBidNJE6)usT=m7{x7#%9Z#FG78{33oDNwxUaStTH)^iInhso{I4>8qi z#?L^xC5jmhky=|fc3w00%+^#b6|_q`PJ`i3!PM^9vr}pTYOK?Eh2CcFn4FR)FZftB zz@5+0mk~|PvBHIk#*SpubD%kV8v7-ahgd*@Dl`s7^7^R7dKiIcq+v?Z6kb5%v1@5# zspx~igYV~kx$);GtrLh}56UvkF6SpLA+pKJ@lGdhuGz{&<*5C^9kg8OsRl)%$lO0$ zKT}^8?=@7#tsF+N3_A`A+QPf7TD$_Xh6&J6W7U{<3aAmgFa3KV-pD*;XHg!SQ%vC+ zbnXf2)=2hs{tvxAd!Kfsz{JpY=+qw!h0z7)lIEc?L9iQ8$u-iVZgpKvnh)1ZhXY0$ z>L;=`LDLHKr=!)=P01}m23}7wOXU?ajEJJG#ERAMp?k{)`X%z~5osB}cH%+ST=Q#L zhi(3Ua(uF}tOZ}QWWUbmuH0Fnp=G-qxIW=d4_XMgScQ?Q+Hu{JPbHzOMCH;QdP-#K zXQzxddK#6e7wK$%9YPp$W;o9hVmu-KjPL(yJ@Dr@BK9vD^m_vi%48CmZ4pw${=#O_ z%(y+3PiSy4nI1UM&umff^NyQhCKRe1pyZ&CX-CQW8$L|-qJC(vvshabSKGO5LegXi zpM773AFi)+5lVb;|5*?|rU3uP6WV`7jpN-{HoD7;HU>7j=~N8&PFwB}Cpc89bVa|r z%$slFm4K{kxzo1q>pK7@W5G@)h&e?B(xD~LaPU&HrKquVL2d{L{4USqCh+1=#q^3s zZ^?5hunU2d{M#;tFvw2!qu^752y%O8hmxl>h-7iX05%t)oS2t+NG$5X=f`84FCM8z zUP1uEar=z;{(5Ou6<0SE~v{ z65x|`gTIV=&wgE_N#}FEwef$VJ+=HIMoEKXjkL8i=ND!K**2l=JtV}-%_D=^rqMHG zRwH-tG{HSZ8Wf-ZcQ^Fp>htgkZNe6JhQ}`^OD&D(B(f2#sQSXA#;8RNLi5^bOhK2S z-7c{Id$s|g68tbd%1g}6$_W`8%_IiHf`qQFEG?@)M&6Y&^s^H_>Nhf>e&42Gd1QyUs&DDgg1!+N$+~Y^9E|5np44tz zZb`1`y{&4kaVaFbc3P?O0yAM8u)nEs%#ma%cEsR2|{3SG{3XBN+ENp2iQ zM{@@?Ep-ZhDRy6UhWX)8u`UDRPN#7fJ$Fq&J-zJV9BGPpeix1)R>dxVI_&^TYhLTVx_>!2Ieds7VnI znheYy0@f!_jcFg65WgVPnl+Gy^k7 zbvOIJ%&y1}9QNv^_dmm2Lgay+UWQ_ezsiJqoh!1)|0q;H7?l2McyuH&7pjTeFn5Q* z_Mv=#tuaoRuM`fA%s3IWlS-W>_7)|XPOqpY&0)Ud+nnFi6V#3qD(23))_WXSdt^*JDe+aR6-(Qdm>H5+NsV`q)jdk zh(L8*N+n4Njc+`RX@xvX25~NKgQPt8uz`o- zKT%_x=p>Zf6X%>+!UNj(%Y)D%8FjbAWgt>RT#`O0xGqDi*onmyhk({l*)_DkP5FIO z^ag#@xt}eInZ?Rav}?et^)8T^Q>Lz)+zV=?q5L)4dE-qdi-_`HW%Wu^l;)LcQu*x? z5ES|3P6Ylzpt0P{Q46CWd@0gIXyv(X*dfGRI-%s8aTz^f*(!n5{7MxW-k^}EO0ZXU z%XCc=MM=_wd6gWDLhz^?y-_C?ashHhL*oQ(P$k&#RrKt@`Ekj}3IrfSK*42k01lko z)gr`x4YiSRx{wmoT5QbW-C~pzDddMsVq=0QnBSLv{x>4BE~CnzT#;@2_oo}fJ4G@* z+wI@~$as67{=XCo?mop_k(VtFK54yirTt1`pC>cVXZs%HG|ZOvLJrG-ZI5VPI@H*dq>i*IlLf^m=U^ugNpG$gymCNdUXl1YGlBvd}lWSFx z8E4C8SWf<-HmeC@;BuisbUpV#(EX~Ok0h);Ou^(*N*N4juRI~BJwHT$T|H8dITk-l zyQ{eu`on&KfPqXH+;cE%YApE-I8fZDsJl<4&DMK8zN2}~rhP97)m<2;PB)(r4GP31povIP+r8@uphcNSHD>Vhu9T_syXw?#jB+zccrEdD9 z8`+CCsm-$w!FqTxJyh;-Dj?g3+6};S(D9yMQ}TN7rfsD z$JZh|2G1R4LzkEOY#aJIr;>aD&`4($Fg@a)5+~C#o<^pN$)b4gP(ENK9K{b&URO|X%(;^jI8y1K znQYF}GFd%yHoVm3=h|-eHY8Voz238LEX>NF5 z!nGtO_=jbd<5vStgA4P_m}4Re--gpR*Khs5NmN(=5~!ZFQFXoIOXDLMI%M0KRqP+- zI&Hx*HK4Bg=?)Vy4ezF~3Gto-&}pjQ&A{R`+Il%|#vvYc6x%Nr;g*XAgR{bQ30~Gv zy`&3idg^YEsNm9k{$*X-7*G}TK&eQ zVfP!M;BP;7=a917_@zKo*`0$Iw`%1Z0^{D3dsrbzIp#^eT}wZX18UZ`k0k*77Y*O%E@NS?DCs=D>j=20Qx$3ym~I z46>;0u%tP=bK@DS?vWyVVRFq>#P$Y#bb*}ngo_axqa`FgtBGUIZ>CvH?H3NJM=0*B zIo^3d&L5-fxlX!#i5$(yN4nJLrO0mOn69f09R}HLnwcdMUiw7nRM|xz>sE^QllerE ztuZQpuLi|(H^q$Zv1HekOdX;##zvE>MpoIfyWmn2d@J#+@T-jr}EoXuO?vV_A7$UB+zQ@;hLcQ}_Y|B~#olWb?koG8*WQ zoZAMs-*~d*MDj~>ZM-V_aJ`)#zPQGKi898WGdSn>rPuX|N~)ghWt>?p_}#Jah$*I& z4evrf>EMd7Vo3;Ulwg(Y@BPsN4M9PJydwig1BhQ}&VP{*5qH!8HbwOU-fSAh8Z4DX z*2@+R*^vezT}|2`P0ASnV4&2EU2`uwZwILQv9J{?& z|4n3_#ssfruIp79i$htfmh{qehiv6FR}BB$$PTLS}f%4g2K zk3Y(F(K76qvTo)L;7}Kuqqa&*IR1$-ugHJOg@|_$U*tJJyF-_D#jC~qrSDTjX@(R_ zmZ|D8B}}BWmkP(71^(wb`FcKe0XRUX8~_c6a@Ag6Sd!JSS2Q)|=xo5^wzMj?R&fih z(v0LN`>1M^v|ShU9WiQb2}3E3U%tyQM@dj8t#jYB&;E1i(2U0p;k4Au0r$?ZHx{*n z8(;Jj3yFL)pLLfghOVG1Ov2sXi!>BL``71I!5CzIyka5Sj%X^be$P*i zsG@nO=Mki;sDCrDt6H%yGhiiuNb;ZTw6Wegjd>SFz_~T)yp(9Lq0)t4fH!Q< zxbG?acEHbXcf=TiP^duY3?VpuE>F5RGqoNgU!O#RMO|pB_+TbzSb3|(_!^I^X|FNq z$dG0mC64ppfeA9IEvmMOPW>VeClq$!ekU#dgnmbmqC^mspF91Pjr0>KlF-~(aLJA> zl?Cp^3)XuWt??Wbbxu5BqZt=ZA^Z?rH9^&|C%j#7<^@3R)Ts zezqINMEHWq%?)MRsc7E03f^ zat!)80z2b*-_I$_89K75d89G!B=Vp`aA!T4{7Lao)^hi}Rtv0HQ~k*S{*Jc<$zjqL z`BY{ZVGTHXaN)BNt`xOXq|WnJJECsA|wLcb_Y6e)@uupr*s*AP>$ z0$Vh+BGK^XE7UeyrnuoPA!C`Npn#VVtl_$epHj?RCl056G_3Mn3cQNkbyr?O!h66H zG@aJRiX#zAS;?N=l0y-T=agFDA#Z?9kMv-zEFgFP-`x=1~3@)jz zTIRo%i6NY?1|Ka=q|$-pt?O(@2sCdS9CQ(f=Vjkng0sqcXen& zM;XMM(?%JnPG=01Fgi`;-RCB|wn!bn51tLkOS zv-@;Na;vl9IG87W9YS6Ja(s|ljA!f~3$$cbv4qG@W275W7}5%he^^Ov$Gx@XO1U`D zbWs#`N}$~u>V_X|m#{FziT$K)me8LlPI0#*V5ix4>U{3TCo1#|l@qDd-&qa)D1)?& zK(bs{mF>JGq$}H51s{qO-n(MDH>pwZWE?N>{(>EMfDxgSf+3tp5b}eVT!b1Uz(XgD z876Y^sP@RdB(v0K7aMKP(OHAe4p6EZ?R%%9{C%ucwr$QL=B@tRLLBRJYegFcU0NrI z#C~)+f5g9SB@(um!_hn17wrj!PfE=0`eRbV>fs~BI2(~e7C|55!)Q_Y6FGMO9WFP{ z^e!Yr*gx2rFueUeVr8u(jrO>~HZbQ&le1f-tQHPNJYr?m5hLj{V{VCf?*3vE655H; z#%Tf%ANgD@ONA?5vV!Xsfb+1D&(+vWy4ftcy+=H%$|FgsI9IZ%G?Wc*4J%g?Un$bOy} z@4_aF=J$A|H5eYev4<7z@qHfR|D)?G+uCZQW{bPK7I%l@P>Q>|yAxc36?Z~$cP|u| zpv8lG@uI=qX({c=bFOnfz1RB>lD+S}?zLuS%}9BDf2lnh5*dcid0r3e-0(!8-CG@P z_84y|(iZmZcsI%XPGtl5jwNF>kfFop!yM;=q-6`rIoK%_JNOFr*fRg`V{5?U{^`qz zWVsSlxahY?B|5#<}?@xNe&8#I+KdjjuS+_hV1& zzZ$Kt#}mm@Y%cA7XPHecbTCt~uJS`qqy;<3*+37>DL>x{>F_ekR57989G}k&_fO_q zJ06*gXinCcGW_IK-xC_bMpAkyP0%qP-epV5Escj+%3{~98t7@$+M}pGxiQ*4aMt`} z%8DW6W?7`C?-)^ka5*W1@R9iEBh>9jqWa3+kvZ%RT^&BH({^e5+|5<&AMIAw6JJpSi|MD2CH8Xwd z3cMbN30mia%lXRf$-M1y^J&H>it47j2643$fjE*&ZF}`3@Mn=FWa#1(SL$*VN$1<{ zWd||c({tT^8?>ek5WjIR3H=`P*FKf6+h-nFK*0lWs@0H*I@C;GwCQdu=Yh`8{cc)q zpK6UwFog=0j~U)K!1c{meNGT3M9hu-TTV78d(@ziq1Bu$*dSADI)dPMiZF}sL^A`M zfVf~Xpe|f6C3O{v`BG`re1yDno~ZXV934R)aq7j2{Dwv~SFWmYe3wRq5;7@IB`3q1>=K{{#EwuW-JVmVti8 z7Gx?P4sXQXTnrfMAzPuS(&5p8*tcNHN;-vk(R1|a)<{&NTwzLVL@G+o(JCk9*YPK; zTag!}BdgJQ#`i;%S?kBrq!qeKWh+ zvs!1U+fg`eqbQk&p`7t```y3n|#+w5JSb=ysfLMz-OkAp^w8Q->6hF~hez=ImKf<$ce#XDlGge5Jho!D#CGesNUISsVG3YUH8%Jd1U7Gut{V7 zwQZngHiNXXlVR-wY{>Uvjs~9fS%~o~KQaoE-H*Xf5p5nP`2+CQwq2o!&-%qC8*Mk4 z)fhe=ZMaYdkNRm(o4|}X$tVdFv=xs;5$6jq-dYP#^X2q`*8<8w$jjlM&(x1DZ?{Gl z!w5y#8$b}v=oa@-t(2R5;@zZ6jl zu_x&yl78{lY{(5tdSKGX8}jnCWrU3j*#hXR7w#9~r7|&yQtED6!kA8kWmh zx*Ra__u>30q~qbJaI~b!!i5})$OCvE^*U*D5rU& zYzzrzv5;IQ_J1UC<+OLhG2QkrV(y;YeD)7KhOv4)BuAfS6ru{m;!`ZkaxS#i2&dy5 z8_`^w&Y#v2IqQSChfFidKf;`>Ix6N&GeToU#+TDgv%fgB!rXWo;J|^4{)Q+fc86%L zb(CBhl)0$Dlx3JZiH-(*bcms`k6m>=<0iZ9bUlUlT*so3cURk+$uF`1{z3xje81jz znI_`V>{9WOxQy&nDa|aSgM+5!iFB7vhQXfdXi&PY2*?IdZRP2owocQ6y!(Wh`+m;JLIiH;fgF zCj4BIo-|%)sDysA-gt+6tL@)Jc1R{K(%6_50l?tVX(;n|w!=m>s=R}vU*&r%iOh*H zt%-k|cKs4AH{l!H&HT)+lcI|}NC!X6Zm!eOCY0~k2=dtVlVao`II*{(MQBRqu3$tE zJ$j=M9qy6Cu_9u=32P^cfXWh?0kVDLHVg6m4n#%1>JjmiU+Lq!2;eLcQ~=QM9jM5a zbfm0NJQsu=@#uu%iuUIM{qdPhS`a~tvH!ykVl&p#7gckLCnghjH^$5KXDox^%n91L zJU+K=&&cJZ%zK=HhWaIQJhJgA%YFlqs_CHgsXHG>7PWzGSM|$P8PW45nO3)-;_K!h zMgNb!>3e4pjlBCcs=t2lreW+MuV}4T4h8T|f+vSQh%=fi26>Ys*Ef~r0ttdd445l`pR?p*acW;;Bo40qq>S>jjhqIv zj1JGZk>|fiBMW4E8*otnO(`4{HBYD}vPSr)9qfS0os5aj$6BdzjNcL590$?UxUAuF zEyS;!5@NxY);v&?KgRlRryid}lm+S?@i*`K@HU5%7fp`(=Z&PB|y^$94{vM|;HuP)Q4L56=~-pY`h9*?^&w&8Csv$6~a- zTo%=---}I?Urygi*HW4u6C&y#`fj7;_?f#Lj0L=n$&O5}zI9AsU>d94 z#4CB$cF{r1AR9{k2SgFnFWWw6YDM)Uw^v-=&!Nv1KV9-=8*D@?{c<<_#8xU-yStLO z&=R>saLbKF0eQV0y~!z-iE3MZv`!E;Tq6$MNT=UIZ2c_=IFLBhaQ!e$onAf_jjI86 z?B^shS@bhHk?*(~`XYoQL-2uYpE7QU}4$78;s!%;; zEdGOEzO})}SZ?Cq>FgYm)wg%SwAVD>*d5_sza+5u(U^RCc#E zqW+=QfYg)5y{T#-twcKYBURJji-Q=f-tLEUT~Xya@|W>A-k&o1FymzIBzh8i1uR}^ za;e0b3jOemgq#29|EmH=L@|2-9?xt(13IW2PvyMbHeEIsQ=+LUE3f3LH9&)>=jRMYS^mqRNNdOyBNWsU|!VSV`bjH zYL=p(D|0qKGEn4p!n1XBdmimH#4W+wbZPBEq_kH+c+6|IcRu?>UHH?9V?s8q_#)=e zWD=Kwm8;%wOI~hQI^0ClIW*-wkYm7hGnL0Os%r7VR>idNC4tvy0et_7+9ZefIoOJC z;ulJn$4ZWWd}joHHOKJ=quq&(s4QafWWRSP%HJ>Wzpv{;o~)}EkAm0=AgyN^;Uap_?Ua6KeFGB8CDKxo3S33k4LLyr?l_ zyuB+llPR$2@u4h)Wap+Pi|wkZ3%#mvVA__0vBLbFaEXdA^|qPi%P+P&QT4T*mrZC9 z6jgg)P4LP-J-G1H7r!)0WthPRn1 zh@N3EqP@fy4>swV5#w6jB74j18Nt+zN zN^hQEWX>=VM0E1KkAo?Q@}P={R|ysy=#KqgX}fh9cUAn^Dtvs^|*8=aZ5HksBXA9LT@l3Q}= z)Y#dTDcyrkN!#-J7wy!!tYo_$75Al4YqZz^1V8-d_;)fw2&Z(2p^PxQEM4ERjGKsBLue%@{x`=DeBiS2Lx*) z%ZdP42oAcz?FaH07DXl6F{XC6ARE4Xvvg(Jd8@?ZD95~ra8TbRA3CN{ z=w_uiF_Hw9vs8H7j}r)~Xap`gXMm#X)(YJOPBzxHbi716Km?w()yP*U!XKrFXj_(ZAoW4uM|96&) zDvaA~rdGopVf+<0>!Kgbm?LM&po+_g-2lm9Xk6*wt3XHiaig+pUGMXYGxX)x2b7c~>=lf1KT8MS{AHyZ` z)89VaT%9vudika3er^B{l6b(M?>k3=*}Rv^T%P9=TF^MkPG@7T2l4^8f%2v8h*W!H zV}YuH^PPz@UqwS@-35-hbV(HSFM*U6fW5c)mHr-A`S5@uk3ss%$(Xg&I-?->c zvZJ_2{MGZzZ?hxNRcM*9*n9~2KRByFC)btoukKr*M?b)7ub`Y!}0kJb1^;s*VxDYSB~^mPx_QXTT7n&$GwAE=^zR* zH+xE;--~SJe!T0x9-Ml-Vt0V`z`qlyUYbBq857gKP%kdC;F*@7?90bB*RrarK zkRZEFw9>&qfhTgw`LkLTMw?v7$;^1sL$IwvB?+)n#ukdGR0OXO!$R=kK@%+ZFMOrv zh<7mR4407Vxko-JgLyPU_*M{cD*9x~W4cTjq`%l-jDlD#+h8nekrdC&=PF@<;z z_c-+u$Nw5QA>Ul9zD?f|cAL#ko+=~u9?3b4?uze9__wgdfV@v|M<`lzGuu@=-%OpS zQ!ne;pqK=+e$!W_%IKAZ~uRY^OczbN>Ucv9lI?%?C%|{9FOI(qh8svI> zY>|{r3s(>g{_3Vod#BIQrsFA^O0kmAfBOQ;)V9Be_w$h+q^Vy8aLL3uv#f1s;#vZH zL`Y>bj?*Pd%f9fzOGGT%-m<>HEeo7buDaKw%62DF%sf*XeC+ZOG5z#9Q8pr_)w@v4b7ogi0x$Ne!;5J@S zjvd=0nycPAz$KTBfR=oF)~bO<>^&QF%UYoi9f~xmQ&9e^y7WM#A@{n(#jw-_guF6c zk0>R@hB_;BWOeezeTxsa6~M|bJ=p2ZI;E&_fe6n~%cudu&84Rt@rKxOp~~ zv5iZ+shYj*8Y?a_(MAjOI{=ru1<)jkO_ZbexqY=eFUaX`=I86e^P$&5)n+=GtsqZK ze{`Y%B?43@1xs46Ee=x4D+_h!-HZZ_$e65XF`XrwZFx5&ophZ6aHb?T&HGQDUWheK zT{T3PXS#br{e>%q|AOyWMb4zRt`kS$2&5rP`rGaU^5KxqXp|OyKl2hBVlAd5-omhA zHfR9Ksa6fRi04mYfQks%5(TU0IrtO*-IAva`7u_tvpO@NX^ho% z1|F0BmD|-WY}3KH?%akB{>2W(ZEVa%Vk(Yzl1(_pB$=th_Th{vMnLP&R8i z8{cOOu{C@ky;&pKgM@w`d5`F^jk1Tm53afV@kkrVWzNNe%bME)}3Ff*JUIpj0d@jrW^R z2FvF*QX)r_n7>O88TvU6gA5x&Qx7*8H?sUBOI@>beX&+}Dbc+o(?e4~I=&CrM~hbn zzgV1swboq_iCm3**qb%Ue=3PO%4h;V@SEV(FAmZYUY90~cHQ(1);yuV4QW-#ee6z% z`4wb^zBCIjsxNjS-(lmsW!YDIFg8ok>01!(ig-A_aC|tG-Cx|#)}AluFJMY4?Nzc> zt(`<>7u-t|Gp23I>A8WA6SGK7Ke$jmc^$PNp&y7z987k9@1p4de?VXrp6=u!F&5G3 z8r-nV09j^)m=nt z$U^v0#q4QY^RU5pgZ;shVnb%*MDr2cu+@Rk0p=>@+|!Y3JKT=ATq#}PFmNAB6Q8+z z5?#EU^`vKAYP}k~71e%@QTcrKP2-V;;L*M-kY9g%RCdQq!kS;&DBXwMLb2=3rK1=- zf4)5{9|a!`tt=CEy|P>(UpU+Dj+lvW4#3z z1;Q{2Q74@AdYfMGA6=TT5lKafVxz|kH1B6;UYumu^r)(XNjFMAPXVO=*^5uE3gBIq?@}cVoy* zok1gPV5l8bKLuhWjUhA2p8_+!9WQR34`6i4vm{Sc?B~rQKjIc#?mCsLcNK~}P#tGB z^C@QlO=R9S_u9uywk-%i6z|ZmxV>4u!|o*XTSf39CN>sux+2O=-X@9Q!*m2IMARV# z_&4oIB1l`~PdVmO;{D~{g!3x65~SyNphOD_z)5V{coss^rJSnjha`{P>^7q3d`j(q zSZ-g10Ll{vn@?l$7U?upe3$-e+7xmMRol>w5>+wZEwDA|yQ!hja-w#3Ys}vwH`gY_ zFV9RG8zWwiwl#ztSLWgmY}8&3u6pf#jw*=#Lb~Xd~8IUlol|#Tq$s z&grrRFoLL+)u*Z+bc-~YY*IAsr4^yp6r@7RXi&L8Q7y#CrTCC`^~o2x;h{Qgl8;n& zB3_>D84q_*%s?r}y)7k5;`8RR;&Ot3LuHd@{PHcu68$Sw`vm{I=%n%PQI8Xt#O}63 z@{y!!xn?6>FbAaXq>y};Ng#OIro;?GllQ9$giKr=AyVDd&86)QWOGBj;ptJl&18*J zYuJ-p0h5|<%I~WdynFfRB}fjOCFx>*Qc|6C(rOIeEC!;a)R7;j#(plI*F{q&IzlO9 zKc=FsZA=;arL2G#eOBP<{Fg899De42;}`2as=V#d(Gu_sB_|OpLFe*a3kHgQrwpYIZ%%((v^cq4|4DgYn+=MhSP!Zh+As|`A0!W^5zuO z>5FmvMood>o#uv9c}J>6`cNt?55(mdHiR0u$tlDc#Kbn+C$S$C6jtd!1rZtd+H=e_ ze}sI%ps+c%?#;|s2sk72+uB8ZTocjepxn1PB>Ox%MG=bE(M184uQK(_j;PUs|u1#l)#ZUA%8M-`-5t+2E%uE1JAbBmGInX=_y@AWwL_vuQ zWFkt&{cvV2c)uAm1jgH=izgv~rWZQC9uRPykEe&&%PO3OP1WYltN82M+R%znf?Z^8 zl({8Czg1ftJ8vfXvk~Ta1y#1J{w`3l4ZRsD-MydhdYic6cvv#FY`2PK_8KEFRd@bU zE$uE3U*6+#)=4`ED<|m(_A+Xx;PHo!+|g3$onnk6hjB5i>DZ}B%*0nFVjSw{>X*93 z@$%h9nRtbyy?Gmt&-7Y{=xpBU#}OH3O|u3YHtrY*eLlN9ILd<1L;~69A}`2&3F@t$ z67nA?5YB#DWppxU-t!2n$8x{uxZ6x0Wd~3ktoSJdt0%J z3(PAwO8$=1r1Xy{n?rMhq!w{oeTMoY|9I&M_5Tr7+dZo+C(l4un?*EintMyBuKkTUj)Og{~KRhL`h3w zQ1`O7?#i$d?U!;Vk2n(WKR(5{V?B`G4%DV9J2;yC*)v=y=KsyIcT@fmay0WO?w%O9 zDDQg#ru6@tS$n0vwX2A}ZvK5ispL2%wo~If`PJM0b?ocE8~@>N4kUUCcF) z90S5%Y&5Kexe2JY$VyHQMw_*eJ4uIGbZTsBVp(@>Ofjw#KA|uBh*w}nm(FLbBS$v^ ziV!djEPeT1eqx~`*+J2Ap9Iu=XAu%xV?Gxg*dy;h-@9H5ZSOQVpY5sObMvLhCb z*Wv4a!o*)kv#v;+v$6g%9L`qfV4G_(lLb_x`SluCE4q-Yx+W z9i{bvX}tkAtVj+){R7T4=$7e zjgLN=2)woFIC5}d%1{Y6;c1pfRajIIJb2;ZzkMA*Whu%dUS4pcwx<%kmCc<|yjSGh zjB5#Ia|ncY+(RQ*+LKMeAVRc3(Yp-Qy08;5@ZQRJo(!<50b5{BBfHSP0~%nt{@Fe` zSAnVJ$^Jh4Fc`iLeLdeZ`1dlkw=f(#eD9zU>n-B;VoV@j1ZKsOLhe zNbkG#yC1$$YS>J3f{>rz606nsfAXD-t&t25Uj&?JbE&9?4J+R|p?x*?r z>@xWXz1FSf$IWK>twzKbd5;@^bS>7oSH{+nEIZ20Zn`mXrhLhws0IN_4X~d_@tEzh z-s>I-v2&t>@i-NK%)@NU%6nW#SDIDwD|1^UgT8HMc$4^V4rF#<0K^XVqV2f!wcJMu zhMbJl=s5{c;l%Lx)V4Yz1<{B`^F9x%0O$E=coJHW8QR1#%kMhf*9KggEj(U`@Ipru zdQFo8ZY@q2s2|qpz&nE@5vTh5953nWoL^JvaCcSi{k=czvji!9YLh&e(zM24-(koU9Uy$wVUlzx4IxiOjd>9*f_?TZaZ z;(R2R`~_XwYvt}ImHfAlGK_*ljrn~2z1Z38chPK_EOZrkhcq$Lx^=y=_mHCTu26le zqE9gJxoIAR75!UvMgCWV%5;dYQY@!TN?|$tE%#YG0JMZb{4NWgsNvZ~GydnDARY-1 z6py$U9p{i&uzOmSj9NwXnCSrN+tW`0OL&*O6NETPUXA)PejQ?HvdiE3L9|xuhNzT6 zfqjX6G*0Ont6ck~S@8jk-&PmP_wV%9;>J5e zB+T^vH;N12=M4kd3>UjJsnssr$kInu1JOUKy-ElSJ#tHh&kd3}E+8dP0vrY^#AmEU zh=1ALUVP;#o1aRz!c$pKH_rzZ$8%~}o9uo0G!(_p_&!@n3T0uLN<=}=y#Rin;;Lj{ zlZ`o?xYErCmyi40^{itMzW;#KK0d*a4QfXv^uuXM*yX?Am{3VydLZg$ZE@}5DZ>N_ zLHd~0-Ps{VJDCW;C+!#t&NaS_Tg1fl*)x}+YC8dcDPLN(&=5ZSjrL5<2oxs|3_KqnCXOYZP_LZdz#ilmk}rcR~UGW(8DL# z!GcriH+r`i%wW^;rgCrvPrJFq6<`(bzP~yfwRdY)3S63H|lR9?tZQxt-%;NfR9;jr-m2d)T;1g84VcArUQa@ zdVi;I{=XN1j$yS)L9X1lg>b9X+3!{vgF%*tr;dB8Tmj**#9og$!>pWd@^`tNN+*1& zW%@O>+>wncys zs3^>-Fvx`4$Qjh5h@HsgO{Kg!So!tDp)ZCSvg*1CydL!% zZZHP03R9aK;3#hf*t zJs8|D3^oSi`*n9+gH(}gRye}W5qZOTzwrS%B;>I*5e_OyaO~+Mo4q>9<)eQrq+!z( zgRM|#UhY^rhBD+7+3QH481qOHgDWj;IAOn^BdE8tK#B1Ac;HFTavYp$AZ`fq7g^*( zVzS?jAevD$F8ZsMaZ}-M8EV9AdZKVHh;nj+t1z_%bE~qFeMWaHJke9t1gUD(R{}|<0b<5!#|cVC*l@1 zcVyR70UJd{yuLGXN2Q~|p_#se^tP@`ZsIr6-?JL* zbo_j`2I6o_vYZHuv+P9GmiO(*o^j1=6g^oO#qDA#=Oh1maQrsyX# z*zJ`4*KOz*SHg>{acq`tCgX!Ojkk z^cWfDqf>#Gdi~9;{^@KOGe3DY=}3M!ad3>%7msI%gF7&+uFb-)niJL%X+Uh;m!Vv0#v?&sFuHr-N+IA4Q<;B#S7&+=;>FOIC zEgG}ofMLA#Op-U4-!ap>z|ck2ZP}vCx+=O9%l%UU3dZf%2+dEQ83QwY{^5xM%&}p6 z8BP|Oy_03aZhu(0nUJ#{V14_fb76LE&q8qz4(0&A%@0MuLqIH>FJ-IFG1UP5z6yR3 z>4?4z$=xlH(Ps($iu+Grmk2wnc|HIFshuHCg8`Vaps~RP(jvQgt0aa(Z@61g4RDP< ztGJrOSLZvL&!Q04xeRMy9A5_oWb2= zp9r2lB}~vV7fUjrvs%AjNtd-^Ze;k4&t7>eT?y${h3lfXNb%jA1fRb&7xZ>T{Ok=Q zHs`|Jh<*`;TEoqn60-je)UGnBXQtxb$gJTO0fn@X{3}O#)204kSZv;WFPl%`C{z8D zv_|c9f(M&{(+hA*moF-_zwxPGXp&o%mIpmMHlF!suSVJ72fBnWzg6$5CT58xU0`tN z=DHCCl+R~SLEzx*w+Mrq3-qvQ<}h}t>ccvn&{MF;{k_ERh4F0)d*cLVJ(5tl#>ivR z?kF}@p!@8ljZt3#A_eCm=I)!fnh(|mt6@+Q{}jmZ0;xG<*0Rkjm{?=--1G~~zJz~x z_aSOZx6$`R^+8bMP)}jD;*aq}{Pb$s4+OrShdzt;rUV=)O)7KYp`Kn_ru{KloR3@@ zPTr(#?Nn`@0GNwFXdm{{TaENt`m=e43h-__(-i+J%r#!pxE@?+-whG#n9@kOyEJCn zlqR`?kZN-?VJ@3Kjo|+@S!BTbFd9*d5(Q@o^;v|L;B@ypS z{fg(Jz*Sd!S26?H8hZy(17OOE1;k4=>5~a$tPEwwzH^tWrfgzqw3nimd^gB!U1tR! z>kq|EOuUgwp(Eh6up0rt3u(z@iclYwWC%hP8ug4^Lf+g2mcO2ksNsBTOdT|1{H>Bs zvB0=avKu%-iiKPGFGxH+jCxKpPJ>L?G2f0-TXqo92PcR0BR=F^_tsM`#Rk94%td^@ zGD;vpFji7i2v+~(J`kCAFfg)?^nf?Vj@}63P=x%;P@U`*-PbT6I2Ku&Xk(1qXduTq zb@LA?H=aD>#FW$+E0B==QuBbZMS$}}+rDeQd?>4>1YDv(3YK@%c_dU*0 z+m*F&!eCS5wxKmku0^vkp5hhbYE6zWZeMroecQjLNuG{04M+Nj3a=DMadE7yd$3d> z97b41qhL*IBwW7Ddx_uno^)E`$Jhmm$kDgd1#oh@2;#Bo6wSMS?k+$=b}F963kt>0 zj$Y{6--fm&u;5JS52Gp$z7p9UYvPEnL2Jf??|87`Qw`MrtZ|)y)I?1*cTge8uCtBF z>rc8Ar~8%kp)jYgq?YU5-|L;Y6uT+IbTQ}zt@P41`bn{gg5ZSo63U3_7ewozN372=R3cTG`ZYC!~Z#mLu!_ZGsa>QHcT`W&@Sx8*#mEu!h zCCGz4Y*NJsorp(+?Ir{ar4l{3q?1q_td*x>7s8u6LAt&Gi_dr=Et zGlbX6uwnwp<+-s!5k5x;RP};m@JtB-#0O2OG)=jv+kV*1O00`L~EUOSS9&OB_y1=J>LBCd&Wg9!~DB`(2ud4ht2;D#Blxk+g}CZ z7N|6Ap4Mjq*El}&v**Dxb)b0=mEZtESB#&c)MUj0mvGAjlZ=K{o;`mVih;+bKhoY~F6GBR-b1F2VS##{hzcKrX&vw`ZlngrVRp^`hT5fvkjQI=( zsAj7tHg~d40c&!Frsr(<^VmK3=M)vXRGdRaFT%Z9gg>ZPa8CJHd6rJcDR_MP$R_Y8 z4)czwjDUSB4RUtp=5_6?UW>&o1GwuY&-6T~RKcmt_i_yl z(Mk^B^-O<=n{!e3|>+tM`8T(Gd!FttiH+_RnRMA6dsqp0z2BMuotO8d_ zE63hmH>L$oIdfLDxmhp+H|o3mL|^V%dWkHV#9%?98?K(X=6Y0}MEQq{_i&kS3DN+V zqCL8NCMg`rmf8q;DZ-SfN>!FpL-X7Ox->`E(Y@`SD7HlEiLN#&A|35n0IM_kIEnGDj&K| zBwudI_tG^asU%nIuf`oo0bdKdDZX!oDXOZl z7x5Lm{NJxIzIusr);oeYPsrbWwMR+O8({mqr>doT`1g0(z_2HQVB)P_lS8x=KDu*R ztH&0HdTtFaY>4<%-nfiOUuD>CF22%kBj_mXP-26Ed%B;bwY_Ef?hud42c@HuJx6r5 zZu*nu0Gwn5=OMj0((FyRTW_AaF>D#eU)BHOQ$9yAE226FgCCI+4C6+WAY84YIQrhO3;IOz3QqT}0_?*BWTdWbZRJ+Kr z!4BI{ueAwUPvRhc+pq^~(skFLM2(*uQ^~Er)C9M^OH&Oemu_wTwBVQC#)As^S8c~) zL`YovJBmzVUWf~Wv_%=!Tq?}M5f;tYi;FMx{Udzb8W9qQ!Gx6w(Uz>nK1Kg~SZ>>1 z=MJVuv3T>r(VtnWbu^Pvl-8{$6+@!F#eRfCC zC<;WNVWNXpE`z#@HAxjd-{;2R7->Lid?#(01Liq8hPC73L z7)-zd7NM?|`N@~f_p_{bJS}ipq>jbnv|<`cpa6pMs_9(t>Xy_|tD|1A!2f>l@~=l3 zX`Olv$~N9JT()9ZryzPb%&KG{ z6`|%vh>}l@QfdJk*RXU;I4APL-4o1$gWvh(;}kph7Y{_OkxU#~A*oc714N+f3+lxP z{FogNkMlmo$^IeLC<)$}2uyVGix@I<02waq@sGW~7xP<&>1#b;5fIG@A2LCC(*s8P zb1sxZfpd@sjje&5%d92A;16tzX2Jlj&4v?72(j{JnMKE203J8G3#HU6n2k6diTrSW z@mT-X+>)zv%a5a*2Oztt9zo5H9xS{(JeY0|QTTMTX6r;ZtnTihj2)9p>df(uZ&ME1 z%yDAoGf6m936kiHdN^c5TfpfvA$4SfDgV!^+P{EqHBV}z@S@qfs+*j4oQ(3RXDs#? zvW#kMx?l;!#nMiZd?Rpcfk42CIBWw|uQMuu)(KTL~*SKJ#rdi!Itt8qVm^91%H43bLx$e3=<(W~IvAYzm( z#C2L-s;)0N9NJ1emsaQ`ADF_Dkuc)BZ<(x!)QS4H;;BP?x8B&>QR`v-|6EyO_oGT8DKI)k_#ANR~qUhB9*NC65R~Yx@M;`9 z@O=y~MQD<}fA8pAe$-XvGD*dbtnnA$7*ouYHFSn#c1VN zbD}C-CkW6nFjze}M*bu|NjgsQ#XSWV2 zhGoJZ$}qJRr~4qCVZVzew@2geK^b*0XXxZr0+|kLrg$5)q8m}&Du=FKwzGNrIOr(O zv-+XS=%lD5qLE2gClO5CFQOk*VXn#t${sfrbu-Q7^*WIQl3r;~4Oa&}RS{mirwZAF*4-~%QCZ!u z9a(P@LJrjBsv0aM1_cb7=xUm_({_W2Zz2JS4j6mOXmDYxJ)RF%0fQF%r6`L~;B+o1 zw{rSjw;_!s`LX(W09=&7KXSy&IQ(>vKVc0K%X0#mH^NmxARvp>JCM}kEq?832c}fC zlM$55@mqw_noYM2&c(zV;ShGm1RN=S9p|f@J6(@j<(ERVRTrvjtp-Udp>)4=4KAkj z*nHdl$@qF^m=KKJ4a)p8_vC_W^U%J9e}XGPMj{*QXmp4ouho!hM-zdt-3c;d9YSS8 zY98=wc}18&l*s;Off(!a2B8z4GiCee?+Wr*$mC3<{S=f@e*IXL8Ax1On9Z|M(fi?A zXs~BsdT0OG{UBZ}zmq90Hs^O8%2K+RR6iFtzQpIsWb8XAK>#}NJqA#6X1HF1^d3DI zj<3XWJ&%aApZaK~+=Jk!0GEFk8Ytgq&BI+rR54K0v(*SP^SB}JQdgdo9onwl*)`ML zO&_wTj?qnPL+He$?QdYWxJZ;`+6&@(rl zk4DXndzfc)RCO9qFJu1vQ6%o*V^*;z+4X-~Viv6d|xseIv;w z>#q;ZW8v&pGdxvgAi!kVGv%>Yq3gHbR)8jVauDCP|1z|ZNf|Z*)@iMa6W={B={P%e z`J5U`W_kUb%=UwVuwa=(JIeu^RVzkY@5>77>06WO4)y67XXr#V3OOR1&juV6H`{?3 zAAXsGhgYs^w6r{>0&ZuU!&9qe2}AoE7hJev$at!Fl_a`XB` zD~SA{x_BE!c2@DzBH01?Me!Z)TgeieT&eVd_-&7u>{Ax8uR$h@)G{qjIB<*SI`yl%vM5Blilvd?>79(H2)OU37as9ONS0SN#=hM@ z-d9q{9q{sClO7c|l5Z|Mc!|C$CBVg{%1#}PLkgOVY#h}hHpb(V5PBMWiJL|e4W~_* zN4!%sh#=$6ZM7R96vxdTM7@wE148iU#oQ9qD>P^?EH^2`cp0i+EgF3tx;Si$$7K7r zU9<0?Avjs*JDxf#lR-^0Pey#Xhfy6NfOGE?JuZPJR|)cOIR+S@mKft57=|{I1ljuD z;JoT;LnYM8Udt5HYxr!dSjKTNHMEK0iPxZxms5|>3gW3dnYZ#GA5|w4ardI%_2*O6 z|5%I6L?wiU3CvnZX-mAYjkh?{dQ_GG8lT6c9K_6!Q81k3M=uU81xzM}CC1JU@g*sQ z;K}<)nbI@pGWH?QqKzqk{Rh}On+EqD*k}q}2$khkSqu2| z_Thm*MCq$HaCA-Ty3>2jM(V+Kp6sJIp;O#VE#+aVpfHMi)RBN!Mkv}--3=Djv8eWe z3@aw*fEN#5*hVb#0gm|FBO@ss`Lz52Sw&W|Z-!G6L(c5O+lT1y5OdP$ zg}oVG7Y@l`SmsZZ{iBnmykaUn+>12;geV^kWHb{k+x% z&p}bdN4wHOhiei^BOI-T_Ru#@g8A=;E2ZYRh=)E>$Y{Jg1&Gi1jYoaB6AQ_)&ADL8 z#WSuvT+nY#nYEu>X7?Ni3|f1M;%E6q6d=P{Ae#xQCG5c<*c-MVxNB#tA@0>3Z`=1G zpc1+?sE&KTY)}cq@v0sLwfy@oKCAs@$qT8ycsgTpURcFc|5#hW#}`ve!!^tID>wth zE$~%CCW%fmhTwCG)w*0@xw84P0;RmN6HR51Z&54UeYK;U*>hB}ml$;R) zyq2X@EuVdi9f=-CdBuXvxB{vR`V`ml8-6XO(qIG7yZ zcAiwVw*YS>)7MXL6HbmBo8j;;^mF(1Nds>+lXHPFW8oN`>z^QyKcxfxLX2*t z=Hqe3d5Z#u5Psc>*IUI4%v*Z&%zzCUCjH&=!@A|&z_j_0u^fu<&uJQ*C&~*lB`vUa zJZ$5Z3_1!)n(`c5;yo(ign@}+-~|kd!V9O4@gwa-!W4Nj4|vN8{#+S=!u4$xi~))q zEuBC;o-6jk=wagH(Q8wh*gn{3Qv|m}hbp9<_8NL`V=-0tWxZ^B9Y}R@+;mV0p16AK z<0r?>^;$<+%v_Jima%Df`Pnk7gCuX;P>}~I{aQkg7UIJ2B0U$!sGbYzQU*~k;)!6F zb`jO4i_I$j6b^LJCU|q@xHb#yLd-vA@9J<-UJDQ2Tsjv_7dA6~iX5_=CT!Ai)0D~g zQ5-pJT*{_T3mwxHj{PT6yq3uTNymlKqdHs=(g9->U7MU~St-&Dc!F^FM-Im0njQI( zUeGp*GgWR_T@oh>het2o3}3%^JN)|D`SA3+KOH{#+kZEF`qw`jzV~N;Wd<)_Jl8iP zZp$BZYdDj4>5Q*QD4uy4$G%u6>>NxeyR1_Mv8Ilj-~Q%Ta@_o4c=YYJcIMo4R?Qv`EGc@(9`ZBOPBUA~$dnX0`LQz;Y|( zS~_r+D0aq1aeL~mK^nGNn~4moB}^SLBQZPzv4Y9UJDSQxW$nmTjX8_hSsG#sWV#1i9E}fs|!sQSHq_ zyTHaCX_s73wdo0dHD7_L3wYsHXG(d2u@|wBM;@4nZ}g`kD`j@LXoJB-53h869GjvE{}{-7()9GSqkQ(~z;D7=rP}7Z z)aZ+L7VTMmj(%PpH8@OY6S`6lPLM%`@DY4zo9VQOW=r3le0|B|N86WM0U=g+1!1Mz z06lQc)ch?@k;m`toywE2zkHH@?q5GK4b>FtEyx=ebN zE9GY))}!50KJJLfVzTFu_1wEH_hm!dhl<>n03GvH+ol`lf+gE}J%utA9W$2x&@RlY zTV->Bw#$udly(IT1j$JeM>zn`OS%BB3!J7}(?Q|uFvW1U6D zHhH8sqJH!0-HscaBAm9VCvCLjL?%9V;y8`@FbbcDuj5QHaC5OEi{*}+ zc#)<-aKl-HlO|!fhoc5(%?)?l(0-!%V7BFf z_KR0lwrl&t7@^PtmcC0;W5nzWgDJ+*n?Z7r8u!60Ed6xUzq0HdchOZ9n=VcpUPN5w zLGLmaX8*JhWFs5p4l{ij$8nB7zzBRi<~1_Q7>lx?NV_C>tjqDV9hXa)9bP#OW}fZ~ zdeO##Ll>&kWre;#BTy`zJa$jZ66Pk}u?3ZJvdMhy+dkN6zPOj%**4#ca4Cmv*|8zB zu2bG#?w!eXP>vfJ| z8x5Q&KzozShOW|5Ub`7#Qw9w&uxylxaBPmU%!CRIG!?CaD&{^cyg)TRaZBJbfwMy+ zu%R+P0az*WiSuc8K}xd?wM>fPHzKgX!?A$OK$u36=fbIh!i%pp_yiELn2s<-qs52b zV>xcVzNF&@S#H}=gRD4jaOiLkIYY0AQ-0Y%{k<-Zo3Fq6a`^3U^p2ZfT+eaSI!RI< zJ8sI!7UaCEuV8)jo$n6c``-75&p!WLpD%{@KlosH@4fqpvi3dsC4$?e>W+4Fa@?d< z%DpxbBAIXlpGhmL6h(U`tp?tLL!z{KH%gUZe>X+SoThy$PSSl-X`j%eP?;*^upg(R zNV{ztnhU*1AJOEmwtMg#0>@E!jN}Q?pi7%OgO3{pEDHh47(C!@0aRARHNm#uBSUqR z0B(F`jr4-v#W6E^dVCa~ohim!j1@`8azGrJiZC9lfUkKavNSE+Qg#uyl1hEnxd7`vC0YKGUi@Y6|f!po${qtEE>Fh_^N((iBayDqaZg^q}fHY%;-hO*Iy zCty?N%0D+NWN3ruC;9C3lpp-rq$895ai%C@XUbKc(z7$fPAuw6NL$BKb``ZhGmP#^ z`=!dDvWZpR$cBZ^x-Td8CbOKlA--+L&8t_h^gf%X!`FZMa`@df9XH-!v&nAkhx#~f ze9}d{XgYWAy*GUP@hAF<)EC3|zW=#C-yh!7*QD+#|FR)@CSLGH2JzM9bmFF1Ms~OH zOY-7RbaRZ3uP53QW2oBpDI#E0{ux`yt8HG9c1$qv^b!sY_fx*=sQ=n`=(mOT6pJ)J z-%YnWSN}ZIr^?liKO=c0PAjtpj_OPi(xZIAX@$vW$LCD0xUJC*C?xY%D)+I9Lr_Ddb!vbv`mQBLZnWoi|$t%;u3DfjkUJKL4r?6Q@e$sDYh^qiov(AON zpWGruI!f4psmz-ZKMog^fy*WYENzaAZk%|UT{jKsSVfjXCpSD!8z%)d6$G&zaNDGV zzz|%ZCmiIdbR?ofA%l2q)leg1p_gG`!N+m|6-X7nF8GyGoIL*dVDlEpNYfk$0Z>LG@-5kbn(NXZ~^AfJY(*lL{X^gQJ_m<7RVJyctSNUO9nnhT>+a^e!JOs$PRc%Y|C6B38Mwo;x;Ym35yH?9+3b z+o5enPf%A)c1=I<=3yNf!CCc$Id*Na8Hrt$hEXK|f6|E? zzAE*Pzxa9mRjIS{bN#~AxgAQYoHg?rFLD=-o0ry!mk@Kx=Er~jV>xX;A3pxx=XwW; zoK^Zo~hwiU*GL^f{9O0@JNoHX>K1w`Pk-@huawl6Qpxlh;e!Z6}yr%8+j z$kUE3DWC76Q(+ko%ebLN1y+nTz?7jNoI4aT&=iMKj*G41hIq=D3_+26YN~;s`R#oQ zBlvI@Euq(p;VqdP6Hm|IjZydtibhku*dDJyY8NQ(kJG-h3&!j6@Yt{}@Q&fKu6N9K zZFZf?cX*B)Ebhr7?rT@TApuH1hrIVBJ2LesSK6reO|)#s^0R9jrY*Csd#>-4b)R?J z$rZa#DR#m;HUiR>2@RFKuwSGt#FDa0!?KxZ14(Er*rCy+>t-h9k#N-slA9tsLCU7F zI6-m)<=qB^y-O*2*>j_K$ukdcOe6&VIAx%T`nmB@{DKoGJU{}&;S=y6`70tK6u(S` zGEvOkG>D|3i3ybu^p&ogvtkGXFuJh$4B&`^Re%+H^78HQP(KI$kI(N6kH7mDqn`tZ z#JY}~aOR*1whsB>l<~Ziw@$#3@%)*-D)seO;kfzv&xdcn`FeQr`0=o5#|=Muj>PZl z7o_+lsh|AiUk;z@JvZO|_|tOQXdQ3FLaV9Mh7zBuP(7@2-0=0OH*(m#eyuyFZM4Yl z(s9$&Z`B$Hj1ukxv^b?Bhqi{}g>4bA;40~}b5MAn}=;yjIE9I5@t=rUz{&RdqJ(nR!&>xi{I^|i0ZV68r_=EyO zwCa;Q$RrdRM8i(RZa}~`R6uLD8=&^3RkWS3)ul0akKpQ@^o89^@otVM$IZKKG=<3a zm-lJ;p3{fvxWQ;l0GC@^?b+0qWY1*Cr?=3nTxq9VIkw3j^>V|(g1n?JEWnEmfURXySYUEeoRq~zkaeyK5(d2*oP-Jj`994?hk+F)h_20ucT50Vo)(MH(L@lFX8WS14e8z$_LbDQ<| zrHH3ZA7vWn*yIzOu5itUS!HaV4U;?s_e?J)e)w{b z=&vQ~EjezU+j;S>6E`?!7;f|HQ)W~yQ5L-0{8e!r9WUfidHC(uddJOghF|{T=X&4G zSHq)+4~sM9te&{JtWyZS>0IGt&&!uDOpBia|G|%bH2n0h{(AUWKMDTfN8goB=yf^S zaXL-IyyFH3jby=LBYpY#Z*aSI++d1o4-zQAzvAu#;U%DdEZTG14$C-D$q0EA($<+@ zc&vhff3UYN$QU^$sefQ&ejQ3b;H1-20E(E&Ba6!PK_#3z;6@pD3dooX6qtyv(LwFh z^>|Q_grDO?Qt+5rP!~3Xk6?>q!!TGc$V;|{j%_aj#Z#Nk7;qhTQShImllqK#K`wAy zo_swgY(>SU9gkVp>*!`XVXNy9*&S|e+q+|V zc}})z#|F(hzr1GK#pqS8qV>sfv)$^yK6NHTK0Sq$C+|JUhAg&Oufi2$NjxudI~UW^ z+MdO(Mf}rk#sapJD|V*vNV3qeT?fphWAngTRfTh~n8=$Q70+xQY^fd1v0dMG(A14jM29teP$Y&B} zXj=1^pyzR-I$o55pgzuLqo{pz@JM5~bj?WQRmQ*MeJ+GYuip;8d2(xb^5DnAcm9k2 zcKGgJ{hQ(AAODTsmvbhk#>>4pZm_{C@qY5;(eV5K^t<7oe)-Gc%Rl~c_(skfoEtbo z&dzb(#80g(6|APnfRq>A8%QbOPaC_O*$Ir1V7-a6QtnIIyBQ! z0t{_lvb>R#;kCZecBbk)Bik_a3$+#gln2|8a_UGD?^;Grk|PELeTtve{YnKfZU{K! z!bzn}kOMs8oa#yZGES%<0su@DRaw-9ntOXPcLBb&La#khr?1e5PB{9uJqm&Xxt$E4v{A$kUrlGpZf7 ztk`;!Y$1OPOKAIneSN z(MkEZJI;jwj|_|s#X&R5FJIGW^r`wKYP~mf(M!6%5UnRVjquwi!|?e19}XY<^Z#b} z=&yb@eEgGtp?7559bV}~%5DFe6u+Dkr(B%&hI9O`8=NO`;s%Dv3tM5+MY@XfjtDR5 z<*Qm>eHrh$`SK6HAHMwZkHd>+&xRK|C4)S-$}e4+;|d;e;w2a^95?Z+IsBT`2XfNz zVq$(#>IXmiGpEVFO1S8_3C96^{azc{+h2tujngLYB|dW8Jb$Q)`Qvc!mw!LJ_y7F& z!>wnJhFi~{F2q{bth9RqH%E1G^U5|>%4^#qO(RM8Na|YgX+L&KCR{C3B=HKFahgop zb6yJ{@=6`Lz67QgRUo4e83P#py%~w|1;@>s;<&+=5nsr2y0X+Je`a&0}K3o+S^J;nQ&R# z`g;$n=(Ko>d&2_S49~imfhU1S=+NerfOxge6ql0+9fOh>!;Bp+v4I1~PxgymBYD=L zBN+3<2HJdv9?hm0Vb%&`dB!O7WqLR9@Km4QKD#|Uy#M)d=coVG@X=rXZ206a|J87I z=RG?W(s2{_+xp5${EAdMWN_9{7HR3gUo#OW+*qD>r#Oki&w@XC_^ll_f0X0qkH7!D z(~tG8n<)=um<#Esu`{3ncrDt5k95-JlTSY#KL5cF^lqEahEG2Eo_-qqp5LX@fUg}L zJ8o#x`Uso0osJsvi2JU1yx%6$t>b2R_VqA)``h8(KmJd0-24xE>5%k(@oXUrb}mhO zkvS7nn~pZ7w(5Q+B;|$UMbYAIh&0YX*%EJnWb>RPK4X|MkJ>MIkF)!X?%AGsxwi7^ z>6OvTwu26~u{>f+H+rR0V5r7VZR=(^S{P?KBk&EPi(wa8Ys8l5RoZXZ0lKmUN;Dz* zsmf$eg44QtjAIND%Bu0l@P+}YjEh9)=#}wnR3;X-so47`%yHrVhZjP~oO{)3hj6*kY?TO`Ncd zuFF)&zZNw7RVPv6k;Nv5P`~n^I2#q+vMe?{>Zyz=qD7x&jRf(!87g$FGex9ZIwVb0 zMU*Q9IWmcpi$G2)!?~11zRI=tcQK-}#YGa2AI=4?zy?&{Ij*R}E8*j~`Tg@@c=+D; zhvCQncKGn8ddJOQ|LfuGy$=Ktr)qGL*m2X`ZxshlzSo8{@>>?~D9u87*aSH)7t*=$ zTqkUvJbpC%>8me?KmPF#!fgWq?UzUjgxf9jgxT-FUN(J ze6tTbJ&`$J*^I`Zd28TnI}2RQG?pjRaTmZh&-GO*V337ho3eAs^|m0| zI!NA@7XX9vT8@!({jB(p{`@b7AOGYphClzwPxV6LkA~Yz96$?-t7Fj40B^nu6*8z6 zO8m7>L-<#tboW=Kl<$)^!ZnU$dxL}K@gIh>Z~kex`|E!{-22D>dERk@Q^W5=8MoEq z^>F)LR{tsI?43HL+9cbO=A&%d@3f84t~q)W@+9rZgCzFJ_PA)z;e?pu)xZeP2T25$ zPK}^nU`%^q|0o;FGcR*W8TZ{B&m z+)&z{Tj7vDf&m9-9xzQ9$ulW~cT1;1TSQmElrngx{T7HfEQaTCYTp}Xg09zX+}4%JK4*=hCxwMFc5JdPVC5Pg`4jxwZPdGAHZlqc`K$TgJ% z`P@LJ1s$a&Bg?acXv4NAHtooMy^4}H%1u$Pa&Ahl)VEzjD3487IzPN=I^L)NW4;g%*_;*uHqpVed72Fj`6>t=A~jBAr?yX@Daemr(6IU1>g!SPymv)uPD;&FZjCD zm%2Q>^YQTF^M5(q`}2S87Zl%p@bPed@4hL9(`NUMo3Ov^CMljieL6gT@_6|2_y5VK zZod8cPs2lfjq1shC&SAZFJudyzL7z2gk(4?P8jIm)Hy%D;};R%)ys%ERdetDeZAM_ zhr{>vZkx|O`~L9W{r8J~)6Pk0{rg57H?Q>eq38Nq)WhElxBvA24R?S0ABVfY`VYe` zoH;71f$DYJovCM+Q66=TRor$8C}wg~Dj?k4HUMWqT^d!kb(f1*=?O}jqV5fE1Cd_j z(WKq1)z9!dl!j=?fP*GXSIL7DL@2F;g8pwAAT{Q>NTbNIvVzYI80af*-9;O$xCRAM zbB1Bx%BpkQ_@s*<~=Wl}tCX3OG^)zd$!#qIHb5euiGF zte)aQKyVkLE#i=ymXX)OlD=sHo82J`51N;+r) z$C*-}RC{-)G*9*gZ%0egVKb)S4JPFLlwJ`Zg<;88PVNAcn>_foX?Ss10etqH;(q}) zcE6ylAJ8K=eV#YS2y}i9`A?j8JmEIRDYV8b0`oe>>d&?)Qg#4?e0q&dzwZ zO?%=-8*wSii5mpYlQq7Aml$KuyLavm@9Aq$4?g^G_)tIn{r(3Z3?DrBaQIm7x8XfE zyrB5bojX^QF3@lg+1XU6EKb{yPaSZNuSP99af9RM?W^a*+o#_Qx4-%2aQ5Z@J>2=- z|EbUaG2FswqtiH-4yt?jV+X%SsT{%@wN=V!8^}qy9XF+|XJZk8II$A#Ipu&fK_o7O zSE5PRI9GdK)^u|$SS6491zq)x8?bnl4swAbDgZN;Jpf0tqHEhPitg_30(TjdvcIB; z8+dWts4g$SxK9htr_PXZrr(>6NHIpujX#z53OXtx#5hqBk%j7GnKQrKNBX5f5~dwj zl{eBoVb-BDfxQt)IvG`d3h%Z6?Jhs>hTc0wj!}CTWR(E6CzsAcul0U_efOvwH*K@^ zVxY}9mUygR`qL)Y5P9!ULRKSQ-1I2gNG6M^QLblOrohOUpUgYm=Pi)+c_{60UW=>c z7Tqsw2+}4@>TNu^C=Z;vfSSN~6U3$|*DVDe^u(Ei3Gx8584dcD4f>)ra@?4{HiWk| z!Sc?=WEdyIxTLt8+!xbfiU`quFIyIRyOXG z@h?Dsg3D{=KYsCgczE`~@Xd!m9&Y{MUknd^^0VRo_kS|n|Kx{;g-w19DZUydCyr=2 zPyFKjHp)=IVZyhSaNd|<$;&eCR|z;haB95NFG}&=n?HT^M>}r5(l1SY`^`7Q_d^FtCi-_;sy?3qm z+Q2UzHtpRqOK$8(nEskn=)~8lc(+Zw-{v(Zhh97xZaw<_aQmzOcR2sU|2~}m@#n+& zSHEZ+H+(hdVqM|{7&|nm?NToYK^s!iU#(KT=Pw!sF<@C#2m^0`>(5MdQEpk(W;Sr9 zrOWH`)e!milnS2N&S-X))nDhLoD?5I@ErX@6M56N3b-9f!hBuy$u^&&B0Aktu(uLc za+zPCz)NTVR30Xh5e1VVPiJ{?l(f?>nUk_ z9f++YYxL*qsk{^HwjJ(-;U$@5-^lwV(YFp>=D4l%*&u`Caon^<@noiqoP64{Lyx0V zuX^~dBq{wZg^VSl$GNEb>mbwhT4h}7p+bN?z3&ZgKKp0Gdve@7_|e}CAO7$^lS6^cptkh##<0=NJDcN7+gVd~rHDp;oV0;Y zC>iO9C-lh8C}N){q}*95SjR*AcbE27ckBvD3!oF39+?nZROX8G9&!R-?9SkTt8%e78}&18?fRK zLWbNB#wJVn-WZCPy)z-xs0kcKdJ@FW8Jsogz>y5Zov(QQsub=3oH~dIN6I6X z@x^^1#pPl+JzhM2E=NuIJb$5+IL|FB&JhQziyR@#nNHvEZkxM0UBl0PbIRtrUyXtf zvV@~1*hZYZ*vl=_IBf#fr)w0?Uzp0%Hjyt6z|*gX+h6{}aQ?ghO)nh_s=PLmrj(FY5B zTLwM;)u*$+s}n>X_K*&3A8+GH+l)FNweljV)~^$@>qxahbkr&VOwEOWi90v3*%a`Hw6z=Y!_ zzADAnrUv~C_}TCE6{%nUufx4x{d@f^__uy3ahza*aFN>fReY4rOtPULu+#^2u?XTz zLgkBTFw6BTSIWl+##iI0?`Y6CZd9Kj`IGQU;wZo|LJ7qRQUAnB22o@HR3ATFQx*7B z5@{8g0^mVP5uM7Z{i1ZLT3#GCBA74uhJ$ehlg^BA!U9WMgb{^?T)>bg#JEznaE8=A z87PKI+9`DFd<6^~Z~TZGuh1h=48vE%l1}mf*!DwJ#Z9N!HDxXYLx&|Wb}oTe^t1>{ z5`veC(y0^Kl^;pVCESsd#{~(jd-9&rX+VxHn)alUGJbSQZb2bzm7gt;-#AICH~Zbj ziEo&o?aHOo&~v@_EZZ8@d3=tWR%s@f>#xyTihaeIkjtdpm0-Qf)f&k%_Gr9TNL8;F zoa4N%Q(5=@o3<}AGEOaaJblTwOKH?@>IS1MpkfmvA*Kte7IvBQ0<`Hy27T18Fn2D} z{{QyAv{{zixDI`nx6I6&jk9t@4u>LTk)|w#6n0p$9g-a(e{lG(*#4CchZUiqY?-Dc zlM+dBMx1@#yjk9M@AWwc$m*=>>b<%fr~=;W!j?#!Ok^#{i$b;f^v^=R55uEXIhgEW zl4TB(_(}*TwzV&X)E)w5Oi?(`d!oP*Y{0F5>XKv%juM1V3`~@ z$j48BbJ8B$E;w%Z8SwZ1GF<-p$KlpL{y5AYr`0y%1YS1od_9Q$Xp*1CRO@4?V>)K= z$#{$@Wsk2h@zo@bA8Wdwlcyc<;zRlLKB0;MKDZ?H-y`%e?H)&on2Hcm89nA$dZs*Pn9j4wNA`az{nlh)@`S`#Xy zIoWBPDYgkKCuw-7(nN@}CC!OBDdq%Ioz5qD%G;!ho%KvUIdKvWwB>*Bl*0gp%|woT zR@JdR3BakDh$)YKN`UntCX1h)zYspW^JaMdhyOmj{)hi5+?-v88*cKO95%KQu~yx1 zqTM)dfSKHZE3Rs>d@PIoT6!K6QK`WkFA;xq9B`EMEUn>(gMcsp@_Jc{zo7g_B zw87psS9oRZ8vEGH9{eWUdhaj7<$HgHRX0BgXHUK&gV>FDX#}NfWx(E%T#WWW8sn#~*v4NshzDg7QaqVKP?<|K`BcX$ z2DUNcE{*C)g9_G>xQ8an-e1==P!c8}>>PH)b3hZXZEm`3XA`kBX{Veu6!6v_Q-f+bR<%_Fp=+LoMIKlu_wsEnXIK-9c zks^r#+msyIP8+I}x{r<$&Fxa&=;-#~iW|Pt6<_b-xW!+5)r*dsr{Vh1!!Y~&*WvEF z{{tHn|8=-{|Nr4@QjdC6#j%V!lpXX?Y+S@^jo|aJBA{C59lGUe60MAZU}i@vTE=!D z&?*%1VJ7jRvr|qUF10ZX$yTwvK`VfgV`J61149HuI*WSFi-g^@O zXrIdf&3@?h!ua*#N$227nm#;p(IekP1DM+d>eXNCnD1UQyEL61T5=h#4$Y*p)PZ?H zCnfkE!cu4U+k?%qo7%PE*!^zRpKdZ+wKzU;?qi=jz70mKI&L;lQr0B65i3}n6IjD! zaZcDg7U9DoXPknED^AWBy-u0-Sef?uE^W<64R{@C=35mb4S2oq#%uW|of1p)Xp`zV zxslVOp`F)>h`f_iq?u2mhTz+OAl|YJ~ya~ous}r(T!sA6`s#;F2e^mcfryI9{1K(LN{C)1v4Y8Bf;n6w;HXE94#rMH z!UIOjHX|k-+cbOJC>>Yh*y~eQPalWtCl5pT@}qG6*)PJa_kSENKm5CJ{_#J?&w$6y zI>)F>_B9W*B04W3hFIQL#+q18&~bR!W*IFVg0WV;#Y}A1^bdyy1MQms<*KLu#B$;{32B&z97oHPLf5;-_*vIfO7~7?D&nBa->MkDU zIgx87v6!IwNh~L!Iw5%bO?z*kNi#upb(Dc^V_i(M?-rhQawU9h*T%t;5FT8ghYz1z zhNo}*D1>kStMJSl-wStNdpq2^^K3Z3ycLOnlp7QCbtyV;0095fN*qvV1rBJUE~j~zb!L=!5d?qt^!l6eSGd8%w!m7{^rjtc>~9d_PgPxsw_kQ z+_*S8YuHDmRm}dGNXj)D6ciK80b_tyId0^j(d!koowx!=jvA)nuu&OeeG%)?m-w30 zC*k4)ydL%8Ps7E>KM!XQJ`87%zKE~os6$@y=>Wh0=*Plt8THFR8Xx+~t<31yCYEuG z$wGVOqNuUia}=@;a^0jc=hu;9p=&Enph^5R?4*i55TwAH&Js2u%RYN#J*)fbq92Hr zZ5oF)8Xvky!p!+VX9@+{yZf=@r+odKNK~6 zv6%fZr1a7{(s)|SC)TaHo<2NZT_=Q%Oc2P|_^4o_2kL+}Q+2Vq0Z3=m+vZKOq0~^r z_;bfzf=!wZEzV|;6A>yY*n zo-SRh&hxQ$S~U|HYE_LUagis;x1az!XtU=%B2UKtdo zF^M$R4`o0FemZxUOb!=0a!5v*l2^x8k|f5lE!q&-+(sjB%Ny;Yt-3aHGJ*?n@IJ@$ zBROAVo;V_>hK5Dy{F2uRZM<_v?TE~5Pr7w|vX9h0(t;s%gnraF<)UMhG{i-WtTz2v zdlr+nlQ+Bzb!3wI>8)cugVA);-8|_Ky8So#R7V=qDbte;$&MsHZNld#tCS4|r>^X` zVUwK5;<=m?q8=>LZ{|j6@GR~T;Ykq;-uW~fH&k1?p()9<%d>1WA$Qrb#|MCg z*oGc33&Gktzsv?o#NHC*h#9Y|`7G^-05a?fI?*`*XwBrxPnZaK4NWrvbCb5#10D6w z8-JHi;%yK5Ek22!B=KSrpJ*Dat}jxszg2bOVseoq(&XDW30+m!&qu6)L5nHEeN2!) zd3qME-}$%U>^pxPUi|it!rhm@DaS)>Ppr;K)2zHPbV4`gFK-r`D;Q>3J|m z&%7j$RCKb$v6~~}0n*iYgAe#mh6ne8ZGJ4(6-8Se$bBuo`k;4o|}Gn4kSkE3ypN%k-wZGblfakt;Rki zkoxgy@h4wm#*cwm<}|U)*Rh24%P=?B7CLV1HLBD>t4-=pJrExA5;ImbjuNY5wK;$& zPoh^WCh^dsVH=aj@t@0=`Z9jT$Ms1}-9C(LQZZF!hCsoi>L#UvorRU|3e_2~+xfDy z{Ksn8wb5hsyJM4#v&Ne1& zIOL4chFUOjWhw!yVYZl9|}fvF?{j# zCft8~9Ui~=FT(X(e-NH~=U;|9uYEVnE^b5q_-x%ko1xQmrgXz%8Eq~8NXsUCBP!Ct8I$U4#SQCOcMY$HZANVQo5x>; zn@10@!seIZ{F9%DTfhEGye9ScVfOfIFy&X4d@y|_jEty5+~@JeRpYp!-lKDdx{kEO zpkkdDd@#T$aK9Rqn=?iWXxNnaQ^r=Ss2^4(ClAS5w#fnpbV5k%Pb*SrzwC3xN}0%; zb`rfkHkpd&*)qqCdW-$ATgfE}l~ygYl8>P9zqv**L)R6@kVLUPi3mHEBpHyK>xu2~ zUwont1pvbKUK1Kd>caIO^XiBlGLWy+lj>OR05$q{*Nx`-WbKoqW-cq4AP|f7rGk2p z2B;GnYt_y61|i*1f7>@bc?%6WPKpw+3!?y^Ty0AM5M$1|boZ^LG89-59XAF%-IFXk zx|laP$~g9rvy6O@;4kMOjJboHD^L%1<-m2ok(93IMK!gd2FylF9QO@HJQ*2ustFJ$ zO5KMN)@iZ`bmpWnps9{oo3N^ilSQ&hlPe>Sb*V4C_CnW2XA+CHylL?-{jrXFq<|TM z`9CySW}XwvW}++-%}pZhDn6`>^bg^<`TFrqc=*z9g(t84cDQ`!Pr~J!KMc3(IhO-&CPQ6~OYhe#t@dTUaU z;|8O{a&zLuJ#H{gR&E;+GlmD`hJ+744QF3|7|yX7@x_OKgH4ICx6SA8$1h3I8{>jj zd0>EAwn1A<_j&f6hA*~)eMlTf!veFv#t|Sp0uenvYye#1X^sRFlYC@{iG5>ZAV8f0 zlF=^7FWD+=d2Wx9S!25`TV1T%T~%rF+3k# zpJWML$~nbiW>{wDqUS@MwTCCtQfF+-dg4Q5`i=@pM^>Bc5yHHhb(`{;w_2WB)z|V? zPT-Ut))o>IJyrAT+XX48lWA|Eplz}^ZCvC?KF7$eS zJbb^W=|ML}*EQuGV=%ca&P_(*$`zaGO89Wd9VhV$Ca#_(U^V?(Ow9JPvKnuu0qM;R zx=U`xVB}P$4|}lid7q{ld>-jIfkJ|9J9SNAw=VH%+A`nJq(qaS*NMKl@p4j{CfOi~ zbpixli*@Qk52H3&WCd7r zwsbm>O=p5*_QcBQkRX6)kFf#D<={X$9ZJC3wPONNTQ-F})DQ6*Z|gJKDv7O+c{}QX zfx6(oJ{ppU7ae3|K}U(?QAO;JY}lNb>8CNr4IlLB$l`zY0UFXNFQ*DBQXZu)oeG<} zZhlTH`G|)k7La9V)v0VbpV$J&rq7hN3bb$Wv+^m%Uf0R)rl^L!WTp=7w-=)mGzM;6 z*}NT`0LDgL3ZnpTM1C?N;>=Z_hRfoUDd{jBPs?%Rb*P&f3epYJSB)Y~xpI~*cYHJg zVa!>HZA-NW@^)zfIjWcs`!(#Y0qL+DTispI&0E^}&`bz1f#ZV)Cu^#Mx-@YEOHObY zEU!#DwD@!$1X_Ho4t!Of`trPN0?4wyzC_FR9#7&Ydhl%FK?C1c3lHno@d58vNSGf! zy$)Z^Zii1_{Xw|;)*pqJfAFW__DkPvk;YXwY4r_e%laHRVtpbUl$<~}t8bWr@Oqsp z)fqER;#udzu^l(;+Z-*1Lym>?WXhq9lXxnKt83&oNKTu$+J@!aC^$N9n2r;FbJ|?N zX>)_$48OBavBKtqpM+cQ{qJ!3;opYv=n=eTkD@%l0aIOZ$IG*YgnfojQPrTrv6 zEgBn=WD~Mo^3lZ_7i_Hz!gOezjZu{32$C#lPaaX$o>e(=0QF#AhKKaP7%hE;UsT`o zzKDVd3(}1sDcvkf3rcr)EX@imjkLr{D=i>Mch?dEvaob_cXv1b_Wk+(?)d}m>z+IF z%rno-IdduFiT{UtC*kG}H4HP>Tt)iF#JG%l48t@51%6FHMP^vA;=zY;W>P(c;gS=$ zBXeRdVa1+R8=Yrz{v#Q6TV1(B_pwI<4RpC@NynRW(^q^m#!0B()_?=#sMkeF834v@ z(*MxT({ER;pFZzJpj$qpq}u_LJ_3g8azndNQFV9P!lFJianef!AQu4SE;ld7^l9h* z!+ktHq21>iSI2!j``dAq4tcH%a-Tb-bT7FhGF^WOQ4)+dOM7FlF@VLNnb)aS@-pST zRs-FR`mHi+@5I+{E7I-Kck8nsZw`J6b>!#<2rb+`p^Qg&84EQTFSI7o@P_K_1m2$*glt6RHknA>Ut^fxwcSTjypf4wY^S$`{)P1xtN|Q#9)I; zf~n&qM6wimU~^Hp&wxyE8IYGTa)i+o-&5=Vwr;*aBLpFTnAX2W9+-_n(V4NmJ#{Pi zJ!EFr;Xhzz{O*x@AF*9x%G@F_{9M8{5wMZ?hl7Z0j(EiuDTE}5T5o5F`pg*wvDKg? z&y!P=#vxMho%fD2G>(_6jl2~uyO`*nK6MzaOb>#Sr;1zDC)yYTTmfot`B#dQ8&&F+ zerDnYXFOb0JQ07 zGMBl*5zDuRO1CaaJf6O_nE$!nkS!XY_^ak>jX15-nFL&ic*FA!=nU z;i@yb$tv6Zwk7D;x2r*h%KquHuoG=;yJ5^kkBn}4_On6zS4u9Q17a9p1)s}ExW%cV zvqDp$A;ZH*VHL-IY(@d7aCy7`kKL&CLI>F9!6s1w|ns@#n9OI?!E%C>Eur}bi7`oVd_7J41%f5zE%*( zchlgGJ}t;ziy#HPyJ0>dqeweA8qPkIy?4JK3JiJZf-SZurM7;T2?ZInBUo#A>ZMY1 ztHi3Ce!E7gv-mOS4{VLL>T*_pT_A#~kj7V3t-HH)IliJj&3Y5k{u+y|wUco)4(HvP z6hl#Tx(o~NKoy)$z~CDUcgFer-1v(HUp-%|WmRc~cZEP*IL4krnsRMzYFI(*?3R7s zfxY3jw#N=A&o|(A|7V(O4 z7CGFM+RN6bCy=W~sJ3qOntlFUEeH9OcITvOGMYarc=jz`u2R|uq_2#H*NsBnH-T{hQT%M9i7{b+x6VUAupELFuFW*`Q>vCz!< zp%E^2r~jQ%kOg0mfnuvUhSt}E@Szc}0NVLUin_#OM{8tEBrRD^fMD2^42!Af%W?aijfizxs{Bv=;^eim+|EdN1_ zE~N#@OtMFY7cRt?Eg-%qUW(Np&6LPtBotMTMER#pb)we9=xTap{*=9B(+llz(2Fgv zMMJjDU94PFG;y&c?N{G4^>p!n6%IM;`@SQS-6i3@5^*f`T#`FPT3CU@-RY^Td>5f> zz%9|F#`PTWP#zn`CMnE;8_B~y1^HACqU^oW$v*Phm&vu+McrlL$!)vRx*kvbgdBUr zwiuv%eS!M5=Vk;)AjBVb{q{_vWS3L3>F)SmX(&`o5tNyaSsb0PJ*^T%F`eig29t5Qwhg~@XZqc} zJk7RG!cQqXSCYa&_Zt}R*h#;df=GAt9}qFMklWlyP|(|SL7~_-92-+V9N6G;NWba7 zkN>9z0$bnZY4a87UOD07&7qAAIN&tnlA7~#sGFzf{{}>o+{m>0)j!*!RUIPlCGfr) ztH$)8Bj`J}ZCC?)1roF0rGpB8ES#Qi)RL0Db9eGnkbp#j^1HfEDg|tthlceAa(0Re zbR=E-KKt=sTJGC%WbX7L(#<=oB6M1!lM62Gcn^Aoz;RSg3?+1S?QVVSe~JYT3ebv2 z{O_S4`TZ$x4}uqlZhevVM@(tW92+rC*sgjCdl1W}7)pDXknAU4M{=LvrSeOVj~p-Q ziGMb8j??a&SG+0ZXy!L$!S_-8j>DTTy*t56zS8&dx%Qf41n5sQA#j9UDgDcwfNxxC zTDTcZ;^T3dW+-C)UXILwP|?)dPcKcYz(KnhJpTzd?S=G|)ij@AGw$~4-h+pi+c9ba z0$UrQwbRqN^XcF+HroB-fTI{TX7r&0;?Z%-PUU1+zgxGEf$Kg-BD}YjjrmpmY0$0r zkI7+0(L-xHl>udSG7pCB#vUoF?C{#s2EkE%!gEwDu8#mP?lxoecb#~AW*Wn1ZlLvn zHa+0X6(WPvJ$NrMy02a%J9CV@R$&)pa#%hS4g zMtr+4IkUxJ$Y6wWtcT|1EoWv1sM_xIg1_|Qma|Q3xnpm4c7f?gPUz7~oSLHA2&d)i zKu7%iFbp=_O5?cnVE(ENl|F1O8PbVf8!Qy+l{oEKu9T=p6(MemDP3oriE)F^h&V^} z#kHAVjj9tGcD@@sB;@`&C|ww)q)*w>)3}+ACU%a)sypE3GVq&oY#4n1(4aGC#%FP4 z=lZnERar6kuj^Cix8pWa)7UlZE)s$lX>Oh2q8BRNx@QTs`y*<_--f6ICDBYhEe3d5 zr_PEatV*p!9t?)w#Qj!=yd7tW2$*=TSjDVZA8C9z1oI8zD9@Jy@f`Fu5a9)7MepRm z@so+32Rf&}r!5*88JK5J@6TnVMjz{#-j=BXH4B#Ro7h|_=M@C+O zcVA{3RQ|wdc}lsujV7~{d@<0XN*rW-N4$bV)PV84>h&>2lcqMMV5Dm3df;QeY+ycY z5;ZdZ6lvL(Kl_UC20WNC9Ad@7xLq|sZs_S2nh4|lO4HLH)Lf`#v2E(+Fk^~7vChEh zabIH3fvFsGYJ>=<`oMR3_{5m$-Wxuq1e~Nz+SkSw^{TZcXaiO(o@`ut>4^0NF4j+n zQ2JW!-5v0X`d#|0?g|Bvm76@8!~Ji4=oXqNtw|e#epRUa3g)7IHoBWVoj1lx1*cUT z@{ecFRVSwwEjMzBXBt&keN|SEqCh!YUK)i?eOO}W>gJ+j{WBI%NdE$CjU-s!WX9c~Ic#DWR04KT1;!Qf< zgOCceJt*b;aw`l`|LkgO&T}RsY*l5R_a@mN_hD9a1Bw}{C0J$H!RI1gCS&^WqbJkV_?VJB{$CzYjcHxdt^P#_bL z+ABDT;KgqJ#pbX>>0byLldJrtES`2IBQ2icghvxMaQl9GLjX(lMBUVQqEr^r9h;@;7R`iI%@86ACtJ$1VnYTN(B)g~-`K*A-$QFY z?#jKH|MW4-FLwP?8op+7JKB~D=yw^a=$|Yh?3cozX)PtYpiB+MkQ7*9*oqI>%6%`CM?(oa{3exrcCB1qNmNp^cV`gsP?q?GkGF zQt=-`z_OtRFnMs_$oH!5oW3tJdgvzCy+}bvLo8AONxxJT4wKxz<`?jFcOIvd56slq z&QE58H0(ows?}oJIC%H6~Z%7Thth`|s~eN%`{3I9PI-Uvme zGHoGOhO4;I5|&UyCYCN&D{)54gNoP9paiaJ+o_-Ly9ds6AyStyGGWs+-v8WS#G4PX zB=n<*3q1E+ljrSj*@DHH5$R3eSkrIa3`quA>38*R(>$(;=`R;uNV=oH?JoRPp!8+j zJ2=%{U0eJ|&bf5o@BD;MC5Q(f!!V6)f>y}7{|!=IPMITHFsq-Is&IpzNlS;Efuw z3$l`30Cj%{0deHKx1Vst=f=J;s+)iB#Sb5Mf&|}*`3aYN=@~C%Z*z+I!8u@xJJ4*(WIB*YFN)B z`}un+@6IuF2EGCuDxiH=OBoXCZeI>M+b>6rqD-d1e@2Lf=`DxsYWeo=A|G`ws*GN0 zZGNP9S!*TkBWdWsQT%7v*Th$@k9d zeL6^Q25Rh+a(a&z4{JPsjJ{@TL-z{aBW$GvW1|U zm&UAz-f6D+9}%C#{HmOuOu^?JiO#%b?H*y?)R`=)b&&3iSmB^KqVx2E!?mB9m-&Li zZGu_5Rwmdv7{H;yUj0PgLW~^=6L%WG+^l)Z;TI=SoKyiL#cJ>`PlpX*EBB)tj0t7W zyy&W>1t@|9J}Mxw&OHVPIr5V0)if%tl!i!^esM9DPn2bRYj8=FiJ4xeE;ntf9p?w( z*a)YPE8zW3h{jTBSkrxk;O;LgRa`$8S3cETQiZ$Qt%e9I%Lz!;_rBk0t{r=Ke^lf! z8sQ8>2&d!ddAkC(2L*JNjQ-rsukKMU15`~PIK_#dKDYBZ$eosP+MXgAtf75*)HJpwNxyoyE@^x)SpOo*=T+lL;fQO7<2Ia*Y$G#gq^|Uv$hN zfH?h7h4Np+920m46UcET;SC;Aj76NH}vP(jG5bBnoE5>s56=~Nmv_HJ7zQHQ=*!q_~2*kkcZP;aNwsW^jG>x z0FsYZ^HO6R?&r^kn`r+NqlMGQx2HdKQ*Q}G>iS}bC(*LI7T<~4B*thgY14!=|@O+fncY@QEc zjM~$7{uG%a;4Ys!v#ztFIxtz9wiW6IIN3i$3-4e6(7HL9yGJZ*fzwdN)&-u_BmVtL zr7a$}zP4V$EFx;5K+TSM*&<#yt3F1qMNnRoCFdK(zkkDvX(YY-q+%7Bi-~$v6!+!} zn%%{RYq$6JMc*dj5qteXkmfmZI)#=*HapL>V08non!R_MSMNRop_O}ETT`xI-0j1r z3#^?l25zSD`D?@+*NQ2h=c?S~q+$F^k(b(3YUV95Bps&a{}H!`x)b`l*{_$!rRo$B zZL9k#y0NRJ-XS;UDdx+mvpK`3y~n1ZHN*b+oZ><2TakEmSf^1HD#jfCVi79Og}GT` z?u_C{Iaz5VWG1grGzH5mv^dnVR_ki8^fM-87{R*(1oi!B@c!jjYOL;Upv zVfSn5+cBzdJrJq3KBv!MUq?O8OZiI=5IYtnUlw%GO#=8`GMAHd^GqiKcjK$_NQ=%m zq3}p=u?UL9A?lm-I`&dwbfr6n&JS8m@q)h~9>b3%XshQfoE`Zq$#RE>%TSWJg8fc;r;P`d11jHcUtbq zDmY-_Jj`x8>c{w}oA-nPyK$+u7U2;>pq&T|MxLbdR2{eMSuPR-llvKlHF9b}lzLTD zA&xxGalRr>=Qs6#V|~4hy`A}w$!NqbST)e0SMi{9*L?lOoOd_&C0{^SZ(FJ*5X>%X zX3#t4o&s5DU`|+Kl);YGl7hX|8FMBx&>L+X))6zr1-r5QAp5B-Z7=JxY!lplepP_BbWC6_^esP*Kpk($wFq6Vln_}3{HG=-E7T@!o`Ng4k`?;wV4Gxcg+ zAX|oVMx)HYye2Ds5?Z6u_~nhWjG?F#T*rz*SuoO7Mj0Z!&i4z89v4+a9U#FLET?6( zD8T9bJi{S4Yv0m_LGcLMY}$CD*20Z`Jo|*Z=%K47CtbuTJ{wrro0DXRL76Nr{$q{1 zTd@V?zV88Vz1Q${f31>M;!^=jBVFpyEige8bebYx|7yOwn>aXl9wX`NxA*YeWr!NM z)uSJkV>UEgqZ76h^)sGl_zf83SjbDg66h82R(Hm1asc4p#EUG#Z2w>r2an z&cnP9oDiyh<92+_N4~=LON*b1tmCqI&`Gf5xcE&+-z{bEd>Yl5H>>z{YBS0^ul8$_ zt9Jitd62oavN(KR@RkXy{ArOP!HL z&mWJw#6I{~R(ZP4_^9~IG}Q*=N%?~_3lw=iu|`{1yl(wQ;EH=y8Y?pgNpm9!##3+X zP35Z>H%@Hg4XV$$64@av`BvR@wgp7UP!3;yfl>i1$0JS9{_5FYN;Bb!2s!r{iD6<6 zVLPqFug+X=c|$Cs{G2)tNvohdI$P=MHETC#6HyWe!3bjD!?v_h5FgiiD{%gWuytHb z*datadiyVLS+#ecZZ-{Ub~1+>F`YeBequnRGo8NpuiN>mm+L|#;6xtyZ_jmEw2fbj z!6%S7!88aZ$%=8|KJuGGwB{*)E;7qOyqL^wiGUAxPs=q3#TN_7xps%YO& z>yz55jT_mAy7fCjo#~Z+O(Q$k^MOdcaC8_zh{u16(jd{*50lOL`<6|o(~y(-OO<;r z2A2&<5@BcB3Bi)jES0yAjmj*Si{*p_t#`0NX;h}@pW^pOt~0yP{Rxrz?h#98+Lvmm z?cqg*eAn_UJec8~cLt_mDk}j>x(Z4Byh$ikP7Mv_%nT=^6A(8HwrVyfvT{KPPHPgH)5v0LkTCZJ&pGo!5WJA(j zBNf|iL4;Ea#P(()JZAHz)*=F%mIcnyeOCMfQ`iF6MicPYvJR8aUaf}^haa`l9W6{2 zM`$bI8OzE>iDTgw;0W=wx(A6lt-u>mncv;QzL(#ga?7Vn4&M(Cbo!*>@Nw}wqjDb%2grxr_x!__Vek5BBB;t!Yau6-rVrk`51h5fvGT#L#W zZ~1xV-HxNRy+F?|R>P&|tA3Pci1^xj&;o4Ny3QSG{Syc60jS&mGjvOYf0folH8OGj z(ommUXhxI~+8pUAw!>kKwW%L;d~oa%xq1(d;Z;5s_js0yO>FR)jgVLI&TDEW%&tlF zHaF};1eVVkqksv4btSTYk%@US2MJy4)^pp<)l&~o6eo9^U1QtV+a|&(;)j3xw~HK= zJkl`DOg$9_XO-iRDf+{Mm7~U4sqRC(R%n1mp|02^(38y~%0oX_#R`p=8@IjC!_eUA zA~@^zCmsnN#}AyZfe4%C%S7e_2^7hMdq!VXRJr)R?=uNc6+@Z`I4`_7GCqH)GqX~& zbXO~M-SBrWmAs9;YdG*+75t^o;K1pafUqmJOtmJs)wkdmmAGS`$~03#!=R7utRk#J zYjIiv$~c$jC%{jeD`uOGZr#^m-OvR-Oiw?^)|DuB#;n!m);H2tKFUJd8_%TFZu4Pr z4bC5H$YIl7Za3DQgF{8N#tsl`(w8+{rYMHK-pXs>)6*C|9yWO8x$q5JIPdK_J>J|l z((wVDR2Ra-ftEA-t&+qZ7&wS|wiZ0An1xVFf68+zfF!f4BoRE5ZYv9S9ZDGbk6A?o-vS75-^*Z5vrxqX3C)Z%VE)0TX zm}RCG=qU#i3oLj6+r6c2l-r*K_#9c4VA1|Rn0%vk^Jv5a3AfdO>-%a(R?@IR9`s?t zfW}J{U(L^GF)q1Jr}Jae?8%2-@gP=i?=Ff#Bi{E8mmj@*V3^x*yV1&|7>2PPy7h$i z`Xu5^8HX6k>hjeaib@{yVHYIz+YSbw+@(WZ_;ZqSF~(laxWQyIE=IG3Y4P{q*Ylvy zGpHjMNVPr=u1dXc^e3=Ub5&Z~9n^gVUh}l#xX0*b-B2Xx zY@ax2jJ~zOTfC*~=uZ4pbo>88V z{ux8gKVht8M0Fl?uEnG2kMo>v#z3QAIh4?u_Cq|rdy;hne$16Feeh^fLlvb_vHua<8d9pDL$$NvWGkQ01flZP~xIB_FQ@ZFhk@R*|NDz3&eh;6$6X z9N7r(R7l^h^yZ=Be{chuCR?<#X&5CEa#rzTmN@H_!&ghxRI7e)UswDsrg+)1$M4fI z7+QtTv)*kAJBOL}>5uzoM{LMStfil*5SbMCW~f9V%}0wI%KNXlZMR8LIe(UKD=hZT z=Z#8p4<*wR+>%n6f0S^F{}bl6;a3(lZ{rx;eo6=z0G-+;>A4C@Ti~@ zs)Whx5q?EKKtTHspZn1uoz3Du-+9v%95wgN3(o{0?D*=dkb~>W0b235{N2WE?;V!s z7c{iO^$(j=3hg_Wx_f)*vrls6l`STlj?+ul$b|f*hKJJgl+KFk(vfEs(V{%7oue3C zeFZRVZMx{Z3L)PWGk^IJBw{Wed8~+FW;SWFm>sw(wR2b+21Am_Q zZL$Bug9%3Fm79QA3kJoaNM7^C3AIj)HhQRS`w+VL$Q}RceM_GHQl7RkD`ao(*7~KA zsZSF5zkD%leBTVau$OZlrrD>5dMoG3b9I_IUw}mzKq_U)uv;B{O?^VxNZ|A(nHwH? z1S;!59(QlN0gJy-McAIc5ykr(eB;s$%w6~{z||d%)lKPjx1K^A&6AP79v0kFRfkz? zgjx3@%EU9T92A&wSW$11ZZU2x)Hv^1_1XkIeOqesk%Dn@Z`2{M0L=t8R==+I^4T*I zQF$3Dt%qk781JKb4avdN3n(?`y7#2ay++m)o6U-9D-o!X*&&gKd6lTp3y%YLY6}Wb znWAeoKgKNIOMPMY$OF`=k9n(oT}%js?$kWv)$JY}QCJLnOC!lX)92V93(8+mxnj2x z@FlUq;teABgtCOW$sDtF1+8~ixg`prm`zaUAU0@SZ*T$@G|8$6uopH?)aDNW2r~xcp3S)+f1q?$ISZklKPFNrfWcp? z;ytJX&;qbews)|S`MrO=A?CBdkH`MtGBxt|7#e^7!oqe|al0V=r43mgwg$IJ}Qq!)TKj?GyuE<(cgq zwlGaLG(c63&>VClH+uNy7jD&PVVYD0ZGN;71XIGJR zlkrcJ;TnYtG7g|7@Ii@$^Xd6a^5M zEnnV@vb^5(Y){0OLo3%n9^umUu;-^ifBo5BP<chkFDp3ADzGgJ%gTuWQjoiFnu&}2kr$W_p|9@O0+z_7@h zd2N?d0d1rh>Fgy>QcPmg%IXmy!M%Tl%+_bz>11Us;(2dLi<95g$Y#(qNWH;*)91dR z=HL3Elky+Y^NrF9B`eT~bufv|Yt71#Q8f47baNj_swmO7`Th|FO_KXMR{O0TiV@%i znXc;SXw@Wc2}VpA+Kk^i{BJOi?j(PFfy!c4*5s2)X;3Scu{;}=N@;7bhg1AaD-X!F z8`Bb3Kf=gvoC5tdRFWFdHOFlrO0+C}N;jlYiTgWv4^A!_E^Gc5mizwDT{_RDy3-UZ zi=i}M)tvL=Ukp%J;OBtH!-<5S0{8FTfPGqbVTG5NUqRfI_9b!JC_`3~LU$XZGW)mt zzVPTb?;L}N4B;$N-&d`R`kQz7kWx(+dXc4CJ)=3b+(|XHkY?(Eq|P5sD|CrO?~q=* zs@_a6gp6YdA50owxu!;79Uo*$X5J)oz!V(&9+uoyAZD!mRu`RuREUhz+laQVp*ZNj zMQGnHg07JMJwweM^RP*PbEkJ3Z|*ecr!;Q31!cxtc(c^3?21)%=xlUXFsr4Rb=c@y z(5y`_XK$b4G2PH9;us1xn3NXaCG<&|+Dj|yfq)_43|Aupo$(xQQhTL9vEoD_f2I8T zjv>ooG4Y(yP2B3W5{g39B2z<8XE#!{OXB~1ggY3)miYPByNxj7s67p z0~FN>>aLr!X7Q&M?{;0N1dGeeSD;&#&dLir0>25WR|BVmI3{uPbg5jXotEJ!ZD5Vj z;y4~=#e@x36wUzbM5cJ&Ed4tiNlrL2ypcHpR>TGj{VY9|E#aV2?!3sHW;A8PcLu zHqP;%m87pTDwK&|`^`ERrt>QAG}UX63hT@X^L?7{S6?R9oRpkwhw`^$eek2+12!$y zC6AWZW*riL(d4*I9~EvZ;h`xq#v+4hytEXXa8UPqgPLQb$|jwZ=U}i{=_Qwk+u464 zM1t3O#R3T-hgnp~azt-k799b#n1|F=g3Ca=G5D0zi9o}mN&;P!6{V}8Z{_ZOF!Kg@ z+x>2)yO2W`UUn4{SP@uLMReiv4O5H!$D4XeURqMOt%u=__~DI8v+^>VeAh0)$!FlZ zH0=PQQ1K@Rw$JP*#%|@=ckD4SvlBBL1_`wf1V_Q+0I`q8aj6ym`-ea{#vPM{`@huS z62D_y$l9EczYm`T9lEj_cAL~xq99Cww&oFW6-Vm%i!{(J_x$mO&9Trig2gT(qN`O)KZMAdUN`@)N=yY$dnPN;NJMU4l`Jp^}HD902J{#;cmu`$DD_-CF*5}yNA>fP>9W;eILX6KjhV*3+w$LHH<{6aH5L7Yi z8&oI}v&(wgCKQNX?K2jJ>f1|#r@&ESANAKNeix&5_-ssaIoQ7_zQ2Xv$B180{g85a z{T+F|5VNWS-|Zciz4tdA;%jGM19{ zEi!|nt02rLl!vmkF-aJIv`pu6cOuyJrsC^UZx#O-U%}?YD}E{zg48T^04g|UTny9CPbfY`x+3Sgm{9Q(#S-7)J14Rm<8l#>o6zL@wJI8(d)xnZ z8raK6ddFyg)E0170N84b^Ofwr2Qd!JU1Ly;=z*H5qUkmoSJCBnU9j3hpuyU5V{T5+ zq00YR4fShE%d!|s%`L5eucI9{EUAR398|pSn8d6xg}WHd5BHR36xH2GeMKbZ5`~Dy zArLC*Ng8<3=0SQ1Sh(&-#hDJ^!Xc0_HSXWuGS?)(qCgVW@Q;XLEIVU6*So(0A|hgO zm#-8$noLp$S-dh(d&aasDB$nmTZo4YK6`t ztGHKigCtH^a&N&OBc=P6f|A__fe|4OcSe<`bsc;iz(EO`vW3@9NJcj9$HA>)wCEyq zp{c46Blo;$%56=C!FqP_mto5H{81!MbB;J}-y1=W50Z1adB>~MxYdx7V$KK`y&myXuwTi<@NWfQn~lqMMCzK?eoP>-i;GKHq=! z^-;!?eo0x_ql{l%E9clqVn)T+FN;`wxQrIpE^s$LzrOc5Oy|?>)vgug$libh){bJd z@j#k6?;R|~2p<*)^&^VP-dVkFBA4i`I+nROWcY_oM?JBdZ;eC?VdHBw&`*Xhexk~H zYHhc|jv=pMMLEE6FH|f$>do3Yx35D|Rl^4;SjLI7dAI zZ{2iB1x(8^_=m)T=QXv;zY}{;N(NEvq@^c3kUl<#trUKL^x``_mwoB7GDJJ=r8ARK z&X8c$7e0t&A0lrwM}bhOw7F~b)>>_r?Ie_M;@NROCf;7W+d|K4yYPF~lW&LNbqp=W zldl4LpAWpGKp_-iytIITY<;hI*UZo`G(B6B>MyJ7F2xGEkNa4Hc*Okbc36&McD?cX zheEn#Tb;QCfB!uS&2NiutnIuo)yl4_drwloSFDFAOrsw>o8t<7xsiB|JW<*GyJ%TA zG%u!EYS-tyi)&M=p3!Mxbmi$UpIa}U9+=GTXgR0y5c)n6aJ^5OVX|I@T>y%J^u zoFqZTvhI;7NXJ+D{7=%{tihF+TMIu`=#HjB_1 zDmdydx~(-f62-XSuzgmjZi}k~@y)Vf{jBS==3;_58Bq(trcWKkoX>={y8!~$u6U82 z<7-d@cOH-PZmB9$uFe*SYV;#8GhbkX{IQR6yc8L;Zu3$gqZC7DR?;|{Prrt&ZY9<1 zq>`bu&|oZ^Bvu){8vm8ROT)mZoSA;)yjhV-TKOOMa}y-0{5y&&|59UPZcG)YMQw5+ zpnP57Se8?wUnYZZs&H<#9%ta0#s7@&IPhzFh3mtA4P`}DwB}#at(7nGm`YCuP6t9P zqs>c;t`6**>{nPQ^gOY++%-IjHkILoPC`oe58ozvTN4GTnm=Gx$TYLzpTF6z;j2RN z>knBQS+F4@ye>XZaTH8BjnO9ZXK|O3Y&+V}yyfZN@iqTz*TYwoC?u#wA=^Izx2Lr; zTNJQ^49$a1tQ;icUtP;AG7qNiwKCN~HYmO?0?7-^=)YH$*FwSmeYvw~T?L)hcFSRt zYs&M{LAbmPzQI5De@77uS(6Ep>tCPPU4XyQIXKXWG0uK|P_m9J6WyMv{6Ca%a%NmJ z{mv}xq$iHy-E?-j`Mw}|vfzHlL4Wd@0B!SZyPhCz(d#k_y`msxnwWk2)I`?OL>2U< zjFc;HS-~dDI9GlZN(slZSvNYAT+G3VR&QPhe=#6!5wgH|@EQCrc@s`&FQ%72lbfP| zGb>P#u_j#;?IRC1ez#Tb&xrVjgPW~ny>3CNScAX^<26WacE4D@}T7h9>Q(zl2F zOHVaJ_^lJG;8A7dv8ah^*+D&BDpMg1{n9X-I_aDD?^OcIRAZ^@>@zB%i8C$ccd2en zw^pACMP@q3*9ba=N=cFf8zZ5R3e|xzSzbk&_#?8ieoY;&3e^e+!~GJB77R;8L$VQ) z+}5;IPBrv-rKUam)aRH$`ktk!e8JNVnRsa^Uv^{(Qcc6k($Y|pXNSaT^ujV1wb$`z z!cyHT)*J^ew}iiEi~D)0g@}GkmlXNAWAUnbbVdIZ=>AjdqaB{2yi5{1{snjXvhc6QT4YL=8=i&9qfb6osgyyv{i@jCO zF2is!0t@kJl}RXqt`_zsQjtstVGqShX(kKzi&E60on|o2JgYCPI>hxF)CHtVqAzh? zANuFwZhzvCRt>GGJQ{f+GCO;4{iy8QU>7CtC^Q z`Sdk`&6I8R!+jvdcjMTZ6vfOJ)ZMS`xfpHRRSQvV*#swe;rP|liGtFsZjvU9bJpQ0 zFbdubMSA@z-By$mXCEJpFOkeqXUtra4&-dB9h_lBxvJv#;YtIe=UY}}C4@FVt^3%o z1$%3Uli#aO3yZS{VJSa@$sj!Jw%rKH`CWHCexuR^Ey=E zhIL@fqByQv-(q5gGIu_(SB_V83M!pe1#R%s6#-d`pe%>yo~D{>wL8mAxC!(_?np&Q z@V4pfYV&7u*cjLnzqF4$Kq8mV7&{p)O!B9Hl(+Ce_Z_rGI#UiN zG3t^3#pwT5_w+O7_$v|rWABR|tW7fAnG_F!R?#Th1ltlu3-OR5Nj#JxzUspL>l$Gy zm$e>7$ciog*-A59*#os>^Z(m1@u-ei>0OtLUL1f=zScC3Ae9GK*s8!QoIrck9^}gh4kz!n!%vu152zc##uS^7+3kr5Gc> z6wUFCfm2p>VA}o|jaCD^jAP$AdSZ9~v!xAU>neTI$vR{0D}7HESr2 z__1r@8$(GEs7#v=3=bTbgvOUYx5kt8}1^ ZzPslp2uZCFc!ql9KdQ)-e=z&{{{Y)li?09x literal 0 HcmV?d00001 diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/index.ts b/superset-frontend/plugins/plugin-chart-handlebars/src/index.ts new file mode 100644 index 0000000000000..c39fe12b95253 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/index.ts @@ -0,0 +1,27 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +// eslint-disable-next-line import/prefer-default-export +export { default as HandlebarsChartPlugin } from './plugin'; +/** + * Note: this file exports the default export from Handlebars.tsx. + * If you want to export multiple visualization modules, you will need to + * either add additional plugin folders (similar in structure to ./plugin) + * OR export multiple instances of `ChartPlugin` extensions in ./plugin/index.ts + * which in turn load exports from Handlebars.tsx + */ diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/buildQuery.ts b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/buildQuery.ts new file mode 100644 index 0000000000000..36bcb965158f4 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/buildQuery.ts @@ -0,0 +1,31 @@ +/** + * 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 { buildQueryContext, QueryFormData } from '@superset-ui/core'; + +export default function buildQuery(formData: QueryFormData) { + const { metric, sort_by_metric, groupby } = formData; + + return buildQueryContext(formData, baseQueryObject => [ + { + ...baseQueryObject, + ...(sort_by_metric && { orderby: [[metric, false]] }), + ...(groupby && { groupby }), + }, + ]); +} diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controlPanel.tsx new file mode 100644 index 0000000000000..32b3a55a79fa1 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controlPanel.tsx @@ -0,0 +1,158 @@ +/** + * 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 { + ControlPanelConfig, + emitFilterControl, + sections, +} from '@superset-ui/chart-controls'; +import { addLocaleData, t } from '@superset-ui/core'; +import i18n from '../i18n'; +import { allColumnsControlSetItem } from './controls/columns'; +import { groupByControlSetItem } from './controls/groupBy'; +import { handlebarsTemplateControlSetItem } from './controls/handlebarTemplate'; +import { includeTimeControlSetItem } from './controls/includeTime'; +import { + rowLimitControlSetItem, + timeSeriesLimitMetricControlSetItem, +} from './controls/limits'; +import { + metricsControlSetItem, + percentMetricsControlSetItem, + showTotalsControlSetItem, +} from './controls/metrics'; +import { + orderByControlSetItem, + orderDescendingControlSetItem, +} from './controls/orderBy'; +import { + serverPageLengthControlSetItem, + serverPaginationControlSetRow, +} from './controls/pagination'; +import { queryModeControlSetItem } from './controls/queryMode'; +import { styleControlSetItem } from './controls/style'; + +addLocaleData(i18n); + +const config: ControlPanelConfig = { + /** + * The control panel is split into two tabs: "Query" and + * "Chart Options". The controls that define the inputs to + * the chart data request, such as columns and metrics, usually + * reside within "Query", while controls that affect the visual + * appearance or functionality of the chart are under the + * "Chart Options" section. + * + * There are several predefined controls that can be used. + * Some examples: + * - groupby: columns to group by (tranlated to GROUP BY statement) + * - series: same as groupby, but single selection. + * - metrics: multiple metrics (translated to aggregate expression) + * - metric: sane as metrics, but single selection + * - adhoc_filters: filters (translated to WHERE or HAVING + * depending on filter type) + * - row_limit: maximum number of rows (translated to LIMIT statement) + * + * If a control panel has both a `series` and `groupby` control, and + * the user has chosen `col1` as the value for the `series` control, + * and `col2` and `col3` as values for the `groupby` control, + * the resulting query will contain three `groupby` columns. This is because + * we considered `series` control a `groupby` query field and its value + * will automatically append the `groupby` field when the query is generated. + * + * It is also possible to define custom controls by importing the + * necessary dependencies and overriding the default parameters, which + * can then be placed in the `controlSetRows` section + * of the `Query` section instead of a predefined control. + * + * import { validateNonEmpty } from '@superset-ui/core'; + * import { + * sharedControls, + * ControlConfig, + * ControlPanelConfig, + * } from '@superset-ui/chart-controls'; + * + * const myControl: ControlConfig<'SelectControl'> = { + * name: 'secondary_entity', + * config: { + * ...sharedControls.entity, + * type: 'SelectControl', + * label: t('Secondary Entity'), + * mapStateToProps: state => ({ + * sharedControls.columnChoices(state.datasource) + * .columns.filter(c => c.groupby) + * }) + * validators: [validateNonEmpty], + * }, + * } + * + * In addition to the basic drop down control, there are several predefined + * control types (can be set via the `type` property) that can be used. Some + * commonly used examples: + * - SelectControl: Dropdown to select single or multiple values, + usually columns + * - MetricsControl: Dropdown to select metrics, triggering a modal + to define Metric details + * - AdhocFilterControl: Control to choose filters + * - CheckboxControl: A checkbox for choosing true/false values + * - SliderControl: A slider with min/max values + * - TextControl: Control for text data + * + * For more control input types, check out the `incubator-superset` repo + * and open this file: superset-frontend/src/explore/components/controls/index.js + * + * To ensure all controls have been filled out correctly, the following + * validators are provided + * by the `@superset-ui/core/lib/validator`: + * - validateNonEmpty: must have at least one value + * - validateInteger: must be an integer value + * - validateNumber: must be an intger or decimal value + */ + + // For control input types, see: superset-frontend/src/explore/components/controls/index.js + controlPanelSections: [ + sections.legacyTimeseriesTime, + { + label: t('Query'), + expanded: true, + controlSetRows: [ + [queryModeControlSetItem], + [groupByControlSetItem], + [metricsControlSetItem, allColumnsControlSetItem], + [percentMetricsControlSetItem], + [timeSeriesLimitMetricControlSetItem, orderByControlSetItem], + serverPaginationControlSetRow, + [rowLimitControlSetItem, serverPageLengthControlSetItem], + [includeTimeControlSetItem, orderDescendingControlSetItem], + [showTotalsControlSetItem], + ['adhoc_filters'], + emitFilterControl, + ], + }, + { + label: t('Options'), + expanded: true, + controlSetRows: [ + [handlebarsTemplateControlSetItem], + [styleControlSetItem], + ], + }, + ], +}; + +export default config; diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/columns.tsx b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/columns.tsx new file mode 100644 index 0000000000000..0582bfc23f9bf --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/columns.tsx @@ -0,0 +1,85 @@ +/** + * 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 { + ColumnOption, + ControlSetItem, + ExtraControlProps, + sharedControls, +} from '@superset-ui/chart-controls'; +import { + ensureIsArray, + FeatureFlag, + isFeatureEnabled, + t, +} from '@superset-ui/core'; +import React from 'react'; +import { getQueryMode, isRawMode } from './shared'; + +export const allColumns: typeof sharedControls.groupby = { + type: 'SelectControl', + label: t('Columns'), + description: t('Columns to display'), + multi: true, + freeForm: true, + allowAll: true, + commaChoosesOption: false, + default: [], + optionRenderer: c => , + valueRenderer: c => , + valueKey: 'column_name', + mapStateToProps: ({ datasource, controls }, controlState) => ({ + options: datasource?.columns || [], + queryMode: getQueryMode(controls), + externalValidationErrors: + isRawMode({ controls }) && ensureIsArray(controlState.value).length === 0 + ? [t('must have a value')] + : [], + }), + visibility: isRawMode, +}; + +const dndAllColumns: typeof sharedControls.groupby = { + type: 'DndColumnSelect', + label: t('Columns'), + description: t('Columns to display'), + default: [], + mapStateToProps({ datasource, controls }, controlState) { + const newState: ExtraControlProps = {}; + if (datasource) { + const options = datasource.columns; + newState.options = Object.fromEntries( + options.map(option => [option.column_name, option]), + ); + } + newState.queryMode = getQueryMode(controls); + newState.externalValidationErrors = + isRawMode({ controls }) && ensureIsArray(controlState.value).length === 0 + ? [t('must have a value')] + : []; + return newState; + }, + visibility: isRawMode, +}; + +export const allColumnsControlSetItem: ControlSetItem = { + name: 'all_columns', + config: isFeatureEnabled(FeatureFlag.ENABLE_EXPLORE_DRAG_AND_DROP) + ? dndAllColumns + : allColumns, +}; diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/groupBy.tsx b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/groupBy.tsx new file mode 100644 index 0000000000000..0df08bc1d46ce --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/groupBy.tsx @@ -0,0 +1,45 @@ +/** + * 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 { + ControlPanelState, + ControlSetItem, + ControlState, + sharedControls, +} from '@superset-ui/chart-controls'; +import { isAggMode, validateAggControlValues } from './shared'; + +export const groupByControlSetItem: ControlSetItem = { + name: 'groupby', + override: { + visibility: isAggMode, + mapStateToProps: (state: ControlPanelState, controlState: ControlState) => { + const { controls } = state; + const originalMapStateToProps = sharedControls?.groupby?.mapStateToProps; + const newState = originalMapStateToProps?.(state, controlState) ?? {}; + newState.externalValidationErrors = validateAggControlValues(controls, [ + controls.metrics?.value, + controls.percent_metrics?.value, + controlState.value, + ]); + + return newState; + }, + rerender: ['metrics', 'percent_metrics'], + }, +}; diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/handlebarTemplate.tsx b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/handlebarTemplate.tsx new file mode 100644 index 0000000000000..4d86cdc928fe2 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/handlebarTemplate.tsx @@ -0,0 +1,77 @@ +/** + * 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 { + ControlSetItem, + CustomControlConfig, + sharedControls, +} from '@superset-ui/chart-controls'; +import { t, validateNonEmpty } from '@superset-ui/core'; +import React from 'react'; +import { CodeEditor } from '../../components/CodeEditor/CodeEditor'; +import { ControlHeader } from '../../components/ControlHeader/controlHeader'; + +interface HandlebarsCustomControlProps { + value: string; +} + +const HandlebarsTemplateControl = ( + props: CustomControlConfig, +) => { + const val = String( + props?.value ? props?.value : props?.default ? props?.default : '', + ); + + const updateConfig = (source: string) => { + props.onChange(source); + }; + return ( +

+ ); +}; + +export const handlebarsTemplateControlSetItem: ControlSetItem = { + name: 'handlebarsTemplate', + config: { + ...sharedControls.entity, + type: HandlebarsTemplateControl, + label: t('Handlebars Template'), + description: t('A handlebars template that is applied to the data'), + default: `
    + {{#each data}} +
  • {{this}}
  • + {{/each}} +
`, + isInt: false, + renderTrigger: true, + + validators: [validateNonEmpty], + mapStateToProps: ({ controls }) => ({ + value: controls?.handlebars_template?.value, + }), + }, +}; diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/includeTime.ts b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/includeTime.ts new file mode 100644 index 0000000000000..7004f45fe3bed --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/includeTime.ts @@ -0,0 +1,34 @@ +/** + * 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 { ControlSetItem } from '@superset-ui/chart-controls'; +import { t } from '@superset-ui/core'; +import { isAggMode } from './shared'; + +export const includeTimeControlSetItem: ControlSetItem = { + name: 'include_time', + config: { + type: 'CheckboxControl', + label: t('Include time'), + description: t( + 'Whether to include the time granularity as defined in the time section', + ), + default: false, + visibility: isAggMode, + }, +}; diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/limits.ts b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/limits.ts new file mode 100644 index 0000000000000..701dc27aae1f2 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/limits.ts @@ -0,0 +1,38 @@ +/** + * 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 { + ControlPanelsContainerProps, + ControlSetItem, +} from '@superset-ui/chart-controls'; +import { isAggMode } from './shared'; + +export const rowLimitControlSetItem: ControlSetItem = { + name: 'row_limit', + override: { + visibility: ({ controls }: ControlPanelsContainerProps) => + !controls?.server_pagination?.value, + }, +}; + +export const timeSeriesLimitMetricControlSetItem: ControlSetItem = { + name: 'timeseries_limit_metric', + override: { + visibility: isAggMode, + }, +}; diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/metrics.tsx b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/metrics.tsx new file mode 100644 index 0000000000000..88777c9c3173a --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/metrics.tsx @@ -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. + */ +import { + ControlPanelState, + ControlSetItem, + ControlState, + sharedControls, +} from '@superset-ui/chart-controls'; +import { FeatureFlag, isFeatureEnabled, t } from '@superset-ui/core'; +import { getQueryMode, isAggMode, validateAggControlValues } from './shared'; + +const percentMetrics: typeof sharedControls.metrics = { + type: 'MetricsControl', + label: t('Percentage metrics'), + description: t( + 'Metrics for which percentage of total are to be displayed. Calculated from only data within the row limit.', + ), + multi: true, + visibility: isAggMode, + mapStateToProps: ({ datasource, controls }, controlState) => ({ + columns: datasource?.columns || [], + savedMetrics: datasource?.metrics || [], + datasource, + datasourceType: datasource?.type, + queryMode: getQueryMode(controls), + externalValidationErrors: validateAggControlValues(controls, [ + controls.groupby?.value, + controls.metrics?.value, + controlState.value, + ]), + }), + rerender: ['groupby', 'metrics'], + default: [], + validators: [], +}; + +const dndPercentMetrics = { + ...percentMetrics, + type: 'DndMetricSelect', +}; + +export const percentMetricsControlSetItem: ControlSetItem = { + name: 'percent_metrics', + config: { + ...(isFeatureEnabled(FeatureFlag.ENABLE_EXPLORE_DRAG_AND_DROP) + ? dndPercentMetrics + : percentMetrics), + }, +}; + +export const metricsControlSetItem: ControlSetItem = { + name: 'metrics', + override: { + validators: [], + visibility: isAggMode, + mapStateToProps: ( + { controls, datasource, form_data }: ControlPanelState, + controlState: ControlState, + ) => ({ + columns: datasource?.columns.filter(c => c.filterable) || [], + savedMetrics: datasource?.metrics || [], + // current active adhoc metrics + selectedMetrics: + form_data.metrics || (form_data.metric ? [form_data.metric] : []), + datasource, + externalValidationErrors: validateAggControlValues(controls, [ + controls.groupby?.value, + controls.percent_metrics?.value, + controlState.value, + ]), + }), + rerender: ['groupby', 'percent_metrics'], + }, +}; + +export const showTotalsControlSetItem: ControlSetItem = { + name: 'show_totals', + config: { + type: 'CheckboxControl', + label: t('Show totals'), + default: false, + description: t( + 'Show total aggregations of selected metrics. Note that row limit does not apply to the result.', + ), + visibility: isAggMode, + }, +}; diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/orderBy.tsx b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/orderBy.tsx new file mode 100644 index 0000000000000..728934d71910c --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/orderBy.tsx @@ -0,0 +1,47 @@ +/** + * 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 { ControlSetItem } from '@superset-ui/chart-controls'; +import { t } from '@superset-ui/core'; +import { isAggMode, isRawMode } from './shared'; + +export const orderByControlSetItem: ControlSetItem = { + name: 'order_by_cols', + config: { + type: 'SelectControl', + label: t('Ordering'), + description: t('Order results by selected columns'), + multi: true, + default: [], + mapStateToProps: ({ datasource }) => ({ + choices: datasource?.order_by_choices || [], + }), + visibility: isRawMode, + }, +}; + +export const orderDescendingControlSetItem: ControlSetItem = { + name: 'order_desc', + config: { + type: 'CheckboxControl', + label: t('Sort descending'), + default: true, + description: t('Whether to sort descending or ascending'), + visibility: isAggMode, + }, +}; diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/pagination.tsx b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/pagination.tsx new file mode 100644 index 0000000000000..bf4c1207174d1 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/pagination.tsx @@ -0,0 +1,57 @@ +/** + * 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 { + ControlPanelsContainerProps, + ControlSetItem, + ControlSetRow, +} from '@superset-ui/chart-controls'; +import { FeatureFlag, isFeatureEnabled, t } from '@superset-ui/core'; +import { PAGE_SIZE_OPTIONS } from '../../consts'; + +export const serverPaginationControlSetRow: ControlSetRow = + isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS) || + isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS) + ? [ + { + name: 'server_pagination', + config: { + type: 'CheckboxControl', + label: t('Server pagination'), + description: t( + 'Enable server side pagination of results (experimental feature)', + ), + default: false, + }, + }, + ] + : []; + +export const serverPageLengthControlSetItem: ControlSetItem = { + name: 'server_page_length', + config: { + type: 'SelectControl', + freeForm: true, + label: t('Server Page Length'), + default: 10, + choices: PAGE_SIZE_OPTIONS, + description: t('Rows per page, 0 means no pagination'), + visibility: ({ controls }: ControlPanelsContainerProps) => + Boolean(controls?.server_pagination?.value), + }, +}; diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/queryMode.tsx b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/queryMode.tsx new file mode 100644 index 0000000000000..b895b97f28a42 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/queryMode.tsx @@ -0,0 +1,42 @@ +/** + * 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 { + ControlConfig, + ControlSetItem, + QueryModeLabel, +} from '@superset-ui/chart-controls'; +import { QueryMode, t } from '@superset-ui/core'; +import { getQueryMode } from './shared'; + +const queryMode: ControlConfig<'RadioButtonControl'> = { + type: 'RadioButtonControl', + label: t('Query mode'), + default: null, + options: [ + [QueryMode.aggregate, QueryModeLabel[QueryMode.aggregate]], + [QueryMode.raw, QueryModeLabel[QueryMode.raw]], + ], + mapStateToProps: ({ controls }) => ({ value: getQueryMode(controls) }), + rerender: ['all_columns', 'groupby', 'metrics', 'percent_metrics'], +}; + +export const queryModeControlSetItem: ControlSetItem = { + name: 'query_mode', + config: queryMode, +}; diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/shared.ts b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/shared.ts new file mode 100644 index 0000000000000..5f364a2880b8c --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/shared.ts @@ -0,0 +1,61 @@ +/** + * 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 { + ControlPanelsContainerProps, + ControlStateMapping, +} from '@superset-ui/chart-controls'; +import { + ensureIsArray, + QueryFormColumn, + QueryMode, + t, +} from '@superset-ui/core'; + +export function getQueryMode(controls: ControlStateMapping): QueryMode { + const mode = controls?.query_mode?.value; + if (mode === QueryMode.aggregate || mode === QueryMode.raw) { + return mode as QueryMode; + } + const rawColumns = controls?.all_columns?.value as + | QueryFormColumn[] + | undefined; + const hasRawColumns = rawColumns && rawColumns.length > 0; + return hasRawColumns ? QueryMode.raw : QueryMode.aggregate; +} + +/** + * Visibility check + */ +export function isQueryMode(mode: QueryMode) { + return ({ controls }: Pick) => + getQueryMode(controls) === mode; +} + +export const isAggMode = isQueryMode(QueryMode.aggregate); +export const isRawMode = isQueryMode(QueryMode.raw); + +export const validateAggControlValues = ( + controls: ControlStateMapping, + values: any[], +) => { + const areControlsEmpty = values.every(val => ensureIsArray(val).length === 0); + return areControlsEmpty && isAggMode({ controls }) + ? [t('Group By, Metrics or Percentage Metrics must have a value')] + : []; +}; diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/style.tsx b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/style.tsx new file mode 100644 index 0000000000000..4d6f259eeb501 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/style.tsx @@ -0,0 +1,72 @@ +/** + * 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 { + ControlSetItem, + CustomControlConfig, + sharedControls, +} from '@superset-ui/chart-controls'; +import { t } from '@superset-ui/core'; +import React from 'react'; +import { CodeEditor } from '../../components/CodeEditor/CodeEditor'; +import { ControlHeader } from '../../components/ControlHeader/controlHeader'; + +interface StyleCustomControlProps { + value: string; +} + +const StyleControl = (props: CustomControlConfig) => { + const val = String( + props?.value ? props?.value : props?.default ? props?.default : '', + ); + + const updateConfig = (source: string) => { + props.onChange(source); + }; + return ( +
+ {props.label} + { + updateConfig(source || ''); + }} + /> +
+ ); +}; + +export const styleControlSetItem: ControlSetItem = { + name: 'styleTemplate', + config: { + ...sharedControls.entity, + type: StyleControl, + label: t('CSS Styles'), + description: t('CSS applied to the chart'), + default: '', + isInt: false, + renderTrigger: true, + + validators: [], + mapStateToProps: ({ controls }) => ({ + value: controls?.handlebars_template?.value, + }), + }, +}; diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/index.ts b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/index.ts new file mode 100644 index 0000000000000..db5ad528f8f6a --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/index.ts @@ -0,0 +1,51 @@ +/** + * 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 { ChartMetadata, ChartPlugin, t } from '@superset-ui/core'; +import thumbnail from '../images/thumbnail.png'; +import buildQuery from './buildQuery'; +import controlPanel from './controlPanel'; +import transformProps from './transformProps'; + +export default class HandlebarsChartPlugin extends ChartPlugin { + /** + * The constructor is used to pass relevant metadata and callbacks that get + * registered in respective registries that are used throughout the library + * and application. A more thorough description of each property is given in + * the respective imported file. + * + * It is worth noting that `buildQuery` and is optional, and only needed for + * advanced visualizations that require either post processing operations + * (pivoting, rolling aggregations, sorting etc) or submitting multiple queries. + */ + constructor() { + const metadata = new ChartMetadata({ + description: 'Write a handlebars template to render the data', + name: t('Handlebars'), + thumbnail, + }); + + super({ + buildQuery, + controlPanel, + loadChart: () => import('../Handlebars'), + metadata, + transformProps, + }); + } +} diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/transformProps.ts b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/transformProps.ts new file mode 100644 index 0000000000000..cb83e112d863d --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/transformProps.ts @@ -0,0 +1,67 @@ +/** + * 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 { ChartProps, TimeseriesDataRecord } from '@superset-ui/core'; + +export default function transformProps(chartProps: ChartProps) { + /** + * This function is called after a successful response has been + * received from the chart data endpoint, and is used to transform + * the incoming data prior to being sent to the Visualization. + * + * The transformProps function is also quite useful to return + * additional/modified props to your data viz component. The formData + * can also be accessed from your Handlebars.tsx file, but + * doing supplying custom props here is often handy for integrating third + * party libraries that rely on specific props. + * + * A description of properties in `chartProps`: + * - `height`, `width`: the height/width of the DOM element in which + * the chart is located + * - `formData`: the chart data request payload that was sent to the + * backend. + * - `queriesData`: the chart data response payload that was received + * from the backend. Some notable properties of `queriesData`: + * - `data`: an array with data, each row with an object mapping + * the column/alias to its value. Example: + * `[{ col1: 'abc', metric1: 10 }, { col1: 'xyz', metric1: 20 }]` + * - `rowcount`: the number of rows in `data` + * - `query`: the query that was issued. + * + * Please note: the transformProps function gets cached when the + * application loads. When making changes to the `transformProps` + * function during development with hot reloading, changes won't + * be seen until restarting the development server. + */ + const { width, height, formData, queriesData } = chartProps; + const data = queriesData[0].data as TimeseriesDataRecord[]; + + return { + width, + height, + + data: data.map(item => ({ + ...item, + // convert epoch to native Date + // eslint-disable-next-line no-underscore-dangle + __timestamp: new Date(item.__timestamp as number), + })), + // and now your control data, manipulated as needed, and passed through as props! + formData, + }; +} diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/types.ts b/superset-frontend/plugins/plugin-chart-handlebars/src/types.ts new file mode 100644 index 0000000000000..2a363059fa7d8 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/types.ts @@ -0,0 +1,65 @@ +/** + * 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 { ColumnConfig } from '@superset-ui/chart-controls'; +import { + QueryFormData, + QueryFormMetric, + QueryMode, + TimeGranularity, + TimeseriesDataRecord, +} from '@superset-ui/core'; + +export interface HandlebarsStylesProps { + height: number; + width: number; +} + +interface HandlebarsCustomizeProps { + handlebarsTemplate?: string; + styleTemplate?: string; +} + +export type HandlebarsQueryFormData = QueryFormData & + HandlebarsStylesProps & + HandlebarsCustomizeProps & { + align_pn?: boolean; + color_pn?: boolean; + include_time?: boolean; + include_search?: boolean; + query_mode?: QueryMode; + page_length?: string | number | null; // null means auto-paginate + metrics?: QueryFormMetric[] | null; + percent_metrics?: QueryFormMetric[] | null; + timeseries_limit_metric?: QueryFormMetric[] | QueryFormMetric | null; + groupby?: QueryFormMetric[] | null; + all_columns?: QueryFormMetric[] | null; + order_desc?: boolean; + table_timestamp_format?: string; + emit_filter?: boolean; + granularitySqla?: string; + time_grain_sqla?: TimeGranularity; + column_config?: Record; + }; + +export type HandlebarsProps = HandlebarsStylesProps & + HandlebarsCustomizeProps & { + data: TimeseriesDataRecord[]; + // add typing here for the props you pass in from transformProps.ts! + formData: HandlebarsQueryFormData; + }; diff --git a/superset-frontend/plugins/plugin-chart-handlebars/test/index.test.ts b/superset-frontend/plugins/plugin-chart-handlebars/test/index.test.ts new file mode 100644 index 0000000000000..9121daeca4d91 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/test/index.test.ts @@ -0,0 +1,33 @@ +/** + * 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 { HandlebarsChartPlugin } from '../src'; + +/** + * The example tests in this file act as a starting point, and + * we encourage you to build more. These tests check that the + * plugin loads properly, and focus on `transformProps` + * to ake sure that data, controls, and props are all + * treated correctly (e.g. formData from plugin controls + * properly transform the data and/or any resulting props). + */ +describe('@superset-ui/plugin-chart-handlebars', () => { + it('exists', () => { + expect(HandlebarsChartPlugin).toBeDefined(); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-handlebars/test/plugin/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-handlebars/test/plugin/buildQuery.test.ts new file mode 100644 index 0000000000000..217ee50485f8a --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/test/plugin/buildQuery.test.ts @@ -0,0 +1,37 @@ +/** + * 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 { HandlebarsQueryFormData } from '../../src/types'; +import buildQuery from '../../src/plugin/buildQuery'; + +describe('Handlebars buildQuery', () => { + const formData: HandlebarsQueryFormData = { + datasource: '5__table', + granularitySqla: 'ds', + groupby: ['foo'], + viz_type: 'my_chart', + width: 500, + height: 500, + }; + + it('should build groupby with series in form data', () => { + const queryContext = buildQuery(formData); + const [query] = queryContext.queries; + expect(query.groupby).toEqual(['foo']); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-handlebars/test/plugin/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-handlebars/test/plugin/transformProps.test.ts new file mode 100644 index 0000000000000..24aa3c3745a21 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/test/plugin/transformProps.test.ts @@ -0,0 +1,56 @@ +/** + * 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 { ChartProps, QueryFormData } from '@superset-ui/core'; +import { HandlebarsQueryFormData } from '../../src/types'; +import transformProps from '../../src/plugin/transformProps'; + +describe('Handlebars tranformProps', () => { + const formData: HandlebarsQueryFormData = { + colorScheme: 'bnbColors', + datasource: '3__table', + granularitySqla: 'ds', + metric: 'sum__num', + groupby: ['name'], + width: 500, + height: 500, + viz_type: 'handlebars', + }; + const chartProps = new ChartProps({ + formData, + width: 800, + height: 600, + queriesData: [ + { + data: [{ name: 'Hulk', sum__num: 1, __timestamp: 599616000000 }], + }, + ], + }); + + it('should tranform chart props for viz', () => { + expect(transformProps(chartProps)).toEqual( + expect.objectContaining({ + width: 800, + height: 600, + data: [ + { name: 'Hulk', sum__num: 1, __timestamp: new Date(599616000000) }, + ], + }), + ); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-handlebars/tsconfig.json b/superset-frontend/plugins/plugin-chart-handlebars/tsconfig.json new file mode 100644 index 0000000000000..b6bfaa2d98446 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "declarationDir": "lib", + "outDir": "lib", + "rootDir": "src" + }, + "exclude": [ + "lib", + "test" + ], + "extends": "../../tsconfig.json", + "include": [ + "src/**/*", + "types/**/*", + "../../types/**/*" + ], + "references": [ + { + "path": "../../packages/superset-ui-chart-controls" + }, + { + "path": "../../packages/superset-ui-core" + } + ] +} diff --git a/superset-frontend/plugins/plugin-chart-handlebars/types/external.d.ts b/superset-frontend/plugins/plugin-chart-handlebars/types/external.d.ts new file mode 100644 index 0000000000000..8f7985ceaf135 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-handlebars/types/external.d.ts @@ -0,0 +1,22 @@ +/** + * 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. + */ +declare module '*.png' { + const value: any; + export default value; +} diff --git a/superset-frontend/src/visualizations/presets/MainPreset.js b/superset-frontend/src/visualizations/presets/MainPreset.js index dc3736ff1728b..837cd98a7aa53 100644 --- a/superset-frontend/src/visualizations/presets/MainPreset.js +++ b/superset-frontend/src/visualizations/presets/MainPreset.js @@ -78,6 +78,7 @@ import { GroupByFilterPlugin, } from 'src/filters/components/'; import { PivotTableChartPlugin as PivotTableChartPluginV2 } from '@superset-ui/plugin-chart-pivot-table'; +import { HandlebarsChartPlugin } from '@superset-ui/plugin-chart-handlebars'; import FilterBoxChartPlugin from '../FilterBox/FilterBoxChartPlugin'; import TimeTableChartPlugin from '../TimeTable'; @@ -164,6 +165,7 @@ export default class MainPreset extends Preset { new TimeColumnFilterPlugin().configure({ key: 'filter_timecolumn' }), new TimeGrainFilterPlugin().configure({ key: 'filter_timegrain' }), new EchartsTreeChartPlugin().configure({ key: 'tree_chart' }), + new HandlebarsChartPlugin().configure({ key: 'handlebars' }), ...experimentalplugins, ], }); From 25e572a56e8cca1c9dd466fcd64ad610e86a385c Mon Sep 17 00:00:00 2001 From: Yongjie Zhao Date: Tue, 26 Apr 2022 20:03:55 +0800 Subject: [PATCH 120/136] fix: count(distinct column_name) in metrics (#19842) --- .../controls/MetricControl/AdhocMetric.js | 21 ++++++++--- .../MetricControl/AdhocMetric.test.js | 36 +++++++++++++++++++ .../AdhocMetricEditPopover/index.jsx | 5 ++- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetric.js b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetric.js index 01fea2dab69f7..5b29d7418c2fe 100644 --- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetric.js +++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetric.js @@ -16,7 +16,10 @@ * specific language governing permissions and limitations * under the License. */ -import { sqlaAutoGeneratedMetricRegex } from 'src/explore/constants'; +import { + sqlaAutoGeneratedMetricRegex, + AGGREGATES, +} from 'src/explore/constants'; export const EXPRESSION_TYPES = { SIMPLE: 'SIMPLE', @@ -86,20 +89,30 @@ export default class AdhocMetric { } getDefaultLabel() { - const label = this.translateToSql(true); + const label = this.translateToSql({ useVerboseName: true }); return label.length < 43 ? label : `${label.substring(0, 40)}...`; } - translateToSql(useVerboseName = false) { + translateToSql( + params = { useVerboseName: false, transformCountDistinct: false }, + ) { if (this.expressionType === EXPRESSION_TYPES.SIMPLE) { const aggregate = this.aggregate || ''; // eslint-disable-next-line camelcase const column = - useVerboseName && this.column?.verbose_name + params.useVerboseName && this.column?.verbose_name ? `(${this.column.verbose_name})` : this.column?.column_name ? `(${this.column.column_name})` : ''; + // transform from `count_distinct(column)` to `count(distinct column)` + if ( + params.transformCountDistinct && + aggregate === AGGREGATES.COUNT_DISTINCT && + /^\(.*\)$/.test(column) + ) { + return `COUNT(DISTINCT ${column.slice(1, -1)})`; + } return aggregate + column; } if (this.expressionType === EXPRESSION_TYPES.SQL) { diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetric.test.js b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetric.test.js index f3b7d08dca4aa..336b194e00240 100644 --- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetric.test.js +++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetric.test.js @@ -216,4 +216,40 @@ describe('AdhocMetric', () => { expect(adhocMetric3.aggregate).toBe(AGGREGATES.AVG); expect(adhocMetric3.column.column_name).toBe('value'); }); + + it('should transform count_distinct SQL and do not change label if does not set metric label', () => { + const withBrackets = new AdhocMetric({ + column: { type: 'TEXT', column_name: '(column-with-barckets)' }, + aggregate: AGGREGATES.COUNT_DISTINCT, + hasCustomLabel: false, + }); + expect(withBrackets.translateToSql({ transformCountDistinct: true })).toBe( + 'COUNT(DISTINCT (column-with-barckets))', + ); + expect(withBrackets.getDefaultLabel()).toBe( + 'COUNT_DISTINCT((column-with-barckets))', + ); + + const withoutBrackets = new AdhocMetric({ + column: { type: 'TEXT', column_name: 'column-without-barckets' }, + aggregate: AGGREGATES.COUNT_DISTINCT, + hasCustomLabel: false, + }); + expect( + withoutBrackets.translateToSql({ transformCountDistinct: true }), + ).toBe('COUNT(DISTINCT column-without-barckets)'); + expect(withoutBrackets.getDefaultLabel()).toBe( + 'COUNT_DISTINCT(column-without-barckets)', + ); + + const emptyColumnName = new AdhocMetric({ + column: { type: 'TEXT', column_name: '' }, + aggregate: AGGREGATES.COUNT_DISTINCT, + hasCustomLabel: false, + }); + expect( + emptyColumnName.translateToSql({ transformCountDistinct: true }), + ).toBe('COUNT_DISTINCT'); + expect(emptyColumnName.getDefaultLabel()).toBe('COUNT_DISTINCT'); + }); }); diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.jsx b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.jsx index be5d15ffb5d8f..decad4c12d5da 100644 --- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.jsx +++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.jsx @@ -465,7 +465,10 @@ export default class AdhocMetricEditPopover extends React.PureComponent { onChange={this.onSqlExpressionChange} width="100%" showGutter={false} - value={adhocMetric.sqlExpression || adhocMetric.translateToSql()} + value={ + adhocMetric.sqlExpression || + adhocMetric.translateToSql({ transformCountDistinct: true }) + } editorProps={{ $blockScrolling: true }} enableLiveAutocompletion className="filter-sql-editor" From c32c505742582c42b8228f07ff948bd7e5ae2676 Mon Sep 17 00:00:00 2001 From: Josh Soref <2119212+jsoref@users.noreply.github.com> Date: Tue, 26 Apr 2022 12:17:15 -0400 Subject: [PATCH 121/136] chore(docs): Spelling (#19675) * spelling: adding Signed-off-by: Josh Soref * spelling: aggregate Signed-off-by: Josh Soref * spelling: avoid Signed-off-by: Josh Soref * spelling: blacklist Signed-off-by: Josh Soref * spelling: cached Signed-off-by: Josh Soref * spelling: discontinue Signed-off-by: Josh Soref * spelling: exhaustive Signed-off-by: Josh Soref * spelling: from Signed-off-by: Josh Soref * spelling: github Signed-off-by: Josh Soref * spelling: hybrid Signed-off-by: Josh Soref * spelling: implicit Signed-off-by: Josh Soref * spelling: interim Signed-off-by: Josh Soref * spelling: introduced Signed-off-by: Josh Soref * spelling: javascript Signed-off-by: Josh Soref * spelling: logstash Signed-off-by: Josh Soref * spelling: metadata Signed-off-by: Josh Soref * spelling: password Signed-off-by: Josh Soref * spelling: recommended Signed-off-by: Josh Soref * spelling: redshift Signed-off-by: Josh Soref * spelling: refactored Signed-off-by: Josh Soref * spelling: referencing Signed-off-by: Josh Soref * spelling: sqlite Signed-off-by: Josh Soref * spelling: the Signed-off-by: Josh Soref * spelling: thumbnails Signed-off-by: Josh Soref * spelling: undoes Signed-off-by: Josh Soref * spelling: very Signed-off-by: Josh Soref Co-authored-by: Josh Soref --- CODE_OF_CONDUCT.md | 2 +- CONTRIBUTING.md | 8 ++++---- RELEASING/README.md | 6 +++--- RELEASING/changelog.py | 2 +- RELEASING/release-notes-0-38/README.md | 2 +- RELEASING/release-notes-1-2/README.md | 2 +- RELEASING/release-notes-1-4/README.md | 2 +- UPDATING.md | 16 ++++++++-------- docker-compose.yml | 2 +- docs/docs/contributing/contributing-page.mdx | 4 ++-- .../contributing/pull-request-guidelines.mdx | 2 +- .../creating-your-first-dashboard.mdx | 4 ++-- .../exploring-data.mdx | 2 +- docs/docs/databases/drill.mdx | 4 ++-- docs/docs/databases/elasticsearch.mdx | 2 +- docs/docs/installation/alerts-reports.mdx | 2 +- docs/docs/installation/configuring-superset.mdx | 4 ++-- ...installing-superset-using-docker-compose.mdx | 2 +- docs/docs/installation/sql-templating.mdx | 6 +++--- docs/docs/intro.mdx | 2 +- docs/docs/miscellaneous/chart-params.mdx | 2 +- docs/src/resources/data.js | 4 ++-- .../img/databases/{sqllite.jpg => sqlite.jpg} | Bin .../img/databases/{sqllite.png => sqlite.png} | Bin .../src/number-format/README.md | 2 +- .../FilterableTable/FilterableTable.tsx | 2 +- superset/charts/post_processing.py | 2 +- ...9fb176a0_add_import_mixing_to_saved_query.py | 2 +- ...500de1855_add_uuid_column_to_import_mixin.py | 2 +- .../c501b7c653a3_add_missing_uuid_column.py | 2 +- superset/utils/pandas_postprocessing/pivot.py | 2 +- 31 files changed, 48 insertions(+), 48 deletions(-) rename docs/static/img/databases/{sqllite.jpg => sqlite.jpg} (100%) rename docs/static/img/databases/{sqllite.png => sqlite.png} (100%) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index e49cf0a32cc06..bee3a24a1e781 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -119,7 +119,7 @@ If you decide to join the [Community Slack](https://join.slack.com/t/apache-supe **3. Ask thoughtful questions.** -- We’re all here to help each other out. The best way to get help is by investing effort into your questions. First check and see if your question is answered in [the Superset documentation](https://superset.apache.org/faq.html) or on [Stack Overflow](https://stackoverflow.com/search?q=apache+superset). You can also check [Github issues](https://github.com/apache/superset/issues) to see if your question or feature request has been submitted before. Then, use Slack search to see if your question has already been asked and answered in the past. If you still feel the need to ask a question, make sure you include: +- We’re all here to help each other out. The best way to get help is by investing effort into your questions. First check and see if your question is answered in [the Superset documentation](https://superset.apache.org/faq.html) or on [Stack Overflow](https://stackoverflow.com/search?q=apache+superset). You can also check [GitHub issues](https://github.com/apache/superset/issues) to see if your question or feature request has been submitted before. Then, use Slack search to see if your question has already been asked and answered in the past. If you still feel the need to ask a question, make sure you include: - The steps you’ve already taken - Relevant details presented cleanly (text stacktraces, formatted markdown, or screenshots. Please don’t paste large blocks of code unformatted or post photos of your screen from your phone) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 865d4b62d6b44..738f018873433 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -116,7 +116,7 @@ Here's a list of repositories that contain Superset-related packages: the [superset-frontend](https://github.com/apache/superset/tree/master/superset-frontend) folder. - [github.com/apache-superset](https://github.com/apache-superset) is the - Github organization under which we manage Superset-related + GitHub organization under which we manage Superset-related small tools, forks and Superset-related experimental ideas. ## Types of Contributions @@ -209,7 +209,7 @@ Finally, never submit a PR that will put master branch in broken state. If the P - `chore` (updating tasks etc; no application logic change) - `perf` (performance-related change) - `build` (build tooling, Docker configuration change) - - `ci` (test runner, Github Actions workflow changes) + - `ci` (test runner, GitHub Actions workflow changes) - `other` (changes that don't correspond to the above -- should be rare!) - Examples: - `feat: export charts as ZIP files` @@ -488,7 +488,7 @@ To bring all dependencies up to date as per the restrictions defined in `setup.p $ pip-compile-multi ``` -This should be done periodically, but it is rcommended to do thorough manual testing of the application to ensure no breaking changes have been introduced that aren't caught by the unit and integration tests. +This should be done periodically, but it is recommended to do thorough manual testing of the application to ensure no breaking changes have been introduced that aren't caught by the unit and integration tests. #### Logging to the browser console @@ -661,7 +661,7 @@ We use [Pylint](https://pylint.org/) for linting which can be invoked via: tox -e pylint ``` -In terms of best practices please advoid blanket disablement of Pylint messages globally (via `.pylintrc`) or top-level within the file header, albeit there being a few exceptions. Disablement should occur inline as it prevents masking issues and provides context as to why said message is disabled. +In terms of best practices please avoid blanket disablement of Pylint messages globally (via `.pylintrc`) or top-level within the file header, albeit there being a few exceptions. Disablement should occur inline as it prevents masking issues and provides context as to why said message is disabled. Additionally, the Python code is auto-formatted using [Black](https://github.com/python/black) which is configured as a pre-commit hook. There are also numerous [editor integrations](https://black.readthedocs.io/en/stable/integrations/editors.html) diff --git a/RELEASING/README.md b/RELEASING/README.md index 32fb1aef34cab..46913d55ecad8 100644 --- a/RELEASING/README.md +++ b/RELEASING/README.md @@ -300,7 +300,7 @@ with the changes on `CHANGELOG.md` and `UPDATING.md`. ### Publishing a Convenience Release to PyPI Using the final release tarball, unpack it and run `./pypi_push.sh`. -This script will build the Javascript bundle and echo the twine command +This script will build the JavaScript bundle and echo the twine command allowing you to publish to PyPI. You may need to ask a fellow committer to grant you access to it if you don't have access already. Make sure to create an account first if you don't have one, and reference your username @@ -315,9 +315,9 @@ Once it's all done, an [ANNOUNCE] thread announcing the release to the dev@ mail python send_email.py announce ``` -### Github Release +### GitHub Release -Finally, so the Github UI reflects the latest release, you should create a release from the +Finally, so the GitHub UI reflects the latest release, you should create a release from the tag corresponding with the new version. Go to https://github.com/apache/superset/tags, click the 3-dot icon and select `Create Release`, paste the content of the ANNOUNCE thread in the release notes, and publish the new release. diff --git a/RELEASING/changelog.py b/RELEASING/changelog.py index 8e329b5fe0f6c..5d4f346c8edfb 100644 --- a/RELEASING/changelog.py +++ b/RELEASING/changelog.py @@ -26,7 +26,7 @@ try: from github import BadCredentialsException, Github, PullRequest, Repository except ModuleNotFoundError: - print("PyGithub is a required package for this script") + print("PyGitHub is a required package for this script") exit(1) SUPERSET_REPO = "apache/superset" diff --git a/RELEASING/release-notes-0-38/README.md b/RELEASING/release-notes-0-38/README.md index 483271fa25a8c..817f27d771966 100644 --- a/RELEASING/release-notes-0-38/README.md +++ b/RELEASING/release-notes-0-38/README.md @@ -167,7 +167,7 @@ Other features Alerts (send notification when a condition is met) ([Roadmap](https://github.com/apache-superset/superset-roadmap/issues/54)) - feat: add test email functionality to SQL-based email alerts (#[10476](https://github.com/apache/superset/pull/10476)) -- feat: refractored SQL-based alerting framework (#[10605](https://github.com/apache/superset/pull/10605)) +- feat: refactored SQL-based alerting framework (#[10605](https://github.com/apache/superset/pull/10605)) [SIP-34] Proposal to establish a new design direction, system, and process for Superset ([SIP](https://github.com/apache/superset/issues/8976)) diff --git a/RELEASING/release-notes-1-2/README.md b/RELEASING/release-notes-1-2/README.md index 2ae0a728f34f9..4e8895cbfea5e 100644 --- a/RELEASING/release-notes-1-2/README.md +++ b/RELEASING/release-notes-1-2/README.md @@ -87,7 +87,7 @@ Expanding the API has been an ongoing effort, and 1.2 introduces several new API - [14461](https://github.com/apache/superset/pull/14461) feat(native-filters): Auto apply changes in FiltersConfigModal (#14461) (@simcha90) - [13507](https://github.com/apache/superset/pull/13507) feat(native-filters): Filter set tabs (#13507) (@simcha90) - [14313](https://github.com/apache/superset/pull/14313) feat(native-filters): Implement adhoc filters and time picker in Range and Select native filters (#14313) (@Kamil Gabryjelski) -- [14261](https://github.com/apache/superset/pull/14261) feat(native-filters): Show/Hide filter bar by metdata ff (#14261) (@simcha90) +- [14261](https://github.com/apache/superset/pull/14261) feat(native-filters): Show/Hide filter bar by metadata ff (#14261) (@simcha90) - [13506](https://github.com/apache/superset/pull/13506) feat(native-filters): Update filter bar buttons (#13506) (@simcha90) - [14374](https://github.com/apache/superset/pull/14374) feat(native-filters): Use datasets in dashboard as default options for native filters (#14374) (@Kamil Gabryjelski) - [14314](https://github.com/apache/superset/pull/14314) feat(native-filters): add option to create value in select filter (#14314) (@Ville Brofeldt) diff --git a/RELEASING/release-notes-1-4/README.md b/RELEASING/release-notes-1-4/README.md index 9d3a7e99d32f8..267b122abaaf9 100644 --- a/RELEASING/release-notes-1-4/README.md +++ b/RELEASING/release-notes-1-4/README.md @@ -19,7 +19,7 @@ under the License. # Release Notes for Superset 1.4 -Superset 1.4 focuses heavily on continuing to polish the core Superset experience. This release has a very very long list of fixes from across the community. +Superset 1.4 focuses heavily on continuing to polish the core Superset experience. This release has a very long list of fixes from across the community. - [**User Experience**](#user-facing-features) - [**Database Experience**](#database-experience) diff --git a/UPDATING.md b/UPDATING.md index fb6565848a164..e6cce388670dd 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -25,7 +25,7 @@ assists people when migrating to a new version. ## Next - [19046](https://github.com/apache/superset/pull/19046): Enables the drag and drop interface in Explore control panel by default. Flips `ENABLE_EXPLORE_DRAG_AND_DROP` and `ENABLE_DND_WITH_CLICK_UX` feature flags to `True`. -- [18936](https://github.com/apache/superset/pull/18936): Removes legacy SIP-15 interm logic/flags—specifically the `SIP_15_ENABLED`, `SIP_15_GRACE_PERIOD_END`, `SIP_15_DEFAULT_TIME_RANGE_ENDPOINTS`, and `SIP_15_TOAST_MESSAGE` flags. Time range endpoints are no longer configurable and strictly adhere to the `[start, end)` paradigm, i.e., inclusive of the start and exclusive of the end. Additionally this change removes the now obsolete `time_range_endpoints` from the form-data and resulting in the cache being busted. +- [18936](https://github.com/apache/superset/pull/18936): Removes legacy SIP-15 interim logic/flags—specifically the `SIP_15_ENABLED`, `SIP_15_GRACE_PERIOD_END`, `SIP_15_DEFAULT_TIME_RANGE_ENDPOINTS`, and `SIP_15_TOAST_MESSAGE` flags. Time range endpoints are no longer configurable and strictly adhere to the `[start, end)` paradigm, i.e., inclusive of the start and exclusive of the end. Additionally this change removes the now obsolete `time_range_endpoints` from the form-data and resulting in the cache being busted. - [19570](https://github.com/apache/superset/pull/19570): makes [sqloxide](https://pypi.org/project/sqloxide/) optional so the SIP-68 migration can be run on aarch64. If the migration is taking too long installing sqloxide manually should improve the performance. ### Breaking Changes @@ -66,8 +66,8 @@ assists people when migrating to a new version. ### Other - [17589](https://github.com/apache/superset/pull/17589): It is now possible to limit access to users' recent activity data by setting the `ENABLE_BROAD_ACTIVITY_ACCESS` config flag to false, or customizing the `raise_for_user_activity_access` method in the security manager. -- [17536](https://github.com/apache/superset/pull/17536): introduced a key-value endpoint to store dashboard filter state. This endpoint is backed by Flask-Caching and the default configuration assumes that the values will be stored in the file system. If you are already using another cache backend like Redis or Memchached, you'll probably want to change this setting in `superset_config.py`. The key is `FILTER_STATE_CACHE_CONFIG` and the available settings can be found in Flask-Caching [docs](https://flask-caching.readthedocs.io/en/latest/). -- [17882](https://github.com/apache/superset/pull/17882): introduced a key-value endpoint to store Explore form data. This endpoint is backed by Flask-Caching and the default configuration assumes that the values will be stored in the file system. If you are already using another cache backend like Redis or Memchached, you'll probably want to change this setting in `superset_config.py`. The key is `EXPLORE_FORM_DATA_CACHE_CONFIG` and the available settings can be found in Flask-Caching [docs](https://flask-caching.readthedocs.io/en/latest/). +- [17536](https://github.com/apache/superset/pull/17536): introduced a key-value endpoint to store dashboard filter state. This endpoint is backed by Flask-Caching and the default configuration assumes that the values will be stored in the file system. If you are already using another cache backend like Redis or Memcached, you'll probably want to change this setting in `superset_config.py`. The key is `FILTER_STATE_CACHE_CONFIG` and the available settings can be found in Flask-Caching [docs](https://flask-caching.readthedocs.io/en/latest/). +- [17882](https://github.com/apache/superset/pull/17882): introduced a key-value endpoint to store Explore form data. This endpoint is backed by Flask-Caching and the default configuration assumes that the values will be stored in the file system. If you are already using another cache backend like Redis or Memcached, you'll probably want to change this setting in `superset_config.py`. The key is `EXPLORE_FORM_DATA_CACHE_CONFIG` and the available settings can be found in Flask-Caching [docs](https://flask-caching.readthedocs.io/en/latest/). ## 1.4.1 @@ -177,7 +177,7 @@ assists people when migrating to a new version. - [11575](https://github.com/apache/superset/pull/11575) The Row Level Security (RLS) config flag has been moved to a feature flag. To migrate, add `ROW_LEVEL_SECURITY: True` to the `FEATURE_FLAGS` dict in `superset_config.py`. -- [11259](https://github.com/apache/superset/pull/11259): config flag ENABLE_REACT_CRUD_VIEWS has been set to `True` by default, set to `False` if you prefer to the vintage look and feel. However, we may discontine support on the vintage list view in the future. +- [11259](https://github.com/apache/superset/pull/11259): config flag ENABLE_REACT_CRUD_VIEWS has been set to `True` by default, set to `False` if you prefer to the vintage look and feel. However, we may discontinue support on the vintage list view in the future. - [11244](https://github.com/apache/superset/pull/11244): The `REDUCE_DASHBOARD_BOOTSTRAP_PAYLOAD` feature flag has been removed after being set to True for multiple months. @@ -190,7 +190,7 @@ assists people when migrating to a new version. ### Potential Downtime -- [11920](https://github.com/apache/superset/pull/11920): Undos the DB migration from [11714](https://github.com/apache/superset/pull/11714) to prevent adding new columns to the logs table. Deploying a sha between these two PRs may result in locking your DB. +- [11920](https://github.com/apache/superset/pull/11920): Undoes the DB migration from [11714](https://github.com/apache/superset/pull/11714) to prevent adding new columns to the logs table. Deploying a sha between these two PRs may result in locking your DB. - [11714](https://github.com/apache/superset/pull/11714): Logs significantly more analytics events (roughly double?), and when @@ -219,7 +219,7 @@ assists people when migrating to a new version. - [10324](https://github.com/apache/superset/pull/10324): Facebook Prophet has been introduced as an optional dependency to add support for timeseries forecasting in the chart data API. To enable this feature, install Superset with the optional dependency `prophet` or directly `pip install fbprophet`. -- [10320](https://github.com/apache/superset/pull/10320): References to blacklst/whitelist language have been replaced with more appropriate alternatives. All configs refencing containing `WHITE`/`BLACK` have been replaced with `ALLOW`/`DENY`. Affected config variables that need to be updated: `TIME_GRAIN_BLACKLIST`, `VIZ_TYPE_BLACKLIST`, `DRUID_DATA_SOURCE_BLACKLIST`. +- [10320](https://github.com/apache/superset/pull/10320): References to blacklist/whitelist language have been replaced with more appropriate alternatives. All configs referencing containing `WHITE`/`BLACK` have been replaced with `ALLOW`/`DENY`. Affected config variables that need to be updated: `TIME_GRAIN_BLACKLIST`, `VIZ_TYPE_BLACKLIST`, `DRUID_DATA_SOURCE_BLACKLIST`. ## 0.37.1 @@ -233,7 +233,7 @@ assists people when migrating to a new version. - [10222](https://github.com/apache/superset/pull/10222): a change which changes how payloads are cached. Previous cached objects cannot be decoded and thus will be reloaded from source. -- [10130](https://github.com/apache/superset/pull/10130): a change which deprecates the `dbs.perm` column in favor of SQLAlchemy [hybird attributes](https://docs.sqlalchemy.org/en/13/orm/extensions/hybrid.html). +- [10130](https://github.com/apache/superset/pull/10130): a change which deprecates the `dbs.perm` column in favor of SQLAlchemy [hybrid attributes](https://docs.sqlalchemy.org/en/13/orm/extensions/hybrid.html). - [10034](https://github.com/apache/superset/pull/10034): a change which deprecates the public security manager `assert_datasource_permission`, `assert_query_context_permission`, `assert_viz_permission`, and `rejected_tables` methods with the `raise_for_access` method which also handles assertion logic for SQL tables. @@ -326,7 +326,7 @@ assists people when migrating to a new version. - We're deprecating the concept of "restricted metric", this feature was not fully working anyhow. - [8117](https://github.com/apache/superset/pull/8117): If you are - using `ENABLE_PROXY_FIX = True`, review the newly-introducted variable, + using `ENABLE_PROXY_FIX = True`, review the newly-introduced variable, `PROXY_FIX_CONFIG`, which changes the proxy behavior in accordance with [Werkzeug](https://werkzeug.palletsprojects.com/en/0.15.x/middleware/proxy_fix/) diff --git a/docker-compose.yml b/docker-compose.yml index 907ca51129caa..2c814363e784c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -118,7 +118,7 @@ services: depends_on: *superset-depends-on user: *superset-user volumes: *superset-volumes - # Bump memory limit if processing selenium / thumbails on superset-worker + # Bump memory limit if processing selenium / thumbnails on superset-worker # mem_limit: 2038m # mem_reservation: 128M diff --git a/docs/docs/contributing/contributing-page.mdx b/docs/docs/contributing/contributing-page.mdx index f4f3cd6400fb6..6e205bf0bbf81 100644 --- a/docs/docs/contributing/contributing-page.mdx +++ b/docs/docs/contributing/contributing-page.mdx @@ -13,8 +13,8 @@ which can be joined by anyone): - [Mailing list](https://lists.apache.org/list.html?dev@superset.apache.org) - [Apache Superset Slack community](https://join.slack.com/t/apache-superset/shared_invite/zt-16jvzmoi8-sI7jKWp~xc2zYRe~NqiY9Q) -- [Github issues and PR's](https://github.com/apache/superset/issues) +- [GitHub issues and PR's](https://github.com/apache/superset/issues) More references: - [Comprehensive Tutorial for Contributing Code to Apache Superset](https://preset.io/blog/tutorial-contributing-code-to-apache-superset/) -- [CONTRIBUTING Guide on Github](https://github.com/apache/superset/blob/master/CONTRIBUTING.md) +- [CONTRIBUTING Guide on GitHub](https://github.com/apache/superset/blob/master/CONTRIBUTING.md) diff --git a/docs/docs/contributing/pull-request-guidelines.mdx b/docs/docs/contributing/pull-request-guidelines.mdx index f37efd785eb60..4e2f823a979ed 100644 --- a/docs/docs/contributing/pull-request-guidelines.mdx +++ b/docs/docs/contributing/pull-request-guidelines.mdx @@ -41,7 +41,7 @@ Finally, never submit a PR that will put master branch in broken state. If the P - `chore` (updating tasks etc; no application logic change) - `perf` (performance-related change) - `build` (build tooling, Docker configuration change) - - `ci` (test runner, Github Actions workflow changes) + - `ci` (test runner, GitHub Actions workflow changes) - `other` (changes that don't correspond to the above -- should be rare!) - Examples: - `feat: export charts as ZIP files` diff --git a/docs/docs/creating-charts-dashboards/creating-your-first-dashboard.mdx b/docs/docs/creating-charts-dashboards/creating-your-first-dashboard.mdx index 39400a1c29031..ecabf896f8e43 100644 --- a/docs/docs/creating-charts-dashboards/creating-your-first-dashboard.mdx +++ b/docs/docs/creating-charts-dashboards/creating-your-first-dashboard.mdx @@ -94,7 +94,7 @@ The Superset semantic layer can store 2 types of computed data: 1. Virtual metrics: you can write SQL queries that aggregate values from multiple column (e.g. `SUM(recovered) / SUM(confirmed)`) and make them available as columns for (e.g. `recovery_rate`) visualization in Explore. -Agggregate functions are allowed and encouraged for metrics. +Aggregate functions are allowed and encouraged for metrics. @@ -182,7 +182,7 @@ Access to dashboards is managed via owners (users that have edit permissions to Non-owner users access can be managed two different ways: -1. Dataset permissions - if you add to the relevant role permissions to datasets it automatically grants implict access to all dashboards that uses those permitted datasets +1. Dataset permissions - if you add to the relevant role permissions to datasets it automatically grants implicit access to all dashboards that uses those permitted datasets 2. Dashboard roles - if you enable **DASHBOARD_RBAC** feature flag then you be able to manage which roles can access the dashboard - Having dashboard access implicitly grants read access to the associated datasets, therefore all charts will load their data even if feature flag is turned on and no roles assigned diff --git a/docs/docs/creating-charts-dashboards/exploring-data.mdx b/docs/docs/creating-charts-dashboards/exploring-data.mdx index 65f7cae737996..0386b2384203f 100644 --- a/docs/docs/creating-charts-dashboards/exploring-data.mdx +++ b/docs/docs/creating-charts-dashboards/exploring-data.mdx @@ -40,7 +40,7 @@ tick the checkbox for **Allow Data Upload**. End by clicking the **Save** button ### Loading CSV Data Download the CSV dataset to your computer from -[Github](https://raw.githubusercontent.com/apache-superset/examples-data/master/tutorial_flights.csv). +[GitHub](https://raw.githubusercontent.com/apache-superset/examples-data/master/tutorial_flights.csv). In the Superset menu, select **Data ‣ Upload a CSV**. diff --git a/docs/docs/databases/drill.mdx b/docs/docs/databases/drill.mdx index 303eb55cbf22e..9006c8f98cf24 100644 --- a/docs/docs/databases/drill.mdx +++ b/docs/docs/databases/drill.mdx @@ -36,12 +36,12 @@ Connecting to Drill through JDBC is more complicated and we recommend following The connection string looks like: ``` -drill+jdbc://:@: +drill+jdbc://:@: ``` ### ODBC We recommend reading the [Apache Drill documentation](https://drill.apache.org/docs/installing-the-driver-on-linux/) and read -the [Github README](https://github.com/JohnOmernik/sqlalchemy-drill#usage-with-odbc) to learn how to +the [GitHub README](https://github.com/JohnOmernik/sqlalchemy-drill#usage-with-odbc) to learn how to work with Drill through ODBC. diff --git a/docs/docs/databases/elasticsearch.mdx b/docs/docs/databases/elasticsearch.mdx index 519bc370edf93..70b7f8f685e2b 100644 --- a/docs/docs/databases/elasticsearch.mdx +++ b/docs/docs/databases/elasticsearch.mdx @@ -46,7 +46,7 @@ POST /_aliases } ``` -Then register your table with the alias name logstasg_all +Then register your table with the alias name logstash_all **Time zone** diff --git a/docs/docs/installation/alerts-reports.mdx b/docs/docs/installation/alerts-reports.mdx index 8ab37cc90529a..a7491ad03eda0 100644 --- a/docs/docs/installation/alerts-reports.mdx +++ b/docs/docs/installation/alerts-reports.mdx @@ -387,7 +387,7 @@ THUMBNAIL_SELENIUM_USER = 'username_with_permission_to_access_dashboards' ### Schedule Reports -You can optionally allow your users to schedule queries directly in SQL Lab. This is done by addding +You can optionally allow your users to schedule queries directly in SQL Lab. This is done by adding extra metadata to saved queries, which are then picked up by an external scheduled (like [Apache Airflow](https://airflow.apache.org/)). diff --git a/docs/docs/installation/configuring-superset.mdx b/docs/docs/installation/configuring-superset.mdx index 1384b62741cba..66c89c580654a 100644 --- a/docs/docs/installation/configuring-superset.mdx +++ b/docs/docs/installation/configuring-superset.mdx @@ -125,7 +125,7 @@ If you're not using Gunicorn, you may want to disable the use of `flask-compress If you are running superset behind a load balancer or reverse proxy (e.g. NGINX or ELB on AWS), you may need to utilize a healthcheck endpoint so that your load balancer knows if your superset instance is running. This is provided at `/health` which will return a 200 response containing “OK” -if the the webserver is running. +if the webserver is running. If the load balancer is inserting `X-Forwarded-For/X-Forwarded-Proto` headers, you should set `ENABLE_PROXY_FIX = True` in the superset config file (`superset_config.py`) to extract and use the @@ -140,7 +140,7 @@ RequestHeader set X-Forwarded-Proto "https" ### Custom OAuth2 Configuration -Beyond FAB supported providers (Github, Twitter, LinkedIn, Google, Azure, etc), its easy to connect +Beyond FAB supported providers (GitHub, Twitter, LinkedIn, Google, Azure, etc), its easy to connect Superset with other OAuth2 Authorization Server implementations that support “code” authorization. Make sure the pip package [`Authlib`](https://authlib.org/) is installed on the webserver. diff --git a/docs/docs/installation/installing-superset-using-docker-compose.mdx b/docs/docs/installation/installing-superset-using-docker-compose.mdx index ced6ba5660a3b..8daaa2e6302b7 100644 --- a/docs/docs/installation/installing-superset-using-docker-compose.mdx +++ b/docs/docs/installation/installing-superset-using-docker-compose.mdx @@ -38,7 +38,7 @@ of that VM. We recommend assigning at least 8GB of RAM to the virtual machine as provisioning a hard drive of at least 40GB, so that there will be enough space for both the OS and all of the required dependencies. Docker Desktop [recently added support for Windows Subsystem for Linux (WSL) 2](https://docs.docker.com/docker-for-windows/wsl/), which may be another option. -### 2. Clone Superset's Github repository +### 2. Clone Superset's GitHub repository [Clone Superset's repo](https://github.com/apache/superset) in your terminal with the following command: diff --git a/docs/docs/installation/sql-templating.mdx b/docs/docs/installation/sql-templating.mdx index 2a80f0fbf65f6..8908d39f0280e 100644 --- a/docs/docs/installation/sql-templating.mdx +++ b/docs/docs/installation/sql-templating.mdx @@ -119,7 +119,7 @@ In this section, we'll walkthrough the pre-defined Jinja macros in Superset. The `{{ current_username() }}` macro returns the username of the currently logged in user. -If you have caching enabled in your Superset configuration, then by default the the `username` value will be used +If you have caching enabled in your Superset configuration, then by default the `username` value will be used by Superset when calculating the cache key. A cache key is a unique identifier that determines if there's a cache hit in the future and Superset can retrieve cached data. @@ -134,7 +134,7 @@ cache key by adding the following parameter to your Jinja code: The `{{ current_user_id() }}` macro returns the user_id of the currently logged in user. -If you have caching enabled in your Superset configuration, then by default the the `user_id` value will be used +If you have caching enabled in your Superset configuration, then by default the `user_id` value will be used by Superset when calculating the cache key. A cache key is a unique identifier that determines if there's a cache hit in the future and Superset can retrieve cached data. @@ -182,7 +182,7 @@ Here's a concrete example: **Explicitly Including Values in Cache Key** The `{{ cache_key_wrapper() }}` function explicitly instructs Superset to add a value to the -accumulated list of values used in the the calculation of the cache key. +accumulated list of values used in the calculation of the cache key. This function is only needed when you want to wrap your own custom function return values in the cache key. You can gain more context diff --git a/docs/docs/intro.mdx b/docs/docs/intro.mdx index c4d0a138446cb..eb8a2f0a61fb0 100644 --- a/docs/docs/intro.mdx +++ b/docs/docs/intro.mdx @@ -19,7 +19,7 @@ Here are a **few different ways you can get started with Superset**: using [Docker Compose](installation/installing-superset-using-docker-compose) - Download the [Docker image](https://hub.docker.com/r/apache/superset) from Dockerhub - Install the latest version of Superset - [from Github](https://github.com/apache/superset/tree/latest) + [from GitHub](https://github.com/apache/superset/tree/latest) Superset provides: diff --git a/docs/docs/miscellaneous/chart-params.mdx b/docs/docs/miscellaneous/chart-params.mdx index 0bd94db22694b..e7bef0e4a4830 100644 --- a/docs/docs/miscellaneous/chart-params.mdx +++ b/docs/docs/miscellaneous/chart-params.mdx @@ -9,7 +9,7 @@ version: 1 Chart parameters are stored as a JSON encoded string the `slices.params` column and are often referenced throughout the code as form-data. Currently the form-data is neither versioned nor typed as thus is somewhat free-formed. Note in the future there may be merit in using something like [JSON Schema](https://json-schema.org/) to both annotate and validate the JSON object in addition to using a Mypy `TypedDict` (introduced in Python 3.8) for typing the form-data in the backend. This section serves as a potential primer for that work. -The following tables provide a non-exhausive list of the various fields which can be present in the JSON object grouped by the Explorer pane sections. These values were obtained by extracting the distinct fields from a legacy deployment consisting of tens of thousands of charts and thus some fields may be missing whilst others may be deprecated. +The following tables provide a non-exhaustive list of the various fields which can be present in the JSON object grouped by the Explorer pane sections. These values were obtained by extracting the distinct fields from a legacy deployment consisting of tens of thousands of charts and thus some fields may be missing whilst others may be deprecated. Note not all fields are correctly categorized. The fields vary based on visualization type and may appear in different sections depending on the type. Verified deprecated columns may indicate a missing migration and/or prior migrations which were unsuccessful and thus future work may be required to clean up the form-data. diff --git a/docs/src/resources/data.js b/docs/src/resources/data.js index 3c0cf718a4b16..08d0781f86965 100644 --- a/docs/src/resources/data.js +++ b/docs/src/resources/data.js @@ -19,7 +19,7 @@ export const Databases = [ { - title: 'Amazon Redshfit', + title: 'Amazon Redshift', href: 'https://aws.amazon.com/redshift/', imgName: 'aws-redshift.png', }, @@ -106,7 +106,7 @@ export const Databases = [ { title: 'SQLite', href: 'https://www.sqlite.org/index.html', - imgName: 'sqllite.png', + imgName: 'sqlite.png', }, { title: 'Trino', diff --git a/docs/static/img/databases/sqllite.jpg b/docs/static/img/databases/sqlite.jpg similarity index 100% rename from docs/static/img/databases/sqllite.jpg rename to docs/static/img/databases/sqlite.jpg diff --git a/docs/static/img/databases/sqllite.png b/docs/static/img/databases/sqlite.png similarity index 100% rename from docs/static/img/databases/sqllite.png rename to docs/static/img/databases/sqlite.png diff --git a/superset-frontend/packages/superset-ui-core/src/number-format/README.md b/superset-frontend/packages/superset-ui-core/src/number-format/README.md index c4663c0149c3b..e3e5099243b52 100644 --- a/superset-frontend/packages/superset-ui-core/src/number-format/README.md +++ b/superset-frontend/packages/superset-ui-core/src/number-format/README.md @@ -68,7 +68,7 @@ There is also a formatter based on [pretty-ms](https://www.npmjs.com/package/pre used to format time durations: ```js -import { createDurationFormatter, formatNumber, getNumberFormatterRegistry } from from '@superset-ui-number-format'; +import { createDurationFormatter, formatNumber, getNumberFormatterRegistry } from '@superset-ui-number-format'; getNumberFormatterRegistry().registerValue('my_duration_format', createDurationFormatter({ colonNotation: true }); console.log(formatNumber('my_duration_format', 95500)) diff --git a/superset-frontend/src/components/FilterableTable/FilterableTable.tsx b/superset-frontend/src/components/FilterableTable/FilterableTable.tsx index c0b49f8619d33..0616c925fd991 100644 --- a/superset-frontend/src/components/FilterableTable/FilterableTable.tsx +++ b/superset-frontend/src/components/FilterableTable/FilterableTable.tsx @@ -312,7 +312,7 @@ export default class FilterableTable extends PureComponent< this.props.orderedColumnKeys.forEach((key, index) => { // we can't use Math.max(...colWidths.slice(...)) here since the number // of elements might be bigger than the number of allowed arguments in a - // Javascript function + // JavaScript function widthsByColumnKey[key] = colWidths .slice( diff --git a/superset/charts/post_processing.py b/superset/charts/post_processing.py index 7b21290396b37..715f465574906 100644 --- a/superset/charts/post_processing.py +++ b/superset/charts/post_processing.py @@ -18,7 +18,7 @@ Functions to reproduce the post-processing of data on text charts. Some text-based charts (pivot tables and t-test table) perform -post-processing of the data in Javascript. When sending the data +post-processing of the data in JavaScript. When sending the data to users in reports we want to show the same data they would see on Explore. diff --git a/superset/migrations/versions/96e99fb176a0_add_import_mixing_to_saved_query.py b/superset/migrations/versions/96e99fb176a0_add_import_mixing_to_saved_query.py index f93deb1d0c950..ffe0caf64c90d 100644 --- a/superset/migrations/versions/96e99fb176a0_add_import_mixing_to_saved_query.py +++ b/superset/migrations/versions/96e99fb176a0_add_import_mixing_to_saved_query.py @@ -78,7 +78,7 @@ def upgrade(): try: # Add uniqueness constraint with op.batch_alter_table("saved_query") as batch_op: - # Batch mode is required for sqllite + # Batch mode is required for sqlite batch_op.create_unique_constraint("uq_saved_query_uuid", ["uuid"]) except OperationalError: pass diff --git a/superset/migrations/versions/b56500de1855_add_uuid_column_to_import_mixin.py b/superset/migrations/versions/b56500de1855_add_uuid_column_to_import_mixin.py index 0872cf5b3bb5d..c392c3e78caff 100644 --- a/superset/migrations/versions/b56500de1855_add_uuid_column_to_import_mixin.py +++ b/superset/migrations/versions/b56500de1855_add_uuid_column_to_import_mixin.py @@ -139,7 +139,7 @@ def upgrade(): # add uniqueness constraint with op.batch_alter_table(table_name) as batch_op: - # batch mode is required for sqllite + # batch mode is required for sqlite batch_op.create_unique_constraint(f"uq_{table_name}_uuid", ["uuid"]) # add UUID to Dashboard.position_json diff --git a/superset/migrations/versions/c501b7c653a3_add_missing_uuid_column.py b/superset/migrations/versions/c501b7c653a3_add_missing_uuid_column.py index 786b41a1c72b8..6cdf7f5616cad 100644 --- a/superset/migrations/versions/c501b7c653a3_add_missing_uuid_column.py +++ b/superset/migrations/versions/c501b7c653a3_add_missing_uuid_column.py @@ -77,7 +77,7 @@ def upgrade(): # add uniqueness constraint with op.batch_alter_table(table_name) as batch_op: - # batch mode is required for sqllite + # batch mode is required for sqlite batch_op.create_unique_constraint(f"uq_{table_name}_uuid", ["uuid"]) # add UUID to Dashboard.position_json; this function is idempotent diff --git a/superset/utils/pandas_postprocessing/pivot.py b/superset/utils/pandas_postprocessing/pivot.py index 829329e71fc8b..89a187ce89c0b 100644 --- a/superset/utils/pandas_postprocessing/pivot.py +++ b/superset/utils/pandas_postprocessing/pivot.py @@ -56,7 +56,7 @@ def pivot( # pylint: disable=too-many-arguments,too-many-locals :param drop_missing_columns: Do not include columns whose entries are all missing :param combine_value_with_metric: Display metrics side by side within each column, as opposed to each column being displayed side by side for each metric. - :param aggregates: A mapping from aggregate column name to the the aggregate + :param aggregates: A mapping from aggregate column name to the aggregate config. :param marginal_distributions: Add totals for row/column. Default to False :param marginal_distribution_name: Name of row/column with marginal distribution. From bebb10e4957bd1bb67ac799397825d340c8bd029 Mon Sep 17 00:00:00 2001 From: Josh Soref <2119212+jsoref@users.noreply.github.com> Date: Tue, 26 Apr 2022 13:35:01 -0400 Subject: [PATCH 122/136] chore(frontend-tests): Spelling (#19853) * spelling: against Signed-off-by: Josh Soref * spelling: been Signed-off-by: Josh Soref * spelling: charts Signed-off-by: Josh Soref * spelling: clicking Signed-off-by: Josh Soref * spelling: columns Signed-off-by: Josh Soref * spelling: duplicate Signed-off-by: Josh Soref * spelling: especially Signed-off-by: Josh Soref * spelling: extensions Signed-off-by: Josh Soref * spelling: fields Signed-off-by: Josh Soref * spelling: filter Signed-off-by: Josh Soref * spelling: for Signed-off-by: Josh Soref * spelling: label Signed-off-by: Josh Soref * spelling: labeled Signed-off-by: Josh Soref * spelling: nativefilter Signed-off-by: Josh Soref * spelling: registry Signed-off-by: Josh Soref * spelling: render Signed-off-by: Josh Soref * spelling: resizable Signed-off-by: Josh Soref * spelling: response Signed-off-by: Josh Soref * spelling: successful Signed-off-by: Josh Soref * spelling: transform Signed-off-by: Josh Soref * spelling: unfortunately Signed-off-by: Josh Soref * spelling: until Signed-off-by: Josh Soref * spelling: virtual Signed-off-by: Josh Soref * spelling: wrapper Signed-off-by: Josh Soref Co-authored-by: Josh Soref --- .../cypress/integration/dashboard/key_value.test.ts | 4 ++-- .../integration/explore/visualizations/bubble.test.js | 4 ++-- .../cypress/integration/sqllab/query.test.ts | 2 +- .../templates/test/plugin/transformProps.test.erb | 4 ++-- .../NumberFormatterRegistrySingleton.test.ts | 2 +- .../superset-ui-core/test/query/api/v1/makeApi.test.ts | 2 +- .../time-format/TimeFormatterRegistrySingleton.test.ts | 2 +- .../test/translation/Translator.test.ts | 2 +- .../test/BoxPlot/transformProps.test.ts | 4 ++-- .../test/Funnel/transformProps.test.ts | 4 ++-- .../test/Graph/transformProps.test.ts | 6 +++--- .../test/Pie/transformProps.test.ts | 4 ++-- .../test/Timeseries/transformProps.test.ts | 2 +- .../test/Tree/transformProps.test.ts | 6 +++--- .../test/Treemap/transformProps.test.ts | 4 ++-- .../plugin-chart-echarts/test/utils/series.test.ts | 2 +- .../plugin-chart-table/test/TableChart.test.tsx | 4 ++-- .../test/legacyPlugin/transformProps.test.ts | 4 ++-- .../components/Datasource/DatasourceEditor.test.jsx | 10 +++++----- .../components/Form/LabeledErrorBoundInput.test.jsx | 2 +- .../CrossFilterScopingForm.test.tsx | 2 +- .../components/filterscope/FilterScope.test.tsx | 4 ++-- .../FiltersConfigForm/getControlItemsMap.test.tsx | 6 +++--- .../dashboard/util/getDetailedComponentWidth.test.js | 6 +++--- .../components/controls/withAsyncVerification.test.tsx | 2 +- .../src/views/CRUD/chart/ChartList.test.jsx | 4 ++-- .../src/views/CRUD/data/dataset/DatasetList.test.jsx | 2 +- superset-frontend/src/views/CRUD/utils.test.tsx | 2 +- 28 files changed, 51 insertions(+), 51 deletions(-) diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/key_value.test.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/key_value.test.ts index 24b6ff0aa7a62..1738ea5bd3d0d 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/key_value.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/key_value.test.ts @@ -27,7 +27,7 @@ interface QueryString { native_filters_key: string; } -xdescribe('nativefiler url param key', () => { +xdescribe('nativefilter url param key', () => { // const urlParams = { param1: '123', param2: 'abc' }; before(() => { cy.login(); @@ -36,7 +36,7 @@ xdescribe('nativefiler url param key', () => { let initialFilterKey: string; it('should have cachekey in nativefilter param', () => { // things in `before` will not retry and the `waitForChartLoad` check is - // especically flaky and may need more retries + // especially flaky and may need more retries cy.visit(WORLD_HEALTH_DASHBOARD); WORLD_HEALTH_CHARTS.forEach(waitForChartLoad); cy.wait(1000); // wait for key to be published (debounced) diff --git a/superset-frontend/cypress-base/cypress/integration/explore/visualizations/bubble.test.js b/superset-frontend/cypress-base/cypress/integration/explore/visualizations/bubble.test.js index 7ed17b1a4d8e7..9bd91f37c5eed 100644 --- a/superset-frontend/cypress-base/cypress/integration/explore/visualizations/bubble.test.js +++ b/superset-frontend/cypress-base/cypress/integration/explore/visualizations/bubble.test.js @@ -57,8 +57,8 @@ describe('Visualization > Bubble', () => { }); // Number of circles are pretty unstable when there are a lot of circles - // Since main functionality is already covered in fitler test below, - // skip this test untill we find a solution. + // Since main functionality is already covered in filter test below, + // skip this test until we find a solution. it.skip('should work', () => { cy.visitChartByParams(JSON.stringify(BUBBLE_FORM_DATA)).then(() => { cy.wait('@getJson').then(xhr => { diff --git a/superset-frontend/cypress-base/cypress/integration/sqllab/query.test.ts b/superset-frontend/cypress-base/cypress/integration/sqllab/query.test.ts index f5033313fcaa2..33b7caf55144a 100644 --- a/superset-frontend/cypress-base/cypress/integration/sqllab/query.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/sqllab/query.test.ts @@ -114,7 +114,7 @@ describe('SqlLab query panel', () => { cy.wait('@sqlLabQuery'); - // Save results to check agains below + // Save results to check against below selectResultsTab().then(resultsA => { initialResultsTable = resultsA[0]; }); diff --git a/superset-frontend/packages/generator-superset/generators/plugin-chart/templates/test/plugin/transformProps.test.erb b/superset-frontend/packages/generator-superset/generators/plugin-chart/templates/test/plugin/transformProps.test.erb index 608f3084fa014..5081180b6b9c6 100644 --- a/superset-frontend/packages/generator-superset/generators/plugin-chart/templates/test/plugin/transformProps.test.erb +++ b/superset-frontend/packages/generator-superset/generators/plugin-chart/templates/test/plugin/transformProps.test.erb @@ -19,7 +19,7 @@ import { ChartProps } from '@superset-ui/core'; import transformProps from '../../src/plugin/transformProps'; -describe('<%= packageLabel %> tranformProps', () => { +describe('<%= packageLabel %> transformProps', () => { const formData = { colorScheme: 'bnbColors', datasource: '3__table', @@ -39,7 +39,7 @@ describe('<%= packageLabel %> tranformProps', () => { }], }); - it('should tranform chart props for viz', () => { + it('should transform chart props for viz', () => { expect(transformProps(chartProps)).toEqual({ width: 800, height: 600, diff --git a/superset-frontend/packages/superset-ui-core/test/number-format/NumberFormatterRegistrySingleton.test.ts b/superset-frontend/packages/superset-ui-core/test/number-format/NumberFormatterRegistrySingleton.test.ts index b978f5b27d6ae..dbb7eac6df4af 100644 --- a/superset-frontend/packages/superset-ui-core/test/number-format/NumberFormatterRegistrySingleton.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/number-format/NumberFormatterRegistrySingleton.test.ts @@ -26,7 +26,7 @@ import { describe('NumberFormatterRegistrySingleton', () => { describe('getNumberFormatterRegistry()', () => { - it('returns a NumberFormatterRegisry', () => { + it('returns a NumberFormatterRegistry', () => { expect(getNumberFormatterRegistry()).toBeInstanceOf( NumberFormatterRegistry, ); diff --git a/superset-frontend/packages/superset-ui-core/test/query/api/v1/makeApi.test.ts b/superset-frontend/packages/superset-ui-core/test/query/api/v1/makeApi.test.ts index 899011b2811d1..f8cd445250455 100644 --- a/superset-frontend/packages/superset-ui-core/test/query/api/v1/makeApi.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/query/api/v1/makeApi.test.ts @@ -204,7 +204,7 @@ describe('makeApi()', () => { expect(result).toBe('ok?'); }); - it('should return raw resposnse when responseType=raw', async () => { + it('should return raw response when responseType=raw', async () => { expect.assertions(2); const api = makeApi({ method: 'DELETE', diff --git a/superset-frontend/packages/superset-ui-core/test/time-format/TimeFormatterRegistrySingleton.test.ts b/superset-frontend/packages/superset-ui-core/test/time-format/TimeFormatterRegistrySingleton.test.ts index eb406c3ca8933..b0a0c0306fb74 100644 --- a/superset-frontend/packages/superset-ui-core/test/time-format/TimeFormatterRegistrySingleton.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/time-format/TimeFormatterRegistrySingleton.test.ts @@ -31,7 +31,7 @@ import TimeFormatterRegistry from '../../src/time-format/TimeFormatterRegistry'; describe('TimeFormatterRegistrySingleton', () => { describe('getTimeFormatterRegistry()', () => { - it('returns a TimeFormatterRegisry', () => { + it('returns a TimeFormatterRegistry', () => { expect(getTimeFormatterRegistry()).toBeInstanceOf(TimeFormatterRegistry); }); }); diff --git a/superset-frontend/packages/superset-ui-core/test/translation/Translator.test.ts b/superset-frontend/packages/superset-ui-core/test/translation/Translator.test.ts index 703d9a5d309c0..9466294aca7a2 100644 --- a/superset-frontend/packages/superset-ui-core/test/translation/Translator.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/translation/Translator.test.ts @@ -156,7 +156,7 @@ describe('Translator', () => { it('throw warning on duplicates', () => { expect(() => { addTranslations({ - haha: ['this is duplciate'], + haha: ['this is duplicate'], }); }).toThrow('Duplicate translation key "haha"'); expect(t('haha')).toEqual('Hahaha'); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/BoxPlot/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/BoxPlot/transformProps.test.ts index 08234fb61ed91..29b912b48ca3d 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/BoxPlot/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/BoxPlot/transformProps.test.ts @@ -20,7 +20,7 @@ import { ChartProps, SqlaFormData } from '@superset-ui/core'; import { EchartsBoxPlotChartProps } from '../../src/BoxPlot/types'; import transformProps from '../../src/BoxPlot/transformProps'; -describe('BoxPlot tranformProps', () => { +describe('BoxPlot transformProps', () => { const formData: SqlaFormData = { datasource: '5__table', granularity_sqla: 'ds', @@ -68,7 +68,7 @@ describe('BoxPlot tranformProps', () => { ], }); - it('should tranform chart props for viz', () => { + it('should transform chart props for viz', () => { expect(transformProps(chartProps as EchartsBoxPlotChartProps)).toEqual( expect.objectContaining({ width: 800, diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Funnel/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Funnel/transformProps.test.ts index 87f377f731f65..74b0510bc66c6 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Funnel/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Funnel/transformProps.test.ts @@ -25,7 +25,7 @@ import { EchartsFunnelLabelTypeType, } from '../../src/Funnel/types'; -describe('Funnel tranformProps', () => { +describe('Funnel transformProps', () => { const formData = { colorScheme: 'bnbColors', datasource: '3__table', @@ -47,7 +47,7 @@ describe('Funnel tranformProps', () => { ], }); - it('should tranform chart props for viz', () => { + it('should transform chart props for viz', () => { expect(transformProps(chartProps as EchartsFunnelChartProps)).toEqual( expect.objectContaining({ width: 800, diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Graph/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Graph/transformProps.test.ts index bb73d77f11f78..831a9bf901edd 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Graph/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Graph/transformProps.test.ts @@ -20,8 +20,8 @@ import { ChartProps } from '@superset-ui/core'; import transformProps from '../../src/Graph/transformProps'; import { DEFAULT_GRAPH_SERIES_OPTION } from '../../src/Graph/constants'; -describe('EchartsGraph tranformProps', () => { - it('should tranform chart props for viz without category', () => { +describe('EchartsGraph transformProps', () => { + it('should transform chart props for viz without category', () => { const formData = { colorScheme: 'bnbColors', datasource: '3__table', @@ -149,7 +149,7 @@ describe('EchartsGraph tranformProps', () => { ); }); - it('should tranform chart props for viz with category and falsey normalization', () => { + it('should transform chart props for viz with category and falsey normalization', () => { const formData = { colorScheme: 'bnbColors', datasource: '3__table', diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Pie/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Pie/transformProps.test.ts index 41280c7667cc7..81f40aa9ad440 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Pie/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Pie/transformProps.test.ts @@ -24,7 +24,7 @@ import { import transformProps, { formatPieLabel } from '../../src/Pie/transformProps'; import { EchartsPieChartProps, EchartsPieLabelType } from '../../src/Pie/types'; -describe('Pie tranformProps', () => { +describe('Pie transformProps', () => { const formData: SqlaFormData = { colorScheme: 'bnbColors', datasource: '3__table', @@ -47,7 +47,7 @@ describe('Pie tranformProps', () => { ], }); - it('should tranform chart props for viz', () => { + it('should transform chart props for viz', () => { expect(transformProps(chartProps as EchartsPieChartProps)).toEqual( expect.objectContaining({ width: 800, diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts index 4b5a21e53faf8..1a3fd5b31355b 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts @@ -54,7 +54,7 @@ describe('EchartsTimeseries transformProps', () => { queriesData, }; - it('should tranform chart props for viz', () => { + it('should transform chart props for viz', () => { const chartProps = new ChartProps(chartPropsConfig); expect(transformProps(chartProps as EchartsTimeseriesChartProps)).toEqual( expect.objectContaining({ diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Tree/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Tree/transformProps.test.ts index bcb762b68cb12..af3de96086e37 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Tree/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Tree/transformProps.test.ts @@ -19,7 +19,7 @@ import { ChartProps } from '@superset-ui/core'; import transformProps from '../../src/Tree/transformProps'; -describe('EchartsTree tranformProps', () => { +describe('EchartsTree transformProps', () => { const formData = { colorScheme: 'bnbColors', datasource: '3__table', @@ -35,7 +35,7 @@ describe('EchartsTree tranformProps', () => { width: 800, height: 600, }; - it('should tranform when parent present before child', () => { + it('should transform when parent present before child', () => { const queriesData = [ { colnames: ['id_column', 'relation_column', 'name_column', 'count'], @@ -102,7 +102,7 @@ describe('EchartsTree tranformProps', () => { }), ); }); - it('should tranform when child is present before parent', () => { + it('should transform when child is present before parent', () => { const queriesData = [ { colnames: ['id_column', 'relation_column', 'name_column', 'count'], diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Treemap/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Treemap/transformProps.test.ts index 9bb6fa03bae4c..141cd90cc77aa 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Treemap/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Treemap/transformProps.test.ts @@ -20,7 +20,7 @@ import { ChartProps } from '@superset-ui/core'; import { EchartsTreemapChartProps } from '../../src/Treemap/types'; import transformProps from '../../src/Treemap/transformProps'; -describe('Treemap tranformProps', () => { +describe('Treemap transformProps', () => { const formData = { colorScheme: 'bnbColors', datasource: '3__table', @@ -42,7 +42,7 @@ describe('Treemap tranformProps', () => { ], }); - it('should tranform chart props for viz', () => { + it('should transform chart props for viz', () => { expect(transformProps(chartProps as EchartsTreemapChartProps)).toEqual( expect.objectContaining({ width: 800, diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts index 36cb2047f3654..ae3871c821e2d 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts @@ -230,7 +230,7 @@ describe('formatSeriesName', () => { expect(formatSeriesName(12345678.9, { numberFormatter })).toEqual('12.3M'); }); - it('should use default formatting for for date values without formatter', () => { + it('should use default formatting for date values without formatter', () => { expect(formatSeriesName(new Date('2020-09-11'))).toEqual( '2020-09-11T00:00:00.000Z', ); diff --git a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx index bcca124a50c9c..867b6e6799c3a 100644 --- a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx +++ b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx @@ -67,7 +67,7 @@ describe('plugin-chart-table', () => { }); describe('TableChart', () => { - let wrap: CommonWrapper; // the ReactDataTable wraper + let wrap: CommonWrapper; // the ReactDataTable wrapper let tree: Cheerio; it('render basic data', () => { @@ -93,7 +93,7 @@ describe('plugin-chart-table', () => { , ); tree = wrap.render(); - // should successfull rerender with new props + // should successful rerender with new props const cells = tree.find('td'); expect(tree.find('th').eq(1).text()).toEqual('Sum of Num'); expect(cells.eq(2).text()).toEqual('12.346%'); diff --git a/superset-frontend/plugins/plugin-chart-word-cloud/test/legacyPlugin/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-word-cloud/test/legacyPlugin/transformProps.test.ts index 53f2dcecb3abd..8873d35ca5ca7 100644 --- a/superset-frontend/plugins/plugin-chart-word-cloud/test/legacyPlugin/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-word-cloud/test/legacyPlugin/transformProps.test.ts @@ -20,7 +20,7 @@ import { ChartProps } from '@superset-ui/core'; import transformProps from '../../src/legacyPlugin/transformProps'; -describe('WordCloud tranformProps', () => { +describe('WordCloud transformProps', () => { const formData = { colorScheme: 'bnbColors', datasource: '3__table', @@ -42,7 +42,7 @@ describe('WordCloud tranformProps', () => { ], }); - it('should tranform chart props for word cloud viz', () => { + it('should transform chart props for word cloud viz', () => { expect(transformProps(chartProps)).toEqual({ width: 800, height: 600, diff --git a/superset-frontend/src/components/Datasource/DatasourceEditor.test.jsx b/superset-frontend/src/components/Datasource/DatasourceEditor.test.jsx index dec75afdc33c9..7ad7b9f9a8420 100644 --- a/superset-frontend/src/components/Datasource/DatasourceEditor.test.jsx +++ b/superset-frontend/src/components/Datasource/DatasourceEditor.test.jsx @@ -88,7 +88,7 @@ describe('DatasourceEditor', () => { 'Certification details', ); - userEvent.type(await inputLabel, 'test_lable'); + userEvent.type(await inputLabel, 'test_label'); userEvent.type(await inputDescription, 'test'); userEvent.type(await inputDtmFormat, 'test'); userEvent.type(await inputCertifiedBy, 'test'); @@ -157,11 +157,11 @@ describe('DatasourceEditor', () => { const physicalRadioBtn = screen.getByRole('radio', { name: /physical \(table or view\)/i, }); - const vituralRadioBtn = screen.getByRole('radio', { + const virtualRadioBtn = screen.getByRole('radio', { name: /virtual \(sql\)/i, }); expect(physicalRadioBtn).toBeEnabled(); - expect(vituralRadioBtn).toBeEnabled(); + expect(virtualRadioBtn).toBeEnabled(); }); it('Source Tab: readOnly mode', () => { @@ -170,11 +170,11 @@ describe('DatasourceEditor', () => { const physicalRadioBtn = screen.getByRole('radio', { name: /physical \(table or view\)/i, }); - const vituralRadioBtn = screen.getByRole('radio', { + const virtualRadioBtn = screen.getByRole('radio', { name: /virtual \(sql\)/i, }); expect(physicalRadioBtn).toBeDisabled(); - expect(vituralRadioBtn).toBeDisabled(); + expect(virtualRadioBtn).toBeDisabled(); }); }); diff --git a/superset-frontend/src/components/Form/LabeledErrorBoundInput.test.jsx b/superset-frontend/src/components/Form/LabeledErrorBoundInput.test.jsx index ebfd2b30e42b0..a49b2713d07ad 100644 --- a/superset-frontend/src/components/Form/LabeledErrorBoundInput.test.jsx +++ b/superset-frontend/src/components/Form/LabeledErrorBoundInput.test.jsx @@ -60,7 +60,7 @@ describe('LabeledErrorBoundInput', () => { expect(textboxInput).toBeVisible(); expect(errorText).toBeVisible(); }); - it('renders a LabledErrorBoundInput with a InfoTooltip', async () => { + it('renders a LabeledErrorBoundInput with a InfoTooltip', async () => { defaultProps.hasTooltip = true; render(); diff --git a/superset-frontend/src/dashboard/components/CrossFilterScopingModal/CrossFilterScopingForm/CrossFilterScopingForm.test.tsx b/superset-frontend/src/dashboard/components/CrossFilterScopingModal/CrossFilterScopingForm/CrossFilterScopingForm.test.tsx index 39cb6f92e1024..f1093e2a9e256 100644 --- a/superset-frontend/src/dashboard/components/CrossFilterScopingModal/CrossFilterScopingForm/CrossFilterScopingForm.test.tsx +++ b/superset-frontend/src/dashboard/components/CrossFilterScopingModal/CrossFilterScopingForm/CrossFilterScopingForm.test.tsx @@ -51,7 +51,7 @@ test('Should send correct props', () => { ); }); -test('Should get correct filds', () => { +test('Should get correct fields', () => { const props = createProps(); render(); expect(props.form.getFieldValue).toBeCalledTimes(2); diff --git a/superset-frontend/src/dashboard/components/filterscope/FilterScope.test.tsx b/superset-frontend/src/dashboard/components/filterscope/FilterScope.test.tsx index 920b5200b052c..186079d8f28e8 100644 --- a/superset-frontend/src/dashboard/components/filterscope/FilterScope.test.tsx +++ b/superset-frontend/src/dashboard/components/filterscope/FilterScope.test.tsx @@ -139,7 +139,7 @@ const createProps = () => ({ type CheckboxState = 'checked' | 'unchecked' | 'indeterminate'; /** - * Unfortunatelly react-checkbox-tree doesn't provide an easy way to + * Unfortunately react-checkbox-tree doesn't provide an easy way to * access the checkbox icon. We need this function to find the element. */ function getCheckboxIcon(element: HTMLElement): Element { @@ -151,7 +151,7 @@ function getCheckboxIcon(element: HTMLElement): Element { } /** - * Unfortunatelly when using react-checkbox-tree, the only perceived change of a + * Unfortunately when using react-checkbox-tree, the only perceived change of a * checkbox state change is the fill color of the SVG icon. */ function getCheckboxState(name: string): CheckboxState { diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.test.tsx index fca1fb372e4a2..7a3af4fe2646b 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.test.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.test.tsx @@ -141,7 +141,7 @@ test('Should render null empty when "controlItems" are falsy', () => { expect(container.children).toHaveLength(0); }); -test('Should render render ControlItems', () => { +test('Should render ControlItems', () => { const props = createProps(); const controlItems = [ @@ -154,7 +154,7 @@ test('Should render render ControlItems', () => { expect(screen.getAllByRole('checkbox')).toHaveLength(2); }); -test('Clickin on checkbox', () => { +test('Clicking on checkbox', () => { const props = createProps(); (getControlItems as jest.Mock).mockReturnValue(createControlItems()); const controlItemsMap = getControlItemsMap(props); @@ -166,7 +166,7 @@ test('Clickin on checkbox', () => { expect(props.forceUpdate).toBeCalled(); }); -test('Clickin on checkbox when resetConfig:flase', () => { +test('Clicking on checkbox when resetConfig:flase', () => { const props = createProps(); (getControlItems as jest.Mock).mockReturnValue([ { name: 'name_1', config: { renderTrigger: true, resetConfig: false } }, diff --git a/superset-frontend/src/dashboard/util/getDetailedComponentWidth.test.js b/superset-frontend/src/dashboard/util/getDetailedComponentWidth.test.js index a99f69fcad3d1..c19306141b644 100644 --- a/superset-frontend/src/dashboard/util/getDetailedComponentWidth.test.js +++ b/superset-frontend/src/dashboard/util/getDetailedComponentWidth.test.js @@ -59,7 +59,7 @@ describe('getDetailedComponentWidth', () => { ).toEqual(empty); }); - it('should match component meta width for resizeable components', () => { + it('should match component meta width for resizable components', () => { expect( getDetailedComponentWidth({ component: { id: '', type: types.CHART_TYPE, meta: { width: 1 } }, @@ -76,7 +76,7 @@ describe('getDetailedComponentWidth', () => { getDetailedComponentWidth({ component: { id: '', type: types.COLUMN_TYPE, meta: { width: 3 } }, }), - // note: occupiedWidth is zero for colunns/see test below + // note: occupiedWidth is zero for columns/see test below ).toEqual({ width: 3, occupiedWidth: 0, minimumWidth: 1 }); }); @@ -217,7 +217,7 @@ describe('getDetailedComponentWidth', () => { }, }, }), - // occupiedWidth is zero for colunns/see test below + // occupiedWidth is zero for columns/see test below ).toEqual({ width: 12, occupiedWidth: 0, minimumWidth: 7 }); }); diff --git a/superset-frontend/src/explore/components/controls/withAsyncVerification.test.tsx b/superset-frontend/src/explore/components/controls/withAsyncVerification.test.tsx index 77a8af9374be1..6be029d7a506e 100644 --- a/superset-frontend/src/explore/components/controls/withAsyncVerification.test.tsx +++ b/superset-frontend/src/explore/components/controls/withAsyncVerification.test.tsx @@ -133,7 +133,7 @@ describe('VerifiedMetricsControl', () => { multi: defaultProps.multi, name: defaultProps.name, // in real life, `onChange` should have been called with the updated - // props (both savedMetrics and value should have beend updated), but + // props (both savedMetrics and value should have been updated), but // because of the limitation of enzyme (it cannot get props updated from // useEffect hooks), we are not able to check that here. savedMetrics: defaultProps.savedMetrics, diff --git a/superset-frontend/src/views/CRUD/chart/ChartList.test.jsx b/superset-frontend/src/views/CRUD/chart/ChartList.test.jsx index 394c8b6028a8c..fa5d363c5123e 100644 --- a/superset-frontend/src/views/CRUD/chart/ChartList.test.jsx +++ b/superset-frontend/src/views/CRUD/chart/ChartList.test.jsx @@ -39,7 +39,7 @@ const mockStore = configureStore([thunk]); const store = mockStore({}); const chartsInfoEndpoint = 'glob:*/api/v1/chart/_info*'; -const chartssOwnersEndpoint = 'glob:*/api/v1/chart/related/owners*'; +const chartsOwnersEndpoint = 'glob:*/api/v1/chart/related/owners*'; const chartsCreatedByEndpoint = 'glob:*/api/v1/chart/related/created_by*'; const chartsEndpoint = 'glob:*/api/v1/chart/*'; const chartsVizTypesEndpoint = 'glob:*/api/v1/chart/viz_types'; @@ -66,7 +66,7 @@ fetchMock.get(chartsInfoEndpoint, { permissions: ['can_read', 'can_write'], }); -fetchMock.get(chartssOwnersEndpoint, { +fetchMock.get(chartsOwnersEndpoint, { result: [], }); fetchMock.get(chartsCreatedByEndpoint, { diff --git a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.test.jsx b/superset-frontend/src/views/CRUD/data/dataset/DatasetList.test.jsx index 0854f006de517..ece974e41e7b7 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.test.jsx +++ b/superset-frontend/src/views/CRUD/data/dataset/DatasetList.test.jsx @@ -148,7 +148,7 @@ describe('DatasetList', () => { wrapper.find('[data-test="bulk-select-copy"]').text(), ).toMatchInlineSnapshot(`"0 Selected"`); - // Vitual Selected + // Virtual Selected act(() => { wrapper.find(IndeterminateCheckbox).at(1).props().onChange(checkedEvent); }); diff --git a/superset-frontend/src/views/CRUD/utils.test.tsx b/superset-frontend/src/views/CRUD/utils.test.tsx index dcce0b83697ed..e727a0c896bdb 100644 --- a/superset-frontend/src/views/CRUD/utils.test.tsx +++ b/superset-frontend/src/views/CRUD/utils.test.tsx @@ -213,7 +213,7 @@ test('successfully modified rison to encode correctly', () => { }); }); -test('checkUploadExtenssions should return valid upload extensions', () => { +test('checkUploadExtensions should return valid upload extensions', () => { const uploadExtensionTest = ['a', 'b', 'c']; const randomExtension = ['a', 'c']; const randomExtensionTwo = ['c']; From 7645eac31f2cc583906f504e8896e4a119eee751 Mon Sep 17 00:00:00 2001 From: Diego Medina Date: Tue, 26 Apr 2022 16:08:07 -0400 Subject: [PATCH 123/136] fix: Regression on Data and Alerts & Reports Headers (#19850) --- .../src/views/components/SubMenu.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/superset-frontend/src/views/components/SubMenu.tsx b/superset-frontend/src/views/components/SubMenu.tsx index 2ae897196b78b..3ac1253709379 100644 --- a/superset-frontend/src/views/components/SubMenu.tsx +++ b/superset-frontend/src/views/components/SubMenu.tsx @@ -67,7 +67,7 @@ const StyledHeader = styled.div` padding-left: 10px; } .menu { - background-color: white; + background-color: ${({ theme }) => theme.colors.grayscale.light5}; .ant-menu-horizontal { line-height: inherit; .ant-menu-item { @@ -88,7 +88,8 @@ const StyledHeader = styled.div` } .menu .ant-menu-item { - li { + li, + div { a, div { font-size: ${({ theme }) => theme.typography.sizes.s}px; @@ -98,6 +99,10 @@ const StyledHeader = styled.div` margin: 0; padding: ${({ theme }) => theme.gridUnit * 4}px; line-height: ${({ theme }) => theme.gridUnit * 5}px; + + &:hover { + text-decoration: none; + } } } @@ -106,11 +111,14 @@ const StyledHeader = styled.div` ${({ theme }) => theme.gridUnit * 4}px; } } + li.active > a, li.active > div, + div.active > div, li > a:hover, li > a:focus, - li > div:hover { + li > div:hover, + div > div:hover { background: ${({ theme }) => theme.colors.secondary.light4}; border-bottom: none; border-radius: ${({ theme }) => theme.borderRadius}px; @@ -148,6 +156,7 @@ const StyledHeader = styled.div` const styledDisabled = (theme: SupersetTheme) => css` color: ${theme.colors.grayscale.base}; backgroundColor: ${theme.colors.grayscale.light2}}; + .ant-menu-item:hover { color: ${theme.colors.grayscale.base}; cursor: default; From 5877470aeef461b362ed3b6a5242d0eb5f1d3730 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 Apr 2022 14:19:40 -0600 Subject: [PATCH 124/136] chore(deps-dev): bump eslint-plugin-jsx-a11y in /superset-frontend (#19847) Bumps [eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y) from 6.4.1 to 6.5.1. - [Release notes](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/releases) - [Changelog](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/CHANGELOG.md) - [Commits](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/compare/v6.4.1...v6.5.1) --- updated-dependencies: - dependency-name: eslint-plugin-jsx-a11y dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 116 ++++++++++++++-------------- 1 file changed, 59 insertions(+), 57 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 9ca8a5ca29250..7494af0ea5928 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -25214,15 +25214,15 @@ "dev": true }, "node_modules/array-includes": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.3.tgz", - "integrity": "sha512-gcem1KlBU7c9rB+Rq8/3PPKsK2kjqeEBa3bD5kkQo4nYlOHQCJqIJFqBXDEfwaRuYTT4E+FxA9xez7Gf/e3Q7A==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.4.tgz", + "integrity": "sha512-ZTNSQkmWumEbiHO2GF4GmWxYVTiQyJy2XOTa15sdQSrvKn7l+180egQMqlrMOUMCyLMD7pmyQe4mMDUT6Behrw==", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.3", - "es-abstract": "^1.18.0-next.2", + "es-abstract": "^1.19.1", "get-intrinsic": "^1.1.1", - "is-string": "^1.0.5" + "is-string": "^1.0.7" }, "engines": { "node": ">= 0.4" @@ -25555,9 +25555,9 @@ "dev": true }, "node_modules/axe-core": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.1.1.tgz", - "integrity": "sha512-5Kgy8Cz6LPC9DJcNb3yjAXTu3XihQgEdnIg50c//zOC/MyLP0Clg+Y8Sh9ZjjnvBrDZU4DgXS9C3T9r4/scGZQ==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.4.1.tgz", + "integrity": "sha512-gd1kmb21kwNuWr6BQz8fv6GNECPBnUasepcoLbekws23NVBLODdsClRZ+bQ8+9Uomf3Sm3+Vwn0oYG9NvwnJCw==", "dev": true, "engines": { "node": ">=4" @@ -31368,9 +31368,9 @@ } }, "node_modules/damerau-levenshtein": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.6.tgz", - "integrity": "sha512-JVrozIeElnj3QzfUIt8tB8YMluBJom4Vw9qTPpjGYQ9fYlB3D/rb6OordUxf3xeFB35LKWs0xqcO5U6ySvBtug==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, "node_modules/dargs": { @@ -33556,34 +33556,35 @@ } }, "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.4.1.tgz", - "integrity": "sha512-0rGPJBbwHoGNPU73/QCLP/vveMlM1b1Z9PponxO87jfr6tuH5ligXbDT6nHSSzBC8ovX2Z+BQu7Bk5D/Xgq9zg==", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.5.1.tgz", + "integrity": "sha512-sVCFKX9fllURnXT2JwLN5Qgo24Ug5NF6dxhkmxsMEUZhXRcGg+X3e1JbJ84YePQKBl5E0ZjAH5Q4rkdcGY99+g==", "dev": true, "dependencies": { - "@babel/runtime": "^7.11.2", + "@babel/runtime": "^7.16.3", "aria-query": "^4.2.2", - "array-includes": "^3.1.1", + "array-includes": "^3.1.4", "ast-types-flow": "^0.0.7", - "axe-core": "^4.0.2", + "axe-core": "^4.3.5", "axobject-query": "^2.2.0", - "damerau-levenshtein": "^1.0.6", - "emoji-regex": "^9.0.0", + "damerau-levenshtein": "^1.0.7", + "emoji-regex": "^9.2.2", "has": "^1.0.3", - "jsx-ast-utils": "^3.1.0", - "language-tags": "^1.0.5" + "jsx-ast-utils": "^3.2.1", + "language-tags": "^1.0.5", + "minimatch": "^3.0.4" }, "engines": { "node": ">=4.0" }, "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7" + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" } }, "node_modules/eslint-plugin-jsx-a11y/node_modules/emoji-regex": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.0.tgz", - "integrity": "sha512-DNc3KFPK18bPdElMJnf/Pkv5TXhxFU3YFDEuGLDRtPmV4rkmCjBkCSEp22u6rBHdSN9Vlp/GK7k98prmE1Jgug==", + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, "node_modules/eslint-plugin-no-only-tests": { @@ -42296,12 +42297,12 @@ } }, "node_modules/jsx-ast-utils": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.0.tgz", - "integrity": "sha512-EIsmt3O3ljsU6sot/J4E1zDRxfBNrhjyf/OKjlydwgEimQuznlM4Wv7U+ueONJMyEn1WRE0K8dhi3dVAXYT24Q==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.2.tgz", + "integrity": "sha512-HDAyJ4MNQBboGpUnHAVUNJs6X0lh058s6FuixsFGP7MgJYpD6Vasd6nzSG5iIfXu1zAYlHJ/zsOKNlrenTUBnw==", "dev": true, "dependencies": { - "array-includes": "^3.1.2", + "array-includes": "^3.1.4", "object.assign": "^4.1.2" }, "engines": { @@ -80524,15 +80525,15 @@ "dev": true }, "array-includes": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.3.tgz", - "integrity": "sha512-gcem1KlBU7c9rB+Rq8/3PPKsK2kjqeEBa3bD5kkQo4nYlOHQCJqIJFqBXDEfwaRuYTT4E+FxA9xez7Gf/e3Q7A==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.4.tgz", + "integrity": "sha512-ZTNSQkmWumEbiHO2GF4GmWxYVTiQyJy2XOTa15sdQSrvKn7l+180egQMqlrMOUMCyLMD7pmyQe4mMDUT6Behrw==", "requires": { "call-bind": "^1.0.2", "define-properties": "^1.1.3", - "es-abstract": "^1.18.0-next.2", + "es-abstract": "^1.19.1", "get-intrinsic": "^1.1.1", - "is-string": "^1.0.5" + "is-string": "^1.0.7" } }, "array-move": { @@ -80793,9 +80794,9 @@ "dev": true }, "axe-core": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.1.1.tgz", - "integrity": "sha512-5Kgy8Cz6LPC9DJcNb3yjAXTu3XihQgEdnIg50c//zOC/MyLP0Clg+Y8Sh9ZjjnvBrDZU4DgXS9C3T9r4/scGZQ==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.4.1.tgz", + "integrity": "sha512-gd1kmb21kwNuWr6BQz8fv6GNECPBnUasepcoLbekws23NVBLODdsClRZ+bQ8+9Uomf3Sm3+Vwn0oYG9NvwnJCw==", "dev": true }, "axios": { @@ -85271,9 +85272,9 @@ } }, "damerau-levenshtein": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.6.tgz", - "integrity": "sha512-JVrozIeElnj3QzfUIt8tB8YMluBJom4Vw9qTPpjGYQ9fYlB3D/rb6OordUxf3xeFB35LKWs0xqcO5U6ySvBtug==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, "dargs": { @@ -87235,28 +87236,29 @@ } }, "eslint-plugin-jsx-a11y": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.4.1.tgz", - "integrity": "sha512-0rGPJBbwHoGNPU73/QCLP/vveMlM1b1Z9PponxO87jfr6tuH5ligXbDT6nHSSzBC8ovX2Z+BQu7Bk5D/Xgq9zg==", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.5.1.tgz", + "integrity": "sha512-sVCFKX9fllURnXT2JwLN5Qgo24Ug5NF6dxhkmxsMEUZhXRcGg+X3e1JbJ84YePQKBl5E0ZjAH5Q4rkdcGY99+g==", "dev": true, "requires": { - "@babel/runtime": "^7.11.2", + "@babel/runtime": "^7.16.3", "aria-query": "^4.2.2", - "array-includes": "^3.1.1", + "array-includes": "^3.1.4", "ast-types-flow": "^0.0.7", - "axe-core": "^4.0.2", + "axe-core": "^4.3.5", "axobject-query": "^2.2.0", - "damerau-levenshtein": "^1.0.6", - "emoji-regex": "^9.0.0", + "damerau-levenshtein": "^1.0.7", + "emoji-regex": "^9.2.2", "has": "^1.0.3", - "jsx-ast-utils": "^3.1.0", - "language-tags": "^1.0.5" + "jsx-ast-utils": "^3.2.1", + "language-tags": "^1.0.5", + "minimatch": "^3.0.4" }, "dependencies": { "emoji-regex": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.0.tgz", - "integrity": "sha512-DNc3KFPK18bPdElMJnf/Pkv5TXhxFU3YFDEuGLDRtPmV4rkmCjBkCSEp22u6rBHdSN9Vlp/GK7k98prmE1Jgug==", + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true } } @@ -93719,12 +93721,12 @@ } }, "jsx-ast-utils": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.0.tgz", - "integrity": "sha512-EIsmt3O3ljsU6sot/J4E1zDRxfBNrhjyf/OKjlydwgEimQuznlM4Wv7U+ueONJMyEn1WRE0K8dhi3dVAXYT24Q==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.2.tgz", + "integrity": "sha512-HDAyJ4MNQBboGpUnHAVUNJs6X0lh058s6FuixsFGP7MgJYpD6Vasd6nzSG5iIfXu1zAYlHJ/zsOKNlrenTUBnw==", "dev": true, "requires": { - "array-includes": "^3.1.2", + "array-includes": "^3.1.4", "object.assign": "^4.1.2" } }, From 528a9cd7c7c061691d0469c8294a20a28dc91d4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 Apr 2022 14:20:19 -0600 Subject: [PATCH 125/136] chore(deps): bump d3-svg-legend in /superset-frontend (#19846) Bumps [d3-svg-legend](https://github.com/susielu/d3-legend) from 1.13.0 to 2.25.6. - [Release notes](https://github.com/susielu/d3-legend/releases) - [Commits](https://github.com/susielu/d3-legend/compare/v1.13.0...v2.25.6) --- updated-dependencies: - dependency-name: d3-svg-legend dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 142 ++++++++++++++++-- .../legacy-plugin-chart-heatmap/package.json | 2 +- 2 files changed, 132 insertions(+), 12 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 7494af0ea5928..027edf8a4162f 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -22588,6 +22588,11 @@ "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-1.5.1.tgz", "integrity": "sha512-7FtJYrmXTEWLykShjYhoGuDNR/Bda0+tstZMkFj4RRxUEryv16AGh3be21tqg84B6KfEwiZyEpBcTyPyU+GWjg==" }, + "node_modules/@types/d3-selection": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-1.0.10.tgz", + "integrity": "sha1-3PsN3837GtJq6kNRMjdx4a6pboQ=" + }, "node_modules/@types/d3-shape": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.8.tgz", @@ -31300,11 +31305,64 @@ } }, "node_modules/d3-svg-legend": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/d3-svg-legend/-/d3-svg-legend-1.13.0.tgz", - "integrity": "sha1-YhdHjJrdnWLLMzYX4ZYTEaQaTbM=", - "peerDependencies": { - "d3": "^3.0.0" + "version": "2.25.6", + "resolved": "https://registry.npmjs.org/d3-svg-legend/-/d3-svg-legend-2.25.6.tgz", + "integrity": "sha1-jY3BvWk8N47ki2+CPook5o8uGtI=", + "dependencies": { + "@types/d3-selection": "1.0.10", + "d3-array": "1.0.1", + "d3-dispatch": "1.0.1", + "d3-format": "1.0.2", + "d3-scale": "1.0.3", + "d3-selection": "1.0.2", + "d3-transition": "1.0.3" + } + }, + "node_modules/d3-svg-legend/node_modules/d3-array": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.0.1.tgz", + "integrity": "sha1-N1wCh0/NlsFu2fG89bSnvlPzWOc=" + }, + "node_modules/d3-svg-legend/node_modules/d3-dispatch": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.1.tgz", + "integrity": "sha1-S9ZaQ87P9DGN653yRVKqi/KBqEA=" + }, + "node_modules/d3-svg-legend/node_modules/d3-format": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.0.2.tgz", + "integrity": "sha1-E4YYMgtLvrQ7XA/zBRkHn7vXN14=" + }, + "node_modules/d3-svg-legend/node_modules/d3-scale": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-1.0.3.tgz", + "integrity": "sha1-T56PDMLqDzkl/wSsJ63AkEX6TJA=", + "dependencies": { + "d3-array": "1", + "d3-collection": "1", + "d3-color": "1", + "d3-format": "1", + "d3-interpolate": "1", + "d3-time": "1", + "d3-time-format": "2" + } + }, + "node_modules/d3-svg-legend/node_modules/d3-selection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.0.2.tgz", + "integrity": "sha1-rmYq/UcCrJxdoDmyEHoXZPockHA=" + }, + "node_modules/d3-svg-legend/node_modules/d3-transition": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-1.0.3.tgz", + "integrity": "sha1-kdyYa92zCXNjkyCoXbcs5KsaJ7s=", + "dependencies": { + "d3-color": "1", + "d3-dispatch": "1", + "d3-ease": "1", + "d3-interpolate": "1", + "d3-selection": "1", + "d3-timer": "1" } }, "node_modules/d3-time": { @@ -59869,7 +59927,7 @@ "license": "Apache-2.0", "dependencies": { "d3": "^3.5.17", - "d3-svg-legend": "^1.x", + "d3-svg-legend": "^2.x", "d3-tip": "^0.9.1", "prop-types": "^15.6.2" }, @@ -77444,7 +77502,7 @@ "version": "file:plugins/legacy-plugin-chart-heatmap", "requires": { "d3": "^3.5.17", - "d3-svg-legend": "^1.x", + "d3-svg-legend": "^2.x", "d3-tip": "^0.9.1", "prop-types": "^15.6.2" } @@ -78337,6 +78395,11 @@ "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-1.5.1.tgz", "integrity": "sha512-7FtJYrmXTEWLykShjYhoGuDNR/Bda0+tstZMkFj4RRxUEryv16AGh3be21tqg84B6KfEwiZyEpBcTyPyU+GWjg==" }, + "@types/d3-selection": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-1.0.10.tgz", + "integrity": "sha1-3PsN3837GtJq6kNRMjdx4a6pboQ=" + }, "@types/d3-shape": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.8.tgz", @@ -85209,10 +85272,67 @@ } }, "d3-svg-legend": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/d3-svg-legend/-/d3-svg-legend-1.13.0.tgz", - "integrity": "sha1-YhdHjJrdnWLLMzYX4ZYTEaQaTbM=", - "requires": {} + "version": "2.25.6", + "resolved": "https://registry.npmjs.org/d3-svg-legend/-/d3-svg-legend-2.25.6.tgz", + "integrity": "sha1-jY3BvWk8N47ki2+CPook5o8uGtI=", + "requires": { + "@types/d3-selection": "1.0.10", + "d3-array": "1.0.1", + "d3-dispatch": "1.0.1", + "d3-format": "1.0.2", + "d3-scale": "1.0.3", + "d3-selection": "1.0.2", + "d3-transition": "1.0.3" + }, + "dependencies": { + "d3-array": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.0.1.tgz", + "integrity": "sha1-N1wCh0/NlsFu2fG89bSnvlPzWOc=" + }, + "d3-dispatch": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.1.tgz", + "integrity": "sha1-S9ZaQ87P9DGN653yRVKqi/KBqEA=" + }, + "d3-format": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.0.2.tgz", + "integrity": "sha1-E4YYMgtLvrQ7XA/zBRkHn7vXN14=" + }, + "d3-scale": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-1.0.3.tgz", + "integrity": "sha1-T56PDMLqDzkl/wSsJ63AkEX6TJA=", + "requires": { + "d3-array": "1", + "d3-collection": "1", + "d3-color": "1", + "d3-format": "1", + "d3-interpolate": "1", + "d3-time": "1", + "d3-time-format": "2" + } + }, + "d3-selection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.0.2.tgz", + "integrity": "sha1-rmYq/UcCrJxdoDmyEHoXZPockHA=" + }, + "d3-transition": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-1.0.3.tgz", + "integrity": "sha1-kdyYa92zCXNjkyCoXbcs5KsaJ7s=", + "requires": { + "d3-color": "1", + "d3-dispatch": "1", + "d3-ease": "1", + "d3-interpolate": "1", + "d3-selection": "1", + "d3-timer": "1" + } + } + } }, "d3-time": { "version": "1.0.10", diff --git a/superset-frontend/plugins/legacy-plugin-chart-heatmap/package.json b/superset-frontend/plugins/legacy-plugin-chart-heatmap/package.json index 692a9dc107704..e3917e28f93ec 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-heatmap/package.json +++ b/superset-frontend/plugins/legacy-plugin-chart-heatmap/package.json @@ -23,7 +23,7 @@ ], "dependencies": { "d3": "^3.5.17", - "d3-svg-legend": "^1.x", + "d3-svg-legend": "^2.x", "d3-tip": "^0.9.1", "prop-types": "^15.6.2" }, From dc0f09586f17ddb843a1588f839749bb953594fc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 Apr 2022 14:20:38 -0600 Subject: [PATCH 126/136] chore(deps): bump react-split from 2.0.9 to 2.0.14 in /superset-frontend (#19845) Bumps [react-split](https://github.com/nathancahill/split) from 2.0.9 to 2.0.14. - [Release notes](https://github.com/nathancahill/split/releases) - [Changelog](https://github.com/nathancahill/split/blob/master/CHANGELOG.md) - [Commits](https://github.com/nathancahill/split/commits) --- updated-dependencies: - dependency-name: react-split dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 027edf8a4162f..e377be7cb7ec6 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -49993,15 +49993,15 @@ } }, "node_modules/react-split": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/react-split/-/react-split-2.0.9.tgz", - "integrity": "sha512-IxKtxxmcbNUmWMSd5vlNnlE0jwbgQS1HyQYxt7h8qFgPskSkUTNzMbO838xapmmNf9D+u9B/bdtFnVjt+JC2JA==", + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/react-split/-/react-split-2.0.14.tgz", + "integrity": "sha512-bKWydgMgaKTg/2JGQnaJPg51T6dmumTWZppFgEbbY0Fbme0F5TuatAScCLaqommbGQQf/ZT1zaejuPDriscISA==", "dependencies": { "prop-types": "^15.5.7", "split.js": "^1.6.0" }, "peerDependencies": { - "react": ">=15.4.2 || >= 16.0.0" + "react": "*" } }, "node_modules/react-split-pane": { @@ -99900,9 +99900,9 @@ } }, "react-split": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/react-split/-/react-split-2.0.9.tgz", - "integrity": "sha512-IxKtxxmcbNUmWMSd5vlNnlE0jwbgQS1HyQYxt7h8qFgPskSkUTNzMbO838xapmmNf9D+u9B/bdtFnVjt+JC2JA==", + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/react-split/-/react-split-2.0.14.tgz", + "integrity": "sha512-bKWydgMgaKTg/2JGQnaJPg51T6dmumTWZppFgEbbY0Fbme0F5TuatAScCLaqommbGQQf/ZT1zaejuPDriscISA==", "requires": { "prop-types": "^15.5.7", "split.js": "^1.6.0" From d65b77ec7dac4c2368fcaa1fe6e98db102966198 Mon Sep 17 00:00:00 2001 From: Diego Medina Date: Tue, 26 Apr 2022 18:25:40 -0400 Subject: [PATCH 127/136] fix: deck.gl GeoJsonLayer Autozoom & fill/stroke options (#19778) * fix: deck.gl GeoJsonLayer Autozoom & fill/stroke options * fix package.json * fix lint --- superset-frontend/package-lock.json | 126 ++++++++++++++++++ .../legacy-preset-chart-deckgl/package.json | 1 + .../src/layers/Geojson/Geojson.jsx | 38 +++++- .../src/layers/Geojson/controlPanel.ts | 8 +- 4 files changed, 162 insertions(+), 11 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index e377be7cb7ec6..7ac71b41cdba6 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -8325,6 +8325,11 @@ "probe.gl": "^3.4.0" } }, + "node_modules/@mapbox/extent": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@mapbox/extent/-/extent-0.4.0.tgz", + "integrity": "sha1-PlkfMuHww5gchkI597CsBuYQ+Kk=" + }, "node_modules/@mapbox/geojson-area": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@mapbox/geojson-area/-/geojson-area-0.2.2.tgz", @@ -8333,6 +8338,42 @@ "wgs84": "0.0.0" } }, + "node_modules/@mapbox/geojson-coords": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-coords/-/geojson-coords-0.0.2.tgz", + "integrity": "sha512-YuVzpseee/P1T5BWyeVVPppyfmuXYHFwZHmybkqaMfu4BWlOf2cmMGKj2Rr92MwfSTOCSUA0PAsVGRG8akY0rg==", + "dependencies": { + "@mapbox/geojson-normalize": "0.0.1", + "geojson-flatten": "^1.0.4" + } + }, + "node_modules/@mapbox/geojson-extent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-extent/-/geojson-extent-1.0.1.tgz", + "integrity": "sha512-hh8LEO3djT4fqfr8sSC6wKt+p0TMiu+KOLMBUiFOyj+zGq7+IXwQGl0ppCVDkyzCewyd9LoGe9zAvDxXrLfhLw==", + "dependencies": { + "@mapbox/extent": "0.4.0", + "@mapbox/geojson-coords": "0.0.2", + "rw": "~0.1.4", + "traverse": "~0.6.6" + }, + "bin": { + "geojson-extent": "bin/geojson-extent" + } + }, + "node_modules/@mapbox/geojson-extent/node_modules/rw": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/rw/-/rw-0.1.4.tgz", + "integrity": "sha1-SQPL2AJIrg7eaFv1j9I2p6mymj4=" + }, + "node_modules/@mapbox/geojson-normalize": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-normalize/-/geojson-normalize-0.0.1.tgz", + "integrity": "sha1-HaHms6et060pkJsw9Dj2BYG3zYA=", + "bin": { + "geojson-normalize": "geojson-normalize" + } + }, "node_modules/@mapbox/geojson-rewind": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.4.1.tgz", @@ -36058,6 +36099,26 @@ "node": ">=6.9.0" } }, + "node_modules/geojson-flatten": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/geojson-flatten/-/geojson-flatten-1.0.4.tgz", + "integrity": "sha512-PpscUXxO6dvvhZxtwuqiI5v+1C/IQYPJRMWoQeaF2oohJgfGYSHKVAe8L+yUqF34PH/hmq9JlwmO+juPw+95/Q==", + "dependencies": { + "get-stdin": "^7.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "geojson-flatten": "geojson-flatten" + } + }, + "node_modules/geojson-flatten/node_modules/get-stdin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-7.0.0.tgz", + "integrity": "sha512-zRKcywvrXlXsA0v0i9Io4KDRaAw7+a1ZpjRwl9Wox8PFlVCCHra7E9c4kqXCoCM9nR5tBkaTTZRBoCm60bFqTQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/geojson-vt": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", @@ -54527,6 +54588,11 @@ "loader-utils": "^1.0.2" } }, + "node_modules/traverse": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.6.tgz", + "integrity": "sha1-y99WD9e5r2MlAv7UD5GMFX6pcTc=" + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -60268,6 +60334,7 @@ "version": "0.4.13", "license": "Apache-2.0", "dependencies": { + "@mapbox/geojson-extent": "^1.0.1", "@math.gl/web-mercator": "^3.2.2", "@types/d3-array": "^2.0.0", "bootstrap-slider": "^10.0.0", @@ -66840,6 +66907,11 @@ "probe.gl": "^3.4.0" } }, + "@mapbox/extent": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@mapbox/extent/-/extent-0.4.0.tgz", + "integrity": "sha1-PlkfMuHww5gchkI597CsBuYQ+Kk=" + }, "@mapbox/geojson-area": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@mapbox/geojson-area/-/geojson-area-0.2.2.tgz", @@ -66848,6 +66920,38 @@ "wgs84": "0.0.0" } }, + "@mapbox/geojson-coords": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-coords/-/geojson-coords-0.0.2.tgz", + "integrity": "sha512-YuVzpseee/P1T5BWyeVVPppyfmuXYHFwZHmybkqaMfu4BWlOf2cmMGKj2Rr92MwfSTOCSUA0PAsVGRG8akY0rg==", + "requires": { + "@mapbox/geojson-normalize": "0.0.1", + "geojson-flatten": "^1.0.4" + } + }, + "@mapbox/geojson-extent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-extent/-/geojson-extent-1.0.1.tgz", + "integrity": "sha512-hh8LEO3djT4fqfr8sSC6wKt+p0TMiu+KOLMBUiFOyj+zGq7+IXwQGl0ppCVDkyzCewyd9LoGe9zAvDxXrLfhLw==", + "requires": { + "@mapbox/extent": "0.4.0", + "@mapbox/geojson-coords": "0.0.2", + "rw": "~0.1.4", + "traverse": "~0.6.6" + }, + "dependencies": { + "rw": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/rw/-/rw-0.1.4.tgz", + "integrity": "sha1-SQPL2AJIrg7eaFv1j9I2p6mymj4=" + } + } + }, + "@mapbox/geojson-normalize": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-normalize/-/geojson-normalize-0.0.1.tgz", + "integrity": "sha1-HaHms6et060pkJsw9Dj2BYG3zYA=" + }, "@mapbox/geojson-rewind": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.4.1.tgz", @@ -77705,6 +77809,7 @@ "@superset-ui/legacy-preset-chart-deckgl": { "version": "file:plugins/legacy-preset-chart-deckgl", "requires": { + "@mapbox/geojson-extent": "^1.0.1", "@math.gl/web-mercator": "^3.2.2", "@types/d3-array": "^2.0.0", "bootstrap-slider": "^10.0.0", @@ -89044,6 +89149,22 @@ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" }, + "geojson-flatten": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/geojson-flatten/-/geojson-flatten-1.0.4.tgz", + "integrity": "sha512-PpscUXxO6dvvhZxtwuqiI5v+1C/IQYPJRMWoQeaF2oohJgfGYSHKVAe8L+yUqF34PH/hmq9JlwmO+juPw+95/Q==", + "requires": { + "get-stdin": "^7.0.0", + "minimist": "^1.2.5" + }, + "dependencies": { + "get-stdin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-7.0.0.tgz", + "integrity": "sha512-zRKcywvrXlXsA0v0i9Io4KDRaAw7+a1ZpjRwl9Wox8PFlVCCHra7E9c4kqXCoCM9nR5tBkaTTZRBoCm60bFqTQ==" + } + } + }, "geojson-vt": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", @@ -103463,6 +103584,11 @@ "loader-utils": "^1.0.2" } }, + "traverse": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.6.tgz", + "integrity": "sha1-y99WD9e5r2MlAv7UD5GMFX6pcTc=" + }, "tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/package.json b/superset-frontend/plugins/legacy-preset-chart-deckgl/package.json index b1ecf1d487e97..7b11af60566e5 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/package.json +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/package.json @@ -23,6 +23,7 @@ "lib" ], "dependencies": { + "@mapbox/geojson-extent": "^1.0.1", "@math.gl/web-mercator": "^3.2.2", "@types/d3-array": "^2.0.0", "bootstrap-slider": "^10.0.0", diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/Geojson.jsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/Geojson.jsx index a1416f6b56ef2..0aefc742934f5 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/Geojson.jsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/Geojson.jsx @@ -1,4 +1,3 @@ -/* eslint-disable react/no-array-index-key */ /** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file @@ -20,13 +19,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import { GeoJsonLayer } from 'deck.gl'; -// TODO import geojsonExtent from 'geojson-extent'; +import geojsonExtent from '@mapbox/geojson-extent'; import { DeckGLContainerStyledWrapper } from '../../DeckGLContainer'; import { hexToRGB } from '../../utils/colors'; import sandboxedEval from '../../utils/sandbox'; import { commonLayerProps } from '../common'; import TooltipRow from '../../TooltipRow'; +import fitViewport from '../../utils/fitViewport'; const propertyMap = { fillColor: 'fillColor', @@ -94,6 +94,9 @@ function setTooltipContent(o) { ); } +const getFillColor = feature => feature?.properties?.fillColor; +const getLineColor = feature => feature?.properties?.strokeColor; + export function getLayer(formData, payload, onAddFilter, setTooltip) { const fd = formData; const fc = fd.fill_color_picker; @@ -125,6 +128,9 @@ export function getLayer(formData, payload, onAddFilter, setTooltip) { stroked: fd.stroked, extruded: fd.extruded, pointRadiusScale: fd.point_radius_scale, + getFillColor, + getLineWidth: fd.line_width || 1, + getLineColor, ...commonLayerProps(fd, setTooltip, setTooltipContent), }); } @@ -151,13 +157,29 @@ class DeckGLGeoJson extends React.Component { }; render() { - const { formData, payload, setControlValue, onAddFilter, viewport } = + const { formData, payload, setControlValue, onAddFilter, height, width } = this.props; - // TODO get this to work - // if (formData.autozoom) { - // viewport = common.fitViewport(viewport, geojsonExtent(payload.data.features)); - // } + let { viewport } = this.props; + if (formData.autozoom) { + const points = + payload?.data?.features?.reduce?.((acc, feature) => { + const bounds = geojsonExtent(feature); + if (bounds) { + return [...acc, [bounds[0], bounds[1]], [bounds[2], bounds[3]]]; + } + + return acc; + }, []) || []; + + if (points.length) { + viewport = fitViewport(viewport, { + width, + height, + points, + }); + } + } const layer = getLayer(formData, payload, onAddFilter, this.setTooltip); @@ -169,6 +191,8 @@ class DeckGLGeoJson extends React.Component { layers={[layer]} mapStyle={formData.mapbox_style} setControlValue={setControlValue} + height={height} + width={width} /> ); } diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/controlPanel.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/controlPanel.ts index c18b7c650f0d4..352e8867b2ad5 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/controlPanel.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/controlPanel.ts @@ -38,6 +38,8 @@ import { viewport, mapboxStyle, geojsonColumn, + autozoom, + lineWidth, } from '../../utilities/Shared_DeckGL'; import { dndGeojsonColumn } from '../../utilities/sharedDndControls'; @@ -60,10 +62,7 @@ const config: ControlPanelConfig = { }, { label: t('Map'), - controlSetRows: [ - [mapboxStyle, viewport], - // TODO [autozoom, null], // import { autozoom } from './Shared_DeckGL' - ], + controlSetRows: [[mapboxStyle, viewport], [autozoom]], }, { label: t('GeoJson Settings'), @@ -71,6 +70,7 @@ const config: ControlPanelConfig = { [fillColorPicker, strokeColorPicker], [filled, stroked], [extruded, null], + [lineWidth, null], [ { name: 'point_radius_scale', From 1d043e53d09f444f15a083ebb961faff092147a5 Mon Sep 17 00:00:00 2001 From: smileydev <47900232+prosdev0107@users.noreply.github.com> Date: Tue, 26 Apr 2022 18:26:07 -0400 Subject: [PATCH 128/136] fix(db & connection): make to show/hide the password when only creating db connection (#19694) * fix(db & connection): make to show/hide the password when only creating db connection * fix(db & connection): make to fix unit test of Database Modal --- .../Form/LabeledErrorBoundInput.tsx | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx b/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx index be9e0af16f2c1..ebbb1c023622e 100644 --- a/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx +++ b/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx @@ -17,7 +17,8 @@ * under the License. */ import React from 'react'; -import { Input } from 'antd'; +import { Input, Tooltip } from 'antd'; +import { EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons'; import { styled, css, SupersetTheme } from '@superset-ui/core'; import InfoTooltip from 'src/components/InfoTooltip'; import errorIcon from 'src/assets/images/icons/error.svg'; @@ -43,6 +44,10 @@ const StyledInput = styled(Input)` margin: ${({ theme }) => `${theme.gridUnit}px 0 ${theme.gridUnit * 2}px`}; `; +const StyledInputPassword = styled(Input.Password)` + margin: ${({ theme }) => `${theme.gridUnit}px 0 ${theme.gridUnit * 2}px`}; +`; + const alertIconStyles = (theme: SupersetTheme, hasError: boolean) => css` .ant-form-item-children-icon { display: none; @@ -114,7 +119,26 @@ const LabeledErrorBoundInput = ({ help={errorMessage || helpText} hasFeedback={!!errorMessage} > - + {props.name === 'password' ? ( + + visible ? ( + + + + ) : ( + + + + ) + } + role="textbox" + /> + ) : ( + + )} ); From 60e06c1692651d5434b69427843e2539f13f4431 Mon Sep 17 00:00:00 2001 From: Smart-Codi Date: Tue, 26 Apr 2022 23:12:47 -0400 Subject: [PATCH 129/136] feat: Update ShortKey for stop query running in SqlLab editor (#19692) * feat: Update shortkey for stop query running in sqllab editor * resolve comment * fix invalid import useMemo --- .../components/RunQueryActionButton/index.tsx | 15 +++++++++++++-- .../src/SqlLab/components/SqlEditor/index.jsx | 2 +- .../translations/de/LC_MESSAGES/messages.json | 1 + .../translations/en/LC_MESSAGES/messages.json | 1 + .../translations/es/LC_MESSAGES/messages.json | 1 + .../translations/fr/LC_MESSAGES/messages.json | 1 + .../translations/it/LC_MESSAGES/messages.json | 1 + .../translations/ja/LC_MESSAGES/messages.json | 1 + .../translations/ko/LC_MESSAGES/messages.json | 1 + .../translations/nl/LC_MESSAGES/messages.json | 1 + superset/translations/pt/LC_MESSAGES/message.json | 1 + .../translations/pt_BR/LC_MESSAGES/messages.json | 1 + .../translations/ru/LC_MESSAGES/messages.json | 1 + .../translations/sk/LC_MESSAGES/messages.json | 1 + .../translations/sl/LC_MESSAGES/messages.json | 1 + .../translations/zh/LC_MESSAGES/messages.json | 1 + 16 files changed, 28 insertions(+), 3 deletions(-) diff --git a/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx b/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx index 2a9e0fbaf8d8b..9da467685b492 100644 --- a/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx +++ b/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { t, styled, useTheme } from '@superset-ui/core'; import { Menu } from 'src/components/Menu'; @@ -26,6 +26,7 @@ import { DropdownButton, DropdownButtonProps, } from 'src/components/DropdownButton'; +import { detectOS } from 'src/utils/common'; interface Props { allowAsync: boolean; @@ -95,6 +96,8 @@ const RunQueryActionButton = ({ }: Props) => { const theme = useTheme(); + const userOS = detectOS(); + const shouldShowStopBtn = !!queryState && ['running', 'pending'].indexOf(queryState) > -1; @@ -104,6 +107,14 @@ const RunQueryActionButton = ({ const isDisabled = !sql.trim(); + const stopButtonTooltipText = useMemo( + () => + userOS === 'MacOS' + ? t('Stop running (Ctrl + x)') + : t('Stop running (Ctrl + e)'), + [userOS], + ); + return ( Date: Tue, 26 Apr 2022 23:22:50 -0400 Subject: [PATCH 130/136] fix: Query execution time is displayed as invalid date (#19605) * fix: Query execution time is displayed as invalid date * PR comment * Fix test * unify response * lint --- superset/queries/api.py | 3 ++- superset/queries/schemas.py | 42 +++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/superset/queries/api.py b/superset/queries/api.py index 611c69d4bd4cf..460e2dd4667e0 100644 --- a/superset/queries/api.py +++ b/superset/queries/api.py @@ -22,7 +22,7 @@ from superset.databases.filters import DatabaseFilter from superset.models.sql_lab import Query from superset.queries.filters import QueryFilter -from superset.queries.schemas import openapi_spec_methods_override +from superset.queries.schemas import openapi_spec_methods_override, QuerySchema from superset.views.base_api import BaseSupersetModelRestApi, RelatedFieldFilter from superset.views.filters import FilterRelatedOwners @@ -94,6 +94,7 @@ class QueryRestApi(BaseSupersetModelRestApi): ] base_filters = [["id", QueryFilter, lambda: []]] base_order = ("changed_on", "desc") + list_model_schema = QuerySchema() openapi_spec_tag = "Queries" openapi_spec_methods = openapi_spec_methods_override diff --git a/superset/queries/schemas.py b/superset/queries/schemas.py index daca322c6371e..f11cf37127756 100644 --- a/superset/queries/schemas.py +++ b/superset/queries/schemas.py @@ -14,6 +14,13 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from typing import List + +from marshmallow import fields, Schema + +from superset.dashboards.schemas import UserSchema +from superset.models.sql_lab import Query +from superset.sql_parse import Table openapi_spec_methods_override = { "get": {"get": {"description": "Get query detail information."}}, @@ -25,3 +32,38 @@ } }, } + + +class DatabaseSchema(Schema): + database_name = fields.String() + + +class QuerySchema(Schema): + """ + Schema for the ``Query`` model. + """ + + changed_on = fields.DateTime() + database = fields.Nested(DatabaseSchema) + end_time = fields.Float(attribute="end_time") + executed_sql = fields.String() + id = fields.Int() + rows = fields.Int() + schema = fields.String() + sql = fields.String() + sql_tables = fields.Method("get_sql_tables") + start_time = fields.Float(attribute="start_time") + status = fields.String() + tab_name = fields.String() + tmp_table_name = fields.String() + tracking_url = fields.String() + user = fields.Nested(UserSchema) + + class Meta: # pylint: disable=too-few-public-methods + model = Query + load_instance = True + include_relationships = True + + # pylint: disable=no-self-use + def get_sql_tables(self, obj: Query) -> List[Table]: + return obj.sql_tables From 768e4b7a546f7f6abdbc079f117014eac0cec23d Mon Sep 17 00:00:00 2001 From: Erik Ritter Date: Tue, 26 Apr 2022 21:12:43 -0700 Subject: [PATCH 131/136] fix: Update eslint error message to reflect location of antd components (#19857) --- superset-frontend/.eslintrc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-frontend/.eslintrc.js b/superset-frontend/.eslintrc.js index facd3431f4367..d4751c18cea46 100644 --- a/superset-frontend/.eslintrc.js +++ b/superset-frontend/.eslintrc.js @@ -247,7 +247,7 @@ module.exports = { { name: 'antd', message: - 'Please import Ant components from the index of common/components', + 'Please import Ant components from the index of src/components', }, { name: '@superset-ui/core', From ad878b07e48edb4059fbc6620accd2f7b993ae4b Mon Sep 17 00:00:00 2001 From: Erik Ritter Date: Tue, 26 Apr 2022 21:52:58 -0700 Subject: [PATCH 132/136] fix: Dashboard report creation error handling (#19859) --- superset-frontend/src/components/ReportModal/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/superset-frontend/src/components/ReportModal/index.tsx b/superset-frontend/src/components/ReportModal/index.tsx index 7707a22896d2b..c45d8fad47625 100644 --- a/superset-frontend/src/components/ReportModal/index.tsx +++ b/superset-frontend/src/components/ReportModal/index.tsx @@ -129,7 +129,7 @@ type ReportActionType = } | { type: ActionType.error; - payload: { name: string[] }; + payload: { name?: string[] }; }; const TEXT_BASED_VISUALIZATION_TYPES = [ @@ -175,7 +175,7 @@ const reportReducer = ( case ActionType.error: return { ...state, - error: action.payload?.name[0] || defaultErrorMsg, + error: action.payload?.name?.[0] || defaultErrorMsg, }; default: return state; From f9d28a107266aff1fea10056ec69d437981df1bd Mon Sep 17 00:00:00 2001 From: Stephen Liu <750188453@qq.com> Date: Wed, 27 Apr 2022 17:44:13 +0800 Subject: [PATCH 133/136] chore: add eslint custom plugin to prevent translation variables (#19828) --- superset-frontend/.eslintrc.js | 10 ++- superset-frontend/jest.config.js | 2 +- superset-frontend/package-lock.json | 27 +++++++- superset-frontend/package.json | 1 + .../src/SqlLab/utils/newQueryTabName.ts | 2 +- .../views/CRUD/annotation/AnnotationList.tsx | 3 +- .../eslint-plugin-translation-vars/index.js | 56 +++++++++++++++ .../no-template-vars.test.js | 68 +++++++++++++++++++ .../package.json | 20 ++++++ 9 files changed, 184 insertions(+), 5 deletions(-) create mode 100644 superset-frontend/tools/eslint-plugin-translation-vars/index.js create mode 100644 superset-frontend/tools/eslint-plugin-translation-vars/no-template-vars.test.js create mode 100644 superset-frontend/tools/eslint-plugin-translation-vars/package.json diff --git a/superset-frontend/.eslintrc.js b/superset-frontend/.eslintrc.js index d4751c18cea46..b77de41338555 100644 --- a/superset-frontend/.eslintrc.js +++ b/superset-frontend/.eslintrc.js @@ -67,7 +67,13 @@ module.exports = { version: 'detect', }, }, - plugins: ['prettier', 'react', 'file-progress', 'theme-colors'], + plugins: [ + 'prettier', + 'react', + 'file-progress', + 'theme-colors', + 'translation-vars', + ], overrides: [ { files: ['*.ts', '*.tsx'], @@ -198,12 +204,14 @@ module.exports = { ], rules: { 'theme-colors/no-literal-colors': 0, + 'translation-vars/no-template-vars': 0, 'no-restricted-imports': 0, }, }, ], rules: { 'theme-colors/no-literal-colors': 1, + 'translation-vars/no-template-vars': ['error', true], camelcase: [ 'error', { diff --git a/superset-frontend/jest.config.js b/superset-frontend/jest.config.js index 8ef49454b022f..18d20a1f97043 100644 --- a/superset-frontend/jest.config.js +++ b/superset-frontend/jest.config.js @@ -19,7 +19,7 @@ module.exports = { testRegex: - '\\/superset-frontend\\/(spec|src|plugins|packages)\\/.*(_spec|\\.test)\\.[jt]sx?$', + '\\/superset-frontend\\/(spec|src|plugins|packages|tools)\\/.*(_spec|\\.test)\\.[jt]sx?$', moduleNameMapper: { '\\.(css|less|geojson)$': '/spec/__mocks__/mockExportObject.js', '\\.(gif|ttf|eot|png|jpg)$': '/spec/__mocks__/mockExportString.js', diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 7ac71b41cdba6..07f2ab7601ae2 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -234,6 +234,7 @@ "eslint-plugin-react-hooks": "^4.2.0", "eslint-plugin-testing-library": "^3.10.1", "eslint-plugin-theme-colors": "file:tools/eslint-plugin-theme-colors", + "eslint-plugin-translation-vars": "file:tools/eslint-plugin-translation-vars", "exports-loader": "^0.7.0", "fetch-mock": "^7.7.3", "fork-ts-checker-webpack-plugin": "^6.3.3", @@ -33942,6 +33943,10 @@ "resolved": "tools/eslint-plugin-theme-colors", "link": true }, + "node_modules/eslint-plugin-translation-vars": { + "resolved": "tools/eslint-plugin-translation-vars", + "link": true + }, "node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -60691,7 +60696,23 @@ "tools/eslint-plugin-theme-colors": { "version": "1.0.0", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "engines": { + "node": "^16.9.1", + "npm": "^7.5.4" + } + }, + "tools/eslint-plugin-translation-vars": { + "version": "1.0.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^16.9.1", + "npm": "^7.5.4" + }, + "peerDependencies": { + "eslint": ">=0.8.0" + } } }, "dependencies": { @@ -87649,6 +87670,10 @@ "eslint-plugin-theme-colors": { "version": "file:tools/eslint-plugin-theme-colors" }, + "eslint-plugin-translation-vars": { + "version": "file:tools/eslint-plugin-translation-vars", + "requires": {} + }, "eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 5cf75e7c44ff4..118377ee102fe 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -294,6 +294,7 @@ "eslint-plugin-react-hooks": "^4.2.0", "eslint-plugin-testing-library": "^3.10.1", "eslint-plugin-theme-colors": "file:tools/eslint-plugin-theme-colors", + "eslint-plugin-translation-vars": "file:tools/eslint-plugin-translation-vars", "exports-loader": "^0.7.0", "fetch-mock": "^7.7.3", "fork-ts-checker-webpack-plugin": "^6.3.3", diff --git a/superset-frontend/src/SqlLab/utils/newQueryTabName.ts b/superset-frontend/src/SqlLab/utils/newQueryTabName.ts index a719a74af59af..3815226cd4ba5 100644 --- a/superset-frontend/src/SqlLab/utils/newQueryTabName.ts +++ b/superset-frontend/src/SqlLab/utils/newQueryTabName.ts @@ -40,7 +40,7 @@ export const newQueryTabName = ( // When there are query tabs open, and at least one is called "Untitled Query #" // Where # is a valid number const largestNumber: number = Math.max(...untitledQueryNumbers); - return t(`${untitledQuery}%s`, largestNumber + 1); + return t('%s%s', untitledQuery, largestNumber + 1); } return resultTitle; } diff --git a/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx b/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx index a4599b9ff5dfb..6dedb5e0ead69 100644 --- a/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx +++ b/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx @@ -280,7 +280,8 @@ function AnnotationList({ {annotationCurrentlyDeleting && ( { if (annotationCurrentlyDeleting) { diff --git a/superset-frontend/tools/eslint-plugin-translation-vars/index.js b/superset-frontend/tools/eslint-plugin-translation-vars/index.js new file mode 100644 index 0000000000000..69493f3b9e39d --- /dev/null +++ b/superset-frontend/tools/eslint-plugin-translation-vars/index.js @@ -0,0 +1,56 @@ +/** + * 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. + */ + +/** + * @fileoverview Rule to warn about translation template variables + * @author Apache + */ + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + rules: { + 'no-template-vars': { + create(context) { + function handler(node) { + if (node.arguments.length) { + const firstArgs = node.arguments[0]; + if ( + firstArgs.type === 'TemplateLiteral' && + firstArgs.expressions.length + ) { + context.report({ + node, + message: + "Don't use variables in translation string templates. Flask-babel is a static translation translation service, so it can’t handle strings that include variables", + }); + } + } + } + return { + "CallExpression[callee.name='t']": handler, + "CallExpression[callee.name='tn']": handler, + }; + }, + }, + }, +}; diff --git a/superset-frontend/tools/eslint-plugin-translation-vars/no-template-vars.test.js b/superset-frontend/tools/eslint-plugin-translation-vars/no-template-vars.test.js new file mode 100644 index 0000000000000..295a2f9fb8c50 --- /dev/null +++ b/superset-frontend/tools/eslint-plugin-translation-vars/no-template-vars.test.js @@ -0,0 +1,68 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * @fileoverview Rule to warn about translation template variables + * @author Apache + */ +/* eslint-disable no-template-curly-in-string */ +const { RuleTester } = require('eslint'); +const plugin = require('.'); + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 6 } }); +const rule = plugin.rules['no-template-vars']; + +const errors = [ + { + type: 'CallExpression', + }, +]; + +ruleTester.run('no-template-vars', rule, { + valid: [ + 't(`foo`)', + 'tn(`foo`)', + 't(`foo %s bar`)', + 'tn(`foo %s bar`)', + 't(`foo %s bar %s`)', + 'tn(`foo %s bar %s`)', + ], + invalid: [ + { + code: 't(`foo${bar}`)', + errors, + }, + { + code: 't(`foo${bar} ${baz}`)', + errors, + }, + { + code: 'tn(`foo${bar}`)', + errors, + }, + { + code: 'tn(`foo${bar} ${baz}`)', + errors, + }, + ], +}); diff --git a/superset-frontend/tools/eslint-plugin-translation-vars/package.json b/superset-frontend/tools/eslint-plugin-translation-vars/package.json new file mode 100644 index 0000000000000..d4353a88df3d2 --- /dev/null +++ b/superset-frontend/tools/eslint-plugin-translation-vars/package.json @@ -0,0 +1,20 @@ +{ + "name": "eslint-plugin-translation-vars", + "version": "1.0.0", + "description": "Warns about translation variables", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "license": "Apache-2.0", + "author": "Apache", + "dependencies": {}, + "peerDependencies": { + "eslint": ">=0.8.0" + }, + "engines": { + "node": "^16.9.1", + "npm": "^7.5.4" + } +} From 795da71751ff1eee5d72db0c1014de0a56abea86 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 Apr 2022 08:19:34 -0600 Subject: [PATCH 134/136] chore(deps): bump react-syntax-highlighter in /superset-frontend (#19864) Bumps [react-syntax-highlighter](https://github.com/react-syntax-highlighter/react-syntax-highlighter) from 15.4.5 to 15.5.0. - [Release notes](https://github.com/react-syntax-highlighter/react-syntax-highlighter/releases) - [Changelog](https://github.com/react-syntax-highlighter/react-syntax-highlighter/blob/master/CHANGELOG.MD) - [Commits](https://github.com/react-syntax-highlighter/react-syntax-highlighter/compare/v15.4.5...15.5.0) --- updated-dependencies: - dependency-name: react-syntax-highlighter dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 147 +++++++--------------------- 1 file changed, 36 insertions(+), 111 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 07f2ab7601ae2..8a37a510eb10f 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -28396,17 +28396,6 @@ "node": ">= 10" } }, - "node_modules/clipboard": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.4.tgz", - "integrity": "sha512-Vw26VSLRpJfBofiVaFb/I8PVfdI1OxKcYShe6fm0sP/DtmiWQNCjhM/okTvdCo0G+lMMm1rMYbk4IK4x1X+kgQ==", - "optional": true, - "dependencies": { - "good-listener": "^1.2.2", - "select": "^1.1.2", - "tiny-emitter": "^2.0.0" - } - }, "node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -32010,12 +31999,6 @@ "node": ">=0.4.0" } }, - "node_modules/delegate": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", - "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", - "optional": true - }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -36917,15 +36900,6 @@ "node": ">=8" } }, - "node_modules/good-listener": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", - "integrity": "sha1-1TswzfkxPf+33JoNR3CWqm0UXFA=", - "optional": true, - "dependencies": { - "delegate": "^3.1.2" - } - }, "node_modules/got": { "version": "6.7.1", "resolved": "https://registry.npmjs.org/got/-/got-6.7.1.tgz", @@ -48034,11 +48008,11 @@ } }, "node_modules/prismjs": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.23.0.tgz", - "integrity": "sha512-c29LVsqOaLbBHuIbsTxaKENh1N2EQBOHaWv7gkHN4dgRbxSREqDnDbtFJYdpPauS4YCplMSNCABQ6Eeor69bAA==", - "optionalDependencies": { - "clipboard": "^2.0.0" + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz", + "integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==", + "engines": { + "node": ">=6" } }, "node_modules/probe.gl": { @@ -50111,24 +50085,27 @@ } }, "node_modules/react-syntax-highlighter": { - "version": "15.4.5", - "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.4.5.tgz", - "integrity": "sha512-RC90KQTxZ/b7+9iE6s9nmiFLFjWswUcfULi4GwVzdFVKVMQySkJWBuOmJFfjwjMVCo0IUUuJrWebNKyviKpwLQ==", + "version": "15.5.0", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.5.0.tgz", + "integrity": "sha512-+zq2myprEnQmH5yw6Gqc8lD55QHnpKaU8TOcFeC/Lg/MQSs8UknEA0JC4nTZGFAXC2J2Hyj/ijJ7NlabyPi2gg==", "dependencies": { "@babel/runtime": "^7.3.1", "highlight.js": "^10.4.1", "lowlight": "^1.17.0", - "prismjs": "^1.25.0", - "refractor": "^3.2.0" + "prismjs": "^1.27.0", + "refractor": "^3.6.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "node_modules/react-syntax-highlighter/node_modules/prismjs": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.25.0.tgz", - "integrity": "sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg==" + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.28.0.tgz", + "integrity": "sha512-8aaXdYvl1F7iC7Xm1spqSaY/OJBpYW3v+KJ+F17iYxvdc8sfjW194COK5wVhMZX45tGteiBQgdvD/nhxcRwylw==", + "engines": { + "node": ">=6" + } }, "node_modules/react-table": { "version": "7.6.3", @@ -50791,13 +50768,13 @@ "dev": true }, "node_modules/refractor": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.3.1.tgz", - "integrity": "sha512-vaN6R56kLMuBszHSWlwTpcZ8KTMG6aUCok4GrxYDT20UIOXxOc5o6oDc8tNTzSlH3m2sI+Eu9Jo2kVdDcUTWYw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz", + "integrity": "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==", "dependencies": { "hastscript": "^6.0.0", "parse-entities": "^2.0.0", - "prismjs": "~1.23.0" + "prismjs": "~1.27.0" }, "funding": { "type": "github", @@ -51994,12 +51971,6 @@ "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==" }, - "node_modules/select": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", - "integrity": "sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0=", - "optional": true - }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -54379,12 +54350,6 @@ "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=", "dev": true }, - "node_modules/tiny-emitter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", - "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", - "optional": true - }, "node_modules/tiny-invariant": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz", @@ -60441,6 +60406,7 @@ } }, "plugins/plugin-chart-handlebars": { + "name": "@superset-ui/plugin-chart-handlebars", "version": "0.0.0", "license": "Apache-2.0", "dependencies": { @@ -83123,17 +83089,6 @@ "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", "devOptional": true }, - "clipboard": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.4.tgz", - "integrity": "sha512-Vw26VSLRpJfBofiVaFb/I8PVfdI1OxKcYShe6fm0sP/DtmiWQNCjhM/okTvdCo0G+lMMm1rMYbk4IK4x1X+kgQ==", - "optional": true, - "requires": { - "good-listener": "^1.2.2", - "select": "^1.1.2", - "tiny-emitter": "^2.0.0" - } - }, "cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -85944,12 +85899,6 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" }, - "delegate": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", - "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", - "optional": true - }, "delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -89786,15 +89735,6 @@ } } }, - "good-listener": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", - "integrity": "sha1-1TswzfkxPf+33JoNR3CWqm0UXFA=", - "optional": true, - "requires": { - "delegate": "^3.1.2" - } - }, "got": { "version": "6.7.1", "resolved": "https://registry.npmjs.org/got/-/got-6.7.1.tgz", @@ -98423,12 +98363,9 @@ } }, "prismjs": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.23.0.tgz", - "integrity": "sha512-c29LVsqOaLbBHuIbsTxaKENh1N2EQBOHaWv7gkHN4dgRbxSREqDnDbtFJYdpPauS4YCplMSNCABQ6Eeor69bAA==", - "requires": { - "clipboard": "^2.0.0" - } + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz", + "integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==" }, "probe.gl": { "version": "3.4.0", @@ -100089,21 +100026,21 @@ } }, "react-syntax-highlighter": { - "version": "15.4.5", - "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.4.5.tgz", - "integrity": "sha512-RC90KQTxZ/b7+9iE6s9nmiFLFjWswUcfULi4GwVzdFVKVMQySkJWBuOmJFfjwjMVCo0IUUuJrWebNKyviKpwLQ==", + "version": "15.5.0", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.5.0.tgz", + "integrity": "sha512-+zq2myprEnQmH5yw6Gqc8lD55QHnpKaU8TOcFeC/Lg/MQSs8UknEA0JC4nTZGFAXC2J2Hyj/ijJ7NlabyPi2gg==", "requires": { "@babel/runtime": "^7.3.1", "highlight.js": "^10.4.1", "lowlight": "^1.17.0", - "prismjs": "^1.25.0", - "refractor": "^3.2.0" + "prismjs": "^1.27.0", + "refractor": "^3.6.0" }, "dependencies": { "prismjs": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.25.0.tgz", - "integrity": "sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg==" + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.28.0.tgz", + "integrity": "sha512-8aaXdYvl1F7iC7Xm1spqSaY/OJBpYW3v+KJ+F17iYxvdc8sfjW194COK5wVhMZX45tGteiBQgdvD/nhxcRwylw==" } } }, @@ -100637,13 +100574,13 @@ "dev": true }, "refractor": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.3.1.tgz", - "integrity": "sha512-vaN6R56kLMuBszHSWlwTpcZ8KTMG6aUCok4GrxYDT20UIOXxOc5o6oDc8tNTzSlH3m2sI+Eu9Jo2kVdDcUTWYw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz", + "integrity": "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==", "requires": { "hastscript": "^6.0.0", "parse-entities": "^2.0.0", - "prismjs": "~1.23.0" + "prismjs": "~1.27.0" }, "dependencies": { "parse-entities": { @@ -101541,12 +101478,6 @@ "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==" }, - "select": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", - "integrity": "sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0=", - "optional": true - }, "select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -103435,12 +103366,6 @@ "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=", "dev": true }, - "tiny-emitter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", - "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", - "optional": true - }, "tiny-invariant": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz", From f5e9f0eb3b2045a9d441f59cb3a6109892e6aea9 Mon Sep 17 00:00:00 2001 From: Yongjie Zhao Date: Wed, 27 Apr 2022 23:36:19 +0800 Subject: [PATCH 135/136] feat: add Advanced Analytics into mixed time series chart (#19851) --- .../src/sections/advancedAnalytics.tsx | 26 +- .../superset-ui-chart-controls/src/types.ts | 5 +- .../src/MixedTimeseries/buildQuery.ts | 112 +++---- .../src/MixedTimeseries/controlPanel.tsx | 24 ++ .../src/utils/formDataSuffix.ts | 74 +++++ .../test/MixedTimeseries/buildQuery.test.ts | 277 ++++++++++++++++++ .../test/utils/formDataSuffix.test.ts | 57 ++++ 7 files changed, 503 insertions(+), 72 deletions(-) create mode 100644 superset-frontend/plugins/plugin-chart-echarts/src/utils/formDataSuffix.ts create mode 100644 superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/buildQuery.test.ts create mode 100644 superset-frontend/plugins/plugin-chart-echarts/test/utils/formDataSuffix.test.ts diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/sections/advancedAnalytics.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/sections/advancedAnalytics.tsx index 3d562309ca948..1ed664b7de62a 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/sections/advancedAnalytics.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/sections/advancedAnalytics.tsx @@ -59,9 +59,16 @@ export const advancedAnalyticsControls: ControlPanelSectionConfig = { 'Defines the size of the rolling window function, ' + 'relative to the time granularity selected', ), - visibility: ({ controls }) => - Boolean(controls?.rolling_type?.value) && - controls.rolling_type.value !== RollingType.Cumsum, + visibility: ({ controls }, { name }) => { + // `rolling_type_b` refer to rolling_type in mixed timeseries Query B + const rollingTypeControlName = name.endsWith('_b') + ? 'rolling_type_b' + : 'rolling_type'; + return ( + Boolean(controls[rollingTypeControlName]?.value) && + controls[rollingTypeControlName]?.value !== RollingType.Cumsum + ); + }, }, }, ], @@ -79,9 +86,16 @@ export const advancedAnalyticsControls: ControlPanelSectionConfig = { 'shown are the total of 7 periods. This will hide the "ramp up" ' + 'taking place over the first 7 periods', ), - visibility: ({ controls }) => - Boolean(controls?.rolling_type?.value) && - controls.rolling_type.value !== RollingType.Cumsum, + visibility: ({ controls }, { name }) => { + // `rolling_type_b` refer to rolling_type in mixed timeseries Query B + const rollingTypeControlName = name.endsWith('_b') + ? 'rolling_type_b' + : 'rolling_type'; + return ( + Boolean(controls[rollingTypeControlName]?.value) && + controls[rollingTypeControlName]?.value !== RollingType.Cumsum + ); + }, }, }, ], diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts index 4eec6e9c0b401..73985bfc743b9 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts @@ -213,7 +213,10 @@ export interface BaseControlConfig< // TODO: add strict `chartState` typing (see superset-frontend/src/explore/types) chartState?: AnyDict, ) => ExtraControlProps; - visibility?: (props: ControlPanelsContainerProps) => boolean; + visibility?: ( + props: ControlPanelsContainerProps, + controlData: AnyDict, + ) => boolean; } export interface ControlValueValidator< diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/buildQuery.ts index 9adc149489a27..a8255b7d999fb 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/buildQuery.ts @@ -21,83 +21,65 @@ import { QueryFormData, QueryObject, normalizeOrderBy, + PostProcessingPivot, } from '@superset-ui/core'; import { pivotOperator, renameOperator, flattenOperator, + isTimeComparison, + timeComparePivotOperator, + rollingWindowOperator, + timeCompareOperator, + resampleOperator, } from '@superset-ui/chart-controls'; +import { + retainFormDataSuffix, + removeFormDataSuffix, +} from '../utils/formDataSuffix'; export default function buildQuery(formData: QueryFormData) { - const { - adhoc_filters, - adhoc_filters_b, - groupby, - groupby_b, - limit, - limit_b, - timeseries_limit_metric, - timeseries_limit_metric_b, - metrics, - metrics_b, - order_desc, - order_desc_b, - ...baseFormData - } = formData; - baseFormData.is_timeseries = true; - const formData1 = { - ...baseFormData, - adhoc_filters, - columns: groupby, - limit, - timeseries_limit_metric, - metrics, - order_desc, - }; - const formData2 = { - ...baseFormData, - adhoc_filters: adhoc_filters_b, - columns: groupby_b, - limit: limit_b, - timeseries_limit_metric: timeseries_limit_metric_b, - metrics: metrics_b, - order_desc: order_desc_b, + const baseFormData = { + ...formData, + is_timeseries: true, + columns: formData.groupby, + columns_b: formData.groupby_b, }; + const formData1 = removeFormDataSuffix(baseFormData, '_b'); + const formData2 = retainFormDataSuffix(baseFormData, '_b'); + + const queryContexts = [formData1, formData2].map(fd => + buildQueryContext(fd, baseQueryObject => { + const queryObject = { + ...baseQueryObject, + is_timeseries: true, + }; - const queryContextA = buildQueryContext(formData1, baseQueryObject => { - const queryObjectA = { - ...baseQueryObject, - is_timeseries: true, - post_processing: [ - pivotOperator(formData1, { ...baseQueryObject, is_timeseries: true }), - renameOperator(formData1, { - ...baseQueryObject, - ...{ is_timeseries: true }, - }), - flattenOperator(formData1, baseQueryObject), - ], - } as QueryObject; - return [normalizeOrderBy(queryObjectA)]; - }); + const pivotOperatorInRuntime: PostProcessingPivot = isTimeComparison( + fd, + queryObject, + ) + ? timeComparePivotOperator(fd, queryObject) + : pivotOperator(fd, queryObject); - const queryContextB = buildQueryContext(formData2, baseQueryObject => { - const queryObjectB = { - ...baseQueryObject, - is_timeseries: true, - post_processing: [ - pivotOperator(formData2, { ...baseQueryObject, is_timeseries: true }), - renameOperator(formData2, { - ...baseQueryObject, - ...{ is_timeseries: true }, - }), - flattenOperator(formData2, baseQueryObject), - ], - } as QueryObject; - return [normalizeOrderBy(queryObjectB)]; - }); + const tmpQueryObject = { + ...queryObject, + time_offsets: isTimeComparison(fd, queryObject) ? fd.time_compare : [], + post_processing: [ + pivotOperatorInRuntime, + rollingWindowOperator(fd, queryObject), + timeCompareOperator(fd, queryObject), + resampleOperator(fd, queryObject), + renameOperator(fd, queryObject), + flattenOperator(fd, queryObject), + ], + } as QueryObject; + return [normalizeOrderBy(tmpQueryObject)]; + }), + ); return { - ...queryContextA, - queries: [...queryContextA.queries, ...queryContextB.queries], + ...queryContexts[0], + queries: [...queryContexts[0].queries, ...queryContexts[1].queries], }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx index 97955eec3500c..e839637885e41 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx @@ -18,10 +18,12 @@ */ import React from 'react'; import { t } from '@superset-ui/core'; +import { cloneDeep } from 'lodash'; import { ControlPanelConfig, ControlPanelSectionConfig, ControlSetRow, + CustomControlItem, emitFilterControl, sections, sharedControls, @@ -253,11 +255,33 @@ function createCustomizeSection( ]; } +function createAdvancedAnalyticsSection( + label: string, + controlSuffix: string, +): ControlPanelSectionConfig { + const aaWithSuffix = cloneDeep(sections.advancedAnalyticsControls); + aaWithSuffix.label = label; + if (!controlSuffix) { + return aaWithSuffix; + } + aaWithSuffix.controlSetRows.forEach(row => + row.forEach((control: CustomControlItem) => { + if (control?.name) { + // eslint-disable-next-line no-param-reassign + control.name = `${control.name}${controlSuffix}`; + } + }), + ); + return aaWithSuffix; +} + const config: ControlPanelConfig = { controlPanelSections: [ sections.legacyTimeseriesTime, createQuerySection(t('Query A'), ''), + createAdvancedAnalyticsSection(t('Advanced analytics Query A'), ''), createQuerySection(t('Query B'), '_b'), + createAdvancedAnalyticsSection(t('Advanced analytics Query B'), '_b'), { label: t('Annotations and Layers'), expanded: false, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/formDataSuffix.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/formDataSuffix.ts new file mode 100644 index 0000000000000..c256e6f874270 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/formDataSuffix.ts @@ -0,0 +1,74 @@ +/** + * 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 { QueryFormData } from '@superset-ui/core'; + +export const retainFormDataSuffix = ( + formData: QueryFormData, + controlSuffix: string, +): QueryFormData => { + /* + * retain controls by suffix and return a new formData + * eg: + * > const fd = { metrics: ['foo', 'bar'], metrics_b: ['zee'], limit: 100, ... } + * > removeFormDataSuffix(fd, '_b') + * { metrics: ['zee'], limit: 100, ... } + * */ + const newFormData = {}; + + Object.entries(formData) + .sort(([a], [b]) => { + // items contained suffix before others + const weight_a = a.endsWith(controlSuffix) ? 1 : 0; + const weight_b = b.endsWith(controlSuffix) ? 1 : 0; + return weight_b - weight_a; + }) + .forEach(([key, value]) => { + if (key.endsWith(controlSuffix)) { + newFormData[key.slice(0, -controlSuffix.length)] = value; + } + + if (!key.endsWith(controlSuffix) && !(key in newFormData)) { + // ignore duplication + newFormData[key] = value; + } + }); + + return newFormData as QueryFormData; +}; + +export const removeFormDataSuffix = ( + formData: QueryFormData, + controlSuffix: string, +): QueryFormData => { + /* + * remove unused controls by suffix and return a new formData + * eg: + * > const fd = { metrics: ['foo', 'bar'], metrics_b: ['zee'], limit: 100, ... } + * > removeUnusedFormData(fd, '_b') + * { metrics: ['foo', 'bar'], limit: 100, ... } + * */ + const newFormData = {}; + Object.entries(formData).forEach(([key, value]) => { + if (!key.endsWith(controlSuffix)) { + newFormData[key] = value; + } + }); + + return newFormData as QueryFormData; +}; diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/buildQuery.test.ts new file mode 100644 index 0000000000000..72f16482cb771 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/buildQuery.test.ts @@ -0,0 +1,277 @@ +/** + * 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 { + ComparisionType, + FreeFormAdhocFilter, + RollingType, + TimeGranularity, +} from '@superset-ui/core'; +import buildQuery from '../../src/MixedTimeseries/buildQuery'; + +const formDataMixedChart = { + datasource: 'dummy', + viz_type: 'my_chart', + // query + // -- common + time_range: '1980 : 2000', + time_grain_sqla: TimeGranularity.WEEK, + granularity_sqla: 'ds', + // -- query a + groupby: ['foo'], + metrics: ['sum(sales)'], + adhoc_filters: [ + { + clause: 'WHERE', + expressionType: 'SQL', + sqlExpression: "foo in ('a', 'b')", + } as FreeFormAdhocFilter, + ], + limit: 5, + row_limit: 10, + timeseries_limit_metric: 'count', + order_desc: true, + emit_filter: true, + // -- query b + groupby_b: [], + metrics_b: ['count'], + adhoc_filters_b: [ + { + clause: 'WHERE', + expressionType: 'SQL', + sqlExpression: "name in ('c', 'd')", + } as FreeFormAdhocFilter, + ], + limit_b: undefined, + row_limit_b: 100, + timeseries_limit_metric_b: undefined, + order_desc_b: false, + emit_filter_b: undefined, + // chart configs + show_value: false, + show_valueB: undefined, +}; +const formDataMixedChartWithAA = { + ...formDataMixedChart, + rolling_type: RollingType.Cumsum, + time_compare: ['1 years ago'], + comparison_type: ComparisionType.Values, + resample_rule: '1AS', + resample_method: 'zerofill', + + rolling_type_b: RollingType.Sum, + rolling_periods_b: 1, + min_periods_b: 1, + comparison_type_b: ComparisionType.Difference, + time_compare_b: ['3 years ago'], + resample_rule_b: '1A', + resample_method_b: 'asfreq', +}; + +test('should compile query object A', () => { + const query_a = buildQuery(formDataMixedChart).queries[0]; + expect(query_a).toEqual({ + time_range: '1980 : 2000', + since: undefined, + until: undefined, + granularity: 'ds', + filters: [], + extras: { + having: '', + having_druid: [], + time_grain_sqla: 'P1W', + where: "(foo in ('a', 'b'))", + }, + applied_time_extras: {}, + columns: ['foo'], + metrics: ['sum(sales)'], + annotation_layers: [], + row_limit: 10, + row_offset: undefined, + series_columns: undefined, + series_limit: undefined, + series_limit_metric: undefined, + timeseries_limit: 5, + url_params: {}, + custom_params: {}, + custom_form_data: {}, + is_timeseries: true, + time_offsets: [], + post_processing: [ + { + operation: 'pivot', + options: { + aggregates: { + 'sum(sales)': { + operator: 'mean', + }, + }, + columns: ['foo'], + drop_missing_columns: false, + flatten_columns: false, + index: ['__timestamp'], + reset_index: false, + }, + }, + undefined, + undefined, + undefined, + { + operation: 'rename', + options: { + columns: { + 'sum(sales)': null, + }, + inplace: true, + level: 0, + }, + }, + { + operation: 'flatten', + }, + ], + orderby: [['count', false]], + }); +}); + +test('should compile query object B', () => { + const query_a = buildQuery(formDataMixedChart).queries[1]; + expect(query_a).toEqual({ + time_range: '1980 : 2000', + since: undefined, + until: undefined, + granularity: 'ds', + filters: [], + extras: { + having: '', + having_druid: [], + time_grain_sqla: 'P1W', + where: "(name in ('c', 'd'))", + }, + applied_time_extras: {}, + columns: [], + metrics: ['count'], + annotation_layers: [], + row_limit: 100, + row_offset: undefined, + series_columns: undefined, + series_limit: undefined, + series_limit_metric: undefined, + timeseries_limit: 0, + url_params: {}, + custom_params: {}, + custom_form_data: {}, + is_timeseries: true, + time_offsets: [], + post_processing: [ + { + operation: 'pivot', + options: { + aggregates: { + count: { + operator: 'mean', + }, + }, + columns: [], + drop_missing_columns: false, + flatten_columns: false, + index: ['__timestamp'], + reset_index: false, + }, + }, + undefined, + undefined, + undefined, + undefined, + { + operation: 'flatten', + }, + ], + orderby: [['count', true]], + }); +}); + +test('should compile AA in query A', () => { + const query_a = buildQuery(formDataMixedChartWithAA).queries[0]; + // time comparison + expect(query_a?.time_offsets).toEqual(['1 years ago']); + + // cumsum + expect( + // prettier-ignore + query_a + .post_processing + ?.find(operator => operator?.operation === 'cum') + ?.operation, + ).toEqual('cum'); + + // resample + expect( + // prettier-ignore + query_a + .post_processing + ?.find(operator => operator?.operation === 'resample'), + ).toEqual({ + operation: 'resample', + options: { + method: 'asfreq', + rule: '1AS', + fill_value: 0, + }, + }); +}); + +test('should compile AA in query B', () => { + const query_b = buildQuery(formDataMixedChartWithAA).queries[1]; + // time comparison + expect(query_b?.time_offsets).toEqual(['3 years ago']); + + // rolling total + expect( + // prettier-ignore + query_b + .post_processing + ?.find(operator => operator?.operation === 'rolling'), + ).toEqual({ + operation: 'rolling', + options: { + rolling_type: 'sum', + window: 1, + min_periods: 1, + columns: { + count: 'count', + 'count__3 years ago': 'count__3 years ago', + }, + }, + }); + + // resample + expect( + // prettier-ignore + query_b + .post_processing + ?.find(operator => operator?.operation === 'resample'), + ).toEqual({ + operation: 'resample', + options: { + method: 'asfreq', + rule: '1A', + fill_value: null, + }, + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/utils/formDataSuffix.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/utils/formDataSuffix.test.ts new file mode 100644 index 0000000000000..2e22583c76c7c --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/utils/formDataSuffix.test.ts @@ -0,0 +1,57 @@ +/** + * 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 { + retainFormDataSuffix, + removeFormDataSuffix, +} from '../../src/utils/formDataSuffix'; + +const formData = { + datasource: 'dummy', + viz_type: 'table', + metrics: ['a', 'b'], + columns: ['foo', 'bar'], + limit: 100, + metrics_b: ['c', 'd'], + columns_b: ['hello', 'world'], + limit_b: 200, +}; + +test('should keep controls with suffix', () => { + expect(retainFormDataSuffix(formData, '_b')).toEqual({ + datasource: 'dummy', + viz_type: 'table', + metrics: ['c', 'd'], + columns: ['hello', 'world'], + limit: 200, + }); + // no side effect + expect(retainFormDataSuffix(formData, '_b')).not.toEqual(formData); +}); + +test('should remove controls with suffix', () => { + expect(removeFormDataSuffix(formData, '_b')).toEqual({ + datasource: 'dummy', + viz_type: 'table', + metrics: ['a', 'b'], + columns: ['foo', 'bar'], + limit: 100, + }); + // no side effect + expect(removeFormDataSuffix(formData, '_b')).not.toEqual(formData); +}); From 4a835a4299bbe90def232e376f919bc494b2d0a1 Mon Sep 17 00:00:00 2001 From: smileydev <47900232+prosdev0107@users.noreply.github.com> Date: Wed, 27 Apr 2022 12:57:15 -0400 Subject: [PATCH 136/136] fix(dashboard-css): make to load saved css template (#19840) * fix(dashboard-css): make to load saved css template * fix(dashboard-css): make to update state css with componentDidMount * fix(dashobard-css): make to inject custom css after updateCss * fix(dashboard-css): make to add RTL for custom css * fix(dashboard-css): make to fix lint issue --- .../HeaderActionsDropdown.test.tsx | 28 +++++++++++++++++++ .../Header/HeaderActionsDropdown/index.jsx | 11 ++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/HeaderActionsDropdown.test.tsx b/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/HeaderActionsDropdown.test.tsx index d1f87ec999e0c..57fe7a1333973 100644 --- a/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/HeaderActionsDropdown.test.tsx +++ b/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/HeaderActionsDropdown.test.tsx @@ -17,6 +17,8 @@ * under the License. */ import React from 'react'; +import { shallow } from 'enzyme'; +import sinon from 'sinon'; import { render, screen } from 'spec/helpers/testing-library'; import userEvent from '@testing-library/user-event'; import fetchMock from 'fetch-mock'; @@ -202,3 +204,29 @@ test('should show the properties modal', async () => { userEvent.click(screen.getByText('Edit dashboard properties')); expect(editModeOnProps.showPropertiesModal).toHaveBeenCalledTimes(1); }); + +describe('UNSAFE_componentWillReceiveProps', () => { + let wrapper: any; + const mockedProps = createProps(); + const props = { ...mockedProps, customCss: '' }; + + beforeEach(() => { + wrapper = shallow(); + wrapper.setState({ css: props.customCss }); + sinon.spy(wrapper.instance(), 'setState'); + }); + + afterEach(() => { + wrapper.instance().setState.restore(); + }); + + it('css should update state and inject custom css', () => { + wrapper.instance().UNSAFE_componentWillReceiveProps({ + ...props, + customCss: mockedProps.customCss, + }); + expect(wrapper.instance().setState.calledOnce).toBe(true); + const stateKeys = Object.keys(wrapper.instance().setState.lastCall.args[0]); + expect(stateKeys).toContain('css'); + }); +}); diff --git a/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx b/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx index 619e10ea22e80..45946ecc8fa13 100644 --- a/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx +++ b/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx @@ -136,10 +136,15 @@ class HeaderActionsDropdown extends React.PureComponent { }); } + UNSAFE_componentWillReceiveProps(nextProps) { + if (this.props.customCss !== nextProps.customCss) { + this.setState({ css: nextProps.customCss }, () => { + injectCustomCss(nextProps.customCss); + }); + } + } + changeCss(css) { - this.setState({ css }, () => { - injectCustomCss(css); - }); this.props.onChange(); this.props.updateCss(css); }