Compare commits

..

1 Commits

Author SHA1 Message Date
Elizabeth Thompson
98212189b8 fix(docker): bind webpack dev server to all interfaces
Change WEBPACK_DEVSERVER_HOST from 127.0.0.1 to 0.0.0.0 to make the
frontend accessible from outside the Docker container. This fixes an
issue where the frontend was only accessible from inside the container.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-23 17:03:49 -07:00
91 changed files with 647 additions and 4713 deletions

View File

@@ -1,10 +0,0 @@
# 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.

View File

@@ -1,684 +0,0 @@
# 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.

View File

@@ -1,199 +0,0 @@
# 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

View File

@@ -1,76 +0,0 @@
# 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/`*

View File

@@ -163,7 +163,7 @@ services:
# configuring the dev-server to use the host.docker.internal to connect to the backend
superset: "http://superset-light:8088"
# Webpack dev server configuration
WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-127.0.0.1}"
WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-0.0.0.0}"
WEBPACK_DEVSERVER_PORT: "${WEBPACK_DEVSERVER_PORT:-9000}"
ports:
- "${NODE_PORT:-9001}:9000" # Parameterized port, accessible on all interfaces

View File

@@ -184,10 +184,8 @@ services:
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
# configuring the dev-server to use the host.docker.internal to connect to the backend
superset: "http://superset:8088"
WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-0.0.0.0}"
WEBPACK_DEVSERVER_PORT: "${WEBPACK_DEVSERVER_PORT:-9000}"
ports:
- "9000:9000" # exposing the dynamic webpack dev server
- "127.0.0.1:9000:9000" # exposing the dynamic webpack dev server
container_name: superset_node
command: ["/app/docker/docker-frontend.sh"]
env_file:

View File

