Compare commits

...

9 Commits

Author SHA1 Message Date
Maxime Beauchemin
58a2eb465a fix tests 2025-09-21 13:57:10 -07:00
Maxime Beauchemin
3c51194bb2 feat: Add 1600px default for screenshot width
Set sensible default of 1600px for custom_width field in AlertReportModal,
matching Superset's dashboard screenshot configuration.

Before: Empty field required manual input
After: Sensible 1600px default matching dashboard width settings

This improves user experience by providing a reasonable starting point
for custom screenshot dimensions instead of requiring manual input.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 11:30:42 -07:00
Maxime Beauchemin
19be7020c7 fix: Correct validation section for Database and SQL fields
Fix dynamic asterisk coloring by using the correct validation section:
- Database field: Use Sections.Alert instead of Sections.Content
- SQL field: Use Sections.Alert instead of Sections.Content
- Fix SQL condition: !currentAlert?.sql?.length (matches actual validation)

The validateAlertSection() function validates both Database and SQL fields,
so error props must check validationStatus[Sections.Alert]?.hasErrors.

Now asterisks should properly turn red when validation fails!

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 11:30:42 -07:00
Maxime Beauchemin
7c9794cc2f refactor: Migrate key AlertReportModal fields to ModalFormField pattern
Migrate critical form fields in AlertReportModal to use the modern ModalFormField pattern
for consistent styling and dynamic validation feedback:

- Database field (Sections.Content validation)
- SQL Query field (Sections.Content validation)
- Trigger Alert If... condition (Sections.Alert validation)
- Value threshold (Sections.Alert validation)
- Timezone (Sections.Schedule validation)
- Log retention (Sections.Schedule validation)
- Working timeout/Grace period (Sections.Schedule validation)

Benefits:
- Dynamic asterisk coloring: subtle by default, red on validation errors
- Consistent form field styling across all Superset modals
- Better accessibility and user experience
- Proper tooltip integration in ModalFormField
- Cleaner code by removing custom StyledInputContainer usage
- Removed unused imports and CSS for cleaner codebase

This continues the migration from custom form styling to the
standard ModalFormField pattern used by Chart/Dashboard properties modals.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 11:30:40 -07:00
Maxime Beauchemin
7a6b084ff7 fix: Consistent asterisk styling in AlertReportModal
Update custom .required CSS in AlertReportModal to use theme.colorIcon
for consistency with the shared ModalFormField component.

AlertReportModal was using its own .required CSS that overrode the
dynamic validation-based styling, causing inconsistent asterisk colors
across different modals.

Now all required field asterisks use the same subtle theme.colorIcon
color for a consistent, professional appearance.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 11:30:00 -07:00
Maxime Beauchemin
17eeeaccac feat: Dynamic asterisk coloring for form field validation
Improve form field UX by making required asterisks (*) context-aware:
- Default: subtle theme.colorIcon for clean appearance
- Error state: theme.colorError when validation fails or error prop set

This affects all modals using ModalFormField across Superset:
- Alert & Report Modal
- Chart Properties Modal
- Dashboard Properties Modal
- Database Connection Modal
- All other modal forms

Users now get visual feedback that clearly distinguishes between
required fields and fields with validation errors.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 11:30:00 -07:00
Maxime Beauchemin
4dd1e80f4c feat: Add "Trigger Now" functionality for Alerts & Reports
Add manual execution capability to Alerts & Reports CRUD interface with immediate trigger functionality.

- Add `/api/v1/report/{id}/execute` REST endpoint with proper RBAC
- Create `ExecuteReportScheduleNowCommand` following existing Command patterns
- Use `security_manager.raise_for_ownership()` for consistent permission checking
- Reuse existing `AsyncExecuteReportScheduleCommand` via Celery for execution
- Add `ReportScheduleExecuteResponseSchema` for structured API responses
- Add `ReportScheduleCeleryNotConfiguredError` for helpful Celery setup guidance

- Add "Trigger Now" () button to AlertReportList actions column
- Create `useExecuteReportSchedule` hook for API integration
- Implement per-button loading states with `executingIds` Set tracking
- Add success/error toast notifications with clear messaging
- Gate feature behind existing edit permissions (`allowEdit`)

- Add 5 comprehensive backend tests covering success, 404, 403, Celery errors, and feature disabled
- Detect Celery backend configuration issues with helpful error messages
- All tests passing with proper mocking and security validation

- One-click manual execution from CRUD list view
- Smart loading states preventing double-clicks
- Professional "Trigger Now" terminology throughout
- Proper error handling for missing Celery backend
- Maintains all existing security and permission patterns

