You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ma...@apache.org on 2025/09/20 22:47:48 UTC
(superset) branch master updated: feat: AI-powered TypeScript migration framework with parallel processing (#35045)
This is an automated email from the ASF dual-hosted git repository.
maximebeauchemin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new ecb3ac68ff feat: AI-powered TypeScript migration framework with parallel processing (#35045)
ecb3ac68ff is described below
commit ecb3ac68ff79fe0d60cdecd54f4bab120ee1ca32
Author: Maxime Beauchemin <ma...@gmail.com>
AuthorDate: Sat Sep 20 15:47:42 2025 -0700
feat: AI-powered TypeScript migration framework with parallel processing (#35045)
Co-authored-by: Claude <no...@anthropic.com>
Co-authored-by: Mehmet Salih Yavuz <sa...@proton.me>
Co-authored-by: Elizabeth Thompson <es...@gmail.com>
---
.claude/commands/js-to-ts.md | 10 +
.claude/projects/js-to-ts/AGENT.md | 684 +++++++++++++++++++++
.claude/projects/js-to-ts/COORDINATOR.md | 199 ++++++
.claude/projects/js-to-ts/PROJECT.md | 76 +++
.../src/utils/{roundDecimal.js => roundDecimal.ts} | 11 +-
.../{roundDecimal.test.js => roundDecimal.test.ts} | 0
.../spec/fixtures/mockDashboardLayout.js | 20 +-
.../MessageToasts/{reducers.js => reducers.ts} | 20 +-
.../src/components/Tag/Tag.stories.tsx | 2 +-
superset-frontend/src/components/Tag/Tag.test.tsx | 2 +-
superset-frontend/src/components/Tag/index.tsx | 2 +-
superset-frontend/src/components/Tag/utils.tsx | 4 +-
.../src/components/TagsList/index.tsx | 2 +-
.../src/dashboard/actions/sliceEntities.ts | 31 +
.../components/AddSliceCard/AddSliceCard.tsx | 4 +-
.../src/dashboard/components/SliceAdder.tsx | 3 +-
.../components/dnd/dragDroppableConfig.js | 85 ---
.../components/dnd/dragDroppableConfig.ts | 188 ++++++
.../DynamicComponent/DynamicComponent.tsx | 3 +-
.../gridComponents/new/DraggableNewComponent.tsx | 3 +-
...yDashboardLayout.js => emptyDashboardLayout.ts} | 39 +-
.../{sliceEntities.js => sliceEntities.ts} | 35 +-
.../src/dashboard/util/activeDashboardFilters.js | 126 ----
.../src/dashboard/util/activeDashboardFilters.ts | 211 +++++++
...peTreeEntry.js => buildFilterScopeTreeEntry.ts} | 32 +-
...sParent.test.js => dropOverflowsParent.test.ts} | 39 +-
...opOverflowsParent.js => dropOverflowsParent.ts} | 15 +-
...entContainer.js => findFirstParentContainer.ts} | 5 +-
...ayout.test.js => getChartIdsFromLayout.test.ts} | 33 +-
...nentFromChartId.js => getChartIdsFromLayout.ts} | 25 +-
...erScopes.js => getDirectPathToTabIndex.test.ts} | 37 +-
...athToTabIndex.js => getDirectPathToTabIndex.ts} | 12 +-
.../util/{getEmptyLayout.js => getEmptyLayout.ts} | 18 +-
.../util/getKeyForFilterScopeTree.test.ts | 78 +++
...terScopeTree.js => getKeyForFilterScopeTree.ts} | 7 +-
.../util/getLayoutComponentFromChartId.test.ts | 105 ++++
...ChartId.js => getLayoutComponentFromChartId.ts} | 6 +-
...IdFromPath.js => getLeafComponentIdFromPath.ts} | 9 +-
.../src/dashboard/util/isDashboardEmpty.test.ts | 2 +-
.../src/dashboard/util/isDashboardLoading.js | 23 -
.../src/dashboard/util/isDashboardLoading.test.ts | 48 ++
...ChartIdsFromLayout.js => isDashboardLoading.ts} | 27 +-
.../util/isInDifferentFilterScopes.test.ts | 197 ++++++
...ilterScopes.js => isInDifferentFilterScopes.ts} | 29 +-
.../util/logging/childChartsDidLoad.test.ts | 246 ++++++++
...childChartsDidLoad.js => childChartsDidLoad.ts} | 30 +-
.../dashboard/util/serializeFilterScopes.test.ts | 113 ++++
...izeFilterScopes.js => serializeFilterScopes.ts} | 24 +-
...ldWrapChildInRow.js => shouldWrapChildInRow.ts} | 23 +-
.../explore/components/DatasourcePanel/index.tsx | 1 +
.../controls/FilterControl/adhocFilterType.js | 37 --
.../controls/FilterControl/adhocFilterType.test.ts | 103 ++++
.../controls/FilterControl/adhocFilterType.ts | 64 ++
.../controls/FilterControl/columnType.test.ts | 53 ++
.../columnType.js => FilterControl/columnType.ts} | 11 +-
.../components/controls/FilterControl/types.ts | 7 +
.../MetricControl/aggregateOptionType.test.ts} | 38 +-
.../aggregateOptionType.ts} | 9 +-
.../types.ts => MetricControl/columnType.ts} | 13 +-
.../controls/MetricControl/savedMetricType.js | 25 -
.../controls/MetricControl/savedMetricType.test.ts | 45 ++
.../{aggregateOptionType.js => savedMetricType.ts} | 7 +-
.../components/controls/MetricControl/types.ts | 4 +
.../src/explore/controlPanels/Separator.test.ts | 76 +++
.../controlPanels/{Separator.js => Separator.ts} | 23 +-
...rides.js => timeGrainSqlaAnimationOverrides.ts} | 17 +-
superset-frontend/src/types/TagType.ts | 2 -
.../src/utils/DebouncedMessageQueue.test.ts | 66 ++
...cedMessageQueue.js => DebouncedMessageQueue.ts} | 31 +-
superset-frontend/src/utils/datasourceUtils.js | 27 -
.../src/utils/datasourceUtils.test.ts | 190 ++++++
superset-frontend/src/utils/datasourceUtils.ts | 57 ++
.../src/utils/getControlsForVizType.test.ts | 101 +++
.../src/utils/getControlsForVizType.ts | 74 +++
.../src/utils/hostNamesConfig.test.ts | 58 ++
.../{hostNamesConfig.js => hostNamesConfig.ts} | 10 +-
superset-frontend/src/utils/reducerUtils.test.ts | 129 ++++
superset-frontend/src/utils/reducerUtils.ts | 107 ++++
78 files changed, 3815 insertions(+), 513 deletions(-)
diff --git a/.claude/commands/js-to-ts.md b/.claude/commands/js-to-ts.md
new file mode 100644
index 0000000000..b3e0048983
--- /dev/null
+++ b/.claude/commands/js-to-ts.md
@@ -0,0 +1,10 @@
+# JavaScript to TypeScript Migration Command
+
+## Usage
+```
+/js-to-ts <core-filename>
+```
+- `<core-filename>` - Path to CORE file relative to `superset-frontend/` (e.g., `src/utils/common.js`, `src/middleware/loggerMiddleware.js`)
+
+## Agent Instructions
+**See:** [../projects/js-to-ts/AGENT.md](../projects/js-to-ts/AGENT.md) for complete migration guide.
diff --git a/.claude/projects/js-to-ts/AGENT.md b/.claude/projects/js-to-ts/AGENT.md
new file mode 100644
index 0000000000..47e6cac7cf
--- /dev/null
+++ b/.claude/projects/js-to-ts/AGENT.md
@@ -0,0 +1,684 @@
+# JavaScript to TypeScript Migration Agent Guide
+
+**Complete technical reference for converting JavaScript/JSX files to TypeScript/TSX in Apache Superset frontend.**
+
+**Agent Role:** Atomic migration unit - migrate the core file + ALL related tests/mocks as one cohesive unit. Use `git mv` to preserve history, NO `git commit`. NO global import changes. Report results upon completion.
+
+---
+
+## π― Migration Principles
+
+1. **Atomic migration units** - Core file + all related tests/mocks migrate together
+2. **Zero `any` types** - Use proper TypeScript throughout
+3. **Leverage existing types** - Reuse established definitions
+4. **Type inheritance** - Derivatives extend base component types
+5. **Strategic placement** - File types for maximum discoverability
+6. **Surgical improvements** - Enhance existing types during migration
+
+---
+
+## Step 0: Dependency Check (MANDATORY)
+
+**Command:**
+```bash
+grep -E "from '\.\./.*\.jsx?'|from '\./.*\.jsx?'|from 'src/.*\.jsx?'" superset-frontend/{filename}
+```
+
+**Decision:**
+- β
No matches β Proceed with atomic migration (core + tests + mocks)
+- β Matches found β EXIT with dependency report (see format below)
+
+---
+
+## Step 1: Identify Related Files (REQUIRED)
+
+**Atomic Migration Scope:**
+For core file `src/utils/example.js`, also migrate:
+- `src/utils/example.test.js` / `src/utils/example.test.jsx`
+- `src/utils/example.spec.js` / `src/utils/example.spec.jsx`
+- `src/utils/__mocks__/example.js`
+- Any other related test/mock files found by pattern matching
+
+**Find all related test and mock files:**
+```bash
+# Pattern-based search for related files
+basename=$(basename {filename} .js)
+dirname=$(dirname superset-frontend/{filename})
+
+# Find test files
+find "$dirname" -name "${basename}.test.js" -o -name "${basename}.test.jsx"
+find "$dirname" -name "${basename}.spec.js" -o -name "${basename}.spec.jsx"
+
+# Find mock files
+find "$dirname" -name "__mocks__/${basename}.js"
+find "$dirname" -name "${basename}.mock.js"
+```
+
+**Migration Requirement:** All discovered related files MUST be migrated together as one atomic unit.
+
+**Test File Creation:** If NO test files exist for the core file, CREATE a minimal test file using the following pattern:
+- Location: Same directory as core file
+- Name: `{basename}.test.ts` (e.g., `DebouncedMessageQueue.test.ts`)
+- Content: Basic test structure importing and testing the main functionality
+- Use proper TypeScript types in test file
+
+---
+
+## πΊοΈ Type Reference Map
+
+### From `@superset-ui/core`
+```typescript
+// Data & Query
+QueryFormData, QueryData, JsonObject, AnnotationData, AdhocMetric
+LatestQueryFormData, GenericDataType, DatasourceType, ExtraFormData
+DataMaskStateWithId, NativeFilterScope, NativeFiltersState, NativeFilterTarget
+
+// UI & Theme
+FeatureFlagMap, LanguagePack, ColorSchemeConfig, SequentialSchemeConfig
+```
+
+### From `@superset-ui/chart-controls`
+```typescript
+Dataset, ColumnMeta, ControlStateMapping
+```
+
+### From Local Types (`src/types/`)
+```typescript
+// Authentication
+User, UserWithPermissionsAndRoles, BootstrapUser, PermissionsAndRoles
+
+// Dashboard
+Dashboard, DashboardState, DashboardInfo, DashboardLayout, LayoutItem
+ComponentType, ChartConfiguration, ActiveFilters
+
+// Charts
+Chart, ChartState, ChartStatus, ChartLinkedDashboard, Slice, SaveActionType
+
+// Data
+Datasource, Database, Owner, Role
+
+// UI Components
+TagType, FavoriteStatus, Filter, ImportResourceName
+```
+
+### From Domain Types
+```typescript
+// src/dashboard/types.ts
+RootState, ChartsState, DatasourcesState, FilterBarOrientation
+ChartCrossFiltersConfig, ActiveTabs, MenuKeys
+
+// src/explore/types.ts
+ExplorePageInitialData, ExplorePageState, ExploreResponsePayload, OptionSortType
+
+// src/SqlLab/types.ts
+[SQL Lab specific types]
+```
+
+---
+
+## ποΈ Type Organization Strategy
+
+### Type Placement Hierarchy
+
+1. **Component-Colocated** (90% of cases)
+ ```typescript
+ // Same file as component
+ interface MyComponentProps {
+ title: string;
+ onClick: () => void;
+ }
+ ```
+
+2. **Feature-Shared**
+ ```typescript
+ // src/[domain]/components/[Feature]/types.ts
+ export interface FilterConfiguration {
+ filterId: string;
+ targets: NativeFilterTarget[];
+ }
+ ```
+
+3. **Domain-Wide**
+ ```typescript
+ // src/[domain]/types.ts
+ export interface ExploreFormData extends QueryFormData {
+ viz_type: string;
+ }
+ ```
+
+4. **Global**
+ ```typescript
+ // src/types/[TypeName].ts
+ export interface ApiResponse<T> {
+ result: T;
+ count?: number;
+ }
+ ```
+
+### Type Discovery Commands
+```bash
+# Search existing types before creating
+find superset-frontend/src -name "types.ts" -exec grep -l "[TypeConcept]" {} \;
+grep -r "interface.*Props\|type.*Props" superset-frontend/src/
+```
+
+### Derivative Component Patterns
+
+**Rule:** Components that extend others should extend their type interfaces.
+
+```typescript
+// β
Base component type
+interface SelectProps {
+ value: string | number;
+ options: SelectOption[];
+ onChange: (value: string | number) => void;
+ disabled?: boolean;
+}
+
+// β
Derivative extends base
+interface ChartSelectProps extends SelectProps {
+ charts: Chart[];
+ onChartSelect: (chart: Chart) => void;
+}
+
+// β
Derivative with modified props
+interface DatabaseSelectProps extends Omit<SelectProps, 'value' | 'onChange'> {
+ value: number; // Narrowed type
+ onChange: (databaseId: number) => void; // Specific signature
+}
+```
+
+**Common Patterns:**
+- **Extension:** `extends BaseProps` - adds new props
+- **Omission:** `Omit<BaseProps, 'prop'>` - removes props
+- **Modification:** `Omit<BaseProps, 'prop'> & { prop: NewType }` - changes prop type
+- **Restriction:** Override with narrower types (union β specific)
+
+---
+
+## π Migration Recipe
+
+### Step 2: File Conversion
+```bash
+# Use git mv to preserve history
+git mv component.js component.ts
+git mv Component.jsx Component.tsx
+```
+
+### Step 3: Import & Type Setup
+```typescript
+// Import order (enforced by linting)
+import { FC, ReactNode } from 'react';
+import { JsonObject, QueryFormData } from '@superset-ui/core';
+import { Dataset } from '@superset-ui/chart-controls';
+import type { Dashboard } from 'src/types/Dashboard';
+```
+
+### Step 4: Function & Component Typing
+```typescript
+// Functions with proper parameter/return types
+export function processData(
+ data: Dataset[],
+ config: JsonObject
+): ProcessedData[] {
+ // implementation
+}
+
+// Component props with inheritance
+interface ComponentProps extends BaseProps {
+ data: Chart[];
+ onSelect: (id: number) => void;
+}
+
+const Component: FC<ComponentProps> = ({ data, onSelect }) => {
+ // implementation
+};
+```
+
+### Step 5: State & Redux Typing
+```typescript
+// Hooks with specific types
+const [data, setData] = useState<Chart[]>([]);
+const [selected, setSelected] = useState<number | null>(null);
+
+// Redux with existing RootState
+const mapStateToProps = (state: RootState) => ({
+ charts: state.charts,
+ user: state.user,
+});
+```
+
+---
+
+## π§ Type Debugging Strategies (Real-World Learnings)
+
+### The Evolution of Type Approaches
+When you hit type errors, follow this debugging evolution:
+
+#### 1. β Idealized Union Types (First Attempt)
+```typescript
+// Looks clean but doesn't match reality
+type DatasourceInput = Datasource | QueryEditor;
+```
+**Problem**: Real calling sites pass variations, not exact types.
+
+#### 2. β Overly Precise Types (Second Attempt)
+```typescript
+// Tried to match exact calling signatures
+type DatasourceInput =
+ | IDatasource // From DatasourcePanel
+ | (QueryEditor & { columns: ColumnMeta[] }); // From SaveQuery
+```
+**Problem**: Too rigid, doesn't handle legacy variations.
+
+#### 3. β
Flexible Interface (Final Solution)
+```typescript
+// Captures what the function actually needs
+interface DatasourceInput {
+ name?: string | null; // Allow null for compatibility
+ datasource_name?: string | null; // Legacy variations
+ columns?: any[]; // Multiple column types accepted
+ database?: { id?: number };
+ // ... other optional properties
+}
+```
+**Success**: Works with all calling sites, focuses on function needs.
+
+### Type Debugging Process
+1. **Start with compilation errors** - they show exact mismatches
+2. **Examine actual usage** - look at calling sites, not idealized types
+3. **Build flexible interfaces** - capture what functions need, not rigid contracts
+4. **Iterate based on downstream validation** - let calling sites guide your types
+
+---
+
+## π¨ Anti-Patterns to Avoid
+
+```typescript
+// β Never use any
+const obj: any = {};
+
+// β
Use proper types
+const obj: Record<string, JsonObject> = {};
+
+// β Don't recreate base component props
+interface ChartSelectProps {
+ value: string; // Duplicated from SelectProps
+ onChange: () => void; // Duplicated from SelectProps
+ charts: Chart[]; // New prop
+}
+
+// β
Inherit and extend
+interface ChartSelectProps extends SelectProps {
+ charts: Chart[]; // Only new props
+}
+
+// β Don't create ad-hoc type variations
+interface UserInfo {
+ name: string;
+ email: string;
+}
+
+// β
Extend existing types (DRY principle)
+import { User } from 'src/types/bootstrapTypes';
+type UserDisplayInfo = Pick<User, 'firstName' | 'lastName' | 'email'>;
+
+// β Don't create overly rigid unions
+type StrictInput = ExactTypeA | ExactTypeB;
+
+// β
Create flexible interfaces for function parameters
+interface FlexibleInput {
+ // Focus on what the function actually needs
+ commonProperty: string;
+ optionalVariations?: any; // Allow for legacy variations
+}
+```
+
+## π DRY Type Guidelines (WHERE TYPES BELONG)
+
+### Type Placement Rules
+**CRITICAL**: Type variations must live close to where they belong, not scattered across files.
+
+#### β
Proper Type Organization
+```typescript
+// β Don't create one-off interfaces in utility files
+// src/utils/datasourceUtils.ts
+interface DatasourceInput { /* custom interface */ } // Wrong!
+
+// β
Use existing types or extend them in their proper domain
+// src/utils/datasourceUtils.ts
+import { IDatasource } from 'src/explore/components/DatasourcePanel';
+import { QueryEditor } from 'src/SqlLab/types';
+
+// Create flexible interface that references existing types
+interface FlexibleDatasourceInput {
+ // Properties that actually exist across variations
+}
+```
+
+#### Type Location Hierarchy
+1. **Domain Types**: `src/{domain}/types.ts` (dashboard, explore, SqlLab)
+2. **Component Types**: Co-located with components
+3. **Global Types**: `src/types/` directory
+4. **Utility Types**: Only when they truly don't belong elsewhere
+
+#### β
DRY Type Patterns
+```typescript
+// β
Extend existing domain types
+interface SaveQueryData extends Pick<QueryEditor, 'sql' | 'dbId' | 'catalog'> {
+ columns: ColumnMeta[]; // Add what's needed
+}
+
+// β
Create flexible interfaces for cross-domain utilities
+interface CrossDomainInput {
+ // Common properties that exist across different source types
+ name?: string | null; // Accommodate legacy null values
+ // Only include properties the function actually uses
+}
+```
+
+---
+
+## π― PropTypes Auto-Generation (Elegant Approach)
+
+**IMPORTANT**: Superset has `babel-plugin-typescript-to-proptypes` configured to automatically generate PropTypes from TypeScript interfaces. Use this instead of manual PropTypes duplication!
+
+### β Manual PropTypes Duplication (Avoid This)
+```typescript
+export interface MyComponentProps {
+ title: string;
+ count?: number;
+}
+
+// 8+ lines of manual PropTypes duplication π±
+const propTypes = PropTypes.shape({
+ title: PropTypes.string.isRequired,
+ count: PropTypes.number,
+});
+
+export default propTypes;
+```
+
+### β
Auto-Generated PropTypes (Use This)
+```typescript
+import { InferProps } from 'prop-types';
+
+export interface MyComponentProps {
+ title: string;
+ count?: number;
+}
+
+// Single validator function - babel plugin auto-generates PropTypes! β¨
+export default function MyComponentValidator(props: MyComponentProps) {
+ return null; // PropTypes auto-assigned by babel-plugin-typescript-to-proptypes
+}
+
+// Optional: For consumers needing PropTypes type inference
+export type MyComponentPropsInferred = InferProps<typeof MyComponentValidator>;
+```
+
+### Migration Pattern for Type-Only Files
+
+**When migrating type-only files with manual PropTypes:**
+
+1. **Keep the TypeScript interfaces** (single source of truth)
+2. **Replace manual PropTypes** with validator function
+3. **Remove PropTypes imports** and manual shape definitions
+4. **Add InferProps import** if type inference needed
+
+**Example Migration:**
+```typescript
+// Before: 25+ lines with manual PropTypes duplication
+export interface AdhocFilterType { /* ... */ }
+const adhocFilterTypePropTypes = PropTypes.oneOfType([...]);
+
+// After: 3 lines with auto-generation
+export interface AdhocFilterType { /* ... */ }
+export default function AdhocFilterValidator(props: { filter: AdhocFilterType }) {
+ return null; // Auto-generated PropTypes by babel plugin
+}
+```
+
+### Component PropTypes Pattern
+
+**For React components, the babel plugin works automatically:**
+
+```typescript
+interface ComponentProps {
+ title: string;
+ onClick: () => void;
+}
+
+const MyComponent: FC<ComponentProps> = ({ title, onClick }) => {
+ // Component implementation
+};
+
+// PropTypes automatically generated by babel plugin - no manual work needed!
+export default MyComponent;
+```
+
+### Auto-Generation Benefits
+
+- β
**Single source of truth**: TypeScript interfaces drive PropTypes
+- β
**No duplication**: Eliminate 15-20 lines of manual PropTypes code
+- β
**Automatic updates**: Changes to TypeScript automatically update PropTypes
+- β
**Type safety**: Compile-time checking ensures PropTypes match interfaces
+- β
**Backward compatibility**: Existing JavaScript components continue working
+
+### Babel Plugin Configuration
+
+The plugin is already configured in `babel.config.js`:
+```javascript
+['babel-plugin-typescript-to-proptypes', { loose: true }]
+```
+
+**No additional setup required** - just use TypeScript interfaces and the plugin handles the rest!
+
+---
+
+## π§ͺ Test File Migration Patterns
+
+### Test File Priority
+- **Always migrate test files** alongside production files
+- **Test files are often leaf nodes** - good starting candidates
+- **Create tests if missing** - Leverage new TypeScript types for better test coverage
+
+### Test-Specific Type Patterns
+```typescript
+// Mock interfaces for testing
+interface MockStore {
+ getState: () => Partial<RootState>; // Partial allows minimal mocking
+}
+
+// Type-safe mocking for complex objects
+const mockDashboardInfo: Partial<DashboardInfo> as DashboardInfo = {
+ id: 123,
+ json_metadata: '{}',
+};
+
+// Sinon stub typing
+let postStub: sinon.SinonStub;
+beforeEach(() => {
+ postStub = sinon.stub(SupersetClient, 'post');
+});
+
+// Use stub reference instead of original method
+expect(postStub.callCount).toBe(1);
+expect(postStub.getCall(0).args[0].endpoint).toMatch('/api/');
+```
+
+### Test Migration Recipe
+1. **Migrate production file first** (if both need migration)
+2. **Update test imports** to point to `.ts/.tsx` files
+3. **Add proper mock typing** using `Partial<T> as T` pattern
+4. **Fix stub typing** - Use stub references, not original methods
+5. **Verify all tests pass** with TypeScript compilation
+
+---
+
+## π§ Type Conflict Resolution
+
+### Multiple Type Definitions Issue
+**Problem**: Same type name defined in multiple files causes compilation errors.
+
+**Example**: `DashboardInfo` defined in both:
+- `src/dashboard/reducers/types.ts` (minimal)
+- `src/dashboard/components/Header/types.ts` (different shape)
+- `src/dashboard/types.ts` (complete - used by RootState)
+
+### Resolution Strategy
+1. **Identify the authoritative type**:
+ ```bash
+ # Find which type is used by RootState/main interfaces
+ grep -r "DashboardInfo" src/dashboard/types.ts
+ ```
+
+2. **Use import from authoritative source**:
+ ```typescript
+ // β
Import from main domain types
+ import { RootState, DashboardInfo } from 'src/dashboard/types';
+
+ // β Don't import from component-specific files
+ import { DashboardInfo } from 'src/dashboard/components/Header/types';
+ ```
+
+3. **Mock complex types in tests**:
+ ```typescript
+ // For testing - provide minimal required fields
+ const mockInfo: Partial<DashboardInfo> as DashboardInfo = {
+ id: 123,
+ json_metadata: '{}',
+ // Only provide fields actually used in test
+ };
+ ```
+
+### Type Hierarchy Discovery Commands
+```bash
+# Find all definitions of a type
+grep -r "interface.*TypeName\|type.*TypeName" src/
+
+# Find import usage patterns
+grep -r "import.*TypeName" src/
+
+# Check what RootState uses
+grep -A 10 -B 10 "TypeName" src/*/types.ts
+```
+
+---
+
+## Agent Constraints (CRITICAL)
+
+1. **Use git mv** - Run `git mv file.js file.ts` to preserve git history, but NO `git commit`
+2. **NO global import changes** - Don't update imports across codebase
+3. **Type files OK** - Can modify existing type files to improve/align types
+4. **Single-File TypeScript Validation** (CRITICAL) - tsc has known issues with multi-file compilation:
+ - **Core Issue**: TypeScript's `tsc` has documented problems validating multiple files simultaneously in complex projects
+ - **Solution**: ALWAYS validate files one at a time using individual `tsc` calls
+ - **Command Pattern**: `cd superset-frontend && npx tscw --noEmit --allowJs --composite false --project tsconfig.json {single-file-path}`
+ - **Why**: Multi-file validation can produce false positives, miss real errors, and conflict during parallel agent execution
+5. **Downstream Impact Validation** (CRITICAL) - Your migration affects calling sites:
+ - **Find downstream files**: `find superset-frontend/src -name "*.tsx" -o -name "*.ts" | xargs grep -l "your-core-filename" 2>/dev/null || echo "No files found"`
+ - **Validate each downstream file individually**: `cd superset-frontend && npx tscw --noEmit --allowJs --composite false --project tsconfig.json {each-downstream-file}`
+ - **Fix type mismatches** you introduced in calling sites
+ - **NEVER ignore downstream errors** - they indicate your types don't match reality
+6. **Avoid Project-Wide Validation During Migration**:
+ - **NEVER use `npm run type`** during parallel agent execution - produces unreliable results
+ - **Single-file validation is authoritative** - trust individual file checks over project-wide scans
+6. **ESLint validation** - Run `npm run eslint -- --fix {file}` for each migrated file to auto-fix formatting/linting issues
+6. Zero `any` types - use proper TypeScript types
+7. Search existing types before creating new ones
+8. Follow patterns from this guide
+
+---
+
+## Success Report Format
+
+```
+SUCCESS: Atomic Migration of {core-filename}
+
+## Files Migrated (Atomic Unit)
+- Core: {core-filename} β {core-filename.ts/tsx}
+- Tests: {list-of-test-files} β {list-of-test-files.ts/tsx} OR "CREATED: {basename}.test.ts"
+- Mocks: {list-of-mock-files} β {list-of-mock-files.ts}
+- Type files modified: {list-of-type-files}
+
+## Types Created/Improved
+- {TypeName}: {location} ({scope}) - {rationale}
+- {ExistingType}: enhanced in {location} - {improvement-description}
+
+## Documentation Recommendations
+- ADD_TO_DIRECTORY: {TypeName} - {reason}
+- NO_DOCUMENTATION: {TypeName} - {reason}
+
+## Quality Validation
+- **Single-File TypeScript Validation**: β
PASS - Core files individually validated
+ - Core file: `npx tscw --noEmit --allowJs --composite false --project tsconfig.json {core-file}`
+ - Test files: `npx tscw --noEmit --allowJs --composite false --project tsconfig.json {test-file}` (if exists)
+- **Downstream Impact Check**: β
PASS - Found {N} files importing this module, all validate individually
+ - Downstream files: {list-of-files-that-import-your-module}
+ - Individual validation: `npx tscw --noEmit --allowJs --composite false --project tsconfig.json {each-downstream-file}`
+- **ESLint validation**: β
PASS (using `npm run eslint -- --fix {files}` to auto-fix formatting)
+- **Zero any types**: β
PASS
+- **Local imports resolved**: β
PASS
+- **Functionality preserved**: β
PASS
+- **Tests pass** (if test file): β
PASS
+- **Follow-up action required**: {YES/NO}
+
+## Validation Strategy Notes
+- **Single-file approach used**: Avoided multi-file tsc validation due to known TypeScript compilation issues
+- **Project-wide validation skipped**: `npm run type` not used during parallel migration to prevent false positives
+
+## Migration Learnings
+- Type conflicts encountered: {describe any multiple type definitions}
+- Mock patterns used: {describe test mocking approaches}
+- Import hierarchy decisions: {note authoritative type sources used}
+- PropTypes strategy: {AUTO_GENERATED via babel plugin | MANUAL_DUPLICATION_REMOVED | N/A}
+
+## Improvement Suggestions for Documentation
+- AGENT.md enhancement: {suggest additions to migration guide}
+- Common pattern identified: {note reusable patterns for future migrations}
+```
+
+---
+
+## Dependency Block Report Format
+
+```
+DEPENDENCY_BLOCK: Cannot migrate {filename}
+
+## Blocking Dependencies
+- {path}: {type} - {usage} - {priority}
+
+## Impact Analysis
+- Estimated types: {number}
+- Expected locations: {list}
+- Cross-domain: {YES/NO}
+
+## Recommended Order
+{ordered-list}
+```
+
+---
+
+## π Quick Reference
+
+**Type Utilities:**
+- `Record<K, V>` - Object with specific key/value types
+- `Partial<T>` - All properties optional
+- `Pick<T, K>` - Subset of properties
+- `Omit<T, K>` - Exclude specific properties
+- `NonNullable<T>` - Exclude null/undefined
+
+**Event Types:**
+- `MouseEvent<HTMLButtonElement>`
+- `ChangeEvent<HTMLInputElement>`
+- `FormEvent<HTMLFormElement>`
+
+**React Types:**
+- `FC<Props>` - Functional component
+- `ReactNode` - Any renderable content
+- `CSSProperties` - Style objects
+
+---
+
+**Remember:** Every type should add value and clarity. The goal is meaningful type safety that catches bugs and improves developer experience.
diff --git a/.claude/projects/js-to-ts/COORDINATOR.md b/.claude/projects/js-to-ts/COORDINATOR.md
new file mode 100644
index 0000000000..4d225a26dd
--- /dev/null
+++ b/.claude/projects/js-to-ts/COORDINATOR.md
@@ -0,0 +1,199 @@
+# JS-to-TS Coordinator Workflow
+
+**Role:** Strategic migration coordination - select leaf-node files, trigger agents, review results, handle integration, manage dependencies.
+
+---
+
+## 1. Core File Selection Strategy
+
+**Target ONLY Core Files**: Coordinators identify core files (production code), agents handle related tests/mocks atomically.
+
+**File Analysis Commands**:
+```bash
+# Find CORE files with no JS/JSX dependencies (exclude tests/mocks) - SIZE PRIORITIZED
+find superset-frontend/src -name "*.js" -o -name "*.jsx" | grep -v "test\|spec\|mock" | xargs wc -l | sort -n | head -20
+
+# Alternative: Get file sizes in lines with paths
+find superset-frontend/src -name "*.js" -o -name "*.jsx" | grep -v "test\|spec\|mock" | while read file; do
+ lines=$(wc -l < "$file")
+ echo "$lines $file"
+done | sort -n | head -20
+
+# Check dependencies for core files only (start with smallest)
+for file in <core-files-sorted-by-size>; do
+ echo "=== $file ($(wc -l < "$file") lines) ==="
+ grep -E "from '\.\./.*\.jsx?'|from '\./.*\.jsx?'|from 'src/.*\.jsx?'" "$file" || echo "β
LEAF CANDIDATE"
+done
+
+# Identify heavily imported files (migrate last)
+grep -r "from.*utils/common" superset-frontend/src/ | wc -l
+
+# Quick leaf analysis with size priority
+find superset-frontend/src -name "*.js" -o -name "*.jsx" | grep -v "test\|spec\|mock" | head -30 | while read file; do
+ deps=$(grep -E "from '\.\./.*\.jsx?'|from '\./.*\.jsx?'|from 'src/.*\.jsx?'" "$file" | wc -l)
+ lines=$(wc -l < "$file")
+ if [ "$deps" -eq 0 ]; then
+ echo "β
LEAF: $lines lines - $file"
+ fi
+done | sort -n
+```
+
+**Priority Order** (Smallest files first for easier wins):
+1. **Small leaf files** (<50 lines) - No JS/JSX imports, quick TypeScript conversion
+2. **Medium leaf files** (50-200 lines) - Self-contained utilities and helpers
+3. **Small dependency files** (<100 lines) - Import only already-migrated files
+4. **Larger components** (200+ lines) - Complex but well-contained functionality
+5. **Core foundational files** (utils/common.js, controls.jsx) - migrate last regardless of size
+
+**Size-First Benefits**:
+- Faster completion builds momentum
+- Earlier validation of migration patterns
+- Easier rollback if issues arise
+- Better success rate for agent learning
+
+**Migration Unit**: Each agent call migrates:
+- 1 core file (primary target)
+- All related `*.test.js/jsx` files
+- All related `*.mock.js` files
+- All related `__mocks__/` files
+
+---
+
+## 2. Task Creation & Agent Control
+
+### Task Triggering
+When triggering the `/js-to-ts` command:
+- **Task Title**: Use the core filename as the task title (e.g., "DebouncedMessageQueue.js migration", "hostNamesConfig.js migration")
+- **Task Description**: Include the full relative path to help agent locate the file
+- **Reference**: Point agent to [AGENT.md](./AGENT.md) for technical instructions
+
+### Post-Processing Workflow
+After each agent completes:
+
+1. **Review Agent Report**: Always read and analyze the complete agent report
+2. **Share Summary**: Provide user with key highlights from agent's work:
+ - Files migrated (core + tests/mocks)
+ - Types created or improved
+ - Any validation issues or coordinator actions needed
+3. **Quality Assessment**: Evaluate agent's TypeScript implementation against criteria:
+ - β
**Type Usage**: Proper types used, no `any` types
+ - β
**Type Filing**: Types placed in correct hierarchy (component β feature β domain β global)
+ - β
**Side Effects**: No unintended changes to other files
+ - β
**Import Alignment**: Proper .ts/.tsx import extensions
+4. **Integration Decision**:
+ - **COMMIT**: If agent work is complete and high quality
+ - **FIX & COMMIT**: If minor issues need coordinator fixes
+ - **ROLLBACK**: If major issues require complete rework
+5. **Next Action**: Ask user preference - commit this work or trigger next migration
+
+---
+
+## 3. Integration Decision Framework
+
+**Automatic Integration** β
:
+- `npm run type` passes without errors
+- Agent created clean TypeScript with proper types
+- Types appropriately filed in hierarchy
+
+**Coordinator Integration** (Fix Side-Effects) π§:
+- `npm run type` fails BUT agent's work is high quality
+- Good type usage, proper patterns, well-organized
+- Side-effects are manageable TypeScript compilation errors
+- **Coordinator Action**: Integrate the change, then fix global compilation issues
+
+**Rollback Only** β:
+- Agent introduced `any` types or poor type choices
+- Types poorly organized or conflicting with existing patterns
+- Fundamental approach issues requiring complete rework
+
+**Integration Process**:
+1. **Review**: Agent already used `git mv` to preserve history
+2. **Fix Side-Effects**: Update dependent files with proper import extensions
+3. **Resolve Types**: Fix any cascading type issues across codebase
+4. **Validate**: Ensure `npm run type` passes after fixes
+
+---
+
+## 4. Common Integration Patterns
+
+**Common Side-Effects (Expect These)**:
+- **Type import conflicts**: Multiple definitions of same type name
+- **Mock object typing**: Tests need complete type satisfaction
+- **Stub method references**: Use stub vars instead of original methods
+
+**Coordinator Fixes (Standard Process)**:
+1. **Import Resolution**:
+ ```bash
+ # Find authoritative type source
+ grep -r "TypeName" src/*/types.ts
+ # Import from domain types (src/dashboard/types.ts) not component types
+ ```
+
+2. **Test Mock Completion**:
+ ```typescript
+ // Use Partial<T> as T pattern for minimal mocking
+ const mockDashboard: Partial<DashboardInfo> as DashboardInfo = {
+ id: 123,
+ json_metadata: '{}',
+ };
+ ```
+
+3. **Stub Reference Fixes**:
+ ```typescript
+ // β
Use stub variable
+ expect(postStub.callCount).toBe(1);
+ // β Don't use original method
+ expect(SupersetClient.post.callCount).toBe(1);
+ ```
+
+4. **Validation Commands**:
+ ```bash
+ npm run type # TypeScript compilation
+ npm test -- filename # Test functionality
+ git status # Should show rename, not add/delete
+ ```
+
+---
+
+## 5. File Categories for Planning
+
+### Leaf Files (Start Here)
+**Self-contained files with minimal JS/JSX dependencies**:
+- Test files (80 files) - Usually only import the file being tested
+- Utility files without internal dependencies
+- Components importing only external libraries
+
+### Heavily Imported Files (Migrate Last)
+**Core files that many others depend on**:
+- `utils/common.js` - Core utility functions
+- `utils/reducerUtils.js` - Redux helpers
+- `@superset-ui/core` equivalent files
+- Major state management files (`explore/store.js`, `dashboard/actions/`)
+
+### Complex Components (Middle Priority)
+**Large files requiring careful type analysis**:
+- `components/Datasource/DatasourceEditor.jsx` (1,809 lines)
+- `explore/components/controls/AnnotationLayerControl/AnnotationLayer.jsx` (1,031 lines)
+- `explore/components/ExploreViewContainer/index.jsx` (911 lines)
+
+---
+
+## 6. Success Metrics & Continuous Improvement
+
+**Per-File Gates**:
+- β
`npm run type` passes after each migration
+- β
Zero `any` types introduced
+- β
All imports properly typed
+- β
Types filed in correct hierarchy
+
+**Linear Scheduling**:
+When agents report `DEPENDENCY_BLOCK`:
+- Queue dependencies in linear order
+- Process one file at a time to avoid conflicts
+- Handle cascading type changes between files
+
+**After Each Migration**:
+1. **Update guides** with new patterns discovered
+2. **Document coordinator fixes** that become common
+3. **Enhance agent instructions** based on recurring issues
+4. **Track success metrics** - automatic vs coordinator integration rates
diff --git a/.claude/projects/js-to-ts/PROJECT.md b/.claude/projects/js-to-ts/PROJECT.md
new file mode 100644
index 0000000000..c8a7b80e62
--- /dev/null
+++ b/.claude/projects/js-to-ts/PROJECT.md
@@ -0,0 +1,76 @@
+# JavaScript to TypeScript Migration Project
+
+Progressive migration of 219 JS/JSX files to TypeScript in Apache Superset frontend.
+
+## π Project Documentation
+
+- **[AGENT.md](./AGENT.md)** - Complete technical migration guide for agents (includes type reference, patterns, validation)
+- **[COORDINATOR.md](./COORDINATOR.md)** - Strategic workflow for coordinators (file selection, task management, integration)
+
+## π― Quick Start
+
+**For Agents:** Read [AGENT.md](./AGENT.md) for complete migration instructions
+**For Coordinators:** Read [COORDINATOR.md](./COORDINATOR.md) for workflow and [AGENT.md](./AGENT.md) for supervision
+
+**Command:** `/js-to-ts <filename>` - See [../../commands/js-to-ts.md](../../commands/js-to-ts.md)
+
+## π Migration Progress
+
+**Scope**: 219 files total (112 JS + 107 JSX)
+- Production files: 139 (63%)
+- Test files: 80 (37%)
+
+**Strategy**: Leaf-first migration with dependency-aware coordination
+
+### Completed Migrations β
+
+1. **roundDecimal** - `plugins/legacy-plugin-chart-map-box/src/utils/roundDecimal.js`
+ - Migrated core + test files
+ - Added proper TypeScript function signature with optional precision parameter
+ - All tests pass
+
+2. **timeGrainSqlaAnimationOverrides** - `src/explore/controlPanels/timeGrainSqlaAnimationOverrides.js`
+ - Migrated to TypeScript with ControlPanelState and Dataset types
+ - Added TimeGrainOverrideState interface for return type
+ - Used type guards for safe property access
+
+3. **DebouncedMessageQueue** - `src/utils/DebouncedMessageQueue.js`
+ - Migrated to TypeScript with proper generics
+ - Created DebouncedMessageQueueOptions interface
+ - **CREATED test file** with 4 comprehensive test cases
+ - Excellent class property typing with private/readonly modifiers
+
+**Files Migrated**: 3/219 (1.4%)
+**Tests Created**: 2 (roundDecimal had existing, DebouncedMessageQueue created)
+
+### Next Candidates (Leaf Nodes) π―
+
+**Identified leaf files with no JS/JSX dependencies:**
+- `src/utils/hostNamesConfig.js` - Domain configuration utility
+- `src/explore/controlPanels/Separator.js` - Control panel configuration
+- `src/middleware/loggerMiddleware.js` - Logging middleware
+
+**Migration Quality**: All completed migrations have:
+- β
Zero `any` types
+- β
Proper TypeScript compilation
+- β
ESLint validation passed
+- β
Test coverage (created where missing)
+
+---
+
+## π Success Metrics
+
+**Per-File Gates**:
+- β
`npm run type` passes after each migration
+- β
Zero `any` types introduced
+- β
All imports properly typed
+- β
Types filed in correct hierarchy
+
+**Overall Progress**:
+- **Automatic Integration Rate**: 100% (3/3 migrations required no coordinator fixes)
+- **Test Coverage**: Improved (1 new test file created)
+- **Type Safety**: Enhanced with proper interfaces and generics
+
+---
+
+*This is a claudette-managed progressive refactor. All documentation and coordination resources are organized under `.claude/projects/js-to-ts/`*
diff --git a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/utils/roundDecimal.js b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/utils/roundDecimal.ts
similarity index 82%
rename from superset-frontend/plugins/legacy-plugin-chart-map-box/src/utils/roundDecimal.js
rename to superset-frontend/plugins/legacy-plugin-chart-map-box/src/utils/roundDecimal.ts
index 50bb0e1f52..6c430c4074 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/utils/roundDecimal.js
+++ b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/utils/roundDecimal.ts
@@ -17,11 +17,14 @@
* under the License.
*/
-export default function roundDecimal(number, precision) {
- let roundedNumber;
- let p = precision;
+export default function roundDecimal(
+ number: number,
+ precision?: number,
+): number {
+ let roundedNumber: number;
if (precision) {
- roundedNumber = Math.round(number * (p = 10 ** p)) / p;
+ const p = 10 ** precision;
+ roundedNumber = Math.round(number * p) / p;
} else {
roundedNumber = Math.round(number);
}
diff --git a/superset-frontend/plugins/legacy-plugin-chart-map-box/test/utils/roundDecimal.test.js b/superset-frontend/plugins/legacy-plugin-chart-map-box/test/utils/roundDecimal.test.ts
similarity index 100%
rename from superset-frontend/plugins/legacy-plugin-chart-map-box/test/utils/roundDecimal.test.js
rename to superset-frontend/plugins/legacy-plugin-chart-map-box/test/utils/roundDecimal.test.ts
diff --git a/superset-frontend/spec/fixtures/mockDashboardLayout.js b/superset-frontend/spec/fixtures/mockDashboardLayout.js
index 404b415d78..85c82edcac 100644
--- a/superset-frontend/spec/fixtures/mockDashboardLayout.js
+++ b/superset-frontend/spec/fixtures/mockDashboardLayout.js
@@ -258,20 +258,36 @@ export const dashboardWithFilter = {
type: DASHBOARD_ROOT_TYPE,
id: DASHBOARD_ROOT_ID,
children: [DASHBOARD_GRID_ID],
+ meta: {
+ chartId: 0,
+ height: 0,
+ uuid: '',
+ width: 0,
+ },
},
[DASHBOARD_GRID_ID]: {
type: DASHBOARD_GRID_TYPE,
id: DASHBOARD_GRID_ID,
children: ['ROW_ID'],
- meta: {},
+ meta: {
+ chartId: 0,
+ height: 0,
+ uuid: '',
+ width: 0,
+ },
},
[DASHBOARD_HEADER_ID]: {
type: DASHBOARD_HEADER_TYPE,
id: DASHBOARD_HEADER_ID,
+ children: [],
meta: {
text: 'New dashboard',
+ chartId: 0,
+ height: 0,
+ uuid: '',
+ width: 0,
},
},
@@ -285,6 +301,7 @@ export const dashboardWithFilter = {
...newComponentFactory(CHART_TYPE),
id: 'FILTER_ID',
meta: {
+ ...newComponentFactory(CHART_TYPE).meta,
chartId: filterId,
width: 3,
height: 10,
@@ -296,6 +313,7 @@ export const dashboardWithFilter = {
...newComponentFactory(CHART_TYPE),
id: 'CHART_ID',
meta: {
+ ...newComponentFactory(CHART_TYPE).meta,
chartId,
width: 3,
height: 10,
diff --git a/superset-frontend/src/components/MessageToasts/reducers.js b/superset-frontend/src/components/MessageToasts/reducers.ts
similarity index 77%
rename from superset-frontend/src/components/MessageToasts/reducers.js
rename to superset-frontend/src/components/MessageToasts/reducers.ts
index e999d12adb..9209164c57 100644
--- a/superset-frontend/src/components/MessageToasts/reducers.js
+++ b/superset-frontend/src/components/MessageToasts/reducers.ts
@@ -17,8 +17,26 @@
* under the License.
*/
import { ADD_TOAST, REMOVE_TOAST } from './actions';
+import { ToastMeta } from './types';
-export default function messageToastsReducer(toasts = [], action) {
+interface AddToastAction {
+ type: typeof ADD_TOAST;
+ payload: ToastMeta;
+}
+
+interface RemoveToastAction {
+ type: typeof REMOVE_TOAST;
+ payload: {
+ id: string;
+ };
+}
+
+type ToastAction = AddToastAction | RemoveToastAction;
+
+export default function messageToastsReducer(
+ toasts: ToastMeta[] = [],
+ action: ToastAction,
+): ToastMeta[] {
switch (action.type) {
case ADD_TOAST: {
const { payload: toast } = action;
diff --git a/superset-frontend/src/components/Tag/Tag.stories.tsx b/superset-frontend/src/components/Tag/Tag.stories.tsx
index 57d2df7a43..36676a89ee 100644
--- a/superset-frontend/src/components/Tag/Tag.stories.tsx
+++ b/superset-frontend/src/components/Tag/Tag.stories.tsx
@@ -20,7 +20,7 @@ import { useState } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { Tag } from 'src/components/Tag';
import type { CheckableTagProps } from 'src/components/Tag';
-import TagType from 'src/types/TagType';
+import type { TagType } from 'src/types/TagType';
export default {
title: 'Components/Tag',
diff --git a/superset-frontend/src/components/Tag/Tag.test.tsx b/superset-frontend/src/components/Tag/Tag.test.tsx
index b130432694..d4077118ee 100644
--- a/superset-frontend/src/components/Tag/Tag.test.tsx
+++ b/superset-frontend/src/components/Tag/Tag.test.tsx
@@ -17,7 +17,7 @@
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
-import TagType from 'src/types/TagType';
+import type { TagType } from 'src/types/TagType';
import { Tag } from '.';
const mockedProps: TagType = {
diff --git a/superset-frontend/src/components/Tag/index.tsx b/superset-frontend/src/components/Tag/index.tsx
index 37cc02ec12..308a5ff6e0 100644
--- a/superset-frontend/src/components/Tag/index.tsx
+++ b/superset-frontend/src/components/Tag/index.tsx
@@ -19,7 +19,7 @@
import { styled } from '@superset-ui/core';
import { Link } from 'react-router-dom';
-import TagType from 'src/types/TagType';
+import type { TagType } from 'src/types/TagType';
import { Tag as AntdTag } from '@superset-ui/core/components/Tag';
import { Tooltip } from '@superset-ui/core/components/Tooltip';
import type { TagProps } from 'antd/es';
diff --git a/superset-frontend/src/components/Tag/utils.tsx b/superset-frontend/src/components/Tag/utils.tsx
index ea3f9b1982..b275698868 100644
--- a/superset-frontend/src/components/Tag/utils.tsx
+++ b/superset-frontend/src/components/Tag/utils.tsx
@@ -23,7 +23,7 @@ import {
SupersetClient,
t,
} from '@superset-ui/core';
-import Tag from 'src/types/TagType';
+import type { TagType } from 'src/types/TagType';
import rison from 'rison';
import { cacheWrapper } from 'src/utils/cacheWrapper';
@@ -43,7 +43,7 @@ type SelectTagsValue = {
};
export const tagToSelectOption = (
- tag: Tag & { table_name: string },
+ tag: TagType & { table_name: string },
): SelectTagsValue => ({
value: tag.id,
label: tag.name,
diff --git a/superset-frontend/src/components/TagsList/index.tsx b/superset-frontend/src/components/TagsList/index.tsx
index 15b95ef440..a83861f26b 100644
--- a/superset-frontend/src/components/TagsList/index.tsx
+++ b/superset-frontend/src/components/TagsList/index.tsx
@@ -19,7 +19,7 @@
import { useMemo, useState } from 'react';
import { styled } from '@superset-ui/core';
-import TagType from 'src/types/TagType';
+import type { TagType } from 'src/types/TagType';
import { Tag } from 'src/components/Tag';
export type TagsListProps = {
diff --git a/superset-frontend/src/dashboard/actions/sliceEntities.ts b/superset-frontend/src/dashboard/actions/sliceEntities.ts
index 91b5faf6f0..de48b7f59e 100644
--- a/superset-frontend/src/dashboard/actions/sliceEntities.ts
+++ b/superset-frontend/src/dashboard/actions/sliceEntities.ts
@@ -26,9 +26,40 @@ import {
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { Dispatch } from 'redux';
import { Slice } from '../types';
+import { HYDRATE_DASHBOARD } from './hydrate';
const FETCH_SLICES_PAGE_SIZE = 200;
+// State types
+export interface SliceEntitiesState {
+ slices: { [id: number]: Slice };
+ isLoading: boolean;
+ errorMessage: string | null;
+ lastUpdated: number;
+}
+
+// Action types
+export type SliceEntitiesActionPayload =
+ | {
+ type: typeof ADD_SLICES;
+ payload: { slices: { [id: number]: Slice } };
+ }
+ | {
+ type: typeof SET_SLICES;
+ payload: { slices: { [id: number]: Slice } };
+ }
+ | {
+ type: typeof FETCH_ALL_SLICES_STARTED;
+ }
+ | {
+ type: typeof FETCH_ALL_SLICES_FAILED;
+ payload: { error: string };
+ }
+ | {
+ type: typeof HYDRATE_DASHBOARD;
+ data: { sliceEntities: SliceEntitiesState };
+ };
+
export function getDatasourceParameter(
datasourceId: number,
datasourceType: DatasourceType,
diff --git a/superset-frontend/src/dashboard/components/AddSliceCard/AddSliceCard.tsx b/superset-frontend/src/dashboard/components/AddSliceCard/AddSliceCard.tsx
index 6e98110537..c913424e6d 100644
--- a/superset-frontend/src/dashboard/components/AddSliceCard/AddSliceCard.tsx
+++ b/superset-frontend/src/dashboard/components/AddSliceCard/AddSliceCard.tsx
@@ -170,7 +170,9 @@ const SliceAddedBadge: FC<{ placeholder?: HTMLDivElement }> = ({
const AddSliceCard: FC<{
datasourceUrl?: string;
datasourceName?: string;
- innerRef?: RefObject<HTMLDivElement>;
+ innerRef?:
+ | RefObject<HTMLDivElement>
+ | ((node: HTMLDivElement | null) => void);
isSelected?: boolean;
lastModified?: string;
sliceName: string;
diff --git a/superset-frontend/src/dashboard/components/SliceAdder.tsx b/superset-frontend/src/dashboard/components/SliceAdder.tsx
index dfde8ec7a8..82c4cae969 100644
--- a/superset-frontend/src/dashboard/components/SliceAdder.tsx
+++ b/superset-frontend/src/dashboard/components/SliceAdder.tsx
@@ -50,6 +50,7 @@ import { Dispatch } from 'redux';
import { Slice } from 'src/dashboard/types';
import { withTheme, Theme } from '@emotion/react';
import { navigateTo } from 'src/utils/navigationUtils';
+import type { ConnectDragSource } from 'react-dnd';
import AddSliceCard from './AddSliceCard';
import AddSliceDragPreview from './dnd/AddSliceDragPreview';
import { DragDroppable } from './dnd/DragDroppable';
@@ -312,7 +313,7 @@ class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
// actual style should be applied to nested AddSliceCard component
style={{}}
>
- {({ dragSourceRef }) => (
+ {({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<AddSliceCard
innerRef={dragSourceRef}
style={style}
diff --git a/superset-frontend/src/dashboard/components/dnd/dragDroppableConfig.js b/superset-frontend/src/dashboard/components/dnd/dragDroppableConfig.js
deleted file mode 100644
index bd8ab744b1..0000000000
--- a/superset-frontend/src/dashboard/components/dnd/dragDroppableConfig.js
+++ /dev/null
@@ -1,85 +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 handleHover from './handleHover';
-import handleDrop from './handleDrop';
-
-// note: the 'type' hook is not useful for us as dropping is contingent on other properties
-const TYPE = 'DRAG_DROPPABLE';
-
-export const dragConfig = [
- TYPE,
- {
- canDrag(props) {
- return !props.disableDragDrop;
- },
-
- // this defines the dragging item object returned by monitor.getItem()
- beginDrag(props /* , monitor, component */) {
- const { component, index, parentComponent = {} } = props;
- return {
- type: component.type,
- id: component.id,
- meta: component.meta,
- index,
- parentId: parentComponent.id,
- parentType: parentComponent.type,
- };
- },
- },
- function dragStateToProps(connect, monitor) {
- return {
- dragSourceRef: connect.dragSource(),
- dragPreviewRef: connect.dragPreview(),
- isDragging: monitor.isDragging(),
- dragComponentType: monitor.getItem()?.type,
- dragComponentId: monitor.getItem()?.id,
- };
- },
-];
-
-export const dropConfig = [
- TYPE,
- {
- canDrop(props) {
- return !props.disableDragDrop;
- },
- hover(props, monitor, component) {
- if (component && component.mounted) {
- handleHover(props, monitor, component);
- }
- },
- // note:
- // the react-dnd api requires that the drop() method return a result or undefined
- // monitor.didDrop() cannot be used because it returns true only for the most-nested target
- drop(props, monitor, component) {
- const dropResult = monitor.getDropResult();
- if ((!dropResult || !dropResult.destination) && component.mounted) {
- return handleDrop(props, monitor, component);
- }
- return undefined;
- },
- },
- function dropStateToProps(connect, monitor) {
- return {
- droppableRef: connect.dropTarget(),
- isDraggingOver: monitor.isOver(),
- isDraggingOverShallow: monitor.isOver({ shallow: true }),
- };
- },
-];
diff --git a/superset-frontend/src/dashboard/components/dnd/dragDroppableConfig.ts b/superset-frontend/src/dashboard/components/dnd/dragDroppableConfig.ts
new file mode 100644
index 0000000000..84a90977c6
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/dnd/dragDroppableConfig.ts
@@ -0,0 +1,188 @@
+/**
+ * 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 {
+ DragSourceMonitor,
+ DropTargetMonitor,
+ ConnectDragSource,
+ ConnectDragPreview,
+ ConnectDropTarget,
+} from 'react-dnd';
+import { LayoutItem, ComponentType } from 'src/dashboard/types';
+import handleHover from './handleHover';
+import handleDrop from './handleDrop';
+
+// note: the 'type' hook is not useful for us as dropping is contingent on other properties
+const TYPE = 'DRAG_DROPPABLE';
+
+export interface DragDroppableProps {
+ component: LayoutItem;
+ parentComponent?: LayoutItem;
+ index: number;
+ disableDragDrop: boolean;
+ onDrop?: (dropResult: DropResult) => void;
+ onHover?: () => void;
+ dropToChild?: boolean | ((draggingItem: DragItem) => boolean);
+}
+
+export interface DragItem {
+ type: ComponentType;
+ id: string;
+ meta: LayoutItem['meta'];
+ index: number;
+ parentId?: string;
+ parentType?: ComponentType;
+}
+
+export interface DropResult {
+ source: {
+ id: string;
+ type: ComponentType;
+ index: number;
+ };
+ dragging: {
+ id: string;
+ type: ComponentType;
+ meta: LayoutItem['meta'];
+ };
+ destination?: {
+ id: string;
+ type: ComponentType;
+ index: number;
+ };
+ position?: string;
+}
+
+export interface DragStateProps {
+ dragSourceRef: ConnectDragSource;
+ dragPreviewRef: ConnectDragPreview;
+ isDragging: boolean;
+ dragComponentType?: ComponentType;
+ dragComponentId?: string;
+}
+
+export interface DropStateProps {
+ droppableRef: ConnectDropTarget;
+ isDraggingOver: boolean;
+ isDraggingOverShallow: boolean;
+}
+
+export interface DragDroppableComponent {
+ mounted: boolean;
+ props: DragDroppableProps;
+ setState: (stateUpdate: () => { dropIndicator: string | null }) => void;
+}
+
+export const dragConfig: [
+ string,
+ {
+ canDrag: (props: DragDroppableProps) => boolean;
+ beginDrag: (props: DragDroppableProps) => DragItem;
+ },
+ (connect: any, monitor: DragSourceMonitor) => DragStateProps,
+] = [
+ TYPE,
+ {
+ canDrag(props: DragDroppableProps): boolean {
+ return !props.disableDragDrop;
+ },
+
+ // this defines the dragging item object returned by monitor.getItem()
+ beginDrag(props: DragDroppableProps): DragItem {
+ const { component, index, parentComponent } = props;
+ return {
+ type: component.type,
+ id: component.id,
+ meta: component.meta,
+ index,
+ parentId: parentComponent?.id,
+ parentType: parentComponent?.type,
+ };
+ },
+ },
+ function dragStateToProps(
+ connect: any,
+ monitor: DragSourceMonitor,
+ ): DragStateProps {
+ return {
+ dragSourceRef: connect.dragSource(),
+ dragPreviewRef: connect.dragPreview(),
+ isDragging: monitor.isDragging(),
+ dragComponentType: monitor.getItem()?.type as ComponentType,
+ dragComponentId: monitor.getItem()?.id as string,
+ };
+ },
+];
+
+export const dropConfig: [
+ string,
+ {
+ canDrop: (props: DragDroppableProps) => boolean;
+ hover: (
+ props: DragDroppableProps,
+ monitor: DropTargetMonitor,
+ component: DragDroppableComponent,
+ ) => void;
+ drop: (
+ props: DragDroppableProps,
+ monitor: DropTargetMonitor,
+ component: DragDroppableComponent,
+ ) => DropResult | undefined;
+ },
+ (connect: any, monitor: DropTargetMonitor) => DropStateProps,
+] = [
+ TYPE,
+ {
+ canDrop(props: DragDroppableProps): boolean {
+ return !props.disableDragDrop;
+ },
+ hover(
+ props: DragDroppableProps,
+ monitor: DropTargetMonitor,
+ component: DragDroppableComponent,
+ ): void {
+ if (component && component.mounted) {
+ handleHover(props, monitor, component);
+ }
+ },
+ // note:
+ // the react-dnd api requires that the drop() method return a result or undefined
+ // monitor.didDrop() cannot be used because it returns true only for the most-nested target
+ drop(
+ props: DragDroppableProps,
+ monitor: DropTargetMonitor,
+ component: DragDroppableComponent,
+ ): DropResult | undefined {
+ const dropResult = monitor.getDropResult() as DropResult | null;
+ if ((!dropResult || !dropResult.destination) && component.mounted) {
+ return handleDrop(props, monitor, component);
+ }
+ return undefined;
+ },
+ },
+ function dropStateToProps(
+ connect: any,
+ monitor: DropTargetMonitor,
+ ): DropStateProps {
+ return {
+ droppableRef: connect.dropTarget(),
+ isDraggingOver: monitor.isOver(),
+ isDraggingOverShallow: monitor.isOver({ shallow: true }),
+ };
+ },
+];
diff --git a/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent/DynamicComponent.tsx b/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent/DynamicComponent.tsx
index eb06833fba..df50ed37b3 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent/DynamicComponent.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent/DynamicComponent.tsx
@@ -22,6 +22,7 @@ import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
import cx from 'classnames';
import { shallowEqual, useSelector } from 'react-redux';
import { ResizeCallback, ResizeStartCallback } from 're-resizable';
+import type { ConnectDragSource } from 'react-dnd';
import { Draggable } from '../../dnd/DragDroppable';
import { COLUMN_TYPE, ROW_TYPE } from '../../../util/componentTypes';
import WithPopoverMenu from '../../menu/WithPopoverMenu';
@@ -119,7 +120,7 @@ const DynamicComponent: FC<DynamicComponentProps> = ({
onDrop={handleComponentDrop}
editMode={editMode}
>
- {({ dragSourceRef }) => (
+ {({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<WithPopoverMenu
menuItems={[
<BackgroundStyleDropdown
diff --git a/superset-frontend/src/dashboard/components/gridComponents/new/DraggableNewComponent.tsx b/superset-frontend/src/dashboard/components/gridComponents/new/DraggableNewComponent.tsx
index 3733bfdead..3ceda79442 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/new/DraggableNewComponent.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/new/DraggableNewComponent.tsx
@@ -20,6 +20,7 @@ import { PureComponent } from 'react';
import cx from 'classnames';
import { css, styled } from '@superset-ui/core';
import { DragDroppable } from 'src/dashboard/components/dnd/DragDroppable';
+import type { ConnectDragSource } from 'react-dnd';
import { NEW_COMPONENTS_SOURCE_ID } from 'src/dashboard/util/constants';
import { NEW_COMPONENT_SOURCE_TYPE } from 'src/dashboard/util/componentTypes';
@@ -81,7 +82,7 @@ export default class DraggableNewComponent extends PureComponent<DraggableNewCom
depth={0}
editMode
>
- {({ dragSourceRef }) => (
+ {({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<NewComponent ref={dragSourceRef} data-test="new-component">
<NewComponentPlaceholder
className={cx('new-component-placeholder', className)}
diff --git a/superset-frontend/src/dashboard/fixtures/emptyDashboardLayout.js b/superset-frontend/src/dashboard/fixtures/emptyDashboardLayout.ts
similarity index 67%
rename from superset-frontend/src/dashboard/fixtures/emptyDashboardLayout.js
rename to superset-frontend/src/dashboard/fixtures/emptyDashboardLayout.ts
index d693aca878..55279e6eef 100644
--- a/superset-frontend/src/dashboard/fixtures/emptyDashboardLayout.js
+++ b/superset-frontend/src/dashboard/fixtures/emptyDashboardLayout.ts
@@ -28,11 +28,38 @@ import {
DASHBOARD_GRID_ID,
} from '../util/constants';
-export default {
+import type { DashboardLayout, LayoutItemMeta } from '../types';
+
+// Create minimal meta objects that satisfy the LayoutItemMeta type requirements
+const rootMeta: LayoutItemMeta = {
+ chartId: 0,
+ height: 0,
+ uuid: '',
+ width: 0,
+};
+
+const gridMeta: LayoutItemMeta = {
+ chartId: 0,
+ height: 0,
+ uuid: '',
+ width: 0,
+};
+
+const headerMeta: LayoutItemMeta = {
+ chartId: 0,
+ height: 0,
+ uuid: '',
+ width: 0,
+ text: 'New dashboard',
+};
+
+const emptyDashboardLayout: DashboardLayout = {
[DASHBOARD_ROOT_ID]: {
type: DASHBOARD_ROOT_TYPE,
id: DASHBOARD_ROOT_ID,
children: [DASHBOARD_GRID_ID],
+ parents: [],
+ meta: rootMeta,
},
[DASHBOARD_GRID_ID]: {
@@ -40,14 +67,16 @@ export default {
id: DASHBOARD_GRID_ID,
children: [],
parents: [DASHBOARD_ROOT_ID],
- meta: {},
+ meta: gridMeta,
},
[DASHBOARD_HEADER_ID]: {
type: HEADER_TYPE,
id: DASHBOARD_HEADER_ID,
- meta: {
- text: 'New dashboard',
- },
+ children: [],
+ parents: [],
+ meta: headerMeta,
},
};
+
+export default emptyDashboardLayout;
diff --git a/superset-frontend/src/dashboard/reducers/sliceEntities.js b/superset-frontend/src/dashboard/reducers/sliceEntities.ts
similarity index 80%
rename from superset-frontend/src/dashboard/reducers/sliceEntities.js
rename to superset-frontend/src/dashboard/reducers/sliceEntities.ts
index 1a065b11fa..eb90138f54 100644
--- a/superset-frontend/src/dashboard/reducers/sliceEntities.js
+++ b/superset-frontend/src/dashboard/reducers/sliceEntities.ts
@@ -23,10 +23,12 @@ import {
FETCH_ALL_SLICES_STARTED,
ADD_SLICES,
SET_SLICES,
+ SliceEntitiesState,
+ SliceEntitiesActionPayload,
} from '../actions/sliceEntities';
import { HYDRATE_DASHBOARD } from '../actions/hydrate';
-export const initSliceEntities = {
+export const initSliceEntities: SliceEntitiesState = {
slices: {},
isLoading: true,
errorMessage: null,
@@ -34,37 +36,34 @@ export const initSliceEntities = {
};
export default function sliceEntitiesReducer(
- state = initSliceEntities,
- action,
-) {
- const actionHandlers = {
- [HYDRATE_DASHBOARD]() {
+ state: SliceEntitiesState = initSliceEntities,
+ action: SliceEntitiesActionPayload,
+): SliceEntitiesState {
+ switch (action.type) {
+ case HYDRATE_DASHBOARD:
return {
...action.data.sliceEntities,
};
- },
- [FETCH_ALL_SLICES_STARTED]() {
+ case FETCH_ALL_SLICES_STARTED:
return {
...state,
isLoading: true,
};
- },
- [ADD_SLICES]() {
+ case ADD_SLICES:
return {
...state,
isLoading: false,
slices: { ...state.slices, ...action.payload.slices },
lastUpdated: new Date().getTime(),
};
- },
- [SET_SLICES]() {
+ case SET_SLICES:
return {
+ ...state,
isLoading: false,
slices: { ...action.payload.slices },
lastUpdated: new Date().getTime(),
};
- },
- [FETCH_ALL_SLICES_FAILED]() {
+ case FETCH_ALL_SLICES_FAILED:
return {
...state,
isLoading: false,
@@ -72,11 +71,7 @@ export default function sliceEntitiesReducer(
errorMessage:
action.payload.error || t('Could not fetch all saved charts'),
};
- },
- };
-
- if (action.type in actionHandlers) {
- return actionHandlers[action.type]();
+ default:
+ return state;
}
- return state;
}
diff --git a/superset-frontend/src/dashboard/util/activeDashboardFilters.js b/superset-frontend/src/dashboard/util/activeDashboardFilters.js
deleted file mode 100644
index ef6fd7f5e1..0000000000
--- a/superset-frontend/src/dashboard/util/activeDashboardFilters.js
+++ /dev/null
@@ -1,126 +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 { isEmpty } from 'lodash';
-import { mapValues, flow, keyBy } from 'lodash/fp';
-import {
- getChartIdAndColumnFromFilterKey,
- getDashboardFilterKey,
-} from './getDashboardFilterKey';
-import { CHART_TYPE } from './componentTypes';
-import { DASHBOARD_FILTER_SCOPE_GLOBAL } from '../reducers/dashboardFilters';
-
-let activeFilters = {};
-let appliedFilterValuesByChart = {};
-let allComponents = {};
-
-// output: { [id_column]: { values, scope } }
-export function getActiveFilters() {
- return activeFilters;
-}
-
-// this function is to find all filter values applied to a chart,
-// it goes through all active filters and their scopes.
-// return: { [column]: array of selected values }
-export function getAppliedFilterValues(chartId, filters) {
- // use cached data if possible
- if (!(chartId in appliedFilterValuesByChart)) {
- const applicableFilters = Object.entries(filters || activeFilters).filter(
- ([, { scope: chartIds }]) => chartIds.includes(chartId),
- );
- appliedFilterValuesByChart[chartId] = flow(
- keyBy(
- ([filterKey]) => getChartIdAndColumnFromFilterKey(filterKey).column,
- ),
- mapValues(([, { values }]) => values),
- )(applicableFilters);
- }
- return appliedFilterValuesByChart[chartId];
-}
-
-/**
- * @deprecated Please use src/dashboard/util/getChartIdsInFilterScope instead
- */
-export function getChartIdsInFilterScope({ filterScope }) {
- function traverse(chartIds = [], component = {}, immuneChartIds = []) {
- if (!component) {
- return;
- }
-
- if (
- component.type === CHART_TYPE &&
- component.meta &&
- component.meta.chartId &&
- !immuneChartIds.includes(component.meta.chartId)
- ) {
- chartIds.push(component.meta.chartId);
- } else if (component.children) {
- component.children.forEach(child =>
- traverse(chartIds, allComponents[child], immuneChartIds),
- );
- }
- }
-
- const chartIds = [];
- const { scope: scopeComponentIds, immune: immuneChartIds } =
- filterScope || DASHBOARD_FILTER_SCOPE_GLOBAL;
- scopeComponentIds.forEach(componentId =>
- traverse(chartIds, allComponents[componentId], immuneChartIds),
- );
-
- return chartIds;
-}
-
-// non-empty filter fields in dashboardFilters,
-// activeFilters map contains selected values and filter scope.
-// values: array of selected values
-// scope: array of chartIds that applicable to the filter field.
-export function buildActiveFilters({ dashboardFilters = {}, components = {} }) {
- // clear cache
- if (!isEmpty(components)) {
- allComponents = components;
- }
- appliedFilterValuesByChart = {};
- activeFilters = Object.values(dashboardFilters).reduce((result, filter) => {
- const { chartId, columns, scopes } = filter;
- const nonEmptyFilters = {};
-
- Object.keys(columns).forEach(column => {
- if (
- Array.isArray(columns[column])
- ? columns[column].length
- : columns[column] !== undefined
- ) {
- // remove filter itself
- const scope = getChartIdsInFilterScope({
- filterScope: scopes[column],
- }).filter(id => chartId !== id);
-
- nonEmptyFilters[getDashboardFilterKey({ chartId, column })] = {
- values: columns[column],
- scope,
- };
- }
- });
-
- return {
- ...result,
- ...nonEmptyFilters,
- };
- }, {});
-}
diff --git a/superset-frontend/src/dashboard/util/activeDashboardFilters.ts b/superset-frontend/src/dashboard/util/activeDashboardFilters.ts
new file mode 100644
index 0000000000..31823ff6f8
--- /dev/null
+++ b/superset-frontend/src/dashboard/util/activeDashboardFilters.ts
@@ -0,0 +1,211 @@
+/**
+ * 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 { isEmpty } from 'lodash';
+import { mapValues, flow, keyBy } from 'lodash/fp';
+import {
+ JsonValue,
+ DataRecordFilters,
+ DataRecordValue,
+} from '@superset-ui/core';
+import {
+ getChartIdAndColumnFromFilterKey,
+ getDashboardFilterKey,
+} from './getDashboardFilterKey';
+import { CHART_TYPE } from './componentTypes';
+import { DASHBOARD_FILTER_SCOPE_GLOBAL } from '../reducers/dashboardFilters';
+import { LayoutItem } from '../types';
+
+// Type definitions for filters
+interface FilterScope {
+ scope: string[];
+ immune?: number[];
+}
+
+interface DashboardFilterColumn {
+ [column: string]: JsonValue[] | JsonValue;
+}
+
+interface DashboardFilterScopes {
+ [column: string]: FilterScope;
+}
+
+interface DashboardFilter {
+ chartId: number;
+ columns: DashboardFilterColumn;
+ scopes: DashboardFilterScopes;
+}
+
+interface DashboardFilters {
+ [filterId: string]: DashboardFilter;
+}
+
+interface Components {
+ [componentId: string]: LayoutItem;
+}
+
+interface ActiveFilter {
+ values: JsonValue[] | JsonValue;
+ scope: number[];
+}
+
+interface ActiveFilters {
+ [filterKey: string]: ActiveFilter;
+}
+
+interface AppliedFilterValuesByChart {
+ [chartId: number]: DataRecordFilters;
+}
+
+interface GetChartIdsInFilterScopeProps {
+ filterScope?: FilterScope;
+}
+
+interface BuildActiveFiltersProps {
+ dashboardFilters?: DashboardFilters;
+ components?: Components;
+}
+
+let activeFilters: ActiveFilters = {};
+let appliedFilterValuesByChart: AppliedFilterValuesByChart = {};
+let allComponents: Components = {};
+
+// output: { [id_column]: { values, scope } }
+export function getActiveFilters(): ActiveFilters {
+ return activeFilters;
+}
+
+// this function is to find all filter values applied to a chart,
+// it goes through all active filters and their scopes.
+// return: { [column]: array of selected values }
+export function getAppliedFilterValues(
+ chartId: number,
+ filters?: ActiveFilters,
+): DataRecordFilters {
+ // use cached data if possible
+ if (!(chartId in appliedFilterValuesByChart)) {
+ const applicableFilters = Object.entries(filters || activeFilters).filter(
+ ([, { scope: chartIds }]) => chartIds.includes(chartId),
+ );
+ appliedFilterValuesByChart[chartId] = flow(
+ keyBy(
+ ([filterKey]: [string, ActiveFilter]) =>
+ getChartIdAndColumnFromFilterKey(filterKey).column,
+ ),
+ mapValues(([, { values }]: [string, ActiveFilter]) => {
+ // Ensure values is always an array of DataRecordValue
+ if (Array.isArray(values)) {
+ return values.filter(
+ val => val !== null && val !== undefined,
+ ) as DataRecordValue[];
+ }
+ // If single value, wrap in array and filter valid values
+ return values !== null && values !== undefined
+ ? [values as DataRecordValue]
+ : [];
+ }),
+ )(applicableFilters);
+ }
+ return appliedFilterValuesByChart[chartId];
+}
+
+/**
+ * @deprecated Please use src/dashboard/util/getChartIdsInFilterScope instead
+ */
+export function getChartIdsInFilterScope({
+ filterScope,
+}: GetChartIdsInFilterScopeProps): number[] {
+ function traverse(
+ chartIds: number[] = [],
+ component: LayoutItem | undefined = undefined,
+ immuneChartIds: number[] = [],
+ ): void {
+ if (!component) {
+ return;
+ }
+
+ if (
+ component.type === CHART_TYPE &&
+ component.meta &&
+ component.meta.chartId &&
+ !immuneChartIds.includes(component.meta.chartId)
+ ) {
+ chartIds.push(component.meta.chartId);
+ } else if (component.children) {
+ component.children.forEach(child =>
+ traverse(chartIds, allComponents[child], immuneChartIds),
+ );
+ }
+ }
+
+ const chartIds: number[] = [];
+ const { scope: scopeComponentIds, immune: immuneChartIds = [] } =
+ filterScope || DASHBOARD_FILTER_SCOPE_GLOBAL;
+ scopeComponentIds.forEach(componentId =>
+ traverse(chartIds, allComponents[componentId], immuneChartIds),
+ );
+
+ return chartIds;
+}
+
+// non-empty filter fields in dashboardFilters,
+// activeFilters map contains selected values and filter scope.
+// values: array of selected values
+// scope: array of chartIds that applicable to the filter field.
+export function buildActiveFilters({
+ dashboardFilters = {},
+ components = {},
+}: BuildActiveFiltersProps): void {
+ // clear cache
+ if (!isEmpty(components)) {
+ allComponents = components;
+ }
+ appliedFilterValuesByChart = {};
+ activeFilters = Object.values(dashboardFilters).reduce(
+ (result: ActiveFilters, filter: DashboardFilter) => {
+ const { chartId, columns, scopes } = filter;
+ const nonEmptyFilters: ActiveFilters = {};
+
+ Object.keys(columns).forEach(column => {
+ if (
+ Array.isArray(columns[column])
+ ? (columns[column] as JsonValue[]).length
+ : columns[column] !== undefined
+ ) {
+ // remove filter itself
+ const scope = getChartIdsInFilterScope({
+ filterScope: scopes[column],
+ }).filter(id => chartId !== id);
+
+ nonEmptyFilters[
+ getDashboardFilterKey({ chartId: String(chartId), column })
+ ] = {
+ values: columns[column],
+ scope,
+ };
+ }
+ });
+
+ return {
+ ...result,
+ ...nonEmptyFilters,
+ };
+ },
+ {},
+ );
+}
diff --git a/superset-frontend/src/dashboard/util/buildFilterScopeTreeEntry.js b/superset-frontend/src/dashboard/util/buildFilterScopeTreeEntry.ts
similarity index 73%
rename from superset-frontend/src/dashboard/util/buildFilterScopeTreeEntry.js
rename to superset-frontend/src/dashboard/util/buildFilterScopeTreeEntry.ts
index e91ac51fbd..e1a5d74172 100644
--- a/superset-frontend/src/dashboard/util/buildFilterScopeTreeEntry.js
+++ b/superset-frontend/src/dashboard/util/buildFilterScopeTreeEntry.ts
@@ -16,17 +16,41 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { DashboardLayout } from '../types';
import getFilterScopeNodesTree from './getFilterScopeNodesTree';
import getFilterScopeParentNodes from './getFilterScopeParentNodes';
import getKeyForFilterScopeTree from './getKeyForFilterScopeTree';
import getSelectedChartIdForFilterScopeTree from './getSelectedChartIdForFilterScopeTree';
+interface FilterScopeMapItem {
+ checked?: number[];
+ expanded?: string[];
+}
+
+interface FilterScopeMap {
+ [key: string]: FilterScopeMapItem;
+}
+
+interface FilterScopeTreeEntry {
+ nodes: any[];
+ nodesFiltered: any[];
+ checked: string[];
+ expanded: string[];
+}
+
+interface BuildFilterScopeTreeEntryProps {
+ checkedFilterFields?: string[];
+ activeFilterField?: string;
+ filterScopeMap?: FilterScopeMap;
+ layout?: DashboardLayout;
+}
+
export default function buildFilterScopeTreeEntry({
checkedFilterFields = [],
activeFilterField,
filterScopeMap = {},
layout = {},
-}) {
+}: BuildFilterScopeTreeEntryProps): Record<string, FilterScopeTreeEntry> {
const key = getKeyForFilterScopeTree({
checkedFilterFields,
activeFilterField,
@@ -43,15 +67,15 @@ export default function buildFilterScopeTreeEntry({
filterFields: editingList,
selectedChartId,
});
- const checkedChartIdSet = new Set();
+ const checkedChartIdSet = new Set<string>();
editingList.forEach(filterField => {
- (filterScopeMap[filterField].checked || []).forEach(chartId => {
+ (filterScopeMap[filterField]?.checked || []).forEach(chartId => {
checkedChartIdSet.add(`${chartId}:${filterField}`);
});
});
const checked = [...checkedChartIdSet];
const expanded = filterScopeMap[key]
- ? filterScopeMap[key].expanded
+ ? filterScopeMap[key].expanded || []
: getFilterScopeParentNodes(nodes, 1);
return {
diff --git a/superset-frontend/src/dashboard/util/dropOverflowsParent.test.js b/superset-frontend/src/dashboard/util/dropOverflowsParent.test.ts
similarity index 82%
rename from superset-frontend/src/dashboard/util/dropOverflowsParent.test.js
rename to superset-frontend/src/dashboard/util/dropOverflowsParent.test.ts
index a790ee0c6e..32528daca2 100644
--- a/superset-frontend/src/dashboard/util/dropOverflowsParent.test.js
+++ b/superset-frontend/src/dashboard/util/dropOverflowsParent.test.ts
@@ -16,7 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
-import dropOverflowsParent from 'src/dashboard/util/dropOverflowsParent';
+// Layout type not directly used in tests - using object shapes for test data
+import dropOverflowsParent, {
+ type DropResult,
+} from 'src/dashboard/util/dropOverflowsParent';
import { NEW_COMPONENTS_SOURCE_ID } from 'src/dashboard/util/constants';
import {
CHART_TYPE,
@@ -28,7 +31,7 @@ import {
describe('dropOverflowsParent', () => {
it('returns true if a parent does NOT have adequate width for child', () => {
- const dropResult = {
+ const dropResult: DropResult = {
source: { id: '_' },
destination: { id: 'a' },
dragging: { id: 'z' },
@@ -56,11 +59,11 @@ describe('dropOverflowsParent', () => {
},
};
- expect(dropOverflowsParent(dropResult, layout)).toBe(true);
+ expect(dropOverflowsParent(dropResult, layout as any)).toBe(true);
});
it('returns false if a parent DOES have adequate width for child', () => {
- const dropResult = {
+ const dropResult: DropResult = {
source: { id: '_' },
destination: { id: 'a' },
dragging: { id: 'z' },
@@ -88,11 +91,11 @@ describe('dropOverflowsParent', () => {
},
};
- expect(dropOverflowsParent(dropResult, layout)).toBe(false);
+ expect(dropOverflowsParent(dropResult, layout as any)).toBe(false);
});
it('returns false if a child CAN shrink to available parent space', () => {
- const dropResult = {
+ const dropResult: DropResult = {
source: { id: '_' },
destination: { id: 'a' },
dragging: { id: 'z' },
@@ -120,11 +123,11 @@ describe('dropOverflowsParent', () => {
},
};
- expect(dropOverflowsParent(dropResult, layout)).toBe(false);
+ expect(dropOverflowsParent(dropResult, layout as any)).toBe(false);
});
it('returns true if a child CANNOT shrink to available parent space', () => {
- const dropResult = {
+ const dropResult: DropResult = {
source: { id: '_' },
destination: { id: 'a' },
dragging: { id: 'b' },
@@ -153,11 +156,11 @@ describe('dropOverflowsParent', () => {
},
};
- expect(dropOverflowsParent(dropResult, layout)).toBe(true);
+ expect(dropOverflowsParent(dropResult, layout as any)).toBe(true);
});
it('returns true if a column has children that CANNOT shrink to available parent space', () => {
- const dropResult = {
+ const dropResult: DropResult = {
source: { id: '_' },
destination: { id: 'destination' },
dragging: { id: 'dragging' },
@@ -191,18 +194,18 @@ describe('dropOverflowsParent', () => {
},
};
- expect(dropOverflowsParent(dropResult, layout)).toBe(true);
+ expect(dropOverflowsParent(dropResult, layout as any)).toBe(true);
// remove children
expect(
dropOverflowsParent(dropResult, {
...layout,
- dragging: { ...layout.dragging, children: [] },
- }),
+ dragging: { ...layout.dragging, children: [] } as any,
+ } as any),
).toBe(false);
});
it('should work with new components that are not in the layout', () => {
- const dropResult = {
+ const dropResult: DropResult = {
source: { id: NEW_COMPONENTS_SOURCE_ID },
destination: { id: 'a' },
dragging: { type: CHART_TYPE },
@@ -212,15 +215,15 @@ describe('dropOverflowsParent', () => {
a: {
id: 'a',
type: ROW_TYPE,
- children: [],
+ children: [] as any,
},
};
- expect(dropOverflowsParent(dropResult, layout)).toBe(false);
+ expect(dropOverflowsParent(dropResult, layout as any)).toBe(false);
});
it('source/destination without widths should not overflow parent', () => {
- const dropResult = {
+ const dropResult: DropResult = {
source: { id: '_' },
destination: { id: 'tab' },
dragging: { id: 'header' },
@@ -237,6 +240,6 @@ describe('dropOverflowsParent', () => {
},
};
- expect(dropOverflowsParent(dropResult, layout)).toBe(false);
+ expect(dropOverflowsParent(dropResult, layout as any)).toBe(false);
});
});
diff --git a/superset-frontend/src/dashboard/util/dropOverflowsParent.js b/superset-frontend/src/dashboard/util/dropOverflowsParent.ts
similarity index 75%
rename from superset-frontend/src/dashboard/util/dropOverflowsParent.js
rename to superset-frontend/src/dashboard/util/dropOverflowsParent.ts
index 00cde762a4..4b0aae990b 100644
--- a/superset-frontend/src/dashboard/util/dropOverflowsParent.js
+++ b/superset-frontend/src/dashboard/util/dropOverflowsParent.ts
@@ -16,9 +16,22 @@
* specific language governing permissions and limitations
* under the License.
*/
+import type { ComponentType, Layout } from 'src/dashboard/types';
import getComponentWidthFromDrop from './getComponentWidthFromDrop';
-export default function doesChildOverflowParent(dropResult, layout) {
+export interface DropResult {
+ source: { id: string };
+ destination: { id: string };
+ dragging: {
+ id?: string;
+ type?: ComponentType;
+ };
+}
+
+export default function doesChildOverflowParent(
+ dropResult: DropResult,
+ layout: Layout,
+): boolean {
const childWidth = getComponentWidthFromDrop({ dropResult, layout });
return typeof childWidth === 'number' && childWidth < 0;
}
diff --git a/superset-frontend/src/dashboard/util/findFirstParentContainer.js b/superset-frontend/src/dashboard/util/findFirstParentContainer.ts
similarity index 89%
rename from superset-frontend/src/dashboard/util/findFirstParentContainer.js
rename to superset-frontend/src/dashboard/util/findFirstParentContainer.ts
index 217504bdf4..7169ee2502 100644
--- a/superset-frontend/src/dashboard/util/findFirstParentContainer.js
+++ b/superset-frontend/src/dashboard/util/findFirstParentContainer.ts
@@ -18,8 +18,11 @@
*/
import { TABS_TYPE } from './componentTypes';
import { DASHBOARD_ROOT_ID } from './constants';
+import { DashboardLayout } from '../types';
-export default function findFirstParentContainerId(layout = {}) {
+export default function findFirstParentContainerId(
+ layout: DashboardLayout = {},
+): string {
// DASHBOARD_GRID_TYPE or TABS_TYPE?
let parent = layout[DASHBOARD_ROOT_ID];
if (
diff --git a/superset-frontend/src/dashboard/util/getChartIdsFromLayout.test.js b/superset-frontend/src/dashboard/util/getChartIdsFromLayout.test.ts
similarity index 70%
rename from superset-frontend/src/dashboard/util/getChartIdsFromLayout.test.js
rename to superset-frontend/src/dashboard/util/getChartIdsFromLayout.test.ts
index a008b5a267..f90e335252 100644
--- a/superset-frontend/src/dashboard/util/getChartIdsFromLayout.test.js
+++ b/superset-frontend/src/dashboard/util/getChartIdsFromLayout.test.ts
@@ -18,36 +18,55 @@
*/
import getChartIdsFromLayout from 'src/dashboard/util/getChartIdsFromLayout';
import { ROW_TYPE, CHART_TYPE } from 'src/dashboard/util/componentTypes';
+import type { DashboardLayout } from '../types';
describe('getChartIdsFromLayout', () => {
- const mockLayout = {
+ const mockLayout: DashboardLayout = {
a: {
id: 'a',
type: CHART_TYPE,
- meta: { chartId: 'A' },
+ children: [],
+ meta: {
+ chartId: 123,
+ height: 400,
+ width: 400,
+ uuid: 'uuid-a',
+ },
},
b: {
id: 'b',
type: CHART_TYPE,
- meta: { chartId: 'B' },
+ children: [],
+ meta: {
+ chartId: 456,
+ height: 400,
+ width: 400,
+ uuid: 'uuid-b',
+ },
},
c: {
id: 'c',
type: ROW_TYPE,
- meta: { chartId: 'C' },
+ children: [],
+ meta: {
+ chartId: 789,
+ height: 400,
+ width: 400,
+ uuid: 'uuid-c',
+ },
},
};
it('should return an array of chartIds', () => {
const result = getChartIdsFromLayout(mockLayout);
expect(Array.isArray(result)).toBe(true);
- expect(result.includes('A')).toBe(true);
- expect(result.includes('B')).toBe(true);
+ expect(result.includes(123)).toBe(true);
+ expect(result.includes(456)).toBe(true);
});
it('should return ids only from CHART_TYPE components', () => {
const result = getChartIdsFromLayout(mockLayout);
expect(result).toHaveLength(2);
- expect(result.includes('C')).toBe(false);
+ expect(result.includes(789)).toBe(false);
});
});
diff --git a/superset-frontend/src/dashboard/util/getLayoutComponentFromChartId.js b/superset-frontend/src/dashboard/util/getChartIdsFromLayout.ts
similarity index 63%
copy from superset-frontend/src/dashboard/util/getLayoutComponentFromChartId.js
copy to superset-frontend/src/dashboard/util/getChartIdsFromLayout.ts
index 42d317adaf..00e1992c4f 100644
--- a/superset-frontend/src/dashboard/util/getLayoutComponentFromChartId.js
+++ b/superset-frontend/src/dashboard/util/getChartIdsFromLayout.ts
@@ -16,15 +16,24 @@
* specific language governing permissions and limitations
* under the License.
*/
-/* eslint-disable no-param-reassign */
import { CHART_TYPE } from './componentTypes';
+import type { DashboardLayout } from '../types';
-export default function getLayoutComponentFromChartId(layout, chartId) {
- return Object.values(layout).find(
- currentComponent =>
- currentComponent &&
- currentComponent.type === CHART_TYPE &&
- currentComponent.meta &&
- currentComponent.meta.chartId === chartId,
+export default function getChartIdsFromLayout(
+ layout: DashboardLayout,
+): number[] {
+ return Object.values(layout).reduce(
+ (chartIds: number[], currentComponent) => {
+ if (
+ currentComponent &&
+ currentComponent.type === CHART_TYPE &&
+ currentComponent.meta &&
+ currentComponent.meta.chartId
+ ) {
+ chartIds.push(currentComponent.meta.chartId);
+ }
+ return chartIds;
+ },
+ [],
);
}
diff --git a/superset-frontend/src/dashboard/util/serializeFilterScopes.js b/superset-frontend/src/dashboard/util/getDirectPathToTabIndex.test.ts
similarity index 56%
copy from superset-frontend/src/dashboard/util/serializeFilterScopes.js
copy to superset-frontend/src/dashboard/util/getDirectPathToTabIndex.test.ts
index 303171f24f..c9a0c769cd 100644
--- a/superset-frontend/src/dashboard/util/serializeFilterScopes.js
+++ b/superset-frontend/src/dashboard/util/getDirectPathToTabIndex.test.ts
@@ -16,19 +16,28 @@
* specific language governing permissions and limitations
* under the License.
*/
-export default function serializeFilterScopes(dashboardFilters) {
- return Object.values(dashboardFilters).reduce((map, { chartId, scopes }) => {
- const scopesById = Object.keys(scopes).reduce(
- (scopesByColumn, column) => ({
- ...scopesByColumn,
- [column]: scopes[column],
- }),
- {},
- );
+import getDirectPathToTabIndex from './getDirectPathToTabIndex';
- return {
- ...map,
- [chartId]: scopesById,
+describe('getDirectPathToTabIndex', () => {
+ it('builds path using parents, id, and child at index', () => {
+ const tabs = {
+ id: 'TABS_ID',
+ parents: ['ROOT', 'ROW_1'],
+ children: ['TAB_A', 'TAB_B', 'TAB_C'],
};
- }, {});
-}
+ expect(getDirectPathToTabIndex(tabs, 1)).toEqual([
+ 'ROOT',
+ 'ROW_1',
+ 'TABS_ID',
+ 'TAB_B',
+ ]);
+ });
+
+ it('handles missing parents', () => {
+ const tabs = {
+ id: 'TABS_ID',
+ children: ['TAB_A'],
+ };
+ expect(getDirectPathToTabIndex(tabs, 0)).toEqual(['TABS_ID', 'TAB_A']);
+ });
+});
diff --git a/superset-frontend/src/dashboard/util/getDirectPathToTabIndex.js b/superset-frontend/src/dashboard/util/getDirectPathToTabIndex.ts
similarity index 80%
rename from superset-frontend/src/dashboard/util/getDirectPathToTabIndex.js
rename to superset-frontend/src/dashboard/util/getDirectPathToTabIndex.ts
index df77c321c0..61d4a105ed 100644
--- a/superset-frontend/src/dashboard/util/getDirectPathToTabIndex.js
+++ b/superset-frontend/src/dashboard/util/getDirectPathToTabIndex.ts
@@ -16,7 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
-export default function getDirectPathToTabIndex(tabsComponent, tabIndex) {
+export interface TabsComponentLike {
+ id: string;
+ parents?: string[];
+ children: string[];
+ [key: string]: unknown;
+}
+
+export default function getDirectPathToTabIndex(
+ tabsComponent: TabsComponentLike,
+ tabIndex: number,
+): string[] {
const directPathToFilter = (tabsComponent.parents || []).slice();
directPathToFilter.push(tabsComponent.id);
directPathToFilter.push(tabsComponent.children[tabIndex]);
diff --git a/superset-frontend/src/dashboard/util/getEmptyLayout.js b/superset-frontend/src/dashboard/util/getEmptyLayout.ts
similarity index 74%
rename from superset-frontend/src/dashboard/util/getEmptyLayout.js
rename to superset-frontend/src/dashboard/util/getEmptyLayout.ts
index 8a672bad0f..2aa5d030db 100644
--- a/superset-frontend/src/dashboard/util/getEmptyLayout.js
+++ b/superset-frontend/src/dashboard/util/getEmptyLayout.ts
@@ -17,6 +17,7 @@
* under the License.
*/
import { DASHBOARD_ROOT_TYPE, DASHBOARD_GRID_TYPE } from './componentTypes';
+import type { ComponentType } from '../types';
import {
DASHBOARD_GRID_ID,
@@ -24,7 +25,22 @@ import {
DASHBOARD_VERSION_KEY,
} from './constants';
-export default function getEmptyLayout() {
+// Basic layout item for empty dashboard (simplified version without meta)
+interface BasicLayoutItem {
+ type: ComponentType;
+ id: string;
+ children: string[];
+ parents?: string[];
+}
+
+// Empty layout structure
+type EmptyLayout = {
+ [DASHBOARD_VERSION_KEY]: string;
+ [DASHBOARD_ROOT_ID]: BasicLayoutItem;
+ [DASHBOARD_GRID_ID]: BasicLayoutItem;
+};
+
+export default function getEmptyLayout(): EmptyLayout {
return {
[DASHBOARD_VERSION_KEY]: 'v2',
[DASHBOARD_ROOT_ID]: {
diff --git a/superset-frontend/src/dashboard/util/getKeyForFilterScopeTree.test.ts b/superset-frontend/src/dashboard/util/getKeyForFilterScopeTree.test.ts
new file mode 100644
index 0000000000..6aef6c06d4
--- /dev/null
+++ b/superset-frontend/src/dashboard/util/getKeyForFilterScopeTree.test.ts
@@ -0,0 +1,78 @@
+/**
+ * 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 getKeyForFilterScopeTree from './getKeyForFilterScopeTree';
+
+describe('getKeyForFilterScopeTree', () => {
+ test('should return stringified activeFilterField array when activeFilterField is provided', () => {
+ const props = {
+ activeFilterField: 'filter1',
+ checkedFilterFields: ['filter2', 'filter3'],
+ };
+
+ const result = getKeyForFilterScopeTree(props);
+ expect(result).toBe('["filter1"]');
+ });
+
+ test('should return stringified checkedFilterFields when activeFilterField is not provided', () => {
+ const props = {
+ checkedFilterFields: ['filter2', 'filter3'],
+ };
+
+ const result = getKeyForFilterScopeTree(props);
+ expect(result).toBe('["filter2","filter3"]');
+ });
+
+ test('should return stringified checkedFilterFields when activeFilterField is undefined', () => {
+ const props = {
+ activeFilterField: undefined,
+ checkedFilterFields: ['filter1'],
+ };
+
+ const result = getKeyForFilterScopeTree(props);
+ expect(result).toBe('["filter1"]');
+ });
+
+ test('should return stringified empty array when both fields are empty', () => {
+ const props = {
+ checkedFilterFields: [],
+ };
+
+ const result = getKeyForFilterScopeTree(props);
+ expect(result).toBe('[]');
+ });
+
+ test('should handle single checked filter field', () => {
+ const props = {
+ checkedFilterFields: ['singleFilter'],
+ };
+
+ const result = getKeyForFilterScopeTree(props);
+ expect(result).toBe('["singleFilter"]');
+ });
+
+ test('should prioritize activeFilterField over checkedFilterFields', () => {
+ const props = {
+ activeFilterField: 'activeFilter',
+ checkedFilterFields: ['checked1', 'checked2'],
+ };
+
+ const result = getKeyForFilterScopeTree(props);
+ expect(result).toBe('["activeFilter"]');
+ });
+});
diff --git a/superset-frontend/src/dashboard/util/getKeyForFilterScopeTree.js b/superset-frontend/src/dashboard/util/getKeyForFilterScopeTree.ts
similarity index 87%
rename from superset-frontend/src/dashboard/util/getKeyForFilterScopeTree.js
rename to superset-frontend/src/dashboard/util/getKeyForFilterScopeTree.ts
index 808bbf8ef0..2a4962c269 100644
--- a/superset-frontend/src/dashboard/util/getKeyForFilterScopeTree.js
+++ b/superset-frontend/src/dashboard/util/getKeyForFilterScopeTree.ts
@@ -18,10 +18,15 @@
*/
import { safeStringify } from '../../utils/safeStringify';
+interface GetKeyForFilterScopeTreeProps {
+ activeFilterField?: string;
+ checkedFilterFields: string[];
+}
+
export default function getKeyForFilterScopeTree({
activeFilterField,
checkedFilterFields,
-}) {
+}: GetKeyForFilterScopeTreeProps): string {
return safeStringify(
activeFilterField ? [activeFilterField] : checkedFilterFields,
);
diff --git a/superset-frontend/src/dashboard/util/getLayoutComponentFromChartId.test.ts b/superset-frontend/src/dashboard/util/getLayoutComponentFromChartId.test.ts
new file mode 100644
index 0000000000..38a2a4f86e
--- /dev/null
+++ b/superset-frontend/src/dashboard/util/getLayoutComponentFromChartId.test.ts
@@ -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 getLayoutComponentFromChartId from './getLayoutComponentFromChartId';
+import { CHART_TYPE, DASHBOARD_ROOT_TYPE } from './componentTypes';
+import type { DashboardLayout, LayoutItem } from '../types';
+
+const mockLayoutItem: LayoutItem = {
+ id: 'CHART-123',
+ type: CHART_TYPE,
+ children: [],
+ meta: {
+ chartId: 456,
+ defaultText: '',
+ height: 400,
+ placeholder: '',
+ sliceName: 'Test Chart',
+ text: '',
+ uuid: 'abc-def-ghi',
+ width: 400,
+ },
+};
+
+const mockRootLayoutItem: LayoutItem = {
+ id: 'ROOT_ID',
+ type: DASHBOARD_ROOT_TYPE,
+ children: ['CHART-123'],
+ meta: {
+ chartId: 0,
+ defaultText: '',
+ height: 0,
+ placeholder: '',
+ text: '',
+ uuid: 'root-uuid',
+ width: 0,
+ },
+};
+
+const mockLayout: DashboardLayout = {
+ 'CHART-123': mockLayoutItem,
+ ROOT_ID: mockRootLayoutItem,
+};
+
+test('should find layout component by chart ID', () => {
+ const result = getLayoutComponentFromChartId(mockLayout, 456);
+ expect(result).toEqual(mockLayoutItem);
+});
+
+test('should return undefined when chart ID is not found', () => {
+ const result = getLayoutComponentFromChartId(mockLayout, 999);
+ expect(result).toBeUndefined();
+});
+
+test('should return undefined when layout is empty', () => {
+ const result = getLayoutComponentFromChartId({}, 456);
+ expect(result).toBeUndefined();
+});
+
+test('should ignore non-chart components', () => {
+ const layoutWithoutChart: DashboardLayout = {
+ ROOT_ID: mockRootLayoutItem,
+ };
+
+ const result = getLayoutComponentFromChartId(layoutWithoutChart, 456);
+ expect(result).toBeUndefined();
+});
+
+test('should handle components without meta', () => {
+ const componentWithoutMeta: LayoutItem = {
+ id: 'NO-META',
+ type: CHART_TYPE,
+ children: [],
+ meta: {
+ chartId: 0,
+ defaultText: '',
+ height: 0,
+ placeholder: '',
+ text: '',
+ uuid: 'no-meta-uuid',
+ width: 0,
+ },
+ };
+
+ const layoutWithoutMeta: DashboardLayout = {
+ 'NO-META': componentWithoutMeta,
+ };
+
+ const result = getLayoutComponentFromChartId(layoutWithoutMeta, 456);
+ expect(result).toBeUndefined();
+});
diff --git a/superset-frontend/src/dashboard/util/getLayoutComponentFromChartId.js b/superset-frontend/src/dashboard/util/getLayoutComponentFromChartId.ts
similarity index 85%
rename from superset-frontend/src/dashboard/util/getLayoutComponentFromChartId.js
rename to superset-frontend/src/dashboard/util/getLayoutComponentFromChartId.ts
index 42d317adaf..6f1e6364cb 100644
--- a/superset-frontend/src/dashboard/util/getLayoutComponentFromChartId.js
+++ b/superset-frontend/src/dashboard/util/getLayoutComponentFromChartId.ts
@@ -18,8 +18,12 @@
*/
/* eslint-disable no-param-reassign */
import { CHART_TYPE } from './componentTypes';
+import type { DashboardLayout, LayoutItem } from '../types';
-export default function getLayoutComponentFromChartId(layout, chartId) {
+export default function getLayoutComponentFromChartId(
+ layout: DashboardLayout,
+ chartId: number,
+): LayoutItem | undefined {
return Object.values(layout).find(
currentComponent =>
currentComponent &&
diff --git a/superset-frontend/src/dashboard/util/getLeafComponentIdFromPath.js b/superset-frontend/src/dashboard/util/getLeafComponentIdFromPath.ts
similarity index 84%
rename from superset-frontend/src/dashboard/util/getLeafComponentIdFromPath.js
rename to superset-frontend/src/dashboard/util/getLeafComponentIdFromPath.ts
index 37bed77c50..ea738498e9 100644
--- a/superset-frontend/src/dashboard/util/getLeafComponentIdFromPath.js
+++ b/superset-frontend/src/dashboard/util/getLeafComponentIdFromPath.ts
@@ -18,7 +18,9 @@
*/
import { IN_COMPONENT_ELEMENT_TYPES } from './constants';
-export default function getLeafComponentIdFromPath(directPathToChild = []) {
+export default function getLeafComponentIdFromPath(
+ directPathToChild: string[] = [],
+): string | null {
if (directPathToChild.length > 0) {
const currentPath = directPathToChild.slice();
@@ -26,7 +28,10 @@ export default function getLeafComponentIdFromPath(directPathToChild = []) {
const componentId = currentPath.pop();
const componentType = componentId && componentId.split('-')[0];
- if (!IN_COMPONENT_ELEMENT_TYPES.includes(componentType)) {
+ if (
+ componentType &&
+ !IN_COMPONENT_ELEMENT_TYPES.includes(componentType)
+ ) {
return componentId;
}
}
diff --git a/superset-frontend/src/dashboard/util/isDashboardEmpty.test.ts b/superset-frontend/src/dashboard/util/isDashboardEmpty.test.ts
index 411f0a3f91..0ebf5b0ed8 100644
--- a/superset-frontend/src/dashboard/util/isDashboardEmpty.test.ts
+++ b/superset-frontend/src/dashboard/util/isDashboardEmpty.test.ts
@@ -20,7 +20,7 @@ import isDashboardEmpty from 'src/dashboard/util/isDashboardEmpty';
import getEmptyLayout from 'src/dashboard/util/getEmptyLayout';
describe('isDashboardEmpty', () => {
- const emptyLayout: object = getEmptyLayout();
+ const emptyLayout = getEmptyLayout();
const testLayout: object = {
...emptyLayout,
'MARKDOWN-IhTGLhyiTd': {
diff --git a/superset-frontend/src/dashboard/util/isDashboardLoading.js b/superset-frontend/src/dashboard/util/isDashboardLoading.js
deleted file mode 100644
index c970fc8978..0000000000
--- a/superset-frontend/src/dashboard/util/isDashboardLoading.js
+++ /dev/null
@@ -1,23 +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.
- */
-export default function isDashboardLoading(charts) {
- return Object.values(charts).some(
- chart => chart.chartUpdateStartTime > (chart.chartUpdateEndTime || 0),
- );
-}
diff --git a/superset-frontend/src/dashboard/util/isDashboardLoading.test.ts b/superset-frontend/src/dashboard/util/isDashboardLoading.test.ts
new file mode 100644
index 0000000000..ee6aa85915
--- /dev/null
+++ b/superset-frontend/src/dashboard/util/isDashboardLoading.test.ts
@@ -0,0 +1,48 @@
+/**
+ * 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 isDashboardLoading, { ChartLoadTimestamps } from './isDashboardLoading';
+
+describe('isDashboardLoading', () => {
+ it('returns false when no charts are loading', () => {
+ const charts: Record<string, ChartLoadTimestamps> = {
+ a: { chartUpdateStartTime: 1, chartUpdateEndTime: 2 },
+ b: { chartUpdateStartTime: 5, chartUpdateEndTime: 5 },
+ };
+ expect(isDashboardLoading(charts)).toBe(false);
+ });
+
+ it('returns true when any chart has start > end', () => {
+ const charts: Record<string, ChartLoadTimestamps> = {
+ a: { chartUpdateStartTime: 10, chartUpdateEndTime: 5 },
+ b: { chartUpdateStartTime: 1, chartUpdateEndTime: 2 },
+ };
+ expect(isDashboardLoading(charts)).toBe(true);
+ });
+
+ it('treats missing end as 0', () => {
+ const charts: Record<string, ChartLoadTimestamps> = {
+ a: { chartUpdateStartTime: 1 },
+ };
+ expect(isDashboardLoading(charts)).toBe(true);
+ });
+
+ it('handles empty charts object', () => {
+ expect(isDashboardLoading({})).toBe(false);
+ });
+});
diff --git a/superset-frontend/src/dashboard/util/getChartIdsFromLayout.js b/superset-frontend/src/dashboard/util/isDashboardLoading.ts
similarity index 63%
rename from superset-frontend/src/dashboard/util/getChartIdsFromLayout.js
rename to superset-frontend/src/dashboard/util/isDashboardLoading.ts
index ac3931b355..ce5ffdc612 100644
--- a/superset-frontend/src/dashboard/util/getChartIdsFromLayout.js
+++ b/superset-frontend/src/dashboard/util/isDashboardLoading.ts
@@ -16,18 +16,19 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { CHART_TYPE } from './componentTypes';
+export interface ChartLoadTimestamps {
+ chartUpdateStartTime?: number;
+ chartUpdateEndTime?: number | null;
+ // allow extra fields without narrowing
+ [key: string]: unknown;
+}
-export default function getChartIdsFromLayout(layout) {
- return Object.values(layout).reduce((chartIds, currentComponent) => {
- if (
- currentComponent &&
- currentComponent.type === CHART_TYPE &&
- currentComponent.meta &&
- currentComponent.meta.chartId
- ) {
- chartIds.push(currentComponent.meta.chartId);
- }
- return chartIds;
- }, []);
+export default function isDashboardLoading(
+ charts: Record<string, ChartLoadTimestamps>,
+): boolean {
+ return Object.values(charts).some(chart => {
+ const start = chart.chartUpdateStartTime ?? 0;
+ const end = chart.chartUpdateEndTime ?? 0;
+ return start > end;
+ });
}
diff --git a/superset-frontend/src/dashboard/util/isInDifferentFilterScopes.test.ts b/superset-frontend/src/dashboard/util/isInDifferentFilterScopes.test.ts
new file mode 100644
index 0000000000..e01299a6c4
--- /dev/null
+++ b/superset-frontend/src/dashboard/util/isInDifferentFilterScopes.test.ts
@@ -0,0 +1,197 @@
+/**
+ * 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 isInDifferentFilterScopes from './isInDifferentFilterScopes';
+
+test('returns false when no dashboard filters are provided', () => {
+ const result = isInDifferentFilterScopes({
+ dashboardFilters: {},
+ source: ['tab1', 'tab2'],
+ destination: ['tab2', 'tab3'],
+ });
+
+ expect(result).toBe(false);
+});
+
+test('returns false when source and destination are in same filter scopes', () => {
+ const dashboardFilters = {
+ filter1: {
+ chartId: 123,
+ scopes: {
+ column1: {
+ scope: ['tab1', 'tab2'],
+ },
+ column2: {
+ scope: ['tab3', 'tab4'],
+ },
+ },
+ },
+ };
+
+ const result = isInDifferentFilterScopes({
+ dashboardFilters,
+ source: ['tab1'],
+ destination: ['tab1'],
+ });
+
+ expect(result).toBe(false);
+});
+
+test('returns true when source and destination are in different filter scopes', () => {
+ const dashboardFilters = {
+ filter1: {
+ chartId: 123,
+ scopes: {
+ column1: {
+ scope: ['tab1', 'tab2'],
+ },
+ },
+ },
+ };
+
+ const result = isInDifferentFilterScopes({
+ dashboardFilters,
+ source: ['tab1'],
+ destination: ['tab3'],
+ });
+
+ expect(result).toBe(true);
+});
+
+test('returns true when one is in scope and the other is not', () => {
+ const dashboardFilters = {
+ filter1: {
+ chartId: 123,
+ scopes: {
+ column1: {
+ scope: ['tab1'],
+ },
+ },
+ },
+ };
+
+ const result = isInDifferentFilterScopes({
+ dashboardFilters,
+ source: ['tab1'], // in scope
+ destination: ['tab2'], // not in scope
+ });
+
+ expect(result).toBe(true);
+});
+
+test('handles multiple filters with complex scopes', () => {
+ const dashboardFilters = {
+ filter1: {
+ chartId: 123,
+ scopes: {
+ column1: {
+ scope: ['tab1', 'tab2'],
+ },
+ column2: {
+ scope: ['tab3'],
+ },
+ },
+ },
+ filter2: {
+ chartId: 456,
+ scopes: {
+ column1: {
+ scope: ['tab2', 'tab4'],
+ },
+ },
+ },
+ };
+
+ const result = isInDifferentFilterScopes({
+ dashboardFilters,
+ source: ['tab1'],
+ destination: ['tab4'],
+ });
+
+ expect(result).toBe(true);
+});
+
+test('handles empty source and destination arrays', () => {
+ const dashboardFilters = {
+ filter1: {
+ chartId: 123,
+ scopes: {
+ column1: {
+ scope: ['tab1'],
+ },
+ },
+ },
+ };
+
+ const result = isInDifferentFilterScopes({
+ dashboardFilters,
+ source: [],
+ destination: [],
+ });
+
+ expect(result).toBe(false);
+});
+
+test('uses default parameters when not provided', () => {
+ const result = isInDifferentFilterScopes({});
+
+ expect(result).toBe(false);
+});
+
+test('returns true when source and destination have different presence in filter scopes', () => {
+ const dashboardFilters = {
+ filter1: {
+ chartId: 123,
+ scopes: {
+ column1: {
+ scope: ['tab1', 'tab2', 'tab3'],
+ },
+ },
+ },
+ };
+
+ const result = isInDifferentFilterScopes({
+ dashboardFilters,
+ source: ['tab1', 'tab2'],
+ destination: ['tab2', 'tab3'],
+ });
+
+ // tab1 is in source but not destination, tab3 is in destination but not source
+ expect(result).toBe(true);
+});
+
+test('returns false when both source and destination contain same tabs', () => {
+ const dashboardFilters = {
+ filter1: {
+ chartId: 123,
+ scopes: {
+ column1: {
+ scope: ['tab1', 'tab2'],
+ },
+ },
+ },
+ };
+
+ const result = isInDifferentFilterScopes({
+ dashboardFilters,
+ source: ['tab1'],
+ destination: ['tab1'],
+ });
+
+ expect(result).toBe(false);
+});
diff --git a/superset-frontend/src/dashboard/util/isInDifferentFilterScopes.js b/superset-frontend/src/dashboard/util/isInDifferentFilterScopes.ts
similarity index 65%
rename from superset-frontend/src/dashboard/util/isInDifferentFilterScopes.js
rename to superset-frontend/src/dashboard/util/isInDifferentFilterScopes.ts
index 3789d07ff2..99b22ded9e 100644
--- a/superset-frontend/src/dashboard/util/isInDifferentFilterScopes.js
+++ b/superset-frontend/src/dashboard/util/isInDifferentFilterScopes.ts
@@ -16,17 +16,40 @@
* specific language governing permissions and limitations
* under the License.
*/
+
+// Dashboard filter structure based on the actual usage pattern
+interface DashboardFilterColumn {
+ scope: string[];
+}
+
+interface DashboardFilter {
+ chartId: number;
+ scopes: Record<string, DashboardFilterColumn>;
+}
+
+interface DashboardFilters {
+ [filterId: string]: DashboardFilter;
+}
+
+interface IsInDifferentFilterScopesProps {
+ dashboardFilters?: DashboardFilters;
+ source?: string[];
+ destination?: string[];
+}
+
export default function isInDifferentFilterScopes({
dashboardFilters = {},
source = [],
destination = [],
-}) {
+}: IsInDifferentFilterScopesProps): boolean {
const sourceSet = new Set(source);
const destinationSet = new Set(destination);
- const allScopes = [].concat(
+ const allScopes = ([] as string[]).concat(
...Object.values(dashboardFilters).map(({ scopes }) =>
- [].concat(...Object.values(scopes).map(({ scope }) => scope)),
+ ([] as string[]).concat(
+ ...Object.values(scopes).map(({ scope }) => scope),
+ ),
),
);
return allScopes.some(tab => destinationSet.has(tab) !== sourceSet.has(tab));
diff --git a/superset-frontend/src/dashboard/util/logging/childChartsDidLoad.test.ts b/superset-frontend/src/dashboard/util/logging/childChartsDidLoad.test.ts
new file mode 100644
index 0000000000..3e492eb4ef
--- /dev/null
+++ b/superset-frontend/src/dashboard/util/logging/childChartsDidLoad.test.ts
@@ -0,0 +1,246 @@
+/**
+ * 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 { ChartState } from 'src/explore/types';
+import { Layout } from 'src/dashboard/types';
+import childChartsDidLoad from './childChartsDidLoad';
+
+import mockFindNonTabChildChartIdsImport from './findNonTabChildChartIds';
+
+// Mock the findNonTabChildChartIds dependency
+jest.mock('./findNonTabChildChartIds', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+
+const mockFindNonTabChildChartIds =
+ mockFindNonTabChildChartIdsImport as jest.MockedFunction<
+ typeof mockFindNonTabChildChartIdsImport
+ >;
+
+describe('childChartsDidLoad', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('returns didLoad true when all charts are in completed states', () => {
+ const chartIds = [1, 2, 3];
+ const layout: Layout = {};
+ const chartQueries: Record<string, Partial<ChartState>> = {
+ '1': { chartStatus: 'rendered', chartUpdateStartTime: 100 },
+ '2': { chartStatus: 'stopped', chartUpdateStartTime: 200 },
+ '3': { chartStatus: 'failed', chartUpdateStartTime: 150 },
+ };
+
+ mockFindNonTabChildChartIds.mockReturnValue(chartIds);
+
+ const result = childChartsDidLoad({
+ chartQueries,
+ layout,
+ id: 'test-id',
+ });
+
+ expect(result.didLoad).toBe(true);
+ expect(result.minQueryStartTime).toBe(100);
+ expect(mockFindNonTabChildChartIds).toHaveBeenCalledWith({
+ id: 'test-id',
+ layout,
+ });
+ });
+
+ test('returns didLoad false when some charts are in loading state', () => {
+ const chartIds = [1, 2, 3];
+ const layout: Layout = {};
+ const chartQueries: Record<string, Partial<ChartState>> = {
+ '1': { chartStatus: 'rendered', chartUpdateStartTime: 100 },
+ '2': { chartStatus: 'loading', chartUpdateStartTime: 200 },
+ '3': { chartStatus: 'failed', chartUpdateStartTime: 150 },
+ };
+
+ mockFindNonTabChildChartIds.mockReturnValue(chartIds);
+
+ const result = childChartsDidLoad({
+ chartQueries,
+ layout,
+ id: 'test-id',
+ });
+
+ expect(result.didLoad).toBe(false);
+ expect(result.minQueryStartTime).toBe(100);
+ });
+
+ test('handles missing chart queries gracefully', () => {
+ const chartIds = [1, 2, 3];
+ const layout: Layout = {};
+ const chartQueries: Record<string, Partial<ChartState>> = {
+ '1': { chartStatus: 'rendered', chartUpdateStartTime: 100 },
+ // Chart 2 is missing from queries
+ '3': { chartStatus: 'failed', chartUpdateStartTime: 150 },
+ };
+
+ mockFindNonTabChildChartIds.mockReturnValue(chartIds);
+
+ const result = childChartsDidLoad({
+ chartQueries,
+ layout,
+ id: 'test-id',
+ });
+
+ expect(result.didLoad).toBe(false);
+ expect(result.minQueryStartTime).toBe(100);
+ });
+
+ test('handles empty chart queries object', () => {
+ const chartIds = [1, 2, 3];
+ const layout: Layout = {};
+ const chartQueries: Record<string, Partial<ChartState>> = {};
+
+ mockFindNonTabChildChartIds.mockReturnValue(chartIds);
+
+ const result = childChartsDidLoad({
+ chartQueries,
+ layout,
+ id: 'test-id',
+ });
+
+ expect(result.didLoad).toBe(false);
+ expect(result.minQueryStartTime).toBe(Infinity);
+ });
+
+ test('handles empty chart IDs array', () => {
+ const chartIds: number[] = [];
+ const layout: Layout = {};
+ const chartQueries: Record<string, Partial<ChartState>> = {
+ '1': { chartStatus: 'rendered', chartUpdateStartTime: 100 },
+ };
+
+ mockFindNonTabChildChartIds.mockReturnValue(chartIds);
+
+ const result = childChartsDidLoad({
+ chartQueries,
+ layout,
+ id: 'test-id',
+ });
+
+ expect(result.didLoad).toBe(true); // every() returns true for empty array
+ expect(result.minQueryStartTime).toBe(Infinity);
+ });
+
+ test('calculates minimum query start time correctly', () => {
+ const chartIds = [1, 2, 3, 4];
+ const layout: Layout = {};
+ const chartQueries: Record<string, Partial<ChartState>> = {
+ '1': { chartStatus: 'rendered', chartUpdateStartTime: 500 },
+ '2': { chartStatus: 'stopped', chartUpdateStartTime: 100 },
+ '3': { chartStatus: 'failed', chartUpdateStartTime: 300 },
+ '4': { chartStatus: 'rendered', chartUpdateStartTime: 200 },
+ };
+
+ mockFindNonTabChildChartIds.mockReturnValue(chartIds);
+
+ const result = childChartsDidLoad({
+ chartQueries,
+ layout,
+ id: 'test-id',
+ });
+
+ expect(result.didLoad).toBe(true);
+ expect(result.minQueryStartTime).toBe(100);
+ });
+
+ test('handles charts with missing chartUpdateStartTime', () => {
+ const chartIds = [1, 2];
+ const layout: Layout = {};
+ const chartQueries: Record<string, Partial<ChartState>> = {
+ '1': { chartStatus: 'rendered' }, // Missing chartUpdateStartTime
+ '2': { chartStatus: 'stopped', chartUpdateStartTime: 200 },
+ };
+
+ mockFindNonTabChildChartIds.mockReturnValue(chartIds);
+
+ const result = childChartsDidLoad({
+ chartQueries,
+ layout,
+ id: 'test-id',
+ });
+
+ expect(result.didLoad).toBe(true);
+ expect(result.minQueryStartTime).toBe(200);
+ });
+
+ test('handles charts with null chartStatus', () => {
+ const chartIds = [1, 2];
+ const layout: Layout = {};
+ const chartQueries: Record<string, Partial<ChartState>> = {
+ '1': { chartStatus: null, chartUpdateStartTime: 100 },
+ '2': { chartStatus: 'stopped', chartUpdateStartTime: 200 },
+ };
+
+ mockFindNonTabChildChartIds.mockReturnValue(chartIds);
+
+ const result = childChartsDidLoad({
+ chartQueries,
+ layout,
+ id: 'test-id',
+ });
+
+ expect(result.didLoad).toBe(false); // null chartStatus is not in the completed states
+ expect(result.minQueryStartTime).toBe(100);
+ });
+
+ test('recognizes all valid completed chart states', () => {
+ const chartIds = [1, 2, 3];
+ const layout: Layout = {};
+ const chartQueries: Record<string, Partial<ChartState>> = {
+ '1': { chartStatus: 'stopped', chartUpdateStartTime: 100 },
+ '2': { chartStatus: 'failed', chartUpdateStartTime: 200 },
+ '3': { chartStatus: 'rendered', chartUpdateStartTime: 150 },
+ };
+
+ mockFindNonTabChildChartIds.mockReturnValue(chartIds);
+
+ const result = childChartsDidLoad({
+ chartQueries,
+ layout,
+ id: 'test-id',
+ });
+
+ expect(result.didLoad).toBe(true);
+ expect(result.minQueryStartTime).toBe(100);
+ });
+
+ test('does not recognize incomplete chart states', () => {
+ const chartIds = [1, 2];
+ const layout: Layout = {};
+ const chartQueries: Record<string, Partial<ChartState>> = {
+ '1': { chartStatus: 'loading', chartUpdateStartTime: 100 },
+ '2': { chartStatus: 'success', chartUpdateStartTime: 200 },
+ };
+
+ mockFindNonTabChildChartIds.mockReturnValue(chartIds);
+
+ const result = childChartsDidLoad({
+ chartQueries,
+ layout,
+ id: 'test-id',
+ });
+
+ expect(result.didLoad).toBe(false); // 'loading' and 'success' are not in completed states
+ expect(result.minQueryStartTime).toBe(100);
+ });
+});
diff --git a/superset-frontend/src/dashboard/util/logging/childChartsDidLoad.js b/superset-frontend/src/dashboard/util/logging/childChartsDidLoad.ts
similarity index 57%
rename from superset-frontend/src/dashboard/util/logging/childChartsDidLoad.js
rename to superset-frontend/src/dashboard/util/logging/childChartsDidLoad.ts
index a23958da0c..3145e87cb2 100644
--- a/superset-frontend/src/dashboard/util/logging/childChartsDidLoad.js
+++ b/superset-frontend/src/dashboard/util/logging/childChartsDidLoad.ts
@@ -16,16 +16,36 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { ChartState } from 'src/explore/types';
+import { Layout } from 'src/dashboard/types';
import findNonTabChildCharIds from './findNonTabChildChartIds';
-export default function childChartsDidLoad({ chartQueries, layout, id }) {
+interface ChildChartsDidLoadParams {
+ chartQueries: Record<string, Partial<ChartState>>;
+ layout: Layout;
+ id: string;
+}
+
+interface ChildChartsDidLoadResult {
+ didLoad: boolean;
+ minQueryStartTime: number;
+}
+
+export default function childChartsDidLoad({
+ chartQueries,
+ layout,
+ id,
+}: ChildChartsDidLoadParams): ChildChartsDidLoadResult {
const chartIds = findNonTabChildCharIds({ id, layout });
let minQueryStartTime = Infinity;
- const didLoad = chartIds.every(chartId => {
- const query = chartQueries[chartId] || {};
- minQueryStartTime = Math.min(query.chartUpdateStartTime, minQueryStartTime);
- return ['stopped', 'failed', 'rendered'].indexOf(query.chartStatus) > -1;
+ const didLoad = chartIds.every((chartId: number) => {
+ const query = chartQueries[chartId.toString()] || {};
+ minQueryStartTime = Math.min(
+ query.chartUpdateStartTime ?? Infinity,
+ minQueryStartTime,
+ );
+ return ['stopped', 'failed', 'rendered'].includes(query.chartStatus || '');
});
return { didLoad, minQueryStartTime };
diff --git a/superset-frontend/src/dashboard/util/serializeFilterScopes.test.ts b/superset-frontend/src/dashboard/util/serializeFilterScopes.test.ts
new file mode 100644
index 0000000000..e10893044d
--- /dev/null
+++ b/superset-frontend/src/dashboard/util/serializeFilterScopes.test.ts
@@ -0,0 +1,113 @@
+/**
+ * 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 serializeFilterScopes from './serializeFilterScopes';
+
+const mockDashboardFilters = {
+ '1': {
+ chartId: 'chart_1',
+ scopes: {
+ column1: {
+ scope: ['ROOT_ID'],
+ immune: [],
+ },
+ column2: {
+ scope: ['ROOT_ID', 'TAB_1'],
+ immune: ['chart_2'],
+ },
+ },
+ },
+ '2': {
+ chartId: 'chart_2',
+ scopes: {
+ region: {
+ scope: ['ROOT_ID'],
+ immune: [],
+ },
+ },
+ },
+};
+
+describe('serializeFilterScopes', () => {
+ test('should serialize dashboard filter scopes correctly', () => {
+ const result = serializeFilterScopes(mockDashboardFilters);
+
+ expect(result).toEqual({
+ chart_1: {
+ column1: {
+ scope: ['ROOT_ID'],
+ immune: [],
+ },
+ column2: {
+ scope: ['ROOT_ID', 'TAB_1'],
+ immune: ['chart_2'],
+ },
+ },
+ chart_2: {
+ region: {
+ scope: ['ROOT_ID'],
+ immune: [],
+ },
+ },
+ });
+ });
+
+ test('should handle empty dashboardFilters', () => {
+ const result = serializeFilterScopes({});
+ expect(result).toEqual({});
+ });
+
+ test('should handle filters with no scopes', () => {
+ const filtersWithEmptyScopes = {
+ '1': {
+ chartId: 'chart_1',
+ scopes: {},
+ },
+ };
+
+ const result = serializeFilterScopes(filtersWithEmptyScopes);
+ expect(result).toEqual({
+ chart_1: {},
+ });
+ });
+
+ test('should handle numeric chart IDs', () => {
+ const filtersWithNumericIds = {
+ '1': {
+ chartId: 123,
+ scopes: {
+ column1: {
+ scope: ['ROOT_ID'],
+ immune: [],
+ },
+ },
+ },
+ };
+
+ const result = serializeFilterScopes(filtersWithNumericIds);
+ expect(result).toEqual({
+ 123: {
+ column1: {
+ scope: ['ROOT_ID'],
+ immune: [],
+ },
+ },
+ });
+ });
+});
diff --git a/superset-frontend/src/dashboard/util/serializeFilterScopes.js b/superset-frontend/src/dashboard/util/serializeFilterScopes.ts
similarity index 68%
rename from superset-frontend/src/dashboard/util/serializeFilterScopes.js
rename to superset-frontend/src/dashboard/util/serializeFilterScopes.ts
index 303171f24f..75fe907978 100644
--- a/superset-frontend/src/dashboard/util/serializeFilterScopes.js
+++ b/superset-frontend/src/dashboard/util/serializeFilterScopes.ts
@@ -16,7 +16,29 @@
* specific language governing permissions and limitations
* under the License.
*/
-export default function serializeFilterScopes(dashboardFilters) {
+import { JsonObject } from '@superset-ui/core';
+
+interface DashboardFilterScope {
+ scope: string[] | JsonObject;
+ immune?: string[];
+}
+
+interface DashboardFilter {
+ chartId: number | string;
+ scopes: Record<string, DashboardFilterScope>;
+}
+
+interface DashboardFilters {
+ [filterId: string]: DashboardFilter;
+}
+
+interface SerializedFilterScopes {
+ [chartId: string]: Record<string, DashboardFilterScope>;
+}
+
+export default function serializeFilterScopes(
+ dashboardFilters: DashboardFilters,
+): SerializedFilterScopes {
return Object.values(dashboardFilters).reduce((map, { chartId, scopes }) => {
const scopesById = Object.keys(scopes).reduce(
(scopesByColumn, column) => ({
diff --git a/superset-frontend/src/dashboard/util/shouldWrapChildInRow.js b/superset-frontend/src/dashboard/util/shouldWrapChildInRow.ts
similarity index 65%
rename from superset-frontend/src/dashboard/util/shouldWrapChildInRow.js
rename to superset-frontend/src/dashboard/util/shouldWrapChildInRow.ts
index 5f5f695228..17ebcc844d 100644
--- a/superset-frontend/src/dashboard/util/shouldWrapChildInRow.js
+++ b/superset-frontend/src/dashboard/util/shouldWrapChildInRow.ts
@@ -23,8 +23,20 @@ import {
MARKDOWN_TYPE,
TAB_TYPE,
} from './componentTypes';
+import { ComponentType } from '../types';
-const typeToWrapChildLookup = {
+interface WrapChildParams {
+ parentType: ComponentType | undefined | null;
+ childType: ComponentType | undefined | null;
+}
+
+type ParentTypes = typeof DASHBOARD_GRID_TYPE | typeof TAB_TYPE;
+type ChildTypes = typeof CHART_TYPE | typeof COLUMN_TYPE | typeof MARKDOWN_TYPE;
+
+const typeToWrapChildLookup: Record<
+ ParentTypes,
+ Record<ChildTypes, boolean>
+> = {
[DASHBOARD_GRID_TYPE]: {
[CHART_TYPE]: true,
[COLUMN_TYPE]: true,
@@ -38,11 +50,14 @@ const typeToWrapChildLookup = {
},
};
-export default function shouldWrapChildInRow({ parentType, childType }) {
+export default function shouldWrapChildInRow({
+ parentType,
+ childType,
+}: WrapChildParams): boolean {
if (!parentType || !childType) return false;
- const wrapChildLookup = typeToWrapChildLookup[parentType];
+ const wrapChildLookup = typeToWrapChildLookup[parentType as ParentTypes];
if (!wrapChildLookup) return false;
- return Boolean(wrapChildLookup[childType]);
+ return Boolean(wrapChildLookup[childType as ChildTypes]);
}
diff --git a/superset-frontend/src/explore/components/DatasourcePanel/index.tsx b/superset-frontend/src/explore/components/DatasourcePanel/index.tsx
index 4552a43a79..7f71633c3c 100644
--- a/superset-frontend/src/explore/components/DatasourcePanel/index.tsx
+++ b/superset-frontend/src/explore/components/DatasourcePanel/index.tsx
@@ -58,6 +58,7 @@ export interface IDatasource {
sql?: string | null;
datasource_name?: string | null;
name?: string | null;
+ catalog?: string | null;
schema?: string | null;
}
diff --git a/superset-frontend/src/explore/components/controls/FilterControl/adhocFilterType.js b/superset-frontend/src/explore/components/controls/FilterControl/adhocFilterType.js
deleted file mode 100644
index b8900c1756..0000000000
--- a/superset-frontend/src/explore/components/controls/FilterControl/adhocFilterType.js
+++ /dev/null
@@ -1,37 +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 PropTypes from 'prop-types';
-import { Clauses, ExpressionTypes } from './types';
-
-export default PropTypes.oneOfType([
- PropTypes.shape({
- expressionType: PropTypes.oneOf([ExpressionTypes.Simple]).isRequired,
- clause: PropTypes.oneOf([Clauses.Having, Clauses.Where]).isRequired,
- subject: PropTypes.string.isRequired,
- comparator: PropTypes.oneOfType([
- PropTypes.string,
- PropTypes.arrayOf(PropTypes.string),
- ]).isRequired,
- }),
- PropTypes.shape({
- expressionType: PropTypes.oneOf([ExpressionTypes.Sql]).isRequired,
- clause: PropTypes.oneOf([Clauses.Where, Clauses.Having]).isRequired,
- sqlExpression: PropTypes.string.isRequired,
- }),
-]);
diff --git a/superset-frontend/src/explore/components/controls/FilterControl/adhocFilterType.test.ts b/superset-frontend/src/explore/components/controls/FilterControl/adhocFilterType.test.ts
new file mode 100644
index 0000000000..5e96174c41
--- /dev/null
+++ b/superset-frontend/src/explore/components/controls/FilterControl/adhocFilterType.test.ts
@@ -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 {
+ AdhocFilterSimple,
+ AdhocFilterSql,
+ AdhocFilterType,
+} from './adhocFilterType';
+import { Clauses, ExpressionTypes } from './types';
+
+describe('adhocFilterType', () => {
+ test('should accept simple adhoc filter type', () => {
+ const simpleFilter: AdhocFilterSimple = {
+ expressionType: ExpressionTypes.Simple,
+ clause: Clauses.Where,
+ subject: 'column_name',
+ comparator: 'test_value',
+ };
+
+ expect(simpleFilter.expressionType).toBe(ExpressionTypes.Simple);
+ expect(simpleFilter.clause).toBe(Clauses.Where);
+ expect(simpleFilter.subject).toBe('column_name');
+ expect(simpleFilter.comparator).toBe('test_value');
+ });
+
+ test('should accept SQL adhoc filter type', () => {
+ const sqlFilter: AdhocFilterSql = {
+ expressionType: ExpressionTypes.Sql,
+ clause: Clauses.Having,
+ sqlExpression: 'COUNT(*) > 5',
+ };
+
+ expect(sqlFilter.expressionType).toBe(ExpressionTypes.Sql);
+ expect(sqlFilter.clause).toBe(Clauses.Having);
+ expect(sqlFilter.sqlExpression).toBe('COUNT(*) > 5');
+ });
+
+ test('should accept both simple and SQL filters as AdhocFilterType', () => {
+ const simpleFilter: AdhocFilterType = {
+ expressionType: ExpressionTypes.Simple,
+ clause: Clauses.Where,
+ subject: 'column_name',
+ comparator: ['value1', 'value2'],
+ };
+
+ const sqlFilter: AdhocFilterType = {
+ expressionType: ExpressionTypes.Sql,
+ clause: Clauses.Having,
+ sqlExpression: 'AVG(sales) > 1000',
+ };
+
+ expect(simpleFilter.expressionType).toBe(ExpressionTypes.Simple);
+ expect(sqlFilter.expressionType).toBe(ExpressionTypes.Sql);
+ });
+
+ test('should handle array comparator for simple filters', () => {
+ const filterWithArrayComparator: AdhocFilterSimple = {
+ expressionType: ExpressionTypes.Simple,
+ clause: Clauses.Where,
+ subject: 'category',
+ comparator: ['A', 'B', 'C'],
+ };
+
+ expect(Array.isArray(filterWithArrayComparator.comparator)).toBe(true);
+ expect(filterWithArrayComparator.comparator).toEqual(['A', 'B', 'C']);
+ });
+
+ test('should handle optional properties', () => {
+ const filterWithOptionalProps: AdhocFilterSimple = {
+ expressionType: ExpressionTypes.Simple,
+ clause: Clauses.Where,
+ subject: 'column_name',
+ comparator: 'test_value',
+ operator: 'EQUALS',
+ operatorId: 'EQUALS',
+ isExtra: true,
+ isNew: false,
+ datasourceWarning: false,
+ deck_slices: [1, 2, 3],
+ layerFilterScope: 'global',
+ filterOptionName: 'custom_filter_name',
+ };
+
+ expect(filterWithOptionalProps.operator).toBe('EQUALS');
+ expect(filterWithOptionalProps.isExtra).toBe(true);
+ expect(filterWithOptionalProps.deck_slices).toEqual([1, 2, 3]);
+ });
+});
diff --git a/superset-frontend/src/explore/components/controls/FilterControl/adhocFilterType.ts b/superset-frontend/src/explore/components/controls/FilterControl/adhocFilterType.ts
new file mode 100644
index 0000000000..df2e663f7a
--- /dev/null
+++ b/superset-frontend/src/explore/components/controls/FilterControl/adhocFilterType.ts
@@ -0,0 +1,64 @@
+/**
+ * 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 { InferProps } from 'prop-types';
+import { Clauses, ExpressionTypes } from './types';
+
+export interface AdhocFilterSimple {
+ expressionType: ExpressionTypes.Simple;
+ clause: Clauses.Having | Clauses.Where;
+ subject: string;
+ comparator: string | string[];
+ operator?: string;
+ operatorId?: string;
+ isExtra?: boolean;
+ isNew?: boolean;
+ datasourceWarning?: boolean;
+ deck_slices?: number[];
+ layerFilterScope?: string;
+ filterOptionName?: string;
+}
+
+export interface AdhocFilterSql {
+ expressionType: ExpressionTypes.Sql;
+ clause: Clauses.Where | Clauses.Having;
+ sqlExpression: string;
+ subject?: string | null;
+ operator?: string | null;
+ operatorId?: string;
+ comparator?: null;
+ isExtra?: boolean;
+ isNew?: boolean;
+ datasourceWarning?: boolean;
+ deck_slices?: number[];
+ layerFilterScope?: string;
+ filterOptionName?: string;
+}
+
+export type AdhocFilterType = AdhocFilterSimple | AdhocFilterSql;
+
+// PropTypes validation function - babel-plugin-typescript-to-proptypes automatically
+// generates PropTypes from the TypeScript interface above
+export default function AdhocFilterValidator(props: {
+ filter: AdhocFilterType;
+}) {
+ return null; // PropTypes auto-generated by babel plugin
+}
+
+// For consumers needing PropTypes type inference
+export type AdhocFilterProps = InferProps<typeof AdhocFilterValidator>;
diff --git a/superset-frontend/src/explore/components/controls/FilterControl/columnType.test.ts b/superset-frontend/src/explore/components/controls/FilterControl/columnType.test.ts
new file mode 100644
index 0000000000..da1ef5eb09
--- /dev/null
+++ b/superset-frontend/src/explore/components/controls/FilterControl/columnType.test.ts
@@ -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.
+ */
+import { ColumnType } from './columnType';
+
+test('ColumnType should have proper structure', () => {
+ const mockColumn: ColumnType = {
+ column_name: 'test_column',
+ type: 'STRING',
+ };
+
+ expect(mockColumn.column_name).toBe('test_column');
+ expect(mockColumn.type).toBe('STRING');
+});
+
+test('ColumnType should allow optional type field', () => {
+ const mockColumn: ColumnType = {
+ column_name: 'test_column',
+ };
+
+ expect(mockColumn.column_name).toBe('test_column');
+ expect(mockColumn.type).toBeUndefined();
+});
+
+test('ColumnType should work with different type values', () => {
+ const stringColumn: ColumnType = {
+ column_name: 'str_col',
+ type: 'STRING',
+ };
+
+ const numericColumn: ColumnType = {
+ column_name: 'num_col',
+ type: 'NUMERIC',
+ };
+
+ expect(stringColumn.type).toBe('STRING');
+ expect(numericColumn.type).toBe('NUMERIC');
+});
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/columnType.js b/superset-frontend/src/explore/components/controls/FilterControl/columnType.ts
similarity index 76%
rename from superset-frontend/src/explore/components/controls/MetricControl/columnType.js
rename to superset-frontend/src/explore/components/controls/FilterControl/columnType.ts
index bb136fb58f..e453115b0b 100644
--- a/superset-frontend/src/explore/components/controls/MetricControl/columnType.js
+++ b/superset-frontend/src/explore/components/controls/FilterControl/columnType.ts
@@ -16,9 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
-import PropTypes from 'prop-types';
+import { Column } from '@superset-ui/core';
-export default PropTypes.shape({
- column_name: PropTypes.string.isRequired,
- type: PropTypes.string,
-});
+export type ColumnType = Pick<Column, 'column_name' | 'type'>;
+
+// For backward compatibility with PropTypes usage - create a placeholder object
+const columnType = {} as any;
+export default columnType;
diff --git a/superset-frontend/src/explore/components/controls/FilterControl/types.ts b/superset-frontend/src/explore/components/controls/FilterControl/types.ts
index d2eebc5bff..869effd23a 100644
--- a/superset-frontend/src/explore/components/controls/FilterControl/types.ts
+++ b/superset-frontend/src/explore/components/controls/FilterControl/types.ts
@@ -26,3 +26,10 @@ export enum Clauses {
Having = 'HAVING',
Where = 'WHERE',
}
+
+// Re-export AdhocFilter types for convenient access
+export type {
+ AdhocFilterSimple,
+ AdhocFilterSql,
+ AdhocFilterType,
+} from './adhocFilterType';
diff --git a/superset-frontend/src/dashboard/util/isDashboardEmpty.test.ts b/superset-frontend/src/explore/components/controls/MetricControl/aggregateOptionType.test.ts
similarity index 51%
copy from superset-frontend/src/dashboard/util/isDashboardEmpty.test.ts
copy to superset-frontend/src/explore/components/controls/MetricControl/aggregateOptionType.test.ts
index 411f0a3f91..3e6001c213 100644
--- a/superset-frontend/src/dashboard/util/isDashboardEmpty.test.ts
+++ b/superset-frontend/src/explore/components/controls/MetricControl/aggregateOptionType.test.ts
@@ -16,27 +16,29 @@
* specific language governing permissions and limitations
* under the License.
*/
-import isDashboardEmpty from 'src/dashboard/util/isDashboardEmpty';
-import getEmptyLayout from 'src/dashboard/util/getEmptyLayout';
-describe('isDashboardEmpty', () => {
- const emptyLayout: object = getEmptyLayout();
- const testLayout: object = {
- ...emptyLayout,
- 'MARKDOWN-IhTGLhyiTd': {
- children: [],
- id: 'MARKDOWN-IhTGLhyiTd',
- meta: { code: 'test me', height: 50, width: 4 },
- parents: ['ROOT_ID', 'GRID_ID', 'ROW-uPjcKNYJQy'],
- type: 'MARKDOWN',
- },
+import { AggregateOption } from './aggregateOptionType';
+
+test('AggregateOption type should enforce aggregate_name as string', () => {
+ // Test that the type can be properly used
+ const validAggregate: AggregateOption = {
+ aggregate_name: 'SUM',
};
- it('should return true for empty dashboard', () => {
- expect(isDashboardEmpty(emptyLayout)).toBe(true);
- });
+ expect(typeof validAggregate.aggregate_name).toBe('string');
+ expect(validAggregate.aggregate_name).toBe('SUM');
+});
+
+test('AggregateOption should work with various aggregate names', () => {
+ const aggregates: AggregateOption[] = [
+ { aggregate_name: 'COUNT' },
+ { aggregate_name: 'AVG' },
+ { aggregate_name: 'MIN' },
+ { aggregate_name: 'MAX' },
+ ];
- it('should return false for non-empty dashboard', () => {
- expect(isDashboardEmpty(testLayout)).toBe(false);
+ aggregates.forEach(aggregate => {
+ expect(typeof aggregate.aggregate_name).toBe('string');
+ expect(aggregate.aggregate_name.length).toBeGreaterThan(0);
});
});
diff --git a/superset-frontend/src/explore/components/controls/FilterControl/columnType.js b/superset-frontend/src/explore/components/controls/MetricControl/aggregateOptionType.ts
similarity index 84%
rename from superset-frontend/src/explore/components/controls/FilterControl/columnType.js
rename to superset-frontend/src/explore/components/controls/MetricControl/aggregateOptionType.ts
index bb136fb58f..c157910ad9 100644
--- a/superset-frontend/src/explore/components/controls/FilterControl/columnType.js
+++ b/superset-frontend/src/explore/components/controls/MetricControl/aggregateOptionType.ts
@@ -16,9 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
-import PropTypes from 'prop-types';
-export default PropTypes.shape({
- column_name: PropTypes.string.isRequired,
- type: PropTypes.string,
-});
+export type { AggregateOption } from './types';
+
+// For backward compatibility with PropTypes usage
+export { AggregateOption as default } from './types';
diff --git a/superset-frontend/src/explore/components/controls/FilterControl/types.ts b/superset-frontend/src/explore/components/controls/MetricControl/columnType.ts
similarity index 76%
copy from superset-frontend/src/explore/components/controls/FilterControl/types.ts
copy to superset-frontend/src/explore/components/controls/MetricControl/columnType.ts
index d2eebc5bff..e453115b0b 100644
--- a/superset-frontend/src/explore/components/controls/FilterControl/types.ts
+++ b/superset-frontend/src/explore/components/controls/MetricControl/columnType.ts
@@ -16,13 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { Column } from '@superset-ui/core';
-export enum ExpressionTypes {
- Simple = 'SIMPLE',
- Sql = 'SQL',
-}
+export type ColumnType = Pick<Column, 'column_name' | 'type'>;
-export enum Clauses {
- Having = 'HAVING',
- Where = 'WHERE',
-}
+// For backward compatibility with PropTypes usage - create a placeholder object
+const columnType = {} as any;
+export default columnType;
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/savedMetricType.js b/superset-frontend/src/explore/components/controls/MetricControl/savedMetricType.js
deleted file mode 100644
index 3c9a987bd1..0000000000
--- a/superset-frontend/src/explore/components/controls/MetricControl/savedMetricType.js
+++ /dev/null
@@ -1,25 +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 PropTypes from 'prop-types';
-
-export default PropTypes.shape({
- metric_name: PropTypes.string,
- verbose_name: PropTypes.string,
- expression: PropTypes.string,
-});
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/savedMetricType.test.ts b/superset-frontend/src/explore/components/controls/MetricControl/savedMetricType.test.ts
new file mode 100644
index 0000000000..91fc77f803
--- /dev/null
+++ b/superset-frontend/src/explore/components/controls/MetricControl/savedMetricType.test.ts
@@ -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 { savedMetricType } from './savedMetricType';
+
+test('savedMetricType exports the correct type structure', () => {
+ // Type assertion test - if this compiles without errors,
+ // the type structure is correct
+ const validMetric: savedMetricType = {
+ metric_name: 'test_metric',
+ verbose_name: 'Test Metric',
+ expression: 'SUM(column)',
+ };
+
+ expect(validMetric.metric_name).toBe('test_metric');
+ expect(validMetric.verbose_name).toBe('Test Metric');
+ expect(validMetric.expression).toBe('SUM(column)');
+});
+
+test('savedMetricType allows optional verbose_name', () => {
+ // Test that verbose_name is optional
+ const validMetricMinimal: savedMetricType = {
+ metric_name: 'minimal_metric',
+ expression: 'COUNT(*)',
+ };
+
+ expect(validMetricMinimal.metric_name).toBe('minimal_metric');
+ expect(validMetricMinimal.expression).toBe('COUNT(*)');
+ expect(validMetricMinimal.verbose_name).toBeUndefined();
+});
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/aggregateOptionType.js b/superset-frontend/src/explore/components/controls/MetricControl/savedMetricType.ts
similarity index 84%
rename from superset-frontend/src/explore/components/controls/MetricControl/aggregateOptionType.js
rename to superset-frontend/src/explore/components/controls/MetricControl/savedMetricType.ts
index 0075d2b74a..4ea5b3409a 100644
--- a/superset-frontend/src/explore/components/controls/MetricControl/aggregateOptionType.js
+++ b/superset-frontend/src/explore/components/controls/MetricControl/savedMetricType.ts
@@ -16,8 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import PropTypes from 'prop-types';
+export { savedMetricType } from './types';
-export default PropTypes.shape({
- aggregate_name: PropTypes.string.isRequired,
-});
+// For backward compatibility with PropTypes usage
+export { savedMetricType as default } from './types';
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/types.ts b/superset-frontend/src/explore/components/controls/MetricControl/types.ts
index f8f52fcb33..7e16810641 100644
--- a/superset-frontend/src/explore/components/controls/MetricControl/types.ts
+++ b/superset-frontend/src/explore/components/controls/MetricControl/types.ts
@@ -21,3 +21,7 @@ export type savedMetricType = {
verbose_name?: string;
expression: string;
};
+
+export interface AggregateOption {
+ aggregate_name: string;
+}
diff --git a/superset-frontend/src/explore/controlPanels/Separator.test.ts b/superset-frontend/src/explore/controlPanels/Separator.test.ts
new file mode 100644
index 0000000000..42212c4927
--- /dev/null
+++ b/superset-frontend/src/explore/controlPanels/Separator.test.ts
@@ -0,0 +1,76 @@
+/**
+ * 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 type {
+ ControlPanelState,
+ ControlState,
+} from '@superset-ui/chart-controls';
+import Separator from './Separator';
+
+function getCodeControlMapStateToProps() {
+ const sections =
+ (Separator.controlPanelSections as unknown as Array<{
+ controlSetRows?: Array<
+ Array<{
+ name?: string;
+ config?: {
+ mapStateToProps?: (s: Partial<ControlPanelState>) => {
+ language: string;
+ };
+ };
+ }>
+ >;
+ }>) || [];
+
+ const codeControl = sections
+ .flatMap(s => s.controlSetRows || [])
+ .flatMap(r => r)
+ .find(i => i?.name === 'code') as unknown as {
+ config: {
+ mapStateToProps: (s: Partial<ControlPanelState>) => { language: string };
+ };
+ };
+
+ if (!codeControl || !codeControl.config?.mapStateToProps) {
+ throw new Error('Code control configuration not found');
+ }
+ return codeControl.config.mapStateToProps;
+}
+
+describe('Separator control panel config', () => {
+ it('defaults language to markdown when markup_type is missing', () => {
+ const mapStateToProps = getCodeControlMapStateToProps();
+ const state: Partial<ControlPanelState> = {};
+ const result = mapStateToProps(state);
+ expect(result.language).toBe('markdown');
+ });
+
+ it('uses markup_type value when provided', () => {
+ const mapStateToProps = getCodeControlMapStateToProps();
+ const state: Partial<ControlPanelState> = {
+ controls: {
+ // minimal mock for the control used in mapStateToProps
+ markup_type: { value: 'html' } as Partial<
+ ControlState<'SelectControl'>
+ > as ControlState<'SelectControl'>,
+ },
+ };
+ const result = mapStateToProps(state);
+ expect(result.language).toBe('html');
+ });
+});
diff --git a/superset-frontend/src/explore/controlPanels/Separator.js b/superset-frontend/src/explore/controlPanels/Separator.ts
similarity index 79%
rename from superset-frontend/src/explore/controlPanels/Separator.js
rename to superset-frontend/src/explore/controlPanels/Separator.ts
index d49d389b90..170d09c997 100644
--- a/superset-frontend/src/explore/controlPanels/Separator.js
+++ b/superset-frontend/src/explore/controlPanels/Separator.ts
@@ -17,9 +17,13 @@
* under the License.
*/
import { t, validateNonEmpty } from '@superset-ui/core';
+import type {
+ ControlPanelConfig,
+ ControlPanelState,
+} from '@superset-ui/chart-controls';
import { formatSelectOptions } from 'src/explore/exploreUtils';
-export default {
+const config: ControlPanelConfig = {
controlPanelSections: [
{
label: t('Code'),
@@ -45,12 +49,15 @@ export default {
type: 'TextAreaControl',
label: t('Code'),
description: t('Put your code here'),
- mapStateToProps: state => ({
- language:
- state.controls && state.controls.markup_type
- ? state.controls.markup_type.value
- : 'markdown',
- }),
+ mapStateToProps: (state: Partial<ControlPanelState>) => {
+ const languageValue = state.controls?.markup_type?.value;
+ return {
+ language:
+ typeof languageValue === 'string'
+ ? languageValue
+ : 'markdown',
+ };
+ },
default: '',
},
},
@@ -74,3 +81,5 @@ export default {
},
},
};
+
+export default config;
diff --git a/superset-frontend/src/explore/controlPanels/timeGrainSqlaAnimationOverrides.js b/superset-frontend/src/explore/controlPanels/timeGrainSqlaAnimationOverrides.ts
similarity index 64%
rename from superset-frontend/src/explore/controlPanels/timeGrainSqlaAnimationOverrides.js
rename to superset-frontend/src/explore/controlPanels/timeGrainSqlaAnimationOverrides.ts
index b7338e10cf..1aef2dbf67 100644
--- a/superset-frontend/src/explore/controlPanels/timeGrainSqlaAnimationOverrides.js
+++ b/superset-frontend/src/explore/controlPanels/timeGrainSqlaAnimationOverrides.ts
@@ -16,11 +16,20 @@
* specific language governing permissions and limitations
* under the License.
*/
+import type { ControlPanelState, Dataset } from '@superset-ui/chart-controls';
+
+interface TimeGrainOverrideState {
+ choices: [string, string][] | null;
+}
+
export default {
default: null,
- mapStateToProps: state => ({
- choices: state.datasource
- ? state.datasource.time_grain_sqla.filter(o => o[0] !== null)
- : null,
+ mapStateToProps: (state: ControlPanelState): TimeGrainOverrideState => ({
+ choices:
+ state.datasource && 'time_grain_sqla' in state.datasource
+ ? ((state.datasource as Dataset).time_grain_sqla?.filter(
+ (o: [string, string]) => o[0] !== null,
+ ) ?? null)
+ : null,
}),
};
diff --git a/superset-frontend/src/types/TagType.ts b/superset-frontend/src/types/TagType.ts
index 04db0c18e6..e7165234a3 100644
--- a/superset-frontend/src/types/TagType.ts
+++ b/superset-frontend/src/types/TagType.ts
@@ -39,5 +39,3 @@ export interface TagType {
css?: SerializedStyles;
closable?: boolean;
}
-
-export default TagType;
diff --git a/superset-frontend/src/utils/DebouncedMessageQueue.test.ts b/superset-frontend/src/utils/DebouncedMessageQueue.test.ts
new file mode 100644
index 0000000000..2631d7c20c
--- /dev/null
+++ b/superset-frontend/src/utils/DebouncedMessageQueue.test.ts
@@ -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 DebouncedMessageQueue from './DebouncedMessageQueue';
+
+describe('DebouncedMessageQueue', () => {
+ it('should create a queue with default options', () => {
+ const queue = new DebouncedMessageQueue();
+ expect(queue).toBeDefined();
+ expect(queue.trigger).toBeInstanceOf(Function);
+ });
+
+ it('should accept custom configuration options', () => {
+ const mockCallback = jest.fn();
+ const queue = new DebouncedMessageQueue({
+ callback: mockCallback,
+ sizeThreshold: 500,
+ delayThreshold: 2000,
+ });
+ expect(queue).toBeDefined();
+ });
+
+ it('should append items to the queue', () => {
+ const mockCallback = jest.fn();
+ const queue = new DebouncedMessageQueue({ callback: mockCallback });
+
+ const testEvent = { id: 1, message: 'test' };
+ queue.append(testEvent);
+
+ // Verify the append method doesn't throw
+ expect(() => queue.append(testEvent)).not.toThrow();
+ });
+
+ it('should handle generic types properly', () => {
+ interface TestEvent {
+ id: number;
+ data: string;
+ }
+
+ const mockCallback = jest.fn();
+ const queue = new DebouncedMessageQueue<TestEvent>({
+ callback: mockCallback,
+ });
+
+ const testEvent: TestEvent = { id: 1, data: 'test' };
+ queue.append(testEvent);
+
+ expect(() => queue.append(testEvent)).not.toThrow();
+ });
+});
diff --git a/superset-frontend/src/utils/DebouncedMessageQueue.js b/superset-frontend/src/utils/DebouncedMessageQueue.ts
similarity index 69%
rename from superset-frontend/src/utils/DebouncedMessageQueue.js
rename to superset-frontend/src/utils/DebouncedMessageQueue.ts
index 6fd6c5b778..99b50650ae 100644
--- a/superset-frontend/src/utils/DebouncedMessageQueue.js
+++ b/superset-frontend/src/utils/DebouncedMessageQueue.ts
@@ -18,26 +18,45 @@
*/
import { debounce } from 'lodash';
-class DebouncedMessageQueue {
+export interface DebouncedMessageQueueOptions<T> {
+ callback?: (events: T[]) => void;
+ sizeThreshold?: number;
+ delayThreshold?: number;
+}
+
+class DebouncedMessageQueue<T = Record<string, unknown>> {
+ private queue: T[];
+
+ private readonly sizeThreshold: number;
+
+ private readonly delayThreshold: number;
+
+ private readonly callback: (events: T[]) => void;
+
+ public readonly trigger: () => void;
+
constructor({
callback = () => {},
sizeThreshold = 1000,
delayThreshold = 1000,
- }) {
+ }: DebouncedMessageQueueOptions<T> = {}) {
this.queue = [];
this.sizeThreshold = sizeThreshold;
this.delayThreshold = delayThreshold;
-
- this.trigger = debounce(this.trigger.bind(this), this.delayThreshold);
this.callback = callback;
+
+ this.trigger = debounce(
+ this.triggerInternal.bind(this),
+ this.delayThreshold,
+ );
}
- append(eventData) {
+ append(eventData: T): void {
this.queue.push(eventData);
this.trigger();
}
- trigger() {
+ private triggerInternal(): void {
if (this.queue.length > 0) {
const events = this.queue.splice(0, this.sizeThreshold);
this.callback.call(null, events);
diff --git a/superset-frontend/src/utils/datasourceUtils.js b/superset-frontend/src/utils/datasourceUtils.js
deleted file mode 100644
index 144a3ff88b..0000000000
--- a/superset-frontend/src/utils/datasourceUtils.js
+++ /dev/null
@@ -1,27 +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.
- */
-export const getDatasourceAsSaveableDataset = source => ({
- columns: source.columns,
- name: source?.datasource_name || source?.name || 'Untitled',
- dbId: source?.database?.id || source?.dbId,
- sql: source?.sql || '',
- catalog: source?.catalog,
- schema: source?.schema,
- templateParams: source?.templateParams,
-});
diff --git a/superset-frontend/src/utils/datasourceUtils.test.ts b/superset-frontend/src/utils/datasourceUtils.test.ts
new file mode 100644
index 0000000000..a935a71d7e
--- /dev/null
+++ b/superset-frontend/src/utils/datasourceUtils.test.ts
@@ -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.
+ */
+import { ColumnMeta, Metric } from '@superset-ui/chart-controls';
+import { DatasourceType } from '@superset-ui/core';
+import type { Datasource } from 'src/explore/types';
+import type { QueryEditor } from 'src/SqlLab/types';
+import { getDatasourceAsSaveableDataset } from './datasourceUtils';
+
+const mockColumnMeta: ColumnMeta = {
+ column_name: 'test_column',
+ type: 'VARCHAR',
+ is_dttm: false,
+ verbose_name: 'Test Column',
+ description: 'A test column',
+ expression: '',
+ filterable: true,
+ groupby: true,
+ id: 1,
+ type_generic: 1,
+ python_date_format: null,
+ optionName: 'test_column',
+};
+
+const mockMetric: Metric = {
+ id: 1,
+ uuid: 'metric-1',
+ metric_name: 'count',
+ verbose_name: 'Count',
+ description: 'Count of records',
+ d3format: null,
+ currency: null,
+ warning_text: null,
+ // optionName removed - not part of Metric interface
+};
+
+const mockDatasource: Datasource = {
+ id: 1,
+ type: DatasourceType.Table,
+ columns: [mockColumnMeta],
+ metrics: [mockMetric],
+ column_formats: {},
+ verbose_map: {},
+ main_dttm_col: '',
+ order_by_choices: null,
+ datasource_name: 'Test Datasource',
+ name: 'test_table',
+ catalog: 'test_catalog',
+ schema: 'test_schema',
+ description: 'Test datasource',
+ database: {
+ id: 123,
+ database_name: 'test_db',
+ sqlalchemy_uri: 'postgresql://test',
+ },
+};
+
+const mockQueryEditor: QueryEditor = {
+ id: 'query-1',
+ immutableId: 'immutable-query-1',
+ version: 1,
+ name: 'Test Query',
+ sql: 'SELECT * FROM users',
+ dbId: 456,
+ autorun: false,
+ remoteId: null,
+ catalog: 'prod_catalog',
+ schema: 'public',
+ templateParams: '{"param1": "value1"}',
+};
+
+describe('getDatasourceAsSaveableDataset', () => {
+ test('should convert Datasource object correctly', () => {
+ const result = getDatasourceAsSaveableDataset(mockDatasource);
+
+ expect(result).toEqual({
+ columns: [mockColumnMeta],
+ name: 'Test Datasource',
+ dbId: 123,
+ sql: '',
+ catalog: 'test_catalog',
+ schema: 'test_schema',
+ templateParams: null,
+ });
+ });
+
+ test('should convert QueryEditor object correctly', () => {
+ const queryWithColumns = { ...mockQueryEditor, columns: [mockColumnMeta] };
+ const result = getDatasourceAsSaveableDataset(queryWithColumns);
+
+ expect(result).toEqual({
+ columns: [mockColumnMeta],
+ name: 'Test Query',
+ dbId: 456,
+ sql: 'SELECT * FROM users',
+ catalog: 'prod_catalog',
+ schema: 'public',
+ templateParams: '{"param1": "value1"}',
+ });
+ });
+
+ test('should handle datasource with fallback name from name property', () => {
+ const datasourceWithoutDatasourceName: Datasource = {
+ ...mockDatasource,
+ datasource_name: null,
+ name: 'fallback_name',
+ };
+
+ const result = getDatasourceAsSaveableDataset(
+ datasourceWithoutDatasourceName,
+ );
+
+ expect(result.name).toBe('fallback_name');
+ });
+
+ test('should use "Untitled" as fallback when no name is available', () => {
+ const datasourceWithoutName: Datasource = {
+ ...mockDatasource,
+ datasource_name: null,
+ name: '',
+ };
+
+ const result = getDatasourceAsSaveableDataset(datasourceWithoutName);
+
+ expect(result.name).toBe('Untitled');
+ });
+
+ test('should handle missing database object', () => {
+ const datasourceWithoutDatabase: Datasource = {
+ ...mockDatasource,
+ database: undefined,
+ };
+
+ const result = getDatasourceAsSaveableDataset(datasourceWithoutDatabase);
+
+ expect(result.dbId).toBe(0);
+ });
+
+ test('should handle QueryEditor with missing dbId', () => {
+ const queryEditorWithoutDbId: QueryEditor = {
+ ...mockQueryEditor,
+ dbId: undefined,
+ };
+
+ const result = getDatasourceAsSaveableDataset(queryEditorWithoutDbId);
+
+ expect(result.dbId).toBe(0);
+ });
+
+ test('should handle QueryEditor without sql property', () => {
+ const queryEditorWithoutSql: QueryEditor = {
+ ...mockQueryEditor,
+ sql: '',
+ };
+
+ const result = getDatasourceAsSaveableDataset(queryEditorWithoutSql);
+
+ expect(result.sql).toBe('');
+ });
+
+ test('should handle null values for optional properties', () => {
+ const minimalQueryEditor: QueryEditor = {
+ ...mockQueryEditor,
+ catalog: null,
+ schema: undefined,
+ templateParams: '',
+ };
+
+ const result = getDatasourceAsSaveableDataset(minimalQueryEditor);
+
+ expect(result.catalog).toBe(null);
+ expect(result.schema).toBe(null);
+ expect(result.templateParams).toBe(null);
+ });
+});
diff --git a/superset-frontend/src/utils/datasourceUtils.ts b/superset-frontend/src/utils/datasourceUtils.ts
new file mode 100644
index 0000000000..3f395f9a82
--- /dev/null
+++ b/superset-frontend/src/utils/datasourceUtils.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 { ColumnMeta } from '@superset-ui/chart-controls';
+import type { ISaveableDatasource } from 'src/SqlLab/components/SaveDatasetModal';
+
+// Flexible interface that captures what this function actually needs to work
+// This allows it to accept various datasource-like objects from different parts of the codebase
+interface DatasourceInput {
+ // Common properties that all datasource-like objects should have
+ name?: string | null; // Allow null for compatibility
+
+ // Optional properties that may exist on different datasource variants
+ datasource_name?: string | null; // Allow null for compatibility
+ columns?: any[]; // Can be ColumnMeta[], DatasourcePanelColumn[], ISimpleColumn[], etc.
+ database?: { id?: number };
+ dbId?: number;
+ sql?: string | null; // Allow null for compatibility
+ catalog?: string | null;
+ schema?: string | null;
+ templateParams?: string;
+
+ // Type discriminator for QueryEditor-like objects
+ version?: number;
+}
+
+export const getDatasourceAsSaveableDataset = (
+ source: DatasourceInput,
+): ISaveableDatasource => {
+ // Type guard: QueryEditor-like objects have version property
+ const isQueryEditorLike = typeof source.version === 'number';
+
+ return {
+ columns: (source.columns as ColumnMeta[]) || [],
+ name: source.datasource_name || source.name || 'Untitled',
+ dbId: source.database?.id || source.dbId || 0,
+ sql: source.sql || '',
+ catalog: source.catalog || null,
+ schema: source.schema || null,
+ templateParams: isQueryEditorLike ? source.templateParams || null : null,
+ };
+};
diff --git a/superset-frontend/src/utils/getControlsForVizType.test.ts b/superset-frontend/src/utils/getControlsForVizType.test.ts
new file mode 100644
index 0000000000..cf94610751
--- /dev/null
+++ b/superset-frontend/src/utils/getControlsForVizType.test.ts
@@ -0,0 +1,101 @@
+/**
+ * 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 { getChartControlPanelRegistry, JsonObject } from '@superset-ui/core';
+import getControlsForVizType from 'src/utils/getControlsForVizType';
+
+const fakePluginControls: JsonObject = {
+ controlPanelSections: [
+ {
+ label: 'Fake Control Panel Sections',
+ expanded: true,
+ controlSetRows: [
+ [
+ {
+ name: 'y_axis_bounds',
+ config: {
+ type: 'BoundsControl',
+ label: 'Value bounds',
+ default: [null, null],
+ description: 'Value bounds for the y axis',
+ },
+ },
+ ],
+ [
+ {
+ name: 'adhoc_filters',
+ config: {
+ type: 'AdhocFilterControl',
+ label: 'Fake Filters',
+ default: null,
+ },
+ },
+ ],
+ ],
+ },
+ {
+ label: 'Fake Control Panel Sections 2',
+ expanded: true,
+ controlSetRows: [
+ [
+ {
+ name: 'column_collection',
+ config: {
+ type: 'CollectionControl',
+ label: 'Fake Collection Control',
+ },
+ },
+ ],
+ ],
+ },
+ ],
+};
+
+describe('getControlsForVizType', () => {
+ beforeEach(() => {
+ getChartControlPanelRegistry().registerValue(
+ 'chart_controls_inventory_fake',
+ fakePluginControls,
+ );
+ });
+
+ it('returns a map of the controls', () => {
+ expect(
+ JSON.stringify(getControlsForVizType('chart_controls_inventory_fake')),
+ ).toEqual(
+ JSON.stringify({
+ y_axis_bounds: {
+ type: 'BoundsControl',
+ label: 'Value bounds',
+ default: [null, null],
+ description: 'Value bounds for the y axis',
+ },
+ adhoc_filters: {
+ type: 'AdhocFilterControl',
+ label: 'Fake Filters',
+ default: null,
+ },
+ column_collection: {
+ type: 'CollectionControl',
+ label: 'Fake Collection Control',
+ },
+ }),
+ );
+ });
+});
diff --git a/superset-frontend/src/utils/getControlsForVizType.ts b/superset-frontend/src/utils/getControlsForVizType.ts
new file mode 100644
index 0000000000..4fff9a06f1
--- /dev/null
+++ b/superset-frontend/src/utils/getControlsForVizType.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 memoizeOne from 'memoize-one';
+import { isControlPanelSectionConfig } from '@superset-ui/chart-controls';
+import { getChartControlPanelRegistry, JsonObject } from '@superset-ui/core';
+import type { ControlMap } from 'src/components/AlteredSliceTag/types';
+import { controls } from '../explore/controls';
+
+const memoizedControls = memoizeOne(
+ (vizType: string, controlPanel: JsonObject | undefined): ControlMap => {
+ const controlsMap: ControlMap = {};
+ if (!controlPanel) return controlsMap;
+
+ const sections = controlPanel.controlPanelSections || [];
+ (Array.isArray(sections) ? sections : [])
+ .filter(isControlPanelSectionConfig)
+ .forEach(section => {
+ if (section.controlSetRows && Array.isArray(section.controlSetRows)) {
+ section.controlSetRows.forEach(row => {
+ if (Array.isArray(row)) {
+ row.forEach(control => {
+ if (!control) return;
+ if (typeof control === 'string') {
+ // For now, we have to look in controls.jsx to get the config for some controls.
+ // Once everything is migrated out, delete this if statement.
+ const controlConfig = (controls as any)[control];
+ if (controlConfig) {
+ controlsMap[control] = controlConfig;
+ }
+ } else if (
+ typeof control === 'object' &&
+ control &&
+ 'name' in control &&
+ 'config' in control
+ ) {
+ // condition needed because there are elements, e.g. <hr /> in some control configs (I'm looking at you, FilterBox!)
+ const controlObj = control as {
+ name: string;
+ config: JsonObject;
+ };
+ controlsMap[controlObj.name] = controlObj.config;
+ }
+ });
+ }
+ });
+ }
+ });
+ return controlsMap;
+ },
+);
+
+const getControlsForVizType = (vizType: string): ControlMap => {
+ const controlPanel = getChartControlPanelRegistry().get(vizType);
+ return memoizedControls(vizType, controlPanel);
+};
+
+export default getControlsForVizType;
diff --git a/superset-frontend/src/utils/hostNamesConfig.test.ts b/superset-frontend/src/utils/hostNamesConfig.test.ts
new file mode 100644
index 0000000000..dcfcf3e2a5
--- /dev/null
+++ b/superset-frontend/src/utils/hostNamesConfig.test.ts
@@ -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.
+ */
+
+import { availableDomains, allowCrossDomain } from './hostNamesConfig';
+
+describe('hostNamesConfig', () => {
+ beforeEach(() => {
+ // Reset DOM
+ document.body.innerHTML = '';
+
+ // Mock window.location
+ Object.defineProperty(window, 'location', {
+ value: {
+ hostname: 'localhost',
+ search: '',
+ },
+ writable: true,
+ });
+ });
+
+ test('should export availableDomains as array of strings', () => {
+ expect(Array.isArray(availableDomains)).toBe(true);
+ availableDomains.forEach(domain => {
+ expect(typeof domain).toBe('string');
+ });
+ });
+
+ test('should export allowCrossDomain as boolean', () => {
+ expect(typeof allowCrossDomain).toBe('boolean');
+ });
+
+ test('should determine allowCrossDomain based on availableDomains length', () => {
+ const expectedValue = availableDomains.length > 1;
+ expect(allowCrossDomain).toBe(expectedValue);
+ });
+
+ test('availableDomains should contain at least the current hostname', () => {
+ // Since we're testing the already computed values, we check they contain localhost
+ // or the configuration returns empty array if app container is missing
+ expect(availableDomains.length >= 0).toBe(true);
+ });
+});
diff --git a/superset-frontend/src/utils/hostNamesConfig.js b/superset-frontend/src/utils/hostNamesConfig.ts
similarity index 85%
rename from superset-frontend/src/utils/hostNamesConfig.js
rename to superset-frontend/src/utils/hostNamesConfig.ts
index 2ba9b7555c..354e8c0fe5 100644
--- a/superset-frontend/src/utils/hostNamesConfig.js
+++ b/superset-frontend/src/utils/hostNamesConfig.ts
@@ -19,7 +19,7 @@
import { initFeatureFlags } from '@superset-ui/core';
import getBootstrapData from './getBootstrapData';
-function getDomainsConfig() {
+function getDomainsConfig(): string[] {
const appContainer = document.getElementById('app');
if (!appContainer) {
return [];
@@ -42,13 +42,15 @@ function getDomainsConfig() {
initFeatureFlags(bootstrapData.common.feature_flags);
if (bootstrapData?.common?.conf?.SUPERSET_WEBSERVER_DOMAINS) {
- bootstrapData.common.conf.SUPERSET_WEBSERVER_DOMAINS.forEach(hostName => {
+ const domains = bootstrapData.common.conf
+ .SUPERSET_WEBSERVER_DOMAINS as string[];
+ domains.forEach((hostName: string) => {
availableDomains.add(hostName);
});
}
return Array.from(availableDomains);
}
-export const availableDomains = getDomainsConfig();
+export const availableDomains: string[] = getDomainsConfig();
-export const allowCrossDomain = availableDomains.length > 1;
+export const allowCrossDomain: boolean = availableDomains.length > 1;
diff --git a/superset-frontend/src/utils/reducerUtils.test.ts b/superset-frontend/src/utils/reducerUtils.test.ts
new file mode 100644
index 0000000000..a342ff274f
--- /dev/null
+++ b/superset-frontend/src/utils/reducerUtils.test.ts
@@ -0,0 +1,129 @@
+/**
+ * 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 {
+ addToObject,
+ alterInObject,
+ alterInArr,
+ removeFromArr,
+ addToArr,
+} from './reducerUtils';
+
+interface TestItem {
+ id?: string;
+ name: string;
+ value: number;
+}
+
+const mockState = {
+ objects: {
+ 'item-1': { id: 'item-1', name: 'Item 1', value: 10 },
+ 'item-2': { id: 'item-2', name: 'Item 2', value: 20 },
+ },
+ items: [
+ { id: 'item-1', name: 'Item 1', value: 10 },
+ { id: 'item-2', name: 'Item 2', value: 20 },
+ ],
+};
+
+test('addToObject adds new object to state with generated id', () => {
+ const newItem: TestItem = { name: 'New Item', value: 30 };
+ const result = addToObject(mockState, 'objects', newItem);
+
+ expect(result).not.toBe(mockState);
+ expect(result.objects).not.toBe(mockState.objects);
+ expect(Object.keys(result.objects)).toHaveLength(3);
+
+ const addedItems = Object.values(result.objects).filter(
+ item => (item as TestItem).name === 'New Item',
+ );
+ expect(addedItems).toHaveLength(1);
+ expect((addedItems[0] as TestItem).id).toBeTruthy();
+});
+
+test('addToObject adds new object with existing id', () => {
+ const newItem: TestItem = { id: 'item-3', name: 'Item 3', value: 30 };
+ const result = addToObject(mockState, 'objects', newItem);
+
+ expect(result.objects['item-3']).toEqual(newItem);
+});
+
+test('alterInObject modifies existing object', () => {
+ const targetItem: TestItem = { id: 'item-1', name: 'Item 1', value: 10 };
+ const alterations = { value: 15 };
+ const result = alterInObject(mockState, 'objects', targetItem, alterations);
+
+ expect(result.objects['item-1'].value).toBe(15);
+ expect(result.objects['item-1'].name).toBe('Item 1');
+ expect(result.objects['item-2']).toBe(mockState.objects['item-2']);
+});
+
+test('alterInArr modifies existing array item', () => {
+ const targetItem: TestItem = { id: 'item-1', name: 'Item 1', value: 10 };
+ const alterations = { value: 15 };
+ const result = alterInArr(mockState, 'items', targetItem, alterations);
+
+ expect(result.items[0].value).toBe(15);
+ expect(result.items[0].name).toBe('Item 1');
+ expect(result.items[1]).toBe(mockState.items[1]);
+});
+
+test('removeFromArr removes item from array', () => {
+ const targetItem: TestItem = { id: 'item-1', name: 'Item 1', value: 10 };
+ const result = removeFromArr(mockState, 'items', targetItem);
+
+ expect(result.items).toHaveLength(1);
+ expect(result.items[0].id).toBe('item-2');
+});
+
+test('removeFromArr with custom idKey', () => {
+ const stateWithCustomKey = {
+ items: [
+ { customId: 'a', name: 'Item A' },
+ { customId: 'b', name: 'Item B' },
+ ],
+ };
+ const targetItem = { customId: 'a', name: 'Item A' };
+ const result = removeFromArr(
+ stateWithCustomKey,
+ 'items',
+ targetItem,
+ 'customId',
+ );
+
+ expect(result.items).toHaveLength(1);
+ expect(result.items[0].customId).toBe('b');
+});
+
+test('addToArr adds new item to array with generated id', () => {
+ const newItem: TestItem = { name: 'New Item', value: 30 };
+ const result = addToArr(mockState, 'items', newItem);
+
+ expect(result.items).toHaveLength(3);
+ expect(result.items[2].name).toBe('New Item');
+ expect(result.items[2].id).toBeTruthy();
+});
+
+test('addToArr adds new item with existing id', () => {
+ const newItem: TestItem = { id: 'item-3', name: 'Item 3', value: 30 };
+ const result = addToArr(mockState, 'items', newItem);
+
+ expect(result.items).toHaveLength(3);
+ expect(result.items[2]).toEqual(newItem);
+});
diff --git a/superset-frontend/src/utils/reducerUtils.ts b/superset-frontend/src/utils/reducerUtils.ts
new file mode 100644
index 0000000000..9a8eb9d4c0
--- /dev/null
+++ b/superset-frontend/src/utils/reducerUtils.ts
@@ -0,0 +1,107 @@
+/**
+ * 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 { nanoid } from 'nanoid';
+
+interface ObjectWithId {
+ id?: string;
+ [key: string]: any;
+}
+
+interface StateWithObject {
+ [key: string]: { [id: string]: ObjectWithId } | any;
+}
+
+interface StateWithArray {
+ [key: string]: ObjectWithId[] | any;
+}
+
+export function addToObject<T extends ObjectWithId>(
+ state: StateWithObject,
+ arrKey: string,
+ obj: T,
+): StateWithObject {
+ const newObject = { ...state[arrKey] };
+ const copiedObject = { ...obj };
+
+ if (!copiedObject.id) {
+ copiedObject.id = nanoid();
+ }
+ newObject[copiedObject.id] = copiedObject;
+ return { ...state, [arrKey]: newObject };
+}
+
+export function alterInObject<T extends ObjectWithId>(
+ state: StateWithObject,
+ arrKey: string,
+ obj: T,
+ alterations: Partial<T>,
+): StateWithObject {
+ const newObject = { ...state[arrKey] };
+ newObject[obj.id!] = { ...newObject[obj.id!], ...alterations };
+ return { ...state, [arrKey]: newObject };
+}
+
+export function alterInArr<T extends ObjectWithId>(
+ state: StateWithArray,
+ arrKey: string,
+ obj: T,
+ alterations: Partial<T>,
+): StateWithArray {
+ // Finds an item in an array in the state and replaces it with a
+ // new object with an altered property
+ const idKey = 'id';
+ const newArr: T[] = [];
+ state[arrKey].forEach((arrItem: T) => {
+ if (obj[idKey] === arrItem[idKey]) {
+ newArr.push({ ...arrItem, ...alterations });
+ } else {
+ newArr.push(arrItem);
+ }
+ });
+ return { ...state, [arrKey]: newArr };
+}
+
+export function removeFromArr<T extends ObjectWithId>(
+ state: StateWithArray,
+ arrKey: string,
+ obj: T,
+ idKey = 'id',
+): StateWithArray {
+ const newArr: T[] = [];
+ state[arrKey].forEach((arrItem: T) => {
+ if (!(obj[idKey as keyof T] === arrItem[idKey as keyof T])) {
+ newArr.push(arrItem);
+ }
+ });
+ return { ...state, [arrKey]: newArr };
+}
+
+export function addToArr<T extends ObjectWithId>(
+ state: StateWithArray,
+ arrKey: string,
+ obj: T,
+): StateWithArray {
+ const newObj = { ...obj };
+ if (!newObj.id) {
+ newObj.id = nanoid();
+ }
+ const newState: { [key: string]: T[] } = {};
+ newState[arrKey] = [...state[arrKey], newObj];
+ return { ...state, ...newState };
+}