@@ -16,11 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
Popover,
type PopoverProps,
SQLEditor,
} from '@superset-ui/core/components';
import { useEffect, useState } from 'react';
import { Popover, type PopoverProps } from '@superset-ui/core/components';
import type ReactAce from 'react-ace';
import { CalculatorOutlined } from '@ant-design/icons';
import { css, styled, useTheme, t } from '@superset-ui/core';
@@ -37,10 +35,24 @@ const StyledCalculatorIcon = styled(CalculatorOutlined)`
export const SQLPopover = (props: PopoverProps & { sqlExpression: string }) => {
const theme = useTheme();
const [AceEditor, setAceEditor] = useState<typeof ReactAce | null>(null);
useEffect(() => {
Promise.all([
import('react-ace'),
import('ace-builds/src-min-noconflict/mode-sql'),
]).then(([reactAceModule]) => {
setAceEditor(() => reactAceModule.default);
});
}, []);
if (!AceEditor) {
return null;
}
return (
<Popover
content={
<SQLEditor
<AceEditor
mode="sql"
value={props.sqlExpression}
editorProps={{ $blockScrolling: true }}
setOptions={{
@@ -53,6 +65,7 @@ export const SQLPopover = (props: PopoverProps & { sqlExpression: string }) => {
wrapEnabled
style={{
border: `1px solid ${theme.colorBorder}`,
background: theme.colorPrimaryBg,
maxWidth: theme.sizeUnit * 100,
}}
/>

View File

@@ -17,14 +17,11 @@
* under the License.
*/
export default function roundDecimal(
number: number,
precision?: number,
): number {
let roundedNumber: number;
export default function roundDecimal(number, precision) {
let roundedNumber;
let p = precision;
if (precision) {
const p = 10 ** precision;
roundedNumber = Math.round(number * p) / p;
roundedNumber = Math.round(number * (p = 10 ** p)) / p;
} else {
roundedNumber = Math.round(number);
}

View File

@@ -258,36 +258,20 @@ 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: {
chartId: 0,
height: 0,
uuid: '',
width: 0,
},
meta: {},
},
[DASHBOARD_HEADER_ID]: {
type: DASHBOARD_HEADER_TYPE,
id: DASHBOARD_HEADER_ID,
children: [],
meta: {
text: 'New dashboard',
chartId: 0,
height: 0,
uuid: '',
width: 0,
},
},
@@ -301,7 +285,6 @@ export const dashboardWithFilter = {
...newComponentFactory(CHART_TYPE),
id: 'FILTER_ID',
meta: {
...newComponentFactory(CHART_TYPE).meta,
chartId: filterId,
width: 3,
height: 10,
@@ -313,7 +296,6 @@ export const dashboardWithFilter = {
...newComponentFactory(CHART_TYPE),
id: 'CHART_ID',
meta: {
...newComponentFactory(CHART_TYPE).meta,
chartId,
width: 3,
height: 10,

View File

@@ -17,26 +17,8 @@
* under the License.
*/
import { ADD_TOAST, REMOVE_TOAST } from './actions';
import { ToastMeta } from './types';
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[] {
export default function messageToastsReducer(toasts = [], action) {
switch (action.type) {
case ADD_TOAST: {
const { payload: toast } = action;

View File

@@ -33,11 +33,8 @@ interface ModalFormFieldProps {
hasFeedback?: boolean;
}
const StyledFieldContainer = styled.div<{
bottomSpacing: boolean;
hasError: boolean;
}>`
${({ theme, bottomSpacing, hasError }) => css`
const StyledFieldContainer = styled.div<{ bottomSpacing: boolean }>`
${({ theme, bottomSpacing }) => css`
flex: 1;
margin-top: 0px;
margin-bottom: ${bottomSpacing ? theme.sizeUnit * 4 : 0}px;
@@ -51,7 +48,7 @@ const StyledFieldContainer = styled.div<{
.required {
margin-left: ${theme.sizeUnit / 2}px;
color: ${hasError ? theme.colorError : theme.colorIcon};
color: ${theme.colorError};
}
.helper {
@@ -131,14 +128,8 @@ export function ModalFormField({
validateStatus,
hasFeedback = false,
}: ModalFormFieldProps) {
const hasError = !!(error || validateStatus === 'error');
return (
<StyledFieldContainer
bottomSpacing={bottomSpacing}
hasError={hasError}
data-test={testId}
>
<StyledFieldContainer bottomSpacing={bottomSpacing} data-test={testId}>
<div className="control-label">
{label}
{tooltip && <InfoTooltip tooltip={tooltip} />}

View File

@@ -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 type { TagType } from 'src/types/TagType';
import TagType from 'src/types/TagType';
export default {
title: 'Components/Tag',

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import type { TagType } from 'src/types/TagType';
import TagType from 'src/types/TagType';
import { Tag } from '.';
const mockedProps: TagType = {

View File

@@ -19,7 +19,7 @@
import { styled } from '@superset-ui/core';
import { Link } from 'react-router-dom';
import type { TagType } from 'src/types/TagType';
import 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';

View File

@@ -23,7 +23,7 @@ import {
SupersetClient,
t,
} from '@superset-ui/core';
import type { TagType } from 'src/types/TagType';
import Tag from 'src/types/TagType';
import rison from 'rison';
import { cacheWrapper } from 'src/utils/cacheWrapper';
@@ -43,7 +43,7 @@ type SelectTagsValue = {
};
export const tagToSelectOption = (
tag: TagType & { table_name: string },
tag: Tag & { table_name: string },
): SelectTagsValue => ({
value: tag.id,
label: tag.name,

View File

@@ -19,7 +19,7 @@
import { useMemo, useState } from 'react';
import { styled } from '@superset-ui/core';
import type { TagType } from 'src/types/TagType';
import TagType from 'src/types/TagType';
import { Tag } from 'src/components/Tag';
export type TagsListProps = {

View File

@@ -26,40 +26,9 @@ 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,

View File

@@ -170,9 +170,7 @@ const SliceAddedBadge: FC<{ placeholder?: HTMLDivElement }> = ({
const AddSliceCard: FC<{
datasourceUrl?: string;
datasourceName?: string;
innerRef?:
| RefObject<HTMLDivElement>
| ((node: HTMLDivElement | null) => void);
innerRef?: RefObject<HTMLDivElement>;
isSelected?: boolean;
lastModified?: string;
sliceName: string;

View File

@@ -50,7 +50,6 @@ 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';
@@ -313,7 +312,7 @@ class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
// actual style should be applied to nested AddSliceCard component
style={{}}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
{({ dragSourceRef }) => (
<AddSliceCard
innerRef={dragSourceRef}
style={style}

View File

@@ -0,0 +1,85 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import 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 }),
};
},
];

View File

@@ -1,188 +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 {
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 }),
};
},
];

View File

@@ -22,7 +22,6 @@ 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';
@@ -120,7 +119,7 @@ const DynamicComponent: FC<DynamicComponentProps> = ({
onDrop={handleComponentDrop}
editMode={editMode}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
{({ dragSourceRef }) => (
<WithPopoverMenu
menuItems={[
<BackgroundStyleDropdown

View File

@@ -20,7 +20,6 @@ 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';
@@ -82,7 +81,7 @@ export default class DraggableNewComponent extends PureComponent<DraggableNewCom
depth={0}
editMode
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
{({ dragSourceRef }) => (
<NewComponent ref={dragSourceRef} data-test="new-component">
<NewComponentPlaceholder
className={cx('new-component-placeholder', className)}

View File

@@ -28,38 +28,11 @@ import {
DASHBOARD_GRID_ID,
} from '../util/constants';
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 = {
export default {
[DASHBOARD_ROOT_ID]: {
type: DASHBOARD_ROOT_TYPE,
id: DASHBOARD_ROOT_ID,
children: [DASHBOARD_GRID_ID],
parents: [],
meta: rootMeta,
},
[DASHBOARD_GRID_ID]: {
@@ -67,16 +40,14 @@ const emptyDashboardLayout: DashboardLayout = {
id: DASHBOARD_GRID_ID,
children: [],
parents: [DASHBOARD_ROOT_ID],
meta: gridMeta,
meta: {},
},
[DASHBOARD_HEADER_ID]: {
type: HEADER_TYPE,
id: DASHBOARD_HEADER_ID,
children: [],
parents: [],
meta: headerMeta,
meta: {
text: 'New dashboard',
},
},
};
export default emptyDashboardLayout;

View File

@@ -23,12 +23,10 @@ import {
FETCH_ALL_SLICES_STARTED,
ADD_SLICES,
SET_SLICES,
SliceEntitiesState,
SliceEntitiesActionPayload,
} from '../actions/sliceEntities';
import { HYDRATE_DASHBOARD } from '../actions/hydrate';
export const initSliceEntities: SliceEntitiesState = {
export const initSliceEntities = {
slices: {},
isLoading: true,
errorMessage: null,
@@ -36,34 +34,37 @@ export const initSliceEntities: SliceEntitiesState = {
};
export default function sliceEntitiesReducer(
state: SliceEntitiesState = initSliceEntities,
action: SliceEntitiesActionPayload,
): SliceEntitiesState {
switch (action.type) {
case HYDRATE_DASHBOARD:
state = initSliceEntities,
action,
) {
const actionHandlers = {
[HYDRATE_DASHBOARD]() {
return {
...action.data.sliceEntities,
};
case FETCH_ALL_SLICES_STARTED:
},
[FETCH_ALL_SLICES_STARTED]() {
return {
...state,
isLoading: true,
};
case ADD_SLICES:
},
[ADD_SLICES]() {
return {
...state,
isLoading: false,
slices: { ...state.slices, ...action.payload.slices },
lastUpdated: new Date().getTime(),
};
case SET_SLICES:
},
[SET_SLICES]() {
return {
...state,
isLoading: false,
slices: { ...action.payload.slices },
lastUpdated: new Date().getTime(),
};
case FETCH_ALL_SLICES_FAILED:
},
[FETCH_ALL_SLICES_FAILED]() {
return {
...state,
isLoading: false,
@@ -71,7 +72,11 @@ export default function sliceEntitiesReducer(
errorMessage:
action.payload.error || t('Could not fetch all saved charts'),
};
default:
return state;
},
};
if (action.type in actionHandlers) {
return actionHandlers[action.type]();
}
return state;
}

View File

@@ -0,0 +1,126 @@
/**
* 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,
};
}, {});
}

View File

@@ -1,211 +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 {
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,
};
},
{},
);
}

View File

@@ -16,41 +16,17 @@
* 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,
@@ -67,15 +43,15 @@ export default function buildFilterScopeTreeEntry({
filterFields: editingList,
selectedChartId,
});
const checkedChartIdSet = new Set<string>();
const checkedChartIdSet = new Set();
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 {

View File

@@ -16,22 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { ComponentType, Layout } from 'src/dashboard/types';
import getComponentWidthFromDrop from './getComponentWidthFromDrop';
export interface DropResult {
source: { id: string };
destination: { id: string };
dragging: {
id?: string;
type?: ComponentType;
};
}
export default function doesChildOverflowParent(
dropResult: DropResult,
layout: Layout,
): boolean {
export default function doesChildOverflowParent(dropResult, layout) {
const childWidth = getComponentWidthFromDrop({ dropResult, layout });
return typeof childWidth === 'number' && childWidth < 0;
}

View File

@@ -16,10 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
// Layout type not directly used in tests - using object shapes for test data
import dropOverflowsParent, {
type DropResult,
} from 'src/dashboard/util/dropOverflowsParent';
import dropOverflowsParent from 'src/dashboard/util/dropOverflowsParent';
import { NEW_COMPONENTS_SOURCE_ID } from 'src/dashboard/util/constants';
import {
CHART_TYPE,
@@ -31,7 +28,7 @@ import {
describe('dropOverflowsParent', () => {
it('returns true if a parent does NOT have adequate width for child', () => {
const dropResult: DropResult = {
const dropResult = {
source: { id: '_' },
destination: { id: 'a' },
dragging: { id: 'z' },
@@ -59,11 +56,11 @@ describe('dropOverflowsParent', () => {
},
};
expect(dropOverflowsParent(dropResult, layout as any)).toBe(true);
expect(dropOverflowsParent(dropResult, layout)).toBe(true);
});
it('returns false if a parent DOES have adequate width for child', () => {
const dropResult: DropResult = {
const dropResult = {
source: { id: '_' },
destination: { id: 'a' },
dragging: { id: 'z' },
@@ -91,11 +88,11 @@ describe('dropOverflowsParent', () => {
},
};
expect(dropOverflowsParent(dropResult, layout as any)).toBe(false);
expect(dropOverflowsParent(dropResult, layout)).toBe(false);
});
it('returns false if a child CAN shrink to available parent space', () => {
const dropResult: DropResult = {
const dropResult = {
source: { id: '_' },
destination: { id: 'a' },
dragging: { id: 'z' },
@@ -123,11 +120,11 @@ describe('dropOverflowsParent', () => {
},
};
expect(dropOverflowsParent(dropResult, layout as any)).toBe(false);
expect(dropOverflowsParent(dropResult, layout)).toBe(false);
});
it('returns true if a child CANNOT shrink to available parent space', () => {
const dropResult: DropResult = {
const dropResult = {
source: { id: '_' },
destination: { id: 'a' },
dragging: { id: 'b' },
@@ -156,11 +153,11 @@ describe('dropOverflowsParent', () => {
},
};
expect(dropOverflowsParent(dropResult, layout as any)).toBe(true);
expect(dropOverflowsParent(dropResult, layout)).toBe(true);
});
it('returns true if a column has children that CANNOT shrink to available parent space', () => {
const dropResult: DropResult = {
const dropResult = {
source: { id: '_' },
destination: { id: 'destination' },
dragging: { id: 'dragging' },
@@ -194,18 +191,18 @@ describe('dropOverflowsParent', () => {
},
};
expect(dropOverflowsParent(dropResult, layout as any)).toBe(true);
expect(dropOverflowsParent(dropResult, layout)).toBe(true);
// remove children
expect(
dropOverflowsParent(dropResult, {
...layout,
dragging: { ...layout.dragging, children: [] } as any,
} as any),
dragging: { ...layout.dragging, children: [] },
}),
).toBe(false);
});
it('should work with new components that are not in the layout', () => {
const dropResult: DropResult = {
const dropResult = {
source: { id: NEW_COMPONENTS_SOURCE_ID },
destination: { id: 'a' },
dragging: { type: CHART_TYPE },
@@ -215,15 +212,15 @@ describe('dropOverflowsParent', () => {
a: {
id: 'a',
type: ROW_TYPE,
children: [] as any,
children: [],
},
};
expect(dropOverflowsParent(dropResult, layout as any)).toBe(false);
expect(dropOverflowsParent(dropResult, layout)).toBe(false);
});
it('source/destination without widths should not overflow parent', () => {
const dropResult: DropResult = {
const dropResult = {
source: { id: '_' },
destination: { id: 'tab' },
dragging: { id: 'header' },
@@ -240,6 +237,6 @@ describe('dropOverflowsParent', () => {
},
};
expect(dropOverflowsParent(dropResult, layout as any)).toBe(false);
expect(dropOverflowsParent(dropResult, layout)).toBe(false);
});
});

View File

@@ -18,11 +18,8 @@
*/
import { TABS_TYPE } from './componentTypes';
import { DASHBOARD_ROOT_ID } from './constants';
import { DashboardLayout } from '../types';
export default function findFirstParentContainerId(
layout: DashboardLayout = {},
): string {
export default function findFirstParentContainerId(layout = {}) {
// DASHBOARD_GRID_TYPE or TABS_TYPE?
let parent = layout[DASHBOARD_ROOT_ID];
if (

View File

@@ -0,0 +1,33 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { CHART_TYPE } from './componentTypes';
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;
}, []);
}

View File

@@ -18,55 +18,36 @@
*/
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: DashboardLayout = {
const mockLayout = {
a: {
id: 'a',
type: CHART_TYPE,
children: [],
meta: {
chartId: 123,
height: 400,
width: 400,
uuid: 'uuid-a',
},
meta: { chartId: 'A' },
},
b: {
id: 'b',
type: CHART_TYPE,
children: [],
meta: {
chartId: 456,
height: 400,
width: 400,
uuid: 'uuid-b',
},
meta: { chartId: 'B' },
},
c: {
id: 'c',
type: ROW_TYPE,
children: [],
meta: {
chartId: 789,
height: 400,
width: 400,
uuid: 'uuid-c',
},
meta: { chartId: 'C' },
},
};
it('should return an array of chartIds', () => {
const result = getChartIdsFromLayout(mockLayout);
expect(Array.isArray(result)).toBe(true);
expect(result.includes(123)).toBe(true);
expect(result.includes(456)).toBe(true);
expect(result.includes('A')).toBe(true);
expect(result.includes('B')).toBe(true);
});
it('should return ids only from CHART_TYPE components', () => {
const result = getChartIdsFromLayout(mockLayout);
expect(result).toHaveLength(2);
expect(result.includes(789)).toBe(false);
expect(result.includes('C')).toBe(false);
});
});

View File

@@ -1,39 +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 { CHART_TYPE } from './componentTypes';
import type { DashboardLayout } from '../types';
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;
},
[],
);
}

View File

@@ -16,17 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
export interface TabsComponentLike {
id: string;
parents?: string[];
children: string[];
[key: string]: unknown;
}
export default function getDirectPathToTabIndex(
tabsComponent: TabsComponentLike,
tabIndex: number,
): string[] {
export default function getDirectPathToTabIndex(tabsComponent, tabIndex) {
const directPathToFilter = (tabsComponent.parents || []).slice();
directPathToFilter.push(tabsComponent.id);
directPathToFilter.push(tabsComponent.children[tabIndex]);

View File

@@ -1,43 +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 getDirectPathToTabIndex from './getDirectPathToTabIndex';
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']);
});
});

View File

@@ -17,7 +17,6 @@
* under the License.
*/
import { DASHBOARD_ROOT_TYPE, DASHBOARD_GRID_TYPE } from './componentTypes';
import type { ComponentType } from '../types';
import {
DASHBOARD_GRID_ID,
@@ -25,22 +24,7 @@ import {
DASHBOARD_VERSION_KEY,
} from './constants';
// 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 {
export default function getEmptyLayout() {
return {
[DASHBOARD_VERSION_KEY]: 'v2',
[DASHBOARD_ROOT_ID]: {

View File

@@ -18,15 +18,10 @@
*/
import { safeStringify } from '../../utils/safeStringify';
interface GetKeyForFilterScopeTreeProps {
activeFilterField?: string;
checkedFilterFields: string[];
}
export default function getKeyForFilterScopeTree({
activeFilterField,
checkedFilterFields,
}: GetKeyForFilterScopeTreeProps): string {
}) {
return safeStringify(
activeFilterField ? [activeFilterField] : checkedFilterFields,
);

View File

@@ -1,78 +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 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"]');
});
});

View File

@@ -18,12 +18,8 @@
*/
/* eslint-disable no-param-reassign */
import { CHART_TYPE } from './componentTypes';
import type { DashboardLayout, LayoutItem } from '../types';
export default function getLayoutComponentFromChartId(
layout: DashboardLayout,
chartId: number,
): LayoutItem | undefined {
export default function getLayoutComponentFromChartId(layout, chartId) {
return Object.values(layout).find(
currentComponent =>
currentComponent &&

View File

@@ -1,105 +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 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();
});

View File

@@ -18,9 +18,7 @@
*/
import { IN_COMPONENT_ELEMENT_TYPES } from './constants';
export default function getLeafComponentIdFromPath(
directPathToChild: string[] = [],
): string | null {
export default function getLeafComponentIdFromPath(directPathToChild = []) {
if (directPathToChild.length > 0) {
const currentPath = directPathToChild.slice();
@@ -28,10 +26,7 @@ export default function getLeafComponentIdFromPath(
const componentId = currentPath.pop();
const componentType = componentId && componentId.split('-')[0];
if (
componentType &&
!IN_COMPONENT_ELEMENT_TYPES.includes(componentType)
) {
if (!IN_COMPONENT_ELEMENT_TYPES.includes(componentType)) {
return componentId;
}
}

View File

@@ -20,7 +20,7 @@ import isDashboardEmpty from 'src/dashboard/util/isDashboardEmpty';
import getEmptyLayout from 'src/dashboard/util/getEmptyLayout';
describe('isDashboardEmpty', () => {
const emptyLayout = getEmptyLayout();
const emptyLayout: object = getEmptyLayout();
const testLayout: object = {
...emptyLayout,
'MARKDOWN-IhTGLhyiTd': {

View File

@@ -0,0 +1,23 @@
/**
* 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),
);
}

View File

@@ -1,48 +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 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);
});
});

View File

@@ -16,40 +16,17 @@
* 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 = ([] as string[]).concat(
const allScopes = [].concat(
...Object.values(dashboardFilters).map(({ scopes }) =>
([] as string[]).concat(
...Object.values(scopes).map(({ scope }) => scope),
),
[].concat(...Object.values(scopes).map(({ scope }) => scope)),
),
);
return allScopes.some(tab => destinationSet.has(tab) !== sourceSet.has(tab));

View File

@@ -1,197 +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 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);
});

View File

@@ -16,19 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
export interface ChartLoadTimestamps {
chartUpdateStartTime?: number;
chartUpdateEndTime?: number | null;
// allow extra fields without narrowing
[key: string]: unknown;
}
import findNonTabChildCharIds from './findNonTabChildChartIds';
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;
export default function childChartsDidLoad({ chartQueries, layout, id }) {
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;
});
return { didLoad, minQueryStartTime };
}

View File

@@ -1,246 +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 { 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);
});
});

View File

@@ -1,52 +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 { ChartState } from 'src/explore/types';
import { Layout } from 'src/dashboard/types';
import findNonTabChildCharIds from './findNonTabChildChartIds';
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: number) => {
const query = chartQueries[chartId.toString()] || {};
minQueryStartTime = Math.min(
query.chartUpdateStartTime ?? Infinity,
minQueryStartTime,
);
return ['stopped', 'failed', 'rendered'].includes(query.chartStatus || '');
});
return { didLoad, minQueryStartTime };
}

View File

@@ -16,29 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
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 {
export default function serializeFilterScopes(dashboardFilters) {
return Object.values(dashboardFilters).reduce((map, { chartId, scopes }) => {
const scopesById = Object.keys(scopes).reduce(
(scopesByColumn, column) => ({

View File

@@ -1,113 +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 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: [],
},
},
});
});
});

View File

@@ -23,20 +23,8 @@ import {
MARKDOWN_TYPE,
TAB_TYPE,
} from './componentTypes';
import { ComponentType } from '../types';
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>
> = {
const typeToWrapChildLookup = {
[DASHBOARD_GRID_TYPE]: {
[CHART_TYPE]: true,
[COLUMN_TYPE]: true,
@@ -50,14 +38,11 @@ const typeToWrapChildLookup: Record<
},
};
export default function shouldWrapChildInRow({
parentType,
childType,
}: WrapChildParams): boolean {
export default function shouldWrapChildInRow({ parentType, childType }) {
if (!parentType || !childType) return false;
const wrapChildLookup = typeToWrapChildLookup[parentType as ParentTypes];
const wrapChildLookup = typeToWrapChildLookup[parentType];
if (!wrapChildLookup) return false;
return Boolean(wrapChildLookup[childType as ChildTypes]);
return Boolean(wrapChildLookup[childType]);
}

View File

@@ -58,7 +58,6 @@ export interface IDatasource {
sql?: string | null;
datasource_name?: string | null;
name?: string | null;
catalog?: string | null;
schema?: string | null;
}

View File

@@ -16,20 +16,22 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { ControlPanelState, Dataset } from '@superset-ui/chart-controls';
import PropTypes from 'prop-types';
import { Clauses, ExpressionTypes } from './types';
interface TimeGrainOverrideState {
choices: [string, string][] | null;
}
export default {
default: 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,
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,
}),
]);

View File

@@ -1,103 +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 {
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]);
});
});

View File

@@ -1,64 +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 { 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>;

View File

@@ -16,7 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
export { savedMetricType } from './types';
import PropTypes from 'prop-types';
// For backward compatibility with PropTypes usage
export { savedMetricType as default } from './types';
export default PropTypes.shape({
column_name: PropTypes.string.isRequired,
type: PropTypes.string,
});

View File

@@ -1,53 +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 { 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');
});

View File

@@ -26,10 +26,3 @@ export enum Clauses {
Having = 'HAVING',
Where = 'WHERE',
}
// Re-export AdhocFilter types for convenient access
export type {
AdhocFilterSimple,
AdhocFilterSql,
AdhocFilterType,
} from './adhocFilterType';

View File

@@ -16,8 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import PropTypes from 'prop-types';
export type { AggregateOption } from './types';
// For backward compatibility with PropTypes usage
export { AggregateOption as default } from './types';
export default PropTypes.shape({
aggregate_name: PropTypes.string.isRequired,
});

View File

@@ -1,44 +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 { 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',
};
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' },
];
aggregates.forEach(aggregate => {
expect(typeof aggregate.aggregate_name).toBe('string');
expect(aggregate.aggregate_name.length).toBeGreaterThan(0);
});
});

View File

@@ -16,10 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Column } from '@superset-ui/core';
import PropTypes from 'prop-types';
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;
export default PropTypes.shape({
column_name: PropTypes.string.isRequired,
type: PropTypes.string,
});

View File

@@ -16,10 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Column } from '@superset-ui/core';
import PropTypes from 'prop-types';
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;
export default PropTypes.shape({
metric_name: PropTypes.string,
verbose_name: PropTypes.string,
expression: PropTypes.string,
});

View File

@@ -1,45 +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 { 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();
});

View File

@@ -21,7 +21,3 @@ export type savedMetricType = {
verbose_name?: string;
expression: string;
};
export interface AggregateOption {
aggregate_name: string;
}

View File

@@ -17,13 +17,9 @@
* 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';
const config: ControlPanelConfig = {
export default {
controlPanelSections: [
{
label: t('Code'),
@@ -49,15 +45,12 @@ const config: ControlPanelConfig = {
type: 'TextAreaControl',
label: t('Code'),
description: t('Put your code here'),
mapStateToProps: (state: Partial<ControlPanelState>) => {
const languageValue = state.controls?.markup_type?.value;
return {
language:
typeof languageValue === 'string'
? languageValue
: 'markdown',
};
},
mapStateToProps: state => ({
language:
state.controls && state.controls.markup_type
? state.controls.markup_type.value
: 'markdown',
}),
default: '',
},
},
@@ -81,5 +74,3 @@ const config: ControlPanelConfig = {
},
},
};
export default config;

View File

@@ -1,76 +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 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');
});
});

View File

@@ -0,0 +1,26 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export default {
default: null,
mapStateToProps: state => ({
choices: state.datasource
? state.datasource.time_grain_sqla.filter(o => o[0] !== null)
: null,
}),
};

View File

@@ -32,6 +32,7 @@ import {
FeatureFlag,
styled,
SupersetClient,
SupersetTheme,
t,
VizType,
useTheme,
@@ -40,6 +41,7 @@ import rison from 'rison';
import { useSingleViewResource } from 'src/views/CRUD/hooks';
import withToasts from 'src/components/MessageToasts/withToasts';
import Owner from 'src/types/Owner';
// import { Form as AntdForm } from 'src/components/Form';
import { propertyComparator } from '@superset-ui/core/components/Select/utils';
import {
AsyncSelect,
@@ -264,6 +266,11 @@ export const StyledInputContainer = styled.div`
text-align: left;
}
.required {
margin-left: ${theme.sizeUnit / 2}px;
color: ${theme.colorError};
}
.control-label {
margin-bottom: ${theme.sizeUnit * 2}px;
color: ${theme.colorText};
@@ -397,6 +404,10 @@ const StyledNotificationMethodWrapper = styled.div`
}
`;
const inputSpacer = (theme: SupersetTheme) => css`
margin-right: ${theme.sizeUnit * 3}px;
`;
type NotificationAddStatus = 'active' | 'disabled' | 'hidden';
interface NotificationMethodAddProps {
@@ -2031,15 +2042,6 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
<ModalFormField
label={isReport ? t('Report name') : t('Alert name')}
required
error={
validationStatus[Sections.General]?.hasErrors &&
!currentAlert?.name?.trim()
? t(
'%s name is required',
isReport ? t('Report') : t('Alert'),
)
: undefined
}
>
<Input
name="name"
@@ -2052,17 +2054,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
onChange={onInputChange}
/>
</ModalFormField>
<ModalFormField
label={t('Owners')}
required
error={
validationStatus[Sections.General]?.hasErrors &&
(!currentAlert?.owners ||
currentAlert.owners.length === 0)
? t('Owners are required')
: undefined
}
>
<ModalFormField label={t('Owners')} required>
<AsyncSelect
ariaLabel={t('Owners')}
allowClear
@@ -2123,46 +2115,40 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
),
children: (
<div>
<ModalFormField
label={t('Database')}
required
error={
validationStatus[Sections.Alert]?.hasErrors &&
!currentAlert?.database
? t('Database is required')
: undefined
}
>
<AsyncSelect
ariaLabel={t('Database')}
name="source"
placeholder={t('Select database')}
value={
currentAlert?.database?.label &&
currentAlert?.database?.value
? {
value: currentAlert.database.value,
label: currentAlert.database.label,
}
: undefined
}
options={loadSourceOptions}
onChange={onSourceChange}
/>
</ModalFormField>
<ModalFormField
label={t('SQL Query')}
required
tooltip={t(
'The result of this query must be a value capable of numeric interpretation e.g. 1, 1.0, or "1" (compatible with Python\'s float() function).',
)}
error={
validationStatus[Sections.Alert]?.hasErrors &&
!currentAlert?.sql?.length
? t('SQL Query is required')
: undefined
}
>
<StyledInputContainer>
<div className="control-label">
{t('Database')}
<span className="required">*</span>
</div>
<div className="input-container">
<AsyncSelect
ariaLabel={t('Database')}
name="source"
placeholder={t('Select database')}
value={
currentAlert?.database?.label &&
currentAlert?.database?.value
? {
value: currentAlert.database.value,
label: currentAlert.database.label,
}
: undefined
}
options={loadSourceOptions}
onChange={onSourceChange}
/>
</div>
</StyledInputContainer>
<StyledInputContainer>
<div className="control-label">
{t('SQL Query')}
<InfoTooltip
tooltip={t(
'The result of this query must be a value capable of numeric interpretation e.g. 1, 1.0, or "1" (compatible with Python\'s float() function).',
)}
/>
<span className="required">*</span>
</div>
<TextAreaControl
name="sql"
language="sql"
@@ -2174,60 +2160,57 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
initialValue={resource?.sql}
key={currentAlert?.id}
/>
</ModalFormField>
</StyledInputContainer>
<div
className="inline-container wrap"
css={css`
gap: ${theme.sizeUnit}px;
`}
>
<ModalFormField
label={t('Trigger Alert If...')}
required
error={
validationStatus[Sections.Alert]?.hasErrors &&
!currentAlert?.validator_config_json?.op
? t('Condition is required')
: undefined
}
>
<Select
ariaLabel={t('Condition')}
onChange={onConditionChange}
placeholder={t('Condition')}
value={
currentAlert?.validator_config_json?.op ||
undefined
}
options={CONDITIONS}
/>
</ModalFormField>
<ModalFormField
label={t('Value')}
required={!conditionNotNull}
error={
validationStatus[Sections.Alert]?.hasErrors &&
!conditionNotNull &&
!currentAlert?.validator_config_json?.threshold
? t('Value is required')
: undefined
}
>
<InputNumber
disabled={conditionNotNull}
type="number"
name="threshold"
value={
currentAlert?.validator_config_json
?.threshold !== undefined && !conditionNotNull
? currentAlert.validator_config_json.threshold
: ''
}
min={0}
placeholder={t('Value')}
onChange={onThresholdChange}
/>
</ModalFormField>
<StyledInputContainer css={noMarginBottom}>
<div className="control-label" css={inputSpacer}>
{t('Trigger Alert If...')}
<span className="required">*</span>
</div>
<div className="input-container">
<Select
ariaLabel={t('Condition')}
onChange={onConditionChange}
placeholder={t('Condition')}
value={
currentAlert?.validator_config_json?.op ||
undefined
}
options={CONDITIONS}
/>
</div>
</StyledInputContainer>
<StyledInputContainer css={noMarginBottom}>
<div className="control-label">
{t('Value')}{' '}
{!conditionNotNull && (
<span className="required">*</span>
)}
</div>
<div className="input-container">
<InputNumber
disabled={conditionNotNull}
type="number"
name="threshold"
value={
currentAlert?.validator_config_json
?.threshold !== undefined &&
!conditionNotNull
? currentAlert.validator_config_json
.threshold
: ''
}
min={0}
placeholder={t('Value')}
onChange={onThresholdChange}
/>
</div>
</StyledInputContainer>
</div>
</div>
),
@@ -2485,7 +2468,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
<InputNumber
type="number"
name="custom_width"
value={currentAlert?.custom_width || 1600}
value={currentAlert?.custom_width || undefined}
min={600}
max={2400}
placeholder={t('Input custom width in pixels')}
@@ -2528,66 +2511,66 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
value={currentAlert?.crontab || ''}
onChange={newVal => updateAlertState('crontab', newVal)}
/>
<ModalFormField
label={t('Timezone')}
required
error={
validationStatus[Sections.Schedule]?.hasErrors &&
!currentAlert?.timezone
? t('Timezone is required')
: undefined
}
>
<StyledInputContainer>
<div className="control-label">
{t('Timezone')} <span className="required">*</span>
</div>
<TimezoneSelector
onTimezoneChange={onTimezoneChange}
timezone={currentAlert?.timezone}
minWidth="100%"
/>
</ModalFormField>
<ModalFormField
label={t('Log retention')}
required
error={
validationStatus[Sections.Schedule]?.hasErrors &&
!currentAlert?.log_retention
? t('Log retention is required')
: undefined
}
>
<Select
ariaLabel={t('Log retention')}
placeholder={t('Log retention')}
onChange={onLogRetentionChange}
value={currentAlert?.log_retention}
options={RETENTION_OPTIONS}
sortComparator={propertyComparator('value')}
/>
</ModalFormField>
<ModalFormField
label={isReport ? t('Working timeout') : t('Grace period')}
required={isReport}
error={
validationStatus[Sections.Schedule]?.hasErrors &&
isReport &&
!currentAlert?.working_timeout
? t('Working timeout is required')
: undefined
}
bottomSpacing={false}
>
<NumberInput
min={1}
name={isReport ? 'working_timeout' : 'grace_period'}
value={
isReport
? currentAlert?.working_timeout || ''
: currentAlert?.grace_period || ''
}
placeholder={t('Time in seconds')}
onChange={onTimeoutVerifyChange}
timeUnit={t('seconds')}
/>
</ModalFormField>
</StyledInputContainer>
<StyledInputContainer>
<div className="control-label">
{t('Log retention')}
<span className="required">*</span>
</div>
<div className="input-container">
<Select
ariaLabel={t('Log retention')}
placeholder={t('Log retention')}
onChange={onLogRetentionChange}
value={currentAlert?.log_retention}
options={RETENTION_OPTIONS}
sortComparator={propertyComparator('value')}
/>
</div>
</StyledInputContainer>
<StyledInputContainer css={noMarginBottom}>
{isReport ? (
<>
<div className="control-label">
{t('Working timeout')}
<span className="required">*</span>
</div>
<div className="input-container">
<NumberInput
min={1}
name="working_timeout"
value={currentAlert?.working_timeout || ''}
placeholder={t('Time in seconds')}
onChange={onTimeoutVerifyChange}
timeUnit={t('seconds')}
/>
</div>
</>
) : (
<>
<div className="control-label">{t('Grace period')}</div>
<div className="input-container">
<NumberInput
min={1}
name="grace_period"
value={currentAlert?.grace_period || ''}
placeholder={t('Time in seconds')}
onChange={onTimeoutVerifyChange}
timeUnit={t('seconds')}
/>
</div>
</>
)}
</StyledInputContainer>
</>
),
},