Resolves common user need for immediate alert/report execution without waiting for scheduled cron jobs.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 11:29:59 -07:00
Maxime Beauchemin
ecb3ac68ff feat: AI-powered TypeScript migration framework with parallel processing (#35045)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
Co-authored-by: Elizabeth Thompson <eschutho@gmail.com>
2025-09-20 15:47:42 -07:00
Mehmet Salih Yavuz
076e477fd4 fix(SQLPopover): Use correct component (#35212) 2025-09-20 12:12:37 +03:00
89 changed files with 4685 additions and 619 deletions

View File

@@ -0,0 +1,10 @@
# JavaScript to TypeScript Migration Command
## Usage
```
/js-to-ts <core-filename>
```
- `<core-filename>` - Path to CORE file relative to `superset-frontend/` (e.g., `src/utils/common.js`, `src/middleware/loggerMiddleware.js`)
## Agent Instructions
**See:** [../projects/js-to-ts/AGENT.md](../projects/js-to-ts/AGENT.md) for complete migration guide.

View File

@@ -0,0 +1,684 @@
# JavaScript to TypeScript Migration Agent Guide
**Complete technical reference for converting JavaScript/JSX files to TypeScript/TSX in Apache Superset frontend.**
**Agent Role:** Atomic migration unit - migrate the core file + ALL related tests/mocks as one cohesive unit. Use `git mv` to preserve history, NO `git commit`. NO global import changes. Report results upon completion.
---
## 🎯 Migration Principles
1. **Atomic migration units** - Core file + all related tests/mocks migrate together
2. **Zero `any` types** - Use proper TypeScript throughout
3. **Leverage existing types** - Reuse established definitions
4. **Type inheritance** - Derivatives extend base component types
5. **Strategic placement** - File types for maximum discoverability
6. **Surgical improvements** - Enhance existing types during migration
---
## Step 0: Dependency Check (MANDATORY)
**Command:**
```bash
grep -E "from '\.\./.*\.jsx?'|from '\./.*\.jsx?'|from 'src/.*\.jsx?'" superset-frontend/{filename}
```
**Decision:**
- ✅ No matches → Proceed with atomic migration (core + tests + mocks)
- ❌ Matches found → EXIT with dependency report (see format below)
---
## Step 1: Identify Related Files (REQUIRED)
**Atomic Migration Scope:**
For core file `src/utils/example.js`, also migrate:
- `src/utils/example.test.js` / `src/utils/example.test.jsx`
- `src/utils/example.spec.js` / `src/utils/example.spec.jsx`
- `src/utils/__mocks__/example.js`
- Any other related test/mock files found by pattern matching
**Find all related test and mock files:**
```bash
# Pattern-based search for related files
basename=$(basename {filename} .js)
dirname=$(dirname superset-frontend/{filename})
# Find test files
find "$dirname" -name "${basename}.test.js" -o -name "${basename}.test.jsx"
find "$dirname" -name "${basename}.spec.js" -o -name "${basename}.spec.jsx"
# Find mock files
find "$dirname" -name "__mocks__/${basename}.js"
find "$dirname" -name "${basename}.mock.js"
```
**Migration Requirement:** All discovered related files MUST be migrated together as one atomic unit.
**Test File Creation:** If NO test files exist for the core file, CREATE a minimal test file using the following pattern:
- Location: Same directory as core file
- Name: `{basename}.test.ts` (e.g., `DebouncedMessageQueue.test.ts`)
- Content: Basic test structure importing and testing the main functionality
- Use proper TypeScript types in test file
---
## 🗺️ Type Reference Map
### From `@superset-ui/core`
```typescript
// Data & Query
QueryFormData, QueryData, JsonObject, AnnotationData, AdhocMetric
LatestQueryFormData, GenericDataType, DatasourceType, ExtraFormData
DataMaskStateWithId, NativeFilterScope, NativeFiltersState, NativeFilterTarget
// UI & Theme
FeatureFlagMap, LanguagePack, ColorSchemeConfig, SequentialSchemeConfig
```
### From `@superset-ui/chart-controls`
```typescript
Dataset, ColumnMeta, ControlStateMapping
```
### From Local Types (`src/types/`)
```typescript
// Authentication
User, UserWithPermissionsAndRoles, BootstrapUser, PermissionsAndRoles
// Dashboard
Dashboard, DashboardState, DashboardInfo, DashboardLayout, LayoutItem
ComponentType, ChartConfiguration, ActiveFilters
// Charts
Chart, ChartState, ChartStatus, ChartLinkedDashboard, Slice, SaveActionType
// Data
Datasource, Database, Owner, Role
// UI Components
TagType, FavoriteStatus, Filter, ImportResourceName
```
### From Domain Types
```typescript
// src/dashboard/types.ts
RootState, ChartsState, DatasourcesState, FilterBarOrientation
ChartCrossFiltersConfig, ActiveTabs, MenuKeys
// src/explore/types.ts
ExplorePageInitialData, ExplorePageState, ExploreResponsePayload, OptionSortType
// src/SqlLab/types.ts
[SQL Lab specific types]
```
---
## 🏗️ Type Organization Strategy
### Type Placement Hierarchy
1. **Component-Colocated** (90% of cases)
```typescript
// Same file as component
interface MyComponentProps {
title: string;
onClick: () => void;
}
```
2. **Feature-Shared**
```typescript
// src/[domain]/components/[Feature]/types.ts
export interface FilterConfiguration {
filterId: string;
targets: NativeFilterTarget[];
}
```
3. **Domain-Wide**
```typescript
// src/[domain]/types.ts
export interface ExploreFormData extends QueryFormData {
viz_type: string;
}
```
4. **Global**
```typescript
// src/types/[TypeName].ts
export interface ApiResponse<T> {
result: T;
count?: number;
}
```
### Type Discovery Commands
```bash
# Search existing types before creating
find superset-frontend/src -name "types.ts" -exec grep -l "[TypeConcept]" {} \;
grep -r "interface.*Props\|type.*Props" superset-frontend/src/
```
### Derivative Component Patterns
**Rule:** Components that extend others should extend their type interfaces.
```typescript
// ✅ Base component type
interface SelectProps {
value: string | number;
options: SelectOption[];
onChange: (value: string | number) => void;
disabled?: boolean;
}
// ✅ Derivative extends base
interface ChartSelectProps extends SelectProps {
charts: Chart[];
onChartSelect: (chart: Chart) => void;
}
// ✅ Derivative with modified props
interface DatabaseSelectProps extends Omit<SelectProps, 'value' | 'onChange'> {
value: number; // Narrowed type
onChange: (databaseId: number) => void; // Specific signature
}
```
**Common Patterns:**
- **Extension:** `extends BaseProps` - adds new props
- **Omission:** `Omit<BaseProps, 'prop'>` - removes props
- **Modification:** `Omit<BaseProps, 'prop'> & { prop: NewType }` - changes prop type
- **Restriction:** Override with narrower types (union → specific)
---
## 📋 Migration Recipe
### Step 2: File Conversion
```bash
# Use git mv to preserve history
git mv component.js component.ts
git mv Component.jsx Component.tsx
```
### Step 3: Import & Type Setup
```typescript
// Import order (enforced by linting)
import { FC, ReactNode } from 'react';
import { JsonObject, QueryFormData } from '@superset-ui/core';
import { Dataset } from '@superset-ui/chart-controls';
import type { Dashboard } from 'src/types/Dashboard';
```
### Step 4: Function & Component Typing
```typescript
// Functions with proper parameter/return types
export function processData(
data: Dataset[],
config: JsonObject
): ProcessedData[] {
// implementation
}
// Component props with inheritance
interface ComponentProps extends BaseProps {
data: Chart[];
onSelect: (id: number) => void;
}
const Component: FC<ComponentProps> = ({ data, onSelect }) => {
// implementation
};
```
### Step 5: State & Redux Typing
```typescript
// Hooks with specific types
const [data, setData] = useState<Chart[]>([]);
const [selected, setSelected] = useState<number | null>(null);
// Redux with existing RootState
const mapStateToProps = (state: RootState) => ({
charts: state.charts,
user: state.user,
});
```
---
## 🧠 Type Debugging Strategies (Real-World Learnings)
### The Evolution of Type Approaches
When you hit type errors, follow this debugging evolution:
#### 1. ❌ Idealized Union Types (First Attempt)
```typescript
// Looks clean but doesn't match reality
type DatasourceInput = Datasource | QueryEditor;
```
**Problem**: Real calling sites pass variations, not exact types.
#### 2. ❌ Overly Precise Types (Second Attempt)
```typescript
// Tried to match exact calling signatures
type DatasourceInput =
| IDatasource // From DatasourcePanel
| (QueryEditor & { columns: ColumnMeta[] }); // From SaveQuery
```
**Problem**: Too rigid, doesn't handle legacy variations.
#### 3. ✅ Flexible Interface (Final Solution)
```typescript
// Captures what the function actually needs
interface DatasourceInput {
name?: string | null; // Allow null for compatibility
datasource_name?: string | null; // Legacy variations
columns?: any[]; // Multiple column types accepted
database?: { id?: number };
// ... other optional properties
}
```
**Success**: Works with all calling sites, focuses on function needs.
### Type Debugging Process
1. **Start with compilation errors** - they show exact mismatches
2. **Examine actual usage** - look at calling sites, not idealized types
3. **Build flexible interfaces** - capture what functions need, not rigid contracts
4. **Iterate based on downstream validation** - let calling sites guide your types
---
## 🚨 Anti-Patterns to Avoid
```typescript
// ❌ Never use any
const obj: any = {};
// ✅ Use proper types
const obj: Record<string, JsonObject> = {};
// ❌ Don't recreate base component props
interface ChartSelectProps {
value: string; // Duplicated from SelectProps
onChange: () => void; // Duplicated from SelectProps
charts: Chart[]; // New prop
}
// ✅ Inherit and extend
interface ChartSelectProps extends SelectProps {
charts: Chart[]; // Only new props
}
// ❌ Don't create ad-hoc type variations
interface UserInfo {
name: string;
email: string;
}
// ✅ Extend existing types (DRY principle)
import { User } from 'src/types/bootstrapTypes';
type UserDisplayInfo = Pick<User, 'firstName' | 'lastName' | 'email'>;
// ❌ Don't create overly rigid unions
type StrictInput = ExactTypeA | ExactTypeB;
// ✅ Create flexible interfaces for function parameters
interface FlexibleInput {
// Focus on what the function actually needs
commonProperty: string;
optionalVariations?: any; // Allow for legacy variations
}
```
## 📍 DRY Type Guidelines (WHERE TYPES BELONG)
### Type Placement Rules
**CRITICAL**: Type variations must live close to where they belong, not scattered across files.
#### ✅ Proper Type Organization
```typescript
// ❌ Don't create one-off interfaces in utility files
// src/utils/datasourceUtils.ts
interface DatasourceInput { /* custom interface */ } // Wrong!
// ✅ Use existing types or extend them in their proper domain
// src/utils/datasourceUtils.ts
import { IDatasource } from 'src/explore/components/DatasourcePanel';
import { QueryEditor } from 'src/SqlLab/types';
// Create flexible interface that references existing types
interface FlexibleDatasourceInput {
// Properties that actually exist across variations
}
```
#### Type Location Hierarchy
1. **Domain Types**: `src/{domain}/types.ts` (dashboard, explore, SqlLab)
2. **Component Types**: Co-located with components
3. **Global Types**: `src/types/` directory
4. **Utility Types**: Only when they truly don't belong elsewhere
#### ✅ DRY Type Patterns
```typescript
// ✅ Extend existing domain types
interface SaveQueryData extends Pick<QueryEditor, 'sql' | 'dbId' | 'catalog'> {
columns: ColumnMeta[]; // Add what's needed
}
// ✅ Create flexible interfaces for cross-domain utilities
interface CrossDomainInput {
// Common properties that exist across different source types
name?: string | null; // Accommodate legacy null values
// Only include properties the function actually uses
}
```
---
## 🎯 PropTypes Auto-Generation (Elegant Approach)
**IMPORTANT**: Superset has `babel-plugin-typescript-to-proptypes` configured to automatically generate PropTypes from TypeScript interfaces. Use this instead of manual PropTypes duplication!
### ❌ Manual PropTypes Duplication (Avoid This)
```typescript
export interface MyComponentProps {
title: string;
count?: number;
}
// 8+ lines of manual PropTypes duplication 😱
const propTypes = PropTypes.shape({
title: PropTypes.string.isRequired,
count: PropTypes.number,
});
export default propTypes;
```
### ✅ Auto-Generated PropTypes (Use This)
```typescript
import { InferProps } from 'prop-types';
export interface MyComponentProps {
title: string;
count?: number;
}
// Single validator function - babel plugin auto-generates PropTypes! ✨
export default function MyComponentValidator(props: MyComponentProps) {
return null; // PropTypes auto-assigned by babel-plugin-typescript-to-proptypes
}
// Optional: For consumers needing PropTypes type inference
export type MyComponentPropsInferred = InferProps<typeof MyComponentValidator>;
```
### Migration Pattern for Type-Only Files
**When migrating type-only files with manual PropTypes:**
1. **Keep the TypeScript interfaces** (single source of truth)
2. **Replace manual PropTypes** with validator function
3. **Remove PropTypes imports** and manual shape definitions
4. **Add InferProps import** if type inference needed
**Example Migration:**
```typescript
// Before: 25+ lines with manual PropTypes duplication
export interface AdhocFilterType { /* ... */ }
const adhocFilterTypePropTypes = PropTypes.oneOfType([...]);
// After: 3 lines with auto-generation
export interface AdhocFilterType { /* ... */ }
export default function AdhocFilterValidator(props: { filter: AdhocFilterType }) {
return null; // Auto-generated PropTypes by babel plugin
}
```
### Component PropTypes Pattern
**For React components, the babel plugin works automatically:**
```typescript
interface ComponentProps {
title: string;
onClick: () => void;
}
const MyComponent: FC<ComponentProps> = ({ title, onClick }) => {
// Component implementation
};
// PropTypes automatically generated by babel plugin - no manual work needed!
export default MyComponent;
```
### Auto-Generation Benefits
- ✅ **Single source of truth**: TypeScript interfaces drive PropTypes
- ✅ **No duplication**: Eliminate 15-20 lines of manual PropTypes code
- ✅ **Automatic updates**: Changes to TypeScript automatically update PropTypes
- ✅ **Type safety**: Compile-time checking ensures PropTypes match interfaces
- ✅ **Backward compatibility**: Existing JavaScript components continue working
### Babel Plugin Configuration
The plugin is already configured in `babel.config.js`:
```javascript
['babel-plugin-typescript-to-proptypes', { loose: true }]
```
**No additional setup required** - just use TypeScript interfaces and the plugin handles the rest!
---
## 🧪 Test File Migration Patterns
### Test File Priority
- **Always migrate test files** alongside production files
- **Test files are often leaf nodes** - good starting candidates
- **Create tests if missing** - Leverage new TypeScript types for better test coverage
### Test-Specific Type Patterns
```typescript
// Mock interfaces for testing
interface MockStore {
getState: () => Partial<RootState>; // Partial allows minimal mocking
}
// Type-safe mocking for complex objects
const mockDashboardInfo: Partial<DashboardInfo> as DashboardInfo = {
id: 123,
json_metadata: '{}',
};
// Sinon stub typing
let postStub: sinon.SinonStub;
beforeEach(() => {
postStub = sinon.stub(SupersetClient, 'post');
});
// Use stub reference instead of original method
expect(postStub.callCount).toBe(1);
expect(postStub.getCall(0).args[0].endpoint).toMatch('/api/');
```
### Test Migration Recipe
1. **Migrate production file first** (if both need migration)
2. **Update test imports** to point to `.ts/.tsx` files
3. **Add proper mock typing** using `Partial<T> as T` pattern
4. **Fix stub typing** - Use stub references, not original methods
5. **Verify all tests pass** with TypeScript compilation
---
## 🔧 Type Conflict Resolution
### Multiple Type Definitions Issue
**Problem**: Same type name defined in multiple files causes compilation errors.
**Example**: `DashboardInfo` defined in both:
- `src/dashboard/reducers/types.ts` (minimal)
- `src/dashboard/components/Header/types.ts` (different shape)
- `src/dashboard/types.ts` (complete - used by RootState)
### Resolution Strategy
1. **Identify the authoritative type**:
```bash
# Find which type is used by RootState/main interfaces
grep -r "DashboardInfo" src/dashboard/types.ts
```
2. **Use import from authoritative source**:
```typescript
// ✅ Import from main domain types
import { RootState, DashboardInfo } from 'src/dashboard/types';
// ❌ Don't import from component-specific files
import { DashboardInfo } from 'src/dashboard/components/Header/types';
```
3. **Mock complex types in tests**:
```typescript
// For testing - provide minimal required fields
const mockInfo: Partial<DashboardInfo> as DashboardInfo = {
id: 123,
json_metadata: '{}',
// Only provide fields actually used in test
};
```
### Type Hierarchy Discovery Commands
```bash
# Find all definitions of a type
grep -r "interface.*TypeName\|type.*TypeName" src/
# Find import usage patterns
grep -r "import.*TypeName" src/
# Check what RootState uses
grep -A 10 -B 10 "TypeName" src/*/types.ts
```
---
## Agent Constraints (CRITICAL)
1. **Use git mv** - Run `git mv file.js file.ts` to preserve git history, but NO `git commit`
2. **NO global import changes** - Don't update imports across codebase
3. **Type files OK** - Can modify existing type files to improve/align types
4. **Single-File TypeScript Validation** (CRITICAL) - tsc has known issues with multi-file compilation:
- **Core Issue**: TypeScript's `tsc` has documented problems validating multiple files simultaneously in complex projects
- **Solution**: ALWAYS validate files one at a time using individual `tsc` calls
- **Command Pattern**: `cd superset-frontend && npx tscw --noEmit --allowJs --composite false --project tsconfig.json {single-file-path}`
- **Why**: Multi-file validation can produce false positives, miss real errors, and conflict during parallel agent execution
5. **Downstream Impact Validation** (CRITICAL) - Your migration affects calling sites:
- **Find downstream files**: `find superset-frontend/src -name "*.tsx" -o -name "*.ts" | xargs grep -l "your-core-filename" 2>/dev/null || echo "No files found"`
- **Validate each downstream file individually**: `cd superset-frontend && npx tscw --noEmit --allowJs --composite false --project tsconfig.json {each-downstream-file}`
- **Fix type mismatches** you introduced in calling sites
- **NEVER ignore downstream errors** - they indicate your types don't match reality
6. **Avoid Project-Wide Validation During Migration**:
- **NEVER use `npm run type`** during parallel agent execution - produces unreliable results
- **Single-file validation is authoritative** - trust individual file checks over project-wide scans
6. **ESLint validation** - Run `npm run eslint -- --fix {file}` for each migrated file to auto-fix formatting/linting issues
6. Zero `any` types - use proper TypeScript types
7. Search existing types before creating new ones
8. Follow patterns from this guide
---
## Success Report Format
```
SUCCESS: Atomic Migration of {core-filename}
## Files Migrated (Atomic Unit)
- Core: {core-filename} → {core-filename.ts/tsx}
- Tests: {list-of-test-files} → {list-of-test-files.ts/tsx} OR "CREATED: {basename}.test.ts"
- Mocks: {list-of-mock-files} → {list-of-mock-files.ts}
- Type files modified: {list-of-type-files}
## Types Created/Improved
- {TypeName}: {location} ({scope}) - {rationale}
- {ExistingType}: enhanced in {location} - {improvement-description}
## Documentation Recommendations
- ADD_TO_DIRECTORY: {TypeName} - {reason}
- NO_DOCUMENTATION: {TypeName} - {reason}
## Quality Validation
- **Single-File TypeScript Validation**: ✅ PASS - Core files individually validated
- Core file: `npx tscw --noEmit --allowJs --composite false --project tsconfig.json {core-file}`
- Test files: `npx tscw --noEmit --allowJs --composite false --project tsconfig.json {test-file}` (if exists)
- **Downstream Impact Check**: ✅ PASS - Found {N} files importing this module, all validate individually
- Downstream files: {list-of-files-that-import-your-module}
- Individual validation: `npx tscw --noEmit --allowJs --composite false --project tsconfig.json {each-downstream-file}`
- **ESLint validation**: ✅ PASS (using `npm run eslint -- --fix {files}` to auto-fix formatting)
- **Zero any types**: ✅ PASS
- **Local imports resolved**: ✅ PASS
- **Functionality preserved**: ✅ PASS
- **Tests pass** (if test file): ✅ PASS
- **Follow-up action required**: {YES/NO}
## Validation Strategy Notes
- **Single-file approach used**: Avoided multi-file tsc validation due to known TypeScript compilation issues
- **Project-wide validation skipped**: `npm run type` not used during parallel migration to prevent false positives
## Migration Learnings
- Type conflicts encountered: {describe any multiple type definitions}
- Mock patterns used: {describe test mocking approaches}
- Import hierarchy decisions: {note authoritative type sources used}
- PropTypes strategy: {AUTO_GENERATED via babel plugin | MANUAL_DUPLICATION_REMOVED | N/A}
## Improvement Suggestions for Documentation
- AGENT.md enhancement: {suggest additions to migration guide}
- Common pattern identified: {note reusable patterns for future migrations}
```
---
## Dependency Block Report Format
```
DEPENDENCY_BLOCK: Cannot migrate {filename}
## Blocking Dependencies
- {path}: {type} - {usage} - {priority}
## Impact Analysis
- Estimated types: {number}
- Expected locations: {list}
- Cross-domain: {YES/NO}
## Recommended Order
{ordered-list}
```
---
## 📚 Quick Reference
**Type Utilities:**
- `Record<K, V>` - Object with specific key/value types
- `Partial<T>` - All properties optional
- `Pick<T, K>` - Subset of properties
- `Omit<T, K>` - Exclude specific properties
- `NonNullable<T>` - Exclude null/undefined
**Event Types:**
- `MouseEvent<HTMLButtonElement>`
- `ChangeEvent<HTMLInputElement>`
- `FormEvent<HTMLFormElement>`
**React Types:**
- `FC<Props>` - Functional component
- `ReactNode` - Any renderable content
- `CSSProperties` - Style objects
---
**Remember:** Every type should add value and clarity. The goal is meaningful type safety that catches bugs and improves developer experience.

View File

@@ -0,0 +1,199 @@
# JS-to-TS Coordinator Workflow
**Role:** Strategic migration coordination - select leaf-node files, trigger agents, review results, handle integration, manage dependencies.
---
## 1. Core File Selection Strategy
**Target ONLY Core Files**: Coordinators identify core files (production code), agents handle related tests/mocks atomically.
**File Analysis Commands**:
```bash
# Find CORE files with no JS/JSX dependencies (exclude tests/mocks) - SIZE PRIORITIZED
find superset-frontend/src -name "*.js" -o -name "*.jsx" | grep -v "test\|spec\|mock" | xargs wc -l | sort -n | head -20
# Alternative: Get file sizes in lines with paths
find superset-frontend/src -name "*.js" -o -name "*.jsx" | grep -v "test\|spec\|mock" | while read file; do
lines=$(wc -l < "$file")
echo "$lines $file"
done | sort -n | head -20
# Check dependencies for core files only (start with smallest)
for file in <core-files-sorted-by-size>; do
echo "=== $file ($(wc -l < "$file") lines) ==="
grep -E "from '\.\./.*\.jsx?'|from '\./.*\.jsx?'|from 'src/.*\.jsx?'" "$file" || echo "✅ LEAF CANDIDATE"
done
# Identify heavily imported files (migrate last)
grep -r "from.*utils/common" superset-frontend/src/ | wc -l
# Quick leaf analysis with size priority
find superset-frontend/src -name "*.js" -o -name "*.jsx" | grep -v "test\|spec\|mock" | head -30 | while read file; do
deps=$(grep -E "from '\.\./.*\.jsx?'|from '\./.*\.jsx?'|from 'src/.*\.jsx?'" "$file" | wc -l)
lines=$(wc -l < "$file")
if [ "$deps" -eq 0 ]; then
echo "✅ LEAF: $lines lines - $file"
fi
done | sort -n
```
**Priority Order** (Smallest files first for easier wins):
1. **Small leaf files** (<50 lines) - No JS/JSX imports, quick TypeScript conversion
2. **Medium leaf files** (50-200 lines) - Self-contained utilities and helpers
3. **Small dependency files** (<100 lines) - Import only already-migrated files
4. **Larger components** (200+ lines) - Complex but well-contained functionality
5. **Core foundational files** (utils/common.js, controls.jsx) - migrate last regardless of size
**Size-First Benefits**:
- Faster completion builds momentum
- Earlier validation of migration patterns
- Easier rollback if issues arise
- Better success rate for agent learning
**Migration Unit**: Each agent call migrates:
- 1 core file (primary target)
- All related `*.test.js/jsx` files
- All related `*.mock.js` files
- All related `__mocks__/` files
---
## 2. Task Creation & Agent Control
### Task Triggering
When triggering the `/js-to-ts` command:
- **Task Title**: Use the core filename as the task title (e.g., "DebouncedMessageQueue.js migration", "hostNamesConfig.js migration")
- **Task Description**: Include the full relative path to help agent locate the file
- **Reference**: Point agent to [AGENT.md](./AGENT.md) for technical instructions
### Post-Processing Workflow
After each agent completes:
1. **Review Agent Report**: Always read and analyze the complete agent report
2. **Share Summary**: Provide user with key highlights from agent's work:
- Files migrated (core + tests/mocks)
- Types created or improved
- Any validation issues or coordinator actions needed
3. **Quality Assessment**: Evaluate agent's TypeScript implementation against criteria:
-**Type Usage**: Proper types used, no `any` types
-**Type Filing**: Types placed in correct hierarchy (component → feature → domain → global)
-**Side Effects**: No unintended changes to other files
-**Import Alignment**: Proper .ts/.tsx import extensions
4. **Integration Decision**:
- **COMMIT**: If agent work is complete and high quality
- **FIX & COMMIT**: If minor issues need coordinator fixes
- **ROLLBACK**: If major issues require complete rework
5. **Next Action**: Ask user preference - commit this work or trigger next migration
---
## 3. Integration Decision Framework
**Automatic Integration** ✅:
- `npm run type` passes without errors
- Agent created clean TypeScript with proper types
- Types appropriately filed in hierarchy
**Coordinator Integration** (Fix Side-Effects) 🔧:
- `npm run type` fails BUT agent's work is high quality
- Good type usage, proper patterns, well-organized
- Side-effects are manageable TypeScript compilation errors
- **Coordinator Action**: Integrate the change, then fix global compilation issues
**Rollback Only** ❌:
- Agent introduced `any` types or poor type choices
- Types poorly organized or conflicting with existing patterns
- Fundamental approach issues requiring complete rework
**Integration Process**:
1. **Review**: Agent already used `git mv` to preserve history
2. **Fix Side-Effects**: Update dependent files with proper import extensions
3. **Resolve Types**: Fix any cascading type issues across codebase
4. **Validate**: Ensure `npm run type` passes after fixes
---
## 4. Common Integration Patterns
**Common Side-Effects (Expect These)**:
- **Type import conflicts**: Multiple definitions of same type name
- **Mock object typing**: Tests need complete type satisfaction
- **Stub method references**: Use stub vars instead of original methods
**Coordinator Fixes (Standard Process)**:
1. **Import Resolution**:
```bash
# Find authoritative type source
grep -r "TypeName" src/*/types.ts
# Import from domain types (src/dashboard/types.ts) not component types
```
2. **Test Mock Completion**:
```typescript
// Use Partial<T> as T pattern for minimal mocking
const mockDashboard: Partial<DashboardInfo> as DashboardInfo = {
id: 123,
json_metadata: '{}',
};
```
3. **Stub Reference Fixes**:
```typescript
// ✅ Use stub variable
expect(postStub.callCount).toBe(1);
// ❌ Don't use original method
expect(SupersetClient.post.callCount).toBe(1);
```
4. **Validation Commands**:
```bash
npm run type # TypeScript compilation
npm test -- filename # Test functionality
git status # Should show rename, not add/delete
```
---
## 5. File Categories for Planning
### Leaf Files (Start Here)
**Self-contained files with minimal JS/JSX dependencies**:
- Test files (80 files) - Usually only import the file being tested
- Utility files without internal dependencies
- Components importing only external libraries
### Heavily Imported Files (Migrate Last)
**Core files that many others depend on**:
- `utils/common.js` - Core utility functions
- `utils/reducerUtils.js` - Redux helpers
- `@superset-ui/core` equivalent files
- Major state management files (`explore/store.js`, `dashboard/actions/`)
### Complex Components (Middle Priority)
**Large files requiring careful type analysis**:
- `components/Datasource/DatasourceEditor.jsx` (1,809 lines)
- `explore/components/controls/AnnotationLayerControl/AnnotationLayer.jsx` (1,031 lines)
- `explore/components/ExploreViewContainer/index.jsx` (911 lines)
---
## 6. Success Metrics & Continuous Improvement
**Per-File Gates**:
- ✅ `npm run type` passes after each migration
- ✅ Zero `any` types introduced
- ✅ All imports properly typed
- ✅ Types filed in correct hierarchy
**Linear Scheduling**:
When agents report `DEPENDENCY_BLOCK`:
- Queue dependencies in linear order
- Process one file at a time to avoid conflicts
- Handle cascading type changes between files
**After Each Migration**:
1. **Update guides** with new patterns discovered
2. **Document coordinator fixes** that become common
3. **Enhance agent instructions** based on recurring issues
4. **Track success metrics** - automatic vs coordinator integration rates

View File

@@ -0,0 +1,76 @@
# JavaScript to TypeScript Migration Project
Progressive migration of 219 JS/JSX files to TypeScript in Apache Superset frontend.
## 📁 Project Documentation
- **[AGENT.md](./AGENT.md)** - Complete technical migration guide for agents (includes type reference, patterns, validation)
- **[COORDINATOR.md](./COORDINATOR.md)** - Strategic workflow for coordinators (file selection, task management, integration)
## 🎯 Quick Start
**For Agents:** Read [AGENT.md](./AGENT.md) for complete migration instructions
**For Coordinators:** Read [COORDINATOR.md](./COORDINATOR.md) for workflow and [AGENT.md](./AGENT.md) for supervision
**Command:** `/js-to-ts <filename>` - See [../../commands/js-to-ts.md](../../commands/js-to-ts.md)
## 📊 Migration Progress
**Scope**: 219 files total (112 JS + 107 JSX)
- Production files: 139 (63%)
- Test files: 80 (37%)
**Strategy**: Leaf-first migration with dependency-aware coordination
### Completed Migrations ✅
1. **roundDecimal** - `plugins/legacy-plugin-chart-map-box/src/utils/roundDecimal.js`
- Migrated core + test files
- Added proper TypeScript function signature with optional precision parameter
- All tests pass
2. **timeGrainSqlaAnimationOverrides** - `src/explore/controlPanels/timeGrainSqlaAnimationOverrides.js`
- Migrated to TypeScript with ControlPanelState and Dataset types
- Added TimeGrainOverrideState interface for return type
- Used type guards for safe property access
3. **DebouncedMessageQueue** - `src/utils/DebouncedMessageQueue.js`
- Migrated to TypeScript with proper generics
- Created DebouncedMessageQueueOptions interface
- **CREATED test file** with 4 comprehensive test cases
- Excellent class property typing with private/readonly modifiers
**Files Migrated**: 3/219 (1.4%)
**Tests Created**: 2 (roundDecimal had existing, DebouncedMessageQueue created)
### Next Candidates (Leaf Nodes) 🎯
**Identified leaf files with no JS/JSX dependencies:**
- `src/utils/hostNamesConfig.js` - Domain configuration utility
- `src/explore/controlPanels/Separator.js` - Control panel configuration
- `src/middleware/loggerMiddleware.js` - Logging middleware
**Migration Quality**: All completed migrations have:
- ✅ Zero `any` types
- ✅ Proper TypeScript compilation
- ✅ ESLint validation passed
- ✅ Test coverage (created where missing)
---
## 📈 Success Metrics
**Per-File Gates**:
-`npm run type` passes after each migration
- ✅ Zero `any` types introduced
- ✅ All imports properly typed
- ✅ Types filed in correct hierarchy
**Overall Progress**:
- **Automatic Integration Rate**: 100% (3/3 migrations required no coordinator fixes)
- **Test Coverage**: Improved (1 new test file created)
- **Type Safety**: Enhanced with proper interfaces and generics
---
*This is a claudette-managed progressive refactor. All documentation and coordination resources are organized under `.claude/projects/js-to-ts/`*

View File

@@ -184,8 +184,10 @@ 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:
- "127.0.0.1:9000:9000" # exposing the dynamic webpack dev server
- "9000:9000" # exposing the dynamic webpack dev server
container_name: superset_node
command: ["/app/docker/docker-frontend.sh"]
env_file:

View File

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

View File

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

View File

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

View File

@@ -17,8 +17,26 @@
* under the License.
*/
import { ADD_TOAST, REMOVE_TOAST } from './actions';
import { ToastMeta } from './types';
export default function messageToastsReducer(toasts = [], action) {
interface AddToastAction {
type: typeof ADD_TOAST;
payload: ToastMeta;
}
interface RemoveToastAction {
type: typeof REMOVE_TOAST;
payload: {
id: string;
};
}
type ToastAction = AddToastAction | RemoveToastAction;
export default function messageToastsReducer(
toasts: ToastMeta[] = [],
action: ToastAction,
): ToastMeta[] {
switch (action.type) {
case ADD_TOAST: {
const { payload: toast } = action;

View File

@@ -33,8 +33,11 @@ interface ModalFormFieldProps {
hasFeedback?: boolean;
}
const StyledFieldContainer = styled.div<{ bottomSpacing: boolean }>`
${({ theme, bottomSpacing }) => css`
const StyledFieldContainer = styled.div<{
bottomSpacing: boolean;
hasError: boolean;
}>`
${({ theme, bottomSpacing, hasError }) => css`
flex: 1;
margin-top: 0px;
margin-bottom: ${bottomSpacing ? theme.sizeUnit * 4 : 0}px;
@@ -48,7 +51,7 @@ const StyledFieldContainer = styled.div<{ bottomSpacing: boolean }>`
.required {
margin-left: ${theme.sizeUnit / 2}px;
color: ${theme.colorError};
color: ${hasError ? theme.colorError : theme.colorIcon};
}
.helper {
@@ -128,8 +131,14 @@ export function ModalFormField({
validateStatus,
hasFeedback = false,
}: ModalFormFieldProps) {
const hasError = !!(error || validateStatus === 'error');
return (
<StyledFieldContainer bottomSpacing={bottomSpacing} data-test={testId}>
<StyledFieldContainer
bottomSpacing={bottomSpacing}
hasError={hasError}
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 TagType from 'src/types/TagType';
import type { 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 TagType from 'src/types/TagType';
import type { 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 TagType from 'src/types/TagType';
import type { TagType } from 'src/types/TagType';
import { Tag as AntdTag } from '@superset-ui/core/components/Tag';
import { Tooltip } from '@superset-ui/core/components/Tooltip';
import type { TagProps } from 'antd/es';

View File

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

View File

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

View File

@@ -26,9 +26,40 @@ import {
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { Dispatch } from 'redux';
import { Slice } from '../types';
import { HYDRATE_DASHBOARD } from './hydrate';
const FETCH_SLICES_PAGE_SIZE = 200;
// State types
export interface SliceEntitiesState {
slices: { [id: number]: Slice };
isLoading: boolean;
errorMessage: string | null;
lastUpdated: number;
}
// Action types
export type SliceEntitiesActionPayload =
| {
type: typeof ADD_SLICES;
payload: { slices: { [id: number]: Slice } };
}
| {
type: typeof SET_SLICES;
payload: { slices: { [id: number]: Slice } };
}
| {
type: typeof FETCH_ALL_SLICES_STARTED;
}
| {
type: typeof FETCH_ALL_SLICES_FAILED;
payload: { error: string };
}
| {
type: typeof HYDRATE_DASHBOARD;
data: { sliceEntities: SliceEntitiesState };
};
export function getDatasourceParameter(
datasourceId: number,
datasourceType: DatasourceType,

View File

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

View File

@@ -50,6 +50,7 @@ import { Dispatch } from 'redux';
import { Slice } from 'src/dashboard/types';
import { withTheme, Theme } from '@emotion/react';
import { navigateTo } from 'src/utils/navigationUtils';
import type { ConnectDragSource } from 'react-dnd';
import AddSliceCard from './AddSliceCard';
import AddSliceDragPreview from './dnd/AddSliceDragPreview';
import { DragDroppable } from './dnd/DragDroppable';
@@ -312,7 +313,7 @@ class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
// actual style should be applied to nested AddSliceCard component
style={{}}
>
{({ dragSourceRef }) => (
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<AddSliceCard
innerRef={dragSourceRef}
style={style}

View File

@@ -1,85 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import handleHover from './handleHover';
import handleDrop from './handleDrop';
// note: the 'type' hook is not useful for us as dropping is contingent on other properties
const TYPE = 'DRAG_DROPPABLE';
export const dragConfig = [
TYPE,
{
canDrag(props) {
return !props.disableDragDrop;
},
// this defines the dragging item object returned by monitor.getItem()
beginDrag(props /* , monitor, component */) {
const { component, index, parentComponent = {} } = props;
return {
type: component.type,
id: component.id,
meta: component.meta,
index,
parentId: parentComponent.id,
parentType: parentComponent.type,
};
},
},
function dragStateToProps(connect, monitor) {
return {
dragSourceRef: connect.dragSource(),
dragPreviewRef: connect.dragPreview(),
isDragging: monitor.isDragging(),
dragComponentType: monitor.getItem()?.type,
dragComponentId: monitor.getItem()?.id,
};
},
];
export const dropConfig = [
TYPE,
{
canDrop(props) {
return !props.disableDragDrop;
},
hover(props, monitor, component) {
if (component && component.mounted) {
handleHover(props, monitor, component);
}
},
// note:
// the react-dnd api requires that the drop() method return a result or undefined
// monitor.didDrop() cannot be used because it returns true only for the most-nested target
drop(props, monitor, component) {
const dropResult = monitor.getDropResult();
if ((!dropResult || !dropResult.destination) && component.mounted) {
return handleDrop(props, monitor, component);
}
return undefined;
},
},
function dropStateToProps(connect, monitor) {
return {
droppableRef: connect.dropTarget(),
isDraggingOver: monitor.isOver(),
isDraggingOverShallow: monitor.isOver({ shallow: true }),
};
},
];

View File

@@ -0,0 +1,188 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
DragSourceMonitor,
DropTargetMonitor,
ConnectDragSource,
ConnectDragPreview,
ConnectDropTarget,
} from 'react-dnd';
import { LayoutItem, ComponentType } from 'src/dashboard/types';
import handleHover from './handleHover';
import handleDrop from './handleDrop';
// note: the 'type' hook is not useful for us as dropping is contingent on other properties
const TYPE = 'DRAG_DROPPABLE';
export interface DragDroppableProps {
component: LayoutItem;
parentComponent?: LayoutItem;
index: number;
disableDragDrop: boolean;
onDrop?: (dropResult: DropResult) => void;
onHover?: () => void;
dropToChild?: boolean | ((draggingItem: DragItem) => boolean);
}
export interface DragItem {
type: ComponentType;
id: string;
meta: LayoutItem['meta'];
index: number;
parentId?: string;
parentType?: ComponentType;
}
export interface DropResult {
source: {
id: string;
type: ComponentType;
index: number;
};
dragging: {
id: string;
type: ComponentType;
meta: LayoutItem['meta'];
};
destination?: {
id: string;
type: ComponentType;
index: number;
};
position?: string;
}
export interface DragStateProps {
dragSourceRef: ConnectDragSource;
dragPreviewRef: ConnectDragPreview;
isDragging: boolean;
dragComponentType?: ComponentType;
dragComponentId?: string;
}
export interface DropStateProps {
droppableRef: ConnectDropTarget;
isDraggingOver: boolean;
isDraggingOverShallow: boolean;
}
export interface DragDroppableComponent {
mounted: boolean;
props: DragDroppableProps;
setState: (stateUpdate: () => { dropIndicator: string | null }) => void;
}
export const dragConfig: [
string,
{
canDrag: (props: DragDroppableProps) => boolean;
beginDrag: (props: DragDroppableProps) => DragItem;
},
(connect: any, monitor: DragSourceMonitor) => DragStateProps,
] = [
TYPE,
{
canDrag(props: DragDroppableProps): boolean {
return !props.disableDragDrop;
},
// this defines the dragging item object returned by monitor.getItem()
beginDrag(props: DragDroppableProps): DragItem {
const { component, index, parentComponent } = props;
return {
type: component.type,
id: component.id,
meta: component.meta,
index,
parentId: parentComponent?.id,
parentType: parentComponent?.type,
};
},
},
function dragStateToProps(
connect: any,
monitor: DragSourceMonitor,
): DragStateProps {
return {
dragSourceRef: connect.dragSource(),
dragPreviewRef: connect.dragPreview(),
isDragging: monitor.isDragging(),
dragComponentType: monitor.getItem()?.type as ComponentType,
dragComponentId: monitor.getItem()?.id as string,
};
},
];
export const dropConfig: [
string,
{
canDrop: (props: DragDroppableProps) => boolean;
hover: (
props: DragDroppableProps,
monitor: DropTargetMonitor,
component: DragDroppableComponent,
) => void;
drop: (
props: DragDroppableProps,
monitor: DropTargetMonitor,
component: DragDroppableComponent,
) => DropResult | undefined;
},
(connect: any, monitor: DropTargetMonitor) => DropStateProps,
] = [
TYPE,
{
canDrop(props: DragDroppableProps): boolean {
return !props.disableDragDrop;
},
hover(
props: DragDroppableProps,
monitor: DropTargetMonitor,
component: DragDroppableComponent,
): void {
if (component && component.mounted) {
handleHover(props, monitor, component);
}
},
// note:
// the react-dnd api requires that the drop() method return a result or undefined
// monitor.didDrop() cannot be used because it returns true only for the most-nested target
drop(
props: DragDroppableProps,
monitor: DropTargetMonitor,
component: DragDroppableComponent,
): DropResult | undefined {
const dropResult = monitor.getDropResult() as DropResult | null;
if ((!dropResult || !dropResult.destination) && component.mounted) {
return handleDrop(props, monitor, component);
}
return undefined;
},
},
function dropStateToProps(
connect: any,
monitor: DropTargetMonitor,
): DropStateProps {
return {
droppableRef: connect.dropTarget(),
isDraggingOver: monitor.isOver(),
isDraggingOverShallow: monitor.isOver({ shallow: true }),
};
},
];

View File

@@ -22,6 +22,7 @@ import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
import cx from 'classnames';
import { shallowEqual, useSelector } from 'react-redux';
import { ResizeCallback, ResizeStartCallback } from 're-resizable';
import type { ConnectDragSource } from 'react-dnd';
import { Draggable } from '../../dnd/DragDroppable';
import { COLUMN_TYPE, ROW_TYPE } from '../../../util/componentTypes';
import WithPopoverMenu from '../../menu/WithPopoverMenu';
@@ -119,7 +120,7 @@ const DynamicComponent: FC<DynamicComponentProps> = ({
onDrop={handleComponentDrop}
editMode={editMode}
>
{({ dragSourceRef }) => (
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<WithPopoverMenu
menuItems={[
<BackgroundStyleDropdown

View File

@@ -20,6 +20,7 @@ import { PureComponent } from 'react';
import cx from 'classnames';
import { css, styled } from '@superset-ui/core';
import { DragDroppable } from 'src/dashboard/components/dnd/DragDroppable';
import type { ConnectDragSource } from 'react-dnd';
import { NEW_COMPONENTS_SOURCE_ID } from 'src/dashboard/util/constants';
import { NEW_COMPONENT_SOURCE_TYPE } from 'src/dashboard/util/componentTypes';
@@ -81,7 +82,7 @@ export default class DraggableNewComponent extends PureComponent<DraggableNewCom
depth={0}
editMode
>
{({ dragSourceRef }) => (
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<NewComponent ref={dragSourceRef} data-test="new-component">
<NewComponentPlaceholder
className={cx('new-component-placeholder', className)}

View File

@@ -28,11 +28,38 @@ import {
DASHBOARD_GRID_ID,
} from '../util/constants';
export default {
import type { DashboardLayout, LayoutItemMeta } from '../types';
// Create minimal meta objects that satisfy the LayoutItemMeta type requirements
const rootMeta: LayoutItemMeta = {
chartId: 0,
height: 0,
uuid: '',
width: 0,
};
const gridMeta: LayoutItemMeta = {
chartId: 0,
height: 0,
uuid: '',
width: 0,
};
const headerMeta: LayoutItemMeta = {
chartId: 0,
height: 0,
uuid: '',
width: 0,
text: 'New dashboard',
};
const emptyDashboardLayout: DashboardLayout = {
[DASHBOARD_ROOT_ID]: {
type: DASHBOARD_ROOT_TYPE,
id: DASHBOARD_ROOT_ID,
children: [DASHBOARD_GRID_ID],
parents: [],
meta: rootMeta,
},
[DASHBOARD_GRID_ID]: {
@@ -40,14 +67,16 @@ export default {
id: DASHBOARD_GRID_ID,
children: [],
parents: [DASHBOARD_ROOT_ID],
meta: {},
meta: gridMeta,
},
[DASHBOARD_HEADER_ID]: {
type: HEADER_TYPE,
id: DASHBOARD_HEADER_ID,
meta: {
text: 'New dashboard',
},
children: [],
parents: [],
meta: headerMeta,
},
};
export default emptyDashboardLayout;

View File

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

View File

@@ -1,126 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { isEmpty } from 'lodash';
import { mapValues, flow, keyBy } from 'lodash/fp';
import {
getChartIdAndColumnFromFilterKey,
getDashboardFilterKey,
} from './getDashboardFilterKey';
import { CHART_TYPE } from './componentTypes';
import { DASHBOARD_FILTER_SCOPE_GLOBAL } from '../reducers/dashboardFilters';
let activeFilters = {};
let appliedFilterValuesByChart = {};
let allComponents = {};
// output: { [id_column]: { values, scope } }
export function getActiveFilters() {
return activeFilters;
}
// this function is to find all filter values applied to a chart,
// it goes through all active filters and their scopes.
// return: { [column]: array of selected values }
export function getAppliedFilterValues(chartId, filters) {
// use cached data if possible
if (!(chartId in appliedFilterValuesByChart)) {
const applicableFilters = Object.entries(filters || activeFilters).filter(
([, { scope: chartIds }]) => chartIds.includes(chartId),
);
appliedFilterValuesByChart[chartId] = flow(
keyBy(
([filterKey]) => getChartIdAndColumnFromFilterKey(filterKey).column,
),
mapValues(([, { values }]) => values),
)(applicableFilters);
}
return appliedFilterValuesByChart[chartId];
}
/**
* @deprecated Please use src/dashboard/util/getChartIdsInFilterScope instead
*/
export function getChartIdsInFilterScope({ filterScope }) {
function traverse(chartIds = [], component = {}, immuneChartIds = []) {
if (!component) {
return;
}
if (
component.type === CHART_TYPE &&
component.meta &&
component.meta.chartId &&
!immuneChartIds.includes(component.meta.chartId)
) {
chartIds.push(component.meta.chartId);
} else if (component.children) {
component.children.forEach(child =>
traverse(chartIds, allComponents[child], immuneChartIds),
);
}
}
const chartIds = [];
const { scope: scopeComponentIds, immune: immuneChartIds } =
filterScope || DASHBOARD_FILTER_SCOPE_GLOBAL;
scopeComponentIds.forEach(componentId =>
traverse(chartIds, allComponents[componentId], immuneChartIds),
);
return chartIds;
}
// non-empty filter fields in dashboardFilters,
// activeFilters map contains selected values and filter scope.
// values: array of selected values
// scope: array of chartIds that applicable to the filter field.
export function buildActiveFilters({ dashboardFilters = {}, components = {} }) {
// clear cache
if (!isEmpty(components)) {
allComponents = components;
}
appliedFilterValuesByChart = {};
activeFilters = Object.values(dashboardFilters).reduce((result, filter) => {
const { chartId, columns, scopes } = filter;
const nonEmptyFilters = {};
Object.keys(columns).forEach(column => {
if (
Array.isArray(columns[column])
? columns[column].length
: columns[column] !== undefined
) {
// remove filter itself
const scope = getChartIdsInFilterScope({
filterScope: scopes[column],
}).filter(id => chartId !== id);
nonEmptyFilters[getDashboardFilterKey({ chartId, column })] = {
values: columns[column],
scope,
};
}
});
return {
...result,
...nonEmptyFilters,
};
}, {});
}

View File

@@ -0,0 +1,211 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { isEmpty } from 'lodash';
import { mapValues, flow, keyBy } from 'lodash/fp';
import {
JsonValue,
DataRecordFilters,
DataRecordValue,
} from '@superset-ui/core';
import {
getChartIdAndColumnFromFilterKey,
getDashboardFilterKey,
} from './getDashboardFilterKey';
import { CHART_TYPE } from './componentTypes';
import { DASHBOARD_FILTER_SCOPE_GLOBAL } from '../reducers/dashboardFilters';
import { LayoutItem } from '../types';
// Type definitions for filters
interface FilterScope {
scope: string[];
immune?: number[];
}
interface DashboardFilterColumn {
[column: string]: JsonValue[] | JsonValue;
}
interface DashboardFilterScopes {
[column: string]: FilterScope;
}
interface DashboardFilter {
chartId: number;
columns: DashboardFilterColumn;
scopes: DashboardFilterScopes;
}
interface DashboardFilters {
[filterId: string]: DashboardFilter;
}
interface Components {
[componentId: string]: LayoutItem;
}
interface ActiveFilter {
values: JsonValue[] | JsonValue;
scope: number[];
}
interface ActiveFilters {
[filterKey: string]: ActiveFilter;
}
interface AppliedFilterValuesByChart {
[chartId: number]: DataRecordFilters;
}
interface GetChartIdsInFilterScopeProps {
filterScope?: FilterScope;
}
interface BuildActiveFiltersProps {
dashboardFilters?: DashboardFilters;
components?: Components;
}
let activeFilters: ActiveFilters = {};
let appliedFilterValuesByChart: AppliedFilterValuesByChart = {};
let allComponents: Components = {};
// output: { [id_column]: { values, scope } }
export function getActiveFilters(): ActiveFilters {
return activeFilters;
}
// this function is to find all filter values applied to a chart,
// it goes through all active filters and their scopes.
// return: { [column]: array of selected values }
export function getAppliedFilterValues(
chartId: number,
filters?: ActiveFilters,
): DataRecordFilters {
// use cached data if possible
if (!(chartId in appliedFilterValuesByChart)) {
const applicableFilters = Object.entries(filters || activeFilters).filter(
([, { scope: chartIds }]) => chartIds.includes(chartId),
);
appliedFilterValuesByChart[chartId] = flow(
keyBy(
([filterKey]: [string, ActiveFilter]) =>
getChartIdAndColumnFromFilterKey(filterKey).column,
),
mapValues(([, { values }]: [string, ActiveFilter]) => {
// Ensure values is always an array of DataRecordValue
if (Array.isArray(values)) {
return values.filter(
val => val !== null && val !== undefined,
) as DataRecordValue[];
}
// If single value, wrap in array and filter valid values
return values !== null && values !== undefined
? [values as DataRecordValue]
: [];
}),
)(applicableFilters);
}
return appliedFilterValuesByChart[chartId];
}
/**
* @deprecated Please use src/dashboard/util/getChartIdsInFilterScope instead
*/
export function getChartIdsInFilterScope({
filterScope,
}: GetChartIdsInFilterScopeProps): number[] {
function traverse(
chartIds: number[] = [],
component: LayoutItem | undefined = undefined,
immuneChartIds: number[] = [],
): void {
if (!component) {
return;
}
if (
component.type === CHART_TYPE &&
component.meta &&
component.meta.chartId &&
!immuneChartIds.includes(component.meta.chartId)
) {
chartIds.push(component.meta.chartId);
} else if (component.children) {
component.children.forEach(child =>
traverse(chartIds, allComponents[child], immuneChartIds),
);
}
}
const chartIds: number[] = [];
const { scope: scopeComponentIds, immune: immuneChartIds = [] } =
filterScope || DASHBOARD_FILTER_SCOPE_GLOBAL;
scopeComponentIds.forEach(componentId =>
traverse(chartIds, allComponents[componentId], immuneChartIds),
);
return chartIds;
}
// non-empty filter fields in dashboardFilters,
// activeFilters map contains selected values and filter scope.
// values: array of selected values
// scope: array of chartIds that applicable to the filter field.
export function buildActiveFilters({
dashboardFilters = {},
components = {},
}: BuildActiveFiltersProps): void {
// clear cache
if (!isEmpty(components)) {
allComponents = components;
}
appliedFilterValuesByChart = {};
activeFilters = Object.values(dashboardFilters).reduce(
(result: ActiveFilters, filter: DashboardFilter) => {
const { chartId, columns, scopes } = filter;
const nonEmptyFilters: ActiveFilters = {};
Object.keys(columns).forEach(column => {
if (
Array.isArray(columns[column])
? (columns[column] as JsonValue[]).length
: columns[column] !== undefined
) {
// remove filter itself
const scope = getChartIdsInFilterScope({
filterScope: scopes[column],
}).filter(id => chartId !== id);
nonEmptyFilters[
getDashboardFilterKey({ chartId: String(chartId), column })
] = {
values: columns[column],
scope,
};
}
});
return {
...result,
...nonEmptyFilters,
};
},
{},
);
}

View File

@@ -16,17 +16,41 @@
* specific language governing permissions and limitations
* under the License.
*/
import { DashboardLayout } from '../types';
import getFilterScopeNodesTree from './getFilterScopeNodesTree';
import getFilterScopeParentNodes from './getFilterScopeParentNodes';
import getKeyForFilterScopeTree from './getKeyForFilterScopeTree';
import getSelectedChartIdForFilterScopeTree from './getSelectedChartIdForFilterScopeTree';
interface FilterScopeMapItem {
checked?: number[];
expanded?: string[];
}
interface FilterScopeMap {
[key: string]: FilterScopeMapItem;
}
interface FilterScopeTreeEntry {
nodes: any[];
nodesFiltered: any[];
checked: string[];
expanded: string[];
}
interface BuildFilterScopeTreeEntryProps {
checkedFilterFields?: string[];
activeFilterField?: string;
filterScopeMap?: FilterScopeMap;
layout?: DashboardLayout;
}
export default function buildFilterScopeTreeEntry({
checkedFilterFields = [],
activeFilterField,
filterScopeMap = {},
layout = {},
}) {
}: BuildFilterScopeTreeEntryProps): Record<string, FilterScopeTreeEntry> {
const key = getKeyForFilterScopeTree({
checkedFilterFields,
activeFilterField,
@@ -43,15 +67,15 @@ export default function buildFilterScopeTreeEntry({
filterFields: editingList,
selectedChartId,
});
const checkedChartIdSet = new Set();
const checkedChartIdSet = new Set<string>();
editingList.forEach(filterField => {
(filterScopeMap[filterField].checked || []).forEach(chartId => {
(filterScopeMap[filterField]?.checked || []).forEach(chartId => {
checkedChartIdSet.add(`${chartId}:${filterField}`);
});
});
const checked = [...checkedChartIdSet];
const expanded = filterScopeMap[key]
? filterScopeMap[key].expanded
? filterScopeMap[key].expanded || []
: getFilterScopeParentNodes(nodes, 1);
return {

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
/**
* 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

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

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

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import { DASHBOARD_ROOT_TYPE, DASHBOARD_GRID_TYPE } from './componentTypes';
import type { ComponentType } from '../types';
import {
DASHBOARD_GRID_ID,
@@ -24,7 +25,22 @@ import {
DASHBOARD_VERSION_KEY,
} from './constants';
export default function getEmptyLayout() {
// Basic layout item for empty dashboard (simplified version without meta)
interface BasicLayoutItem {
type: ComponentType;
id: string;
children: string[];
parents?: string[];
}
// Empty layout structure
type EmptyLayout = {
[DASHBOARD_VERSION_KEY]: string;
[DASHBOARD_ROOT_ID]: BasicLayoutItem;
[DASHBOARD_GRID_ID]: BasicLayoutItem;
};
export default function getEmptyLayout(): EmptyLayout {
return {
[DASHBOARD_VERSION_KEY]: 'v2',
[DASHBOARD_ROOT_ID]: {

View File

@@ -0,0 +1,78 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import getKeyForFilterScopeTree from './getKeyForFilterScopeTree';
describe('getKeyForFilterScopeTree', () => {
test('should return stringified activeFilterField array when activeFilterField is provided', () => {
const props = {
activeFilterField: 'filter1',
checkedFilterFields: ['filter2', 'filter3'],
};
const result = getKeyForFilterScopeTree(props);
expect(result).toBe('["filter1"]');
});
test('should return stringified checkedFilterFields when activeFilterField is not provided', () => {
const props = {
checkedFilterFields: ['filter2', 'filter3'],
};
const result = getKeyForFilterScopeTree(props);
expect(result).toBe('["filter2","filter3"]');
});
test('should return stringified checkedFilterFields when activeFilterField is undefined', () => {
const props = {
activeFilterField: undefined,
checkedFilterFields: ['filter1'],
};
const result = getKeyForFilterScopeTree(props);
expect(result).toBe('["filter1"]');
});
test('should return stringified empty array when both fields are empty', () => {
const props = {
checkedFilterFields: [],
};
const result = getKeyForFilterScopeTree(props);
expect(result).toBe('[]');
});
test('should handle single checked filter field', () => {
const props = {
checkedFilterFields: ['singleFilter'],
};
const result = getKeyForFilterScopeTree(props);
expect(result).toBe('["singleFilter"]');
});
test('should prioritize activeFilterField over checkedFilterFields', () => {
const props = {
activeFilterField: 'activeFilter',
checkedFilterFields: ['checked1', 'checked2'],
};
const result = getKeyForFilterScopeTree(props);
expect(result).toBe('["activeFilter"]');
});
});

View File

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

View File

@@ -0,0 +1,105 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import getLayoutComponentFromChartId from './getLayoutComponentFromChartId';
import { CHART_TYPE, DASHBOARD_ROOT_TYPE } from './componentTypes';
import type { DashboardLayout, LayoutItem } from '../types';
const mockLayoutItem: LayoutItem = {
id: 'CHART-123',
type: CHART_TYPE,
children: [],
meta: {
chartId: 456,
defaultText: '',
height: 400,
placeholder: '',
sliceName: 'Test Chart',
text: '',
uuid: 'abc-def-ghi',
width: 400,
},
};
const mockRootLayoutItem: LayoutItem = {
id: 'ROOT_ID',
type: DASHBOARD_ROOT_TYPE,
children: ['CHART-123'],
meta: {
chartId: 0,
defaultText: '',
height: 0,
placeholder: '',
text: '',
uuid: 'root-uuid',
width: 0,
},
};
const mockLayout: DashboardLayout = {
'CHART-123': mockLayoutItem,
ROOT_ID: mockRootLayoutItem,
};
test('should find layout component by chart ID', () => {
const result = getLayoutComponentFromChartId(mockLayout, 456);
expect(result).toEqual(mockLayoutItem);
});
test('should return undefined when chart ID is not found', () => {
const result = getLayoutComponentFromChartId(mockLayout, 999);
expect(result).toBeUndefined();
});
test('should return undefined when layout is empty', () => {
const result = getLayoutComponentFromChartId({}, 456);
expect(result).toBeUndefined();
});
test('should ignore non-chart components', () => {
const layoutWithoutChart: DashboardLayout = {
ROOT_ID: mockRootLayoutItem,
};
const result = getLayoutComponentFromChartId(layoutWithoutChart, 456);
expect(result).toBeUndefined();
});
test('should handle components without meta', () => {
const componentWithoutMeta: LayoutItem = {
id: 'NO-META',
type: CHART_TYPE,
children: [],
meta: {
chartId: 0,
defaultText: '',
height: 0,
placeholder: '',
text: '',
uuid: 'no-meta-uuid',
width: 0,
},
};
const layoutWithoutMeta: DashboardLayout = {
'NO-META': componentWithoutMeta,
};
const result = getLayoutComponentFromChartId(layoutWithoutMeta, 456);
expect(result).toBeUndefined();
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import isDashboardLoading, { ChartLoadTimestamps } from './isDashboardLoading';
describe('isDashboardLoading', () => {
it('returns false when no charts are loading', () => {
const charts: Record<string, ChartLoadTimestamps> = {
a: { chartUpdateStartTime: 1, chartUpdateEndTime: 2 },
b: { chartUpdateStartTime: 5, chartUpdateEndTime: 5 },
};
expect(isDashboardLoading(charts)).toBe(false);
});
it('returns true when any chart has start > end', () => {
const charts: Record<string, ChartLoadTimestamps> = {
a: { chartUpdateStartTime: 10, chartUpdateEndTime: 5 },
b: { chartUpdateStartTime: 1, chartUpdateEndTime: 2 },
};
expect(isDashboardLoading(charts)).toBe(true);
});
it('treats missing end as 0', () => {
const charts: Record<string, ChartLoadTimestamps> = {
a: { chartUpdateStartTime: 1 },
};
expect(isDashboardLoading(charts)).toBe(true);
});
it('handles empty charts object', () => {
expect(isDashboardLoading({})).toBe(false);
});
});

View File

@@ -16,18 +16,19 @@
* 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;
}, []);
export interface ChartLoadTimestamps {
chartUpdateStartTime?: number;
chartUpdateEndTime?: number | null;
// allow extra fields without narrowing
[key: string]: unknown;
}
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;
});
}

View File

@@ -0,0 +1,197 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import isInDifferentFilterScopes from './isInDifferentFilterScopes';
test('returns false when no dashboard filters are provided', () => {
const result = isInDifferentFilterScopes({
dashboardFilters: {},
source: ['tab1', 'tab2'],
destination: ['tab2', 'tab3'],
});
expect(result).toBe(false);
});
test('returns false when source and destination are in same filter scopes', () => {
const dashboardFilters = {
filter1: {
chartId: 123,
scopes: {
column1: {
scope: ['tab1', 'tab2'],
},
column2: {
scope: ['tab3', 'tab4'],
},
},
},
};
const result = isInDifferentFilterScopes({
dashboardFilters,
source: ['tab1'],
destination: ['tab1'],
});
expect(result).toBe(false);
});
test('returns true when source and destination are in different filter scopes', () => {
const dashboardFilters = {
filter1: {
chartId: 123,
scopes: {
column1: {
scope: ['tab1', 'tab2'],
},
},
},
};
const result = isInDifferentFilterScopes({
dashboardFilters,
source: ['tab1'],
destination: ['tab3'],
});
expect(result).toBe(true);
});
test('returns true when one is in scope and the other is not', () => {
const dashboardFilters = {
filter1: {
chartId: 123,
scopes: {
column1: {
scope: ['tab1'],
},
},
},
};
const result = isInDifferentFilterScopes({
dashboardFilters,
source: ['tab1'], // in scope
destination: ['tab2'], // not in scope
});
expect(result).toBe(true);
});
test('handles multiple filters with complex scopes', () => {
const dashboardFilters = {
filter1: {
chartId: 123,
scopes: {
column1: {
scope: ['tab1', 'tab2'],
},
column2: {
scope: ['tab3'],
},
},
},
filter2: {
chartId: 456,
scopes: {
column1: {
scope: ['tab2', 'tab4'],
},
},
},
};
const result = isInDifferentFilterScopes({
dashboardFilters,
source: ['tab1'],
destination: ['tab4'],
});
expect(result).toBe(true);
});
test('handles empty source and destination arrays', () => {
const dashboardFilters = {
filter1: {
chartId: 123,
scopes: {
column1: {
scope: ['tab1'],
},
},
},
};
const result = isInDifferentFilterScopes({
dashboardFilters,
source: [],
destination: [],
});
expect(result).toBe(false);
});
test('uses default parameters when not provided', () => {
const result = isInDifferentFilterScopes({});
expect(result).toBe(false);
});
test('returns true when source and destination have different presence in filter scopes', () => {
const dashboardFilters = {
filter1: {
chartId: 123,
scopes: {
column1: {
scope: ['tab1', 'tab2', 'tab3'],
},
},
},
};
const result = isInDifferentFilterScopes({
dashboardFilters,
source: ['tab1', 'tab2'],
destination: ['tab2', 'tab3'],
});
// tab1 is in source but not destination, tab3 is in destination but not source
expect(result).toBe(true);
});
test('returns false when both source and destination contain same tabs', () => {
const dashboardFilters = {
filter1: {
chartId: 123,
scopes: {
column1: {
scope: ['tab1', 'tab2'],
},
},
},
};
const result = isInDifferentFilterScopes({
dashboardFilters,
source: ['tab1'],
destination: ['tab1'],
});
expect(result).toBe(false);
});

View File

@@ -16,17 +16,40 @@
* specific language governing permissions and limitations
* under the License.
*/
// Dashboard filter structure based on the actual usage pattern
interface DashboardFilterColumn {
scope: string[];
}
interface DashboardFilter {
chartId: number;
scopes: Record<string, DashboardFilterColumn>;
}
interface DashboardFilters {
[filterId: string]: DashboardFilter;
}
interface IsInDifferentFilterScopesProps {
dashboardFilters?: DashboardFilters;
source?: string[];
destination?: string[];
}
export default function isInDifferentFilterScopes({
dashboardFilters = {},
source = [],
destination = [],
}) {
}: IsInDifferentFilterScopesProps): boolean {
const sourceSet = new Set(source);
const destinationSet = new Set(destination);
const allScopes = [].concat(
const allScopes = ([] as string[]).concat(
...Object.values(dashboardFilters).map(({ scopes }) =>
[].concat(...Object.values(scopes).map(({ scope }) => scope)),
([] as string[]).concat(
...Object.values(scopes).map(({ scope }) => scope),
),
),
);
return allScopes.some(tab => destinationSet.has(tab) !== sourceSet.has(tab));

View File

@@ -0,0 +1,246 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ChartState } from 'src/explore/types';
import { Layout } from 'src/dashboard/types';
import childChartsDidLoad from './childChartsDidLoad';
import mockFindNonTabChildChartIdsImport from './findNonTabChildChartIds';
// Mock the findNonTabChildChartIds dependency
jest.mock('./findNonTabChildChartIds', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockFindNonTabChildChartIds =
mockFindNonTabChildChartIdsImport as jest.MockedFunction<
typeof mockFindNonTabChildChartIdsImport
>;
describe('childChartsDidLoad', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('returns didLoad true when all charts are in completed states', () => {
const chartIds = [1, 2, 3];
const layout: Layout = {};
const chartQueries: Record<string, Partial<ChartState>> = {
'1': { chartStatus: 'rendered', chartUpdateStartTime: 100 },
'2': { chartStatus: 'stopped', chartUpdateStartTime: 200 },
'3': { chartStatus: 'failed', chartUpdateStartTime: 150 },
};
mockFindNonTabChildChartIds.mockReturnValue(chartIds);
const result = childChartsDidLoad({
chartQueries,
layout,
id: 'test-id',
});
expect(result.didLoad).toBe(true);
expect(result.minQueryStartTime).toBe(100);
expect(mockFindNonTabChildChartIds).toHaveBeenCalledWith({
id: 'test-id',
layout,
});
});
test('returns didLoad false when some charts are in loading state', () => {
const chartIds = [1, 2, 3];
const layout: Layout = {};
const chartQueries: Record<string, Partial<ChartState>> = {
'1': { chartStatus: 'rendered', chartUpdateStartTime: 100 },
'2': { chartStatus: 'loading', chartUpdateStartTime: 200 },
'3': { chartStatus: 'failed', chartUpdateStartTime: 150 },
};
mockFindNonTabChildChartIds.mockReturnValue(chartIds);
const result = childChartsDidLoad({
chartQueries,
layout,
id: 'test-id',
});
expect(result.didLoad).toBe(false);
expect(result.minQueryStartTime).toBe(100);
});
test('handles missing chart queries gracefully', () => {
const chartIds = [1, 2, 3];
const layout: Layout = {};
const chartQueries: Record<string, Partial<ChartState>> = {
'1': { chartStatus: 'rendered', chartUpdateStartTime: 100 },
// Chart 2 is missing from queries
'3': { chartStatus: 'failed', chartUpdateStartTime: 150 },
};
mockFindNonTabChildChartIds.mockReturnValue(chartIds);
const result = childChartsDidLoad({
chartQueries,
layout,
id: 'test-id',
});
expect(result.didLoad).toBe(false);
expect(result.minQueryStartTime).toBe(100);
});
test('handles empty chart queries object', () => {
const chartIds = [1, 2, 3];
const layout: Layout = {};
const chartQueries: Record<string, Partial<ChartState>> = {};
mockFindNonTabChildChartIds.mockReturnValue(chartIds);
const result = childChartsDidLoad({
chartQueries,
layout,
id: 'test-id',
});
expect(result.didLoad).toBe(false);
expect(result.minQueryStartTime).toBe(Infinity);
});
test('handles empty chart IDs array', () => {
const chartIds: number[] = [];
const layout: Layout = {};
const chartQueries: Record<string, Partial<ChartState>> = {
'1': { chartStatus: 'rendered', chartUpdateStartTime: 100 },
};
mockFindNonTabChildChartIds.mockReturnValue(chartIds);
const result = childChartsDidLoad({
chartQueries,
layout,
id: 'test-id',
});
expect(result.didLoad).toBe(true); // every() returns true for empty array
expect(result.minQueryStartTime).toBe(Infinity);
});
test('calculates minimum query start time correctly', () => {
const chartIds = [1, 2, 3, 4];
const layout: Layout = {};
const chartQueries: Record<string, Partial<ChartState>> = {
'1': { chartStatus: 'rendered', chartUpdateStartTime: 500 },
'2': { chartStatus: 'stopped', chartUpdateStartTime: 100 },
'3': { chartStatus: 'failed', chartUpdateStartTime: 300 },
'4': { chartStatus: 'rendered', chartUpdateStartTime: 200 },
};
mockFindNonTabChildChartIds.mockReturnValue(chartIds);
const result = childChartsDidLoad({
chartQueries,
layout,
id: 'test-id',
});
expect(result.didLoad).toBe(true);
expect(result.minQueryStartTime).toBe(100);
});
test('handles charts with missing chartUpdateStartTime', () => {
const chartIds = [1, 2];
const layout: Layout = {};
const chartQueries: Record<string, Partial<ChartState>> = {
'1': { chartStatus: 'rendered' }, // Missing chartUpdateStartTime
'2': { chartStatus: 'stopped', chartUpdateStartTime: 200 },
};
mockFindNonTabChildChartIds.mockReturnValue(chartIds);
const result = childChartsDidLoad({
chartQueries,
layout,
id: 'test-id',
});
expect(result.didLoad).toBe(true);
expect(result.minQueryStartTime).toBe(200);
});
test('handles charts with null chartStatus', () => {
const chartIds = [1, 2];
const layout: Layout = {};
const chartQueries: Record<string, Partial<ChartState>> = {
'1': { chartStatus: null, chartUpdateStartTime: 100 },
'2': { chartStatus: 'stopped', chartUpdateStartTime: 200 },
};
mockFindNonTabChildChartIds.mockReturnValue(chartIds);
const result = childChartsDidLoad({
chartQueries,
layout,
id: 'test-id',
});
expect(result.didLoad).toBe(false); // null chartStatus is not in the completed states
expect(result.minQueryStartTime).toBe(100);
});
test('recognizes all valid completed chart states', () => {
const chartIds = [1, 2, 3];
const layout: Layout = {};
const chartQueries: Record<string, Partial<ChartState>> = {
'1': { chartStatus: 'stopped', chartUpdateStartTime: 100 },
'2': { chartStatus: 'failed', chartUpdateStartTime: 200 },
'3': { chartStatus: 'rendered', chartUpdateStartTime: 150 },
};
mockFindNonTabChildChartIds.mockReturnValue(chartIds);
const result = childChartsDidLoad({
chartQueries,
layout,
id: 'test-id',
});
expect(result.didLoad).toBe(true);
expect(result.minQueryStartTime).toBe(100);
});
test('does not recognize incomplete chart states', () => {
const chartIds = [1, 2];
const layout: Layout = {};
const chartQueries: Record<string, Partial<ChartState>> = {
'1': { chartStatus: 'loading', chartUpdateStartTime: 100 },
'2': { chartStatus: 'success', chartUpdateStartTime: 200 },
};
mockFindNonTabChildChartIds.mockReturnValue(chartIds);
const result = childChartsDidLoad({
chartQueries,
layout,
id: 'test-id',
});
expect(result.didLoad).toBe(false); // 'loading' and 'success' are not in completed states
expect(result.minQueryStartTime).toBe(100);
});
});

View File

@@ -16,16 +16,36 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ChartState } from 'src/explore/types';
import { Layout } from 'src/dashboard/types';
import findNonTabChildCharIds from './findNonTabChildChartIds';
export default function childChartsDidLoad({ chartQueries, layout, id }) {
interface ChildChartsDidLoadParams {
chartQueries: Record<string, Partial<ChartState>>;
layout: Layout;
id: string;
}
interface ChildChartsDidLoadResult {
didLoad: boolean;
minQueryStartTime: number;
}
export default function childChartsDidLoad({
chartQueries,
layout,
id,
}: ChildChartsDidLoadParams): ChildChartsDidLoadResult {
const chartIds = findNonTabChildCharIds({ id, layout });
let minQueryStartTime = Infinity;
const didLoad = chartIds.every(chartId => {
const query = chartQueries[chartId] || {};
minQueryStartTime = Math.min(query.chartUpdateStartTime, minQueryStartTime);
return ['stopped', 'failed', 'rendered'].indexOf(query.chartStatus) > -1;
const didLoad = chartIds.every((chartId: number) => {
const query = chartQueries[chartId.toString()] || {};
minQueryStartTime = Math.min(
query.chartUpdateStartTime ?? Infinity,
minQueryStartTime,
);
return ['stopped', 'failed', 'rendered'].includes(query.chartStatus || '');
});
return { didLoad, minQueryStartTime };

View File

@@ -0,0 +1,113 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import serializeFilterScopes from './serializeFilterScopes';
const mockDashboardFilters = {
'1': {
chartId: 'chart_1',
scopes: {
column1: {
scope: ['ROOT_ID'],
immune: [],
},
column2: {
scope: ['ROOT_ID', 'TAB_1'],
immune: ['chart_2'],
},
},
},
'2': {
chartId: 'chart_2',
scopes: {
region: {
scope: ['ROOT_ID'],
immune: [],
},
},
},
};
describe('serializeFilterScopes', () => {
test('should serialize dashboard filter scopes correctly', () => {
const result = serializeFilterScopes(mockDashboardFilters);
expect(result).toEqual({
chart_1: {
column1: {
scope: ['ROOT_ID'],
immune: [],
},
column2: {
scope: ['ROOT_ID', 'TAB_1'],
immune: ['chart_2'],
},
},
chart_2: {
region: {
scope: ['ROOT_ID'],
immune: [],
},
},
});
});
test('should handle empty dashboardFilters', () => {
const result = serializeFilterScopes({});
expect(result).toEqual({});
});
test('should handle filters with no scopes', () => {
const filtersWithEmptyScopes = {
'1': {
chartId: 'chart_1',
scopes: {},
},
};
const result = serializeFilterScopes(filtersWithEmptyScopes);
expect(result).toEqual({
chart_1: {},
});
});
test('should handle numeric chart IDs', () => {
const filtersWithNumericIds = {
'1': {
chartId: 123,
scopes: {
column1: {
scope: ['ROOT_ID'],
immune: [],
},
},
},
};
const result = serializeFilterScopes(filtersWithNumericIds);
expect(result).toEqual({
123: {
column1: {
scope: ['ROOT_ID'],
immune: [],
},
},
});
});
});

View File

@@ -16,7 +16,29 @@
* specific language governing permissions and limitations
* under the License.
*/
export default function serializeFilterScopes(dashboardFilters) {
import { JsonObject } from '@superset-ui/core';
interface DashboardFilterScope {
scope: string[] | JsonObject;
immune?: string[];
}
interface DashboardFilter {
chartId: number | string;
scopes: Record<string, DashboardFilterScope>;
}
interface DashboardFilters {
[filterId: string]: DashboardFilter;
}
interface SerializedFilterScopes {
[chartId: string]: Record<string, DashboardFilterScope>;
}
export default function serializeFilterScopes(
dashboardFilters: DashboardFilters,
): SerializedFilterScopes {
return Object.values(dashboardFilters).reduce((map, { chartId, scopes }) => {
const scopesById = Object.keys(scopes).reduce(
(scopesByColumn, column) => ({

View File

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

View File

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

View File

@@ -1,37 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import PropTypes from 'prop-types';
import { Clauses, ExpressionTypes } from './types';
export default PropTypes.oneOfType([
PropTypes.shape({
expressionType: PropTypes.oneOf([ExpressionTypes.Simple]).isRequired,
clause: PropTypes.oneOf([Clauses.Having, Clauses.Where]).isRequired,
subject: PropTypes.string.isRequired,
comparator: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
]).isRequired,
}),
PropTypes.shape({
expressionType: PropTypes.oneOf([ExpressionTypes.Sql]).isRequired,
clause: PropTypes.oneOf([Clauses.Where, Clauses.Having]).isRequired,
sqlExpression: PropTypes.string.isRequired,
}),
]);

View File

@@ -0,0 +1,103 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
AdhocFilterSimple,
AdhocFilterSql,
AdhocFilterType,
} from './adhocFilterType';
import { Clauses, ExpressionTypes } from './types';
describe('adhocFilterType', () => {
test('should accept simple adhoc filter type', () => {
const simpleFilter: AdhocFilterSimple = {
expressionType: ExpressionTypes.Simple,
clause: Clauses.Where,
subject: 'column_name',
comparator: 'test_value',
};
expect(simpleFilter.expressionType).toBe(ExpressionTypes.Simple);
expect(simpleFilter.clause).toBe(Clauses.Where);
expect(simpleFilter.subject).toBe('column_name');
expect(simpleFilter.comparator).toBe('test_value');
});
test('should accept SQL adhoc filter type', () => {
const sqlFilter: AdhocFilterSql = {
expressionType: ExpressionTypes.Sql,
clause: Clauses.Having,
sqlExpression: 'COUNT(*) > 5',
};
expect(sqlFilter.expressionType).toBe(ExpressionTypes.Sql);
expect(sqlFilter.clause).toBe(Clauses.Having);
expect(sqlFilter.sqlExpression).toBe('COUNT(*) > 5');
});
test('should accept both simple and SQL filters as AdhocFilterType', () => {
const simpleFilter: AdhocFilterType = {
expressionType: ExpressionTypes.Simple,
clause: Clauses.Where,
subject: 'column_name',
comparator: ['value1', 'value2'],
};
const sqlFilter: AdhocFilterType = {
expressionType: ExpressionTypes.Sql,
clause: Clauses.Having,
sqlExpression: 'AVG(sales) > 1000',
};
expect(simpleFilter.expressionType).toBe(ExpressionTypes.Simple);
expect(sqlFilter.expressionType).toBe(ExpressionTypes.Sql);
});
test('should handle array comparator for simple filters', () => {
const filterWithArrayComparator: AdhocFilterSimple = {
expressionType: ExpressionTypes.Simple,
clause: Clauses.Where,
subject: 'category',
comparator: ['A', 'B', 'C'],
};
expect(Array.isArray(filterWithArrayComparator.comparator)).toBe(true);
expect(filterWithArrayComparator.comparator).toEqual(['A', 'B', 'C']);
});
test('should handle optional properties', () => {
const filterWithOptionalProps: AdhocFilterSimple = {
expressionType: ExpressionTypes.Simple,
clause: Clauses.Where,
subject: 'column_name',
comparator: 'test_value',
operator: 'EQUALS',
operatorId: 'EQUALS',
isExtra: true,
isNew: false,
datasourceWarning: false,
deck_slices: [1, 2, 3],
layerFilterScope: 'global',
filterOptionName: 'custom_filter_name',
};
expect(filterWithOptionalProps.operator).toBe('EQUALS');
expect(filterWithOptionalProps.isExtra).toBe(true);
expect(filterWithOptionalProps.deck_slices).toEqual([1, 2, 3]);
});
});

View File

@@ -0,0 +1,64 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { InferProps } from 'prop-types';
import { Clauses, ExpressionTypes } from './types';
export interface AdhocFilterSimple {
expressionType: ExpressionTypes.Simple;
clause: Clauses.Having | Clauses.Where;
subject: string;
comparator: string | string[];
operator?: string;
operatorId?: string;
isExtra?: boolean;
isNew?: boolean;
datasourceWarning?: boolean;
deck_slices?: number[];
layerFilterScope?: string;
filterOptionName?: string;
}
export interface AdhocFilterSql {
expressionType: ExpressionTypes.Sql;
clause: Clauses.Where | Clauses.Having;
sqlExpression: string;
subject?: string | null;
operator?: string | null;
operatorId?: string;
comparator?: null;
isExtra?: boolean;
isNew?: boolean;
datasourceWarning?: boolean;
deck_slices?: number[];
layerFilterScope?: string;
filterOptionName?: string;
}
export type AdhocFilterType = AdhocFilterSimple | AdhocFilterSql;
// PropTypes validation function - babel-plugin-typescript-to-proptypes automatically
// generates PropTypes from the TypeScript interface above
export default function AdhocFilterValidator(props: {
filter: AdhocFilterType;
}) {
return null; // PropTypes auto-generated by babel plugin
}
// For consumers needing PropTypes type inference
export type AdhocFilterProps = InferProps<typeof AdhocFilterValidator>;

View File

@@ -0,0 +1,53 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ColumnType } from './columnType';
test('ColumnType should have proper structure', () => {
const mockColumn: ColumnType = {
column_name: 'test_column',
type: 'STRING',
};
expect(mockColumn.column_name).toBe('test_column');
expect(mockColumn.type).toBe('STRING');
});
test('ColumnType should allow optional type field', () => {
const mockColumn: ColumnType = {
column_name: 'test_column',
};
expect(mockColumn.column_name).toBe('test_column');
expect(mockColumn.type).toBeUndefined();
});
test('ColumnType should work with different type values', () => {
const stringColumn: ColumnType = {
column_name: 'str_col',
type: 'STRING',
};
const numericColumn: ColumnType = {
column_name: 'num_col',
type: 'NUMERIC',
};
expect(stringColumn.type).toBe('STRING');
expect(numericColumn.type).toBe('NUMERIC');
});

View File

@@ -16,9 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import PropTypes from 'prop-types';
import { Column } from '@superset-ui/core';
export default PropTypes.shape({
column_name: PropTypes.string.isRequired,
type: PropTypes.string,
});
export type ColumnType = Pick<Column, 'column_name' | 'type'>;
// For backward compatibility with PropTypes usage - create a placeholder object
const columnType = {} as any;
export default columnType;

View File

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

View File

@@ -0,0 +1,44 @@
/**
* 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,9 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import PropTypes from 'prop-types';
export default PropTypes.shape({
column_name: PropTypes.string.isRequired,
type: PropTypes.string,
});
export type { AggregateOption } from './types';
// For backward compatibility with PropTypes usage
export { AggregateOption as default } from './types';

View File

@@ -16,10 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import PropTypes from 'prop-types';
import { Column } from '@superset-ui/core';
export default PropTypes.shape({
metric_name: PropTypes.string,
verbose_name: PropTypes.string,
expression: PropTypes.string,
});
export type ColumnType = Pick<Column, 'column_name' | 'type'>;
// For backward compatibility with PropTypes usage - create a placeholder object
const columnType = {} as any;
export default columnType;

View File

@@ -0,0 +1,45 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { savedMetricType } from './savedMetricType';
test('savedMetricType exports the correct type structure', () => {
// Type assertion test - if this compiles without errors,
// the type structure is correct
const validMetric: savedMetricType = {
metric_name: 'test_metric',
verbose_name: 'Test Metric',
expression: 'SUM(column)',
};
expect(validMetric.metric_name).toBe('test_metric');
expect(validMetric.verbose_name).toBe('Test Metric');
expect(validMetric.expression).toBe('SUM(column)');
});
test('savedMetricType allows optional verbose_name', () => {
// Test that verbose_name is optional
const validMetricMinimal: savedMetricType = {
metric_name: 'minimal_metric',
expression: 'COUNT(*)',
};
expect(validMetricMinimal.metric_name).toBe('minimal_metric');
expect(validMetricMinimal.expression).toBe('COUNT(*)');
expect(validMetricMinimal.verbose_name).toBeUndefined();
});

View File

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

View File

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

View File

@@ -0,0 +1,76 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type {
ControlPanelState,
ControlState,
} from '@superset-ui/chart-controls';
import Separator from './Separator';
function getCodeControlMapStateToProps() {
const sections =
(Separator.controlPanelSections as unknown as Array<{
controlSetRows?: Array<
Array<{
name?: string;
config?: {
mapStateToProps?: (s: Partial<ControlPanelState>) => {
language: string;
};
};
}>
>;
}>) || [];
const codeControl = sections
.flatMap(s => s.controlSetRows || [])
.flatMap(r => r)
.find(i => i?.name === 'code') as unknown as {
config: {
mapStateToProps: (s: Partial<ControlPanelState>) => { language: string };
};
};
if (!codeControl || !codeControl.config?.mapStateToProps) {
throw new Error('Code control configuration not found');
}
return codeControl.config.mapStateToProps;
}
describe('Separator control panel config', () => {
it('defaults language to markdown when markup_type is missing', () => {
const mapStateToProps = getCodeControlMapStateToProps();
const state: Partial<ControlPanelState> = {};
const result = mapStateToProps(state);
expect(result.language).toBe('markdown');
});
it('uses markup_type value when provided', () => {
const mapStateToProps = getCodeControlMapStateToProps();
const state: Partial<ControlPanelState> = {
controls: {
// minimal mock for the control used in mapStateToProps
markup_type: { value: 'html' } as Partial<
ControlState<'SelectControl'>
> as ControlState<'SelectControl'>,
},
};
const result = mapStateToProps(state);
expect(result.language).toBe('html');
});
});

View File

@@ -17,9 +17,13 @@
* under the License.
*/
import { t, validateNonEmpty } from '@superset-ui/core';
import type {
ControlPanelConfig,
ControlPanelState,
} from '@superset-ui/chart-controls';
import { formatSelectOptions } from 'src/explore/exploreUtils';
export default {
const config: ControlPanelConfig = {
controlPanelSections: [
{
label: t('Code'),
@@ -45,12 +49,15 @@ export default {
type: 'TextAreaControl',
label: t('Code'),
description: t('Put your code here'),
mapStateToProps: state => ({
language:
state.controls && state.controls.markup_type
? state.controls.markup_type.value
: 'markdown',
}),
mapStateToProps: (state: Partial<ControlPanelState>) => {
const languageValue = state.controls?.markup_type?.value;
return {
language:
typeof languageValue === 'string'
? languageValue
: 'markdown',
};
},
default: '',
},
},
@@ -74,3 +81,5 @@ export default {
},
},
};
export default config;

View File

@@ -16,11 +16,20 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { ControlPanelState, Dataset } from '@superset-ui/chart-controls';
interface TimeGrainOverrideState {
choices: [string, string][] | null;
}
export default {
default: null,
mapStateToProps: state => ({
choices: state.datasource
? state.datasource.time_grain_sqla.filter(o => o[0] !== null)
: null,
mapStateToProps: (state: ControlPanelState): TimeGrainOverrideState => ({
choices:
state.datasource && 'time_grain_sqla' in state.datasource
? ((state.datasource as Dataset).time_grain_sqla?.filter(
(o: [string, string]) => o[0] !== null,
) ?? null)
: null,
}),
};

View File

@@ -32,7 +32,6 @@ import {
FeatureFlag,
styled,
SupersetClient,
SupersetTheme,
t,
VizType,
useTheme,
@@ -41,7 +40,6 @@ 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,
@@ -266,11 +264,6 @@ 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};
@@ -404,10 +397,6 @@ const StyledNotificationMethodWrapper = styled.div`
}
`;
const inputSpacer = (theme: SupersetTheme) => css`
margin-right: ${theme.sizeUnit * 3}px;
`;
type NotificationAddStatus = 'active' | 'disabled' | 'hidden';
interface NotificationMethodAddProps {
@@ -2042,6 +2031,15 @@ 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"
@@ -2054,7 +2052,17 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
onChange={onInputChange}
/>
</ModalFormField>
<ModalFormField label={t('Owners')} required>
<ModalFormField
label={t('Owners')}
required
error={
validationStatus[Sections.General]?.hasErrors &&
(!currentAlert?.owners ||
currentAlert.owners.length === 0)
? t('Owners are required')
: undefined
}
>
<AsyncSelect
ariaLabel={t('Owners')}
allowClear
@@ -2115,40 +2123,46 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
),
children: (
<div>
<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>
<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
}
>
<TextAreaControl
name="sql"
language="sql"
@@ -2160,57 +2174,60 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
initialValue={resource?.sql}
key={currentAlert?.id}
/>
</StyledInputContainer>
</ModalFormField>
<div
className="inline-container wrap"
css={css`
gap: ${theme.sizeUnit}px;
`}
>
<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>
<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>
</div>
</div>
),
@@ -2468,7 +2485,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
<InputNumber
type="number"
name="custom_width"
value={currentAlert?.custom_width || undefined}
value={currentAlert?.custom_width || 1600}
min={600}
max={2400}
placeholder={t('Input custom width in pixels')}
@@ -2511,66 +2528,66 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
value={currentAlert?.crontab || ''}
onChange={newVal => updateAlertState('crontab', newVal)}
/>
<StyledInputContainer>
<div className="control-label">
{t('Timezone')} <span className="required">*</span>
</div>
<ModalFormField
label={t('Timezone')}
required
error={
validationStatus[Sections.Schedule]?.hasErrors &&
!currentAlert?.timezone
? t('Timezone is required')
: undefined
}
>
<TimezoneSelector
onTimezoneChange={onTimezoneChange}
timezone={currentAlert?.timezone}
minWidth="100%"
/>
</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>
</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>
</>
),
},

View File

@@ -0,0 +1,121 @@
/**
* 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

@@ -0,0 +1,86 @@
/**
* 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,6 +59,7 @@ 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';
@@ -157,12 +158,16 @@ 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) {
@@ -246,6 +251,51 @@ 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(
() => [
{
@@ -397,6 +447,16 @@ 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',
@@ -424,7 +484,14 @@ function AlertList({
id: QueryObjectColumns.ChangedBy,
},
],
[canDelete, canEdit, isReportEnabled, toggleActive],
[
canDelete,
canEdit,
isReportEnabled,
toggleActive,
executingIds,
handleExecuteReport,
],
);
const subMenuButtons: SubMenuProps['buttons'] = [];

View File

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

View File

@@ -0,0 +1,66 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import DebouncedMessageQueue from './DebouncedMessageQueue';
describe('DebouncedMessageQueue', () => {
it('should create a queue with default options', () => {
const queue = new DebouncedMessageQueue();
expect(queue).toBeDefined();
expect(queue.trigger).toBeInstanceOf(Function);
});
it('should accept custom configuration options', () => {
const mockCallback = jest.fn();
const queue = new DebouncedMessageQueue({
callback: mockCallback,
sizeThreshold: 500,
delayThreshold: 2000,
});
expect(queue).toBeDefined();
});
it('should append items to the queue', () => {
const mockCallback = jest.fn();
const queue = new DebouncedMessageQueue({ callback: mockCallback });
const testEvent = { id: 1, message: 'test' };
queue.append(testEvent);
// Verify the append method doesn't throw
expect(() => queue.append(testEvent)).not.toThrow();
});
it('should handle generic types properly', () => {
interface TestEvent {
id: number;
data: string;
}
const mockCallback = jest.fn();
const queue = new DebouncedMessageQueue<TestEvent>({
callback: mockCallback,
});
const testEvent: TestEvent = { id: 1, data: 'test' };
queue.append(testEvent);
expect(() => queue.append(testEvent)).not.toThrow();
});
});

View File

@@ -18,26 +18,45 @@
*/
import { debounce } from 'lodash';
class DebouncedMessageQueue {
export interface DebouncedMessageQueueOptions<T> {
callback?: (events: T[]) => void;
sizeThreshold?: number;
delayThreshold?: number;
}
class DebouncedMessageQueue<T = Record<string, unknown>> {
private queue: T[];
private readonly sizeThreshold: number;
private readonly delayThreshold: number;
private readonly callback: (events: T[]) => void;
public readonly trigger: () => void;
constructor({
callback = () => {},
sizeThreshold = 1000,
delayThreshold = 1000,
}) {
}: DebouncedMessageQueueOptions<T> = {}) {
this.queue = [];
this.sizeThreshold = sizeThreshold;
this.delayThreshold = delayThreshold;
this.trigger = debounce(this.trigger.bind(this), this.delayThreshold);
this.callback = callback;
this.trigger = debounce(
this.triggerInternal.bind(this),
this.delayThreshold,
);
}
append(eventData) {
append(eventData: T): void {
this.queue.push(eventData);
this.trigger();
}
trigger() {
private triggerInternal(): void {
if (this.queue.length > 0) {
const events = this.queue.splice(0, this.sizeThreshold);
this.callback.call(null, events);

View File

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

View File

@@ -0,0 +1,190 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ColumnMeta, Metric } from '@superset-ui/chart-controls';
import { DatasourceType } from '@superset-ui/core';
import type { Datasource } from 'src/explore/types';
import type { QueryEditor } from 'src/SqlLab/types';
import { getDatasourceAsSaveableDataset } from './datasourceUtils';
const mockColumnMeta: ColumnMeta = {
column_name: 'test_column',
type: 'VARCHAR',
is_dttm: false,
verbose_name: 'Test Column',
description: 'A test column',
expression: '',
filterable: true,
groupby: true,
id: 1,
type_generic: 1,
python_date_format: null,
optionName: 'test_column',
};
const mockMetric: Metric = {
id: 1,
uuid: 'metric-1',
metric_name: 'count',
verbose_name: 'Count',
description: 'Count of records',
d3format: null,
currency: null,
warning_text: null,
// optionName removed - not part of Metric interface
};
const mockDatasource: Datasource = {
id: 1,
type: DatasourceType.Table,
columns: [mockColumnMeta],
metrics: [mockMetric],
column_formats: {},
verbose_map: {},
main_dttm_col: '',
order_by_choices: null,
datasource_name: 'Test Datasource',
name: 'test_table',
catalog: 'test_catalog',
schema: 'test_schema',
description: 'Test datasource',
database: {
id: 123,
database_name: 'test_db',
sqlalchemy_uri: 'postgresql://test',
},
};
const mockQueryEditor: QueryEditor = {
id: 'query-1',
immutableId: 'immutable-query-1',
version: 1,
name: 'Test Query',
sql: 'SELECT * FROM users',
dbId: 456,
autorun: false,
remoteId: null,
catalog: 'prod_catalog',
schema: 'public',
templateParams: '{"param1": "value1"}',
};
describe('getDatasourceAsSaveableDataset', () => {
test('should convert Datasource object correctly', () => {
const result = getDatasourceAsSaveableDataset(mockDatasource);
expect(result).toEqual({
columns: [mockColumnMeta],
name: 'Test Datasource',
dbId: 123,
sql: '',
catalog: 'test_catalog',
schema: 'test_schema',
templateParams: null,
});
});
test('should convert QueryEditor object correctly', () => {
const queryWithColumns = { ...mockQueryEditor, columns: [mockColumnMeta] };
const result = getDatasourceAsSaveableDataset(queryWithColumns);
expect(result).toEqual({
columns: [mockColumnMeta],
name: 'Test Query',
dbId: 456,
sql: 'SELECT * FROM users',
catalog: 'prod_catalog',
schema: 'public',
templateParams: '{"param1": "value1"}',
});
});
test('should handle datasource with fallback name from name property', () => {
const datasourceWithoutDatasourceName: Datasource = {
...mockDatasource,
datasource_name: null,
name: 'fallback_name',
};
const result = getDatasourceAsSaveableDataset(
datasourceWithoutDatasourceName,
);
expect(result.name).toBe('fallback_name');
});
test('should use "Untitled" as fallback when no name is available', () => {
const datasourceWithoutName: Datasource = {
...mockDatasource,
datasource_name: null,
name: '',
};
const result = getDatasourceAsSaveableDataset(datasourceWithoutName);
expect(result.name).toBe('Untitled');
});
test('should handle missing database object', () => {
const datasourceWithoutDatabase: Datasource = {
...mockDatasource,
database: undefined,
};
const result = getDatasourceAsSaveableDataset(datasourceWithoutDatabase);
expect(result.dbId).toBe(0);
});
test('should handle QueryEditor with missing dbId', () => {
const queryEditorWithoutDbId: QueryEditor = {
...mockQueryEditor,
dbId: undefined,
};
const result = getDatasourceAsSaveableDataset(queryEditorWithoutDbId);
expect(result.dbId).toBe(0);
});
test('should handle QueryEditor without sql property', () => {
const queryEditorWithoutSql: QueryEditor = {
...mockQueryEditor,
sql: '',
};
const result = getDatasourceAsSaveableDataset(queryEditorWithoutSql);
expect(result.sql).toBe('');
});
test('should handle null values for optional properties', () => {
const minimalQueryEditor: QueryEditor = {
...mockQueryEditor,
catalog: null,
schema: undefined,
templateParams: '',
};
const result = getDatasourceAsSaveableDataset(minimalQueryEditor);
expect(result.catalog).toBe(null);
expect(result.schema).toBe(null);
expect(result.templateParams).toBe(null);
});
});

View File

@@ -0,0 +1,57 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ColumnMeta } from '@superset-ui/chart-controls';
import type { ISaveableDatasource } from 'src/SqlLab/components/SaveDatasetModal';
// Flexible interface that captures what this function actually needs to work
// This allows it to accept various datasource-like objects from different parts of the codebase
interface DatasourceInput {
// Common properties that all datasource-like objects should have
name?: string | null; // Allow null for compatibility
// Optional properties that may exist on different datasource variants
datasource_name?: string | null; // Allow null for compatibility
columns?: any[]; // Can be ColumnMeta[], DatasourcePanelColumn[], ISimpleColumn[], etc.
database?: { id?: number };
dbId?: number;
sql?: string | null; // Allow null for compatibility
catalog?: string | null;
schema?: string | null;
templateParams?: string;
// Type discriminator for QueryEditor-like objects
version?: number;
}
export const getDatasourceAsSaveableDataset = (
source: DatasourceInput,
): ISaveableDatasource => {
// Type guard: QueryEditor-like objects have version property
const isQueryEditorLike = typeof source.version === 'number';
return {
columns: (source.columns as ColumnMeta[]) || [],
name: source.datasource_name || source.name || 'Untitled',
dbId: source.database?.id || source.dbId || 0,
sql: source.sql || '',
catalog: source.catalog || null,
schema: source.schema || null,
templateParams: isQueryEditorLike ? source.templateParams || null : null,
};
};

View File

@@ -0,0 +1,101 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { getChartControlPanelRegistry, JsonObject } from '@superset-ui/core';
import getControlsForVizType from 'src/utils/getControlsForVizType';
const fakePluginControls: JsonObject = {
controlPanelSections: [
{
label: 'Fake Control Panel Sections',
expanded: true,
controlSetRows: [
[
{
name: 'y_axis_bounds',
config: {
type: 'BoundsControl',
label: 'Value bounds',
default: [null, null],
description: 'Value bounds for the y axis',
},
},
],
[
{
name: 'adhoc_filters',
config: {
type: 'AdhocFilterControl',
label: 'Fake Filters',
default: null,
},
},
],
],
},
{
label: 'Fake Control Panel Sections 2',
expanded: true,
controlSetRows: [
[
{
name: 'column_collection',
config: {
type: 'CollectionControl',
label: 'Fake Collection Control',
},
},
],
],
},
],
};
describe('getControlsForVizType', () => {
beforeEach(() => {
getChartControlPanelRegistry().registerValue(
'chart_controls_inventory_fake',
fakePluginControls,
);
});
it('returns a map of the controls', () => {
expect(
JSON.stringify(getControlsForVizType('chart_controls_inventory_fake')),
).toEqual(
JSON.stringify({
y_axis_bounds: {
type: 'BoundsControl',
label: 'Value bounds',
default: [null, null],
description: 'Value bounds for the y axis',
},
adhoc_filters: {
type: 'AdhocFilterControl',
label: 'Fake Filters',
default: null,
},
column_collection: {
type: 'CollectionControl',
label: 'Fake Collection Control',
},
}),
);
});
});

View File

@@ -0,0 +1,74 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import memoizeOne from 'memoize-one';
import { isControlPanelSectionConfig } from '@superset-ui/chart-controls';
import { getChartControlPanelRegistry, JsonObject } from '@superset-ui/core';
import type { ControlMap } from 'src/components/AlteredSliceTag/types';
import { controls } from '../explore/controls';
const memoizedControls = memoizeOne(
(vizType: string, controlPanel: JsonObject | undefined): ControlMap => {
const controlsMap: ControlMap = {};
if (!controlPanel) return controlsMap;
const sections = controlPanel.controlPanelSections || [];
(Array.isArray(sections) ? sections : [])
.filter(isControlPanelSectionConfig)
.forEach(section => {
if (section.controlSetRows && Array.isArray(section.controlSetRows)) {
section.controlSetRows.forEach(row => {
if (Array.isArray(row)) {
row.forEach(control => {
if (!control) return;
if (typeof control === 'string') {
// For now, we have to look in controls.jsx to get the config for some controls.
// Once everything is migrated out, delete this if statement.
const controlConfig = (controls as any)[control];
if (controlConfig) {
controlsMap[control] = controlConfig;
}
} else if (
typeof control === 'object' &&
control &&
'name' in control &&
'config' in control
) {
// condition needed because there are elements, e.g. <hr /> in some control configs (I'm looking at you, FilterBox!)
const controlObj = control as {
name: string;
config: JsonObject;
};
controlsMap[controlObj.name] = controlObj.config;
}
});
}
});
}
});
return controlsMap;
},
);
const getControlsForVizType = (vizType: string): ControlMap => {
const controlPanel = getChartControlPanelRegistry().get(vizType);
return memoizedControls(vizType, controlPanel);
};
export default getControlsForVizType;

View File

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

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

View File

@@ -0,0 +1,129 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
addToObject,
alterInObject,
alterInArr,
removeFromArr,
addToArr,
} from './reducerUtils';
interface TestItem {
id?: string;
name: string;
value: number;
}
const mockState = {
objects: {
'item-1': { id: 'item-1', name: 'Item 1', value: 10 },
'item-2': { id: 'item-2', name: 'Item 2', value: 20 },
},
items: [
{ id: 'item-1', name: 'Item 1', value: 10 },
{ id: 'item-2', name: 'Item 2', value: 20 },
],
};
test('addToObject adds new object to state with generated id', () => {
const newItem: TestItem = { name: 'New Item', value: 30 };
const result = addToObject(mockState, 'objects', newItem);
expect(result).not.toBe(mockState);
expect(result.objects).not.toBe(mockState.objects);
expect(Object.keys(result.objects)).toHaveLength(3);
const addedItems = Object.values(result.objects).filter(
item => (item as TestItem).name === 'New Item',
);
expect(addedItems).toHaveLength(1);
expect((addedItems[0] as TestItem).id).toBeTruthy();
});
test('addToObject adds new object with existing id', () => {
const newItem: TestItem = { id: 'item-3', name: 'Item 3', value: 30 };
const result = addToObject(mockState, 'objects', newItem);
expect(result.objects['item-3']).toEqual(newItem);
});
test('alterInObject modifies existing object', () => {
const targetItem: TestItem = { id: 'item-1', name: 'Item 1', value: 10 };
const alterations = { value: 15 };
const result = alterInObject(mockState, 'objects', targetItem, alterations);
expect(result.objects['item-1'].value).toBe(15);
expect(result.objects['item-1'].name).toBe('Item 1');
expect(result.objects['item-2']).toBe(mockState.objects['item-2']);
});
test('alterInArr modifies existing array item', () => {
const targetItem: TestItem = { id: 'item-1', name: 'Item 1', value: 10 };
const alterations = { value: 15 };
const result = alterInArr(mockState, 'items', targetItem, alterations);
expect(result.items[0].value).toBe(15);
expect(result.items[0].name).toBe('Item 1');
expect(result.items[1]).toBe(mockState.items[1]);
});
test('removeFromArr removes item from array', () => {
const targetItem: TestItem = { id: 'item-1', name: 'Item 1', value: 10 };
const result = removeFromArr(mockState, 'items', targetItem);
expect(result.items).toHaveLength(1);
expect(result.items[0].id).toBe('item-2');
});
test('removeFromArr with custom idKey', () => {
const stateWithCustomKey = {
items: [
{ customId: 'a', name: 'Item A' },
{ customId: 'b', name: 'Item B' },
],
};
const targetItem = { customId: 'a', name: 'Item A' };
const result = removeFromArr(
stateWithCustomKey,
'items',
targetItem,
'customId',
);
expect(result.items).toHaveLength(1);
expect(result.items[0].customId).toBe('b');
});
test('addToArr adds new item to array with generated id', () => {
const newItem: TestItem = { name: 'New Item', value: 30 };
const result = addToArr(mockState, 'items', newItem);
expect(result.items).toHaveLength(3);
expect(result.items[2].name).toBe('New Item');
expect(result.items[2].id).toBeTruthy();
});
test('addToArr adds new item with existing id', () => {
const newItem: TestItem = { id: 'item-3', name: 'Item 3', value: 30 };
const result = addToArr(mockState, 'items', newItem);
expect(result.items).toHaveLength(3);
expect(result.items[2]).toEqual(newItem);
});

View File

@@ -0,0 +1,107 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { nanoid } from 'nanoid';
interface ObjectWithId {
id?: string;
[key: string]: any;
}
interface StateWithObject {
[key: string]: { [id: string]: ObjectWithId } | any;
}
interface StateWithArray {
[key: string]: ObjectWithId[] | any;
}
export function addToObject<T extends ObjectWithId>(
state: StateWithObject,
arrKey: string,
obj: T,
): StateWithObject {
const newObject = { ...state[arrKey] };
const copiedObject = { ...obj };
if (!copiedObject.id) {
copiedObject.id = nanoid();
}
newObject[copiedObject.id] = copiedObject;
return { ...state, [arrKey]: newObject };
}
export function alterInObject<T extends ObjectWithId>(
state: StateWithObject,
arrKey: string,
obj: T,
alterations: Partial<T>,
): StateWithObject {
const newObject = { ...state[arrKey] };
newObject[obj.id!] = { ...newObject[obj.id!], ...alterations };
return { ...state, [arrKey]: newObject };
}
export function alterInArr<T extends ObjectWithId>(
state: StateWithArray,
arrKey: string,
obj: T,
alterations: Partial<T>,
): StateWithArray {
// Finds an item in an array in the state and replaces it with a
// new object with an altered property
const idKey = 'id';
const newArr: T[] = [];
state[arrKey].forEach((arrItem: T) => {
if (obj[idKey] === arrItem[idKey]) {
newArr.push({ ...arrItem, ...alterations });
} else {
newArr.push(arrItem);
}
});
return { ...state, [arrKey]: newArr };
}
export function removeFromArr<T extends ObjectWithId>(
state: StateWithArray,
arrKey: string,
obj: T,
idKey = 'id',
): StateWithArray {
const newArr: T[] = [];
state[arrKey].forEach((arrItem: T) => {
if (!(obj[idKey as keyof T] === arrItem[idKey as keyof T])) {
newArr.push(arrItem);
}
});
return { ...state, [arrKey]: newArr };
}
export function addToArr<T extends ObjectWithId>(
state: StateWithArray,
arrKey: string,
obj: T,
): StateWithArray {
const newObj = { ...obj };
if (!newObj.id) {
newObj.id = nanoid();
}
const newState: { [key: string]: T[] } = {};
newState[arrKey] = [...state[arrKey], newObj];
return { ...state, ...newState };
}

View File

@@ -309,3 +309,15 @@ 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

@@ -0,0 +1,147 @@
# 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,13 +29,16 @@ 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
@@ -48,6 +51,7 @@ from superset.reports.schemas import (
get_delete_ids_schema,
get_slack_channels_schema,
openapi_spec_methods_override,
ReportScheduleExecuteResponseSchema,
ReportSchedulePostSchema,
ReportSchedulePutSchema,
)
@@ -76,6 +80,7 @@ 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
@@ -588,3 +593,77 @@ 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,3 +413,15 @@ 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,3 +2049,94 @@ 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()