View File

@@ -1,121 +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 { act } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import fetchMock from 'fetch-mock';
import { SupersetClient } from '@superset-ui/core';
import { useExecuteReportSchedule } from './useExecuteReportSchedule';
const mockExecuteResponse = {
execution_id: 'test-uuid-123',
message: 'Report schedule execution started successfully',
};
beforeAll(() => {
SupersetClient.configure().init();
});
afterEach(() => {
fetchMock.reset();
});
test('successfully executes a report', async () => {
const reportId = 123;
fetchMock.post(
`glob:*/api/v1/report/${reportId}/execute`,
mockExecuteResponse,
);
const { result } = renderHook(() => useExecuteReportSchedule());
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
let executeResult: any;
await act(async () => {
executeResult = await result.current.executeReport(reportId);
});
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
expect(executeResult).toEqual(mockExecuteResponse);
expect(fetchMock.calls()).toHaveLength(1);
});
test('handles execution errors', async () => {
const reportId = 123;
const errorMessage = 'Report not found';
fetchMock.post(`glob:*/api/v1/report/${reportId}/execute`, {
status: 404,
body: { message: errorMessage },
});
const { result } = renderHook(() => useExecuteReportSchedule());
await act(async () => {
try {
await result.current.executeReport(reportId);
} catch (error) {
// Expected to throw
}
});
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(errorMessage);
});
test('calls success callback on successful execution', async () => {
const reportId = 123;
const onSuccess = jest.fn();
fetchMock.post(
`glob:*/api/v1/report/${reportId}/execute`,
mockExecuteResponse,
);
const { result } = renderHook(() => useExecuteReportSchedule());
await act(async () => {
await result.current.executeReport(reportId, onSuccess);
});
expect(onSuccess).toHaveBeenCalledWith(mockExecuteResponse);
});
test('calls error callback on failed execution', async () => {
const reportId = 123;
const onError = jest.fn();
const errorMessage = 'Execution failed';
fetchMock.post(`glob:*/api/v1/report/${reportId}/execute`, {
status: 500,
body: { message: errorMessage },
});
const { result } = renderHook(() => useExecuteReportSchedule());
await act(async () => {
try {
await result.current.executeReport(reportId, undefined, onError);
} catch (error) {
// Expected to throw
}
});
expect(onError).toHaveBeenCalledWith(errorMessage);
});

View File

@@ -1,86 +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 { useState, useCallback } from 'react';
import { SupersetClient, t } from '@superset-ui/core';
interface ExecuteResponse {
execution_id: string;
message: string;
}
interface UseExecuteReportScheduleState {
loading: boolean;
error: string | null;
}
export function useExecuteReportSchedule() {
const [state, setState] = useState<UseExecuteReportScheduleState>({
loading: false,
error: null,
});
const executeReport = useCallback(
async (
reportId: number,
onSuccess?: (response: ExecuteResponse) => void,
onError?: (error: string) => void,
) => {
setState({ loading: true, error: null });
try {
const response = await SupersetClient.post({
endpoint: `/api/v1/report/${reportId}/execute`,
});
const result = response.json as ExecuteResponse;
setState({ loading: false, error: null });
if (onSuccess) {
onSuccess(result);
}
return result;
} catch (error) {
let errorMessage = t('An error occurred while triggering the report');
if (error && typeof error === 'object' && 'json' in error) {
const errorJson = error.json as any;
if (errorJson?.message) {
errorMessage = errorJson.message;
}
}
setState({ loading: false, error: errorMessage });
if (onError) {
onError(errorMessage);
}
throw error;
}
},
[],
);
return {
executeReport,
loading: state.loading,
error: state.error,
};
}

View File

@@ -59,7 +59,6 @@ import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
import Owner from 'src/types/Owner';
import AlertReportModal from 'src/features/alerts/AlertReportModal';
import { AlertObject, AlertState } from 'src/features/alerts/types';
import { useExecuteReportSchedule } from 'src/features/alerts/hooks/useExecuteReportSchedule';
import { QueryObjectColumns } from 'src/views/CRUD/types';
import { Icons } from '@superset-ui/core/components/Icons';
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
@@ -158,16 +157,12 @@ function AlertList({
addDangerToast,
);
// Execute hook for Fire Now functionality
const { executeReport } = useExecuteReportSchedule();
const [alertModalOpen, setAlertModalOpen] = useState<boolean>(false);
const [currentAlert, setCurrentAlert] = useState<Partial<AlertObject> | null>(
null,
);
const [currentAlertDeleting, setCurrentAlertDeleting] =
useState<AlertObject | null>(null);
const [executingIds, setExecutingIds] = useState<Set<number>>(new Set());
// Actions
function handleAlertEdit(alert: AlertObject | null) {
@@ -251,51 +246,6 @@ function AlertList({
[alerts, setResourceCollection, updateResource],
);
const handleExecuteReport = useCallback(
async (alert: AlertObject) => {
const alertId = alert.id;
if (!alertId || executingIds.has(alertId)) {
return;
}
// Add to executing set
setExecutingIds(prev => new Set(prev).add(alertId));
try {
await executeReport(
alertId,
response => {
addSuccessToast(
t('%(alertType)s "%(alertName)s" triggered successfully', {
alertType: alert.type,
alertName: alert.name,
}),
);
},
error => {
addDangerToast(
t('Failed to trigger %(alertType)s "%(alertName)s": %(error)s', {
alertType: alert.type,
alertName: alert.name,
error,
}),
);
},
);
} catch (error) {
// Error already handled by onError callback
} finally {
// Remove from executing set
setExecutingIds(prev => {
const newSet = new Set(prev);
newSet.delete(alertId);
return newSet;
});
}
},
[executeReport, executingIds, addSuccessToast, addDangerToast],
);
const columns = useMemo(
() => [
{
@@ -447,16 +397,6 @@ function AlertList({
onClick: handleEdit,
}
: null,
allowEdit
? {
label: 'trigger-now-action',
tooltip: t('Trigger Now'),
placement: 'bottom',
icon: 'ThunderboltOutlined',
loading: executingIds.has(original.id),
onClick: () => handleExecuteReport(original),
}
: null,
allowEdit && canDelete
? {
label: 'delete-action',
@@ -484,14 +424,7 @@ function AlertList({
id: QueryObjectColumns.ChangedBy,
},
],
[
canDelete,
canEdit,
isReportEnabled,
toggleActive,
executingIds,
handleExecuteReport,
],
[canDelete, canEdit, isReportEnabled, toggleActive],
);
const subMenuButtons: SubMenuProps['buttons'] = [];

View File

@@ -39,3 +39,5 @@ export interface TagType {
css?: SerializedStyles;
closable?: boolean;
}
export default TagType;

View File

@@ -18,45 +18,26 @@
*/
import { debounce } from 'lodash';
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;
class DebouncedMessageQueue {
constructor({
callback = () => {},
sizeThreshold = 1000,
delayThreshold = 1000,
}: DebouncedMessageQueueOptions<T> = {}) {
}) {
this.queue = [];
this.sizeThreshold = sizeThreshold;
this.delayThreshold = delayThreshold;
this.callback = callback;
this.trigger = debounce(
this.triggerInternal.bind(this),
this.delayThreshold,
);
this.trigger = debounce(this.trigger.bind(this), this.delayThreshold);
this.callback = callback;
}
append(eventData: T): void {
append(eventData) {
this.queue.push(eventData);
this.trigger();
}
private triggerInternal(): void {
trigger() {
if (this.queue.length > 0) {
const events = this.queue.splice(0, this.sizeThreshold);
this.callback.call(null, events);

View File

@@ -1,66 +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 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();
});
});

View File

@@ -0,0 +1,27 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
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,
});

View File

@@ -1,190 +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 { 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);
});
});

View File

@@ -1,57 +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 { 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,
};
};

View File

@@ -1,101 +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 { 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',
},
}),
);
});
});

View File

@@ -1,74 +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 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;

View File

@@ -19,7 +19,7 @@
import { initFeatureFlags } from '@superset-ui/core';
import getBootstrapData from './getBootstrapData';
function getDomainsConfig(): string[] {
function getDomainsConfig() {
const appContainer = document.getElementById('app');
if (!appContainer) {
return [];
@@ -42,15 +42,13 @@ function getDomainsConfig(): string[] {
initFeatureFlags(bootstrapData.common.feature_flags);
if (bootstrapData?.common?.conf?.SUPERSET_WEBSERVER_DOMAINS) {
const domains = bootstrapData.common.conf
.SUPERSET_WEBSERVER_DOMAINS as string[];
domains.forEach((hostName: string) => {
bootstrapData.common.conf.SUPERSET_WEBSERVER_DOMAINS.forEach(hostName => {
availableDomains.add(hostName);
});
}
return Array.from(availableDomains);
}
export const availableDomains: string[] = getDomainsConfig();
export const availableDomains = getDomainsConfig();
export const allowCrossDomain: boolean = availableDomains.length > 1;
export const allowCrossDomain = availableDomains.length > 1;

View File

@@ -1,58 +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 { 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);
});
});

View File

@@ -1,129 +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 {
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);
});

View File

@@ -1,107 +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 { 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 };
}

View File

@@ -309,15 +309,3 @@ class ReportScheduleForbiddenError(ForbiddenError):
class ReportSchedulePruneLogError(CommandException):
message = _("An error occurred while pruning logs ")
class ReportScheduleExecuteNowFailedError(CommandException):
message = _("Report Schedule execute now failed.")
class ReportScheduleCeleryNotConfiguredError(CommandException):
status = 503
message = _(
"Report Schedule execution requires a Celery backend to be configured. "
"Please configure a Celery broker (Redis or RabbitMQ) and worker processes."
)

View File

@@ -1,147 +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 logging
from typing import Any, Optional
from uuid import uuid4
from flask import current_app
from superset import security_manager
from superset.commands.base import BaseCommand
from superset.commands.exceptions import CommandException
from superset.commands.report.exceptions import (
ReportScheduleCeleryNotConfiguredError,
ReportScheduleExecuteNowFailedError,
ReportScheduleForbiddenError,
ReportScheduleNotFoundError,
)
from superset.daos.report import ReportScheduleDAO
from superset.exceptions import SupersetSecurityException
from superset.reports.models import ReportSchedule
from superset.utils.decorators import transaction
logger = logging.getLogger(__name__)
class ExecuteReportScheduleNowCommand(BaseCommand):
"""
Execute a report schedule immediately (manual trigger).
This command validates permissions and triggers immediate execution
of a report or alert via Celery task, similar to scheduled execution
but without waiting for the cron schedule.
"""
def __init__(self, model_id: int) -> None:
self._model_id = model_id
self._model: Optional[ReportSchedule] = None
@transaction()
def run(self) -> str:
"""
Execute the command and return execution UUID for tracking.
Returns:
str: Execution UUID that can be used to track the execution status
Raises:
ReportScheduleNotFoundError: Report schedule not found
ReportScheduleForbiddenError: User doesn't have permission to execute
ReportScheduleExecuteNowFailedError: Execution failed to start
"""
try:
self.validate()
if not self._model:
raise ReportScheduleExecuteNowFailedError()
# Generate execution UUID for tracking
execution_id = str(uuid4())
# Trigger immediate execution via Celery
logger.info(
"Manually executing report schedule %s (id: %d), execution_id: %s",
self._model.name,
self._model.id,
execution_id,
)
# Import the existing execute task to avoid circular imports
from superset.tasks.scheduler import execute
# Set async options similar to scheduler but for immediate execution
async_options: dict[str, Any] = {"task_id": execution_id}
if self._model.working_timeout is not None and current_app.config.get(
"ALERT_REPORTS_WORKING_TIME_OUT_KILL", True
):
async_options["time_limit"] = (
self._model.working_timeout
+ current_app.config.get("ALERT_REPORTS_WORKING_TIME_OUT_LAG", 10)
)
async_options["soft_time_limit"] = (
self._model.working_timeout
+ current_app.config.get(
"ALERT_REPORTS_WORKING_SOFT_TIME_OUT_LAG", 5
)
)
# Execute the task
try:
execute.apply_async((self._model.id,), **async_options)
except Exception as celery_ex:
# Check for common Celery configuration issues
error_msg = str(celery_ex).lower()
if any(
keyword in error_msg
for keyword in [
"no broker",
"broker connection",
"kombu",
"redis",
"rabbitmq",
"celery",
"not registered",
"connection refused",
]
):
logger.error("Celery backend not configured: %s", str(celery_ex))
raise ReportScheduleCeleryNotConfiguredError() from celery_ex
else:
logger.error("Celery task execution failed: %s", str(celery_ex))
raise ReportScheduleExecuteNowFailedError() from celery_ex
return execution_id
except CommandException:
raise
except Exception as ex:
logger.exception(
"Unexpected error executing report schedule %d", self._model_id
)
raise ReportScheduleExecuteNowFailedError() from ex
def validate(self) -> None:
"""Validate the report schedule exists and user has permission to execute it."""
# Validate model exists
self._model = ReportScheduleDAO.find_by_id(self._model_id)
if not self._model:
raise ReportScheduleNotFoundError()
# Check ownership using the same pattern as delete command
try:
security_manager.raise_for_ownership(self._model)
except SupersetSecurityException as ex:
raise ReportScheduleForbiddenError() from ex

View File

@@ -29,16 +29,13 @@ from superset.charts.filters import ChartFilter
from superset.commands.report.create import CreateReportScheduleCommand
from superset.commands.report.delete import DeleteReportScheduleCommand
from superset.commands.report.exceptions import (
ReportScheduleCeleryNotConfiguredError,
ReportScheduleCreateFailedError,
ReportScheduleDeleteFailedError,
ReportScheduleExecuteNowFailedError,
ReportScheduleForbiddenError,
ReportScheduleInvalidError,
ReportScheduleNotFoundError,
ReportScheduleUpdateFailedError,
)
from superset.commands.report.execute_now import ExecuteReportScheduleNowCommand
from superset.commands.report.update import UpdateReportScheduleCommand
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
from superset.dashboards.filters import DashboardAccessFilter
@@ -51,7 +48,6 @@ from superset.reports.schemas import (
get_delete_ids_schema,
get_slack_channels_schema,
openapi_spec_methods_override,
ReportScheduleExecuteResponseSchema,
ReportSchedulePostSchema,
ReportSchedulePutSchema,
)
@@ -80,7 +76,6 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
RouteMethod.RELATED,
"bulk_delete",
"slack_channels", # not using RouteMethod since locally defined
"execute", # not using RouteMethod since locally defined
}
class_permission_name = "ReportSchedule"
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
@@ -593,77 +588,3 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
except SupersetException as ex:
logger.error("Error fetching slack channels %s", str(ex))
return self.response_422(message=str(ex))
@expose("/<int:pk>/execute", methods=("POST",))
@protect()
@safe
@permission_name("write")
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.execute",
log_to_statsd=False,
)
def execute(self, pk: int) -> Response:
"""Execute a report schedule immediately.
---
post:
summary: Execute a report schedule immediately
parameters:
- in: path
schema:
type: integer
name: pk
description: The report schedule pk
responses:
200:
description: Report schedule execution started
content:
application/json:
schema:
type: object
properties:
execution_id:
type: string
description: UUID to track the execution status
message:
type: string
description: Success message
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
"""
try:
execution_id = ExecuteReportScheduleNowCommand(pk).run()
response_schema = ReportScheduleExecuteResponseSchema()
return self.response(
200,
**response_schema.dump(
{
"execution_id": execution_id,
"message": "Report schedule execution started successfully",
}
),
)
except ReportScheduleNotFoundError:
return self.response_404()
except ReportScheduleForbiddenError:
return self.response_403()
except ReportScheduleCeleryNotConfiguredError as ex:
logger.error(
"Celery backend not configured for report schedule execution: %s",
str(ex),
)
return self.response(503, message=str(ex))
except ReportScheduleExecuteNowFailedError as ex:
logger.error(
"Error executing report schedule %s: %s",
self.__class__.__name__,
str(ex),
exc_info=True,
)
return self.response_422(message=str(ex))

View File

@@ -413,15 +413,3 @@ class SlackChannelSchema(Schema):
name = fields.String()
is_member = fields.Boolean()
is_private = fields.Boolean()
class ReportScheduleExecuteResponseSchema(Schema):
"""
Schema for the response when executing a report schedule immediately.
"""
class Meta:
unknown = EXCLUDE
execution_id = fields.String(description="UUID to track the execution status")
message = fields.String(description="Success message")

View File

@@ -2049,94 +2049,3 @@ class TestReportSchedulesApi(SupersetTestCase):
)
assert json.loads(report_schedule.extra_json) == extra_json
@pytest.mark.usefixtures("create_report_schedules")
@patch("superset.tasks.scheduler.execute.apply_async")
def test_execute_report_schedule(self, mock_execute):
"""
ReportSchedule Api: Test execute report schedule
"""
report_schedule = (
db.session.query(ReportSchedule)
.filter(ReportSchedule.name == "name1")
.one_or_none()
)
self.login(ADMIN_USERNAME)
uri = f"api/v1/report/{report_schedule.id}/execute"
rv = self.client.post(uri)
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
assert "execution_id" in data
assert "message" in data
assert data["message"] == "Report schedule execution started successfully"
# Verify the task was called
mock_execute.assert_called_once()
# Verify that the task was called with the correct report_schedule_id and eta
call_args = mock_execute.call_args
assert call_args[0][0] == (report_schedule.id,)
# Check that eta was set for manual execution
assert "eta" in call_args[1]
assert call_args[1]["eta"] is not None
@pytest.mark.usefixtures("create_report_schedules")
def test_execute_report_schedule_not_found(self):
"""
ReportSchedule Api: Test execute report schedule not found
"""
self.login(ADMIN_USERNAME)
uri = "api/v1/report/9999999/execute"
rv = self.client.post(uri)
assert rv.status_code == 404
@pytest.mark.usefixtures("create_report_schedules")
def test_execute_report_schedule_not_owned(self):
"""
ReportSchedule Api: Test execute report schedule not owned
"""
report_schedule = (
db.session.query(ReportSchedule)
.filter(ReportSchedule.name == "name1")
.one_or_none()
)
self.login(GAMMA_USERNAME)
uri = f"api/v1/report/{report_schedule.id}/execute"
rv = self.client.post(uri)
assert rv.status_code == 403
def test_execute_report_schedule_disabled(self):
"""
ReportSchedule Api: Test execute report schedule 404s when feature is disabled
"""
self.login(ADMIN_USERNAME)
with patch("superset.is_feature_enabled", return_value=False):
uri = "api/v1/report/1/execute"
rv = self.client.post(uri)
assert rv.status_code == 404
@pytest.mark.usefixtures("create_report_schedules")
@patch("superset.tasks.scheduler.execute.apply_async")
def test_execute_report_schedule_celery_error(self, mock_execute):
"""
ReportSchedule Api: Test execute report schedule with Celery backend error
"""
# Simulate Celery backend not configured
mock_execute.side_effect = Exception(
"kombu.exceptions.ConnectionError: broker connection"
)
report_schedule = (
db.session.query(ReportSchedule)
.filter(ReportSchedule.name == "name1")
.one_or_none()
)
self.login(ADMIN_USERNAME)
uri = f"api/v1/report/{report_schedule.id}/execute"
rv = self.client.post(uri)
assert rv.status_code == 503
data = json.loads(rv.data.decode("utf-8"))
assert "Celery backend" in data["message"]
assert "broker" in data["message"].lower()