mirror of
https://github.com/apache/superset.git
synced 2026-05-06 08:24:26 +00:00
Compare commits
9 Commits
fix-webpac
...
fire-alert
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58a2eb465a | ||
|
|
3c51194bb2 | ||
|
|
19be7020c7 | ||
|
|
7c9794cc2f | ||
|
|
7a6b084ff7 | ||
|
|
17eeeaccac | ||
|
|
4dd1e80f4c | ||
|
|
ecb3ac68ff | ||
|
|
076e477fd4 |
10
.claude/commands/js-to-ts.md
Normal file
10
.claude/commands/js-to-ts.md
Normal 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.
|
||||||
684
.claude/projects/js-to-ts/AGENT.md
Normal file
684
.claude/projects/js-to-ts/AGENT.md
Normal 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.
|
||||||
199
.claude/projects/js-to-ts/COORDINATOR.md
Normal file
199
.claude/projects/js-to-ts/COORDINATOR.md
Normal 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
|
||||||
76
.claude/projects/js-to-ts/PROJECT.md
Normal file
76
.claude/projects/js-to-ts/PROJECT.md
Normal 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/`*
|
||||||
@@ -184,8 +184,10 @@ services:
|
|||||||
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
|
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
|
||||||
# configuring the dev-server to use the host.docker.internal to connect to the backend
|
# configuring the dev-server to use the host.docker.internal to connect to the backend
|
||||||
superset: "http://superset:8088"
|
superset: "http://superset:8088"
|
||||||
|
WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-0.0.0.0}"
|
||||||
|
WEBPACK_DEVSERVER_PORT: "${WEBPACK_DEVSERVER_PORT:-9000}"
|
||||||
ports:
|
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
|
container_name: superset_node
|
||||||
command: ["/app/docker/docker-frontend.sh"]
|
command: ["/app/docker/docker-frontend.sh"]
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
@@ -16,9 +16,11 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react';
|
import {
|
||||||
import { Popover, type PopoverProps } from '@superset-ui/core/components';
|
Popover,
|
||||||
import type ReactAce from 'react-ace';
|
type PopoverProps,
|
||||||
|
SQLEditor,
|
||||||
|
} from '@superset-ui/core/components';
|
||||||
import { CalculatorOutlined } from '@ant-design/icons';
|
import { CalculatorOutlined } from '@ant-design/icons';
|
||||||
import { css, styled, useTheme, t } from '@superset-ui/core';
|
import { css, styled, useTheme, t } from '@superset-ui/core';
|
||||||
|
|
||||||
@@ -35,24 +37,10 @@ const StyledCalculatorIcon = styled(CalculatorOutlined)`
|
|||||||
|
|
||||||
export const SQLPopover = (props: PopoverProps & { sqlExpression: string }) => {
|
export const SQLPopover = (props: PopoverProps & { sqlExpression: string }) => {
|
||||||
const theme = useTheme();
|
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 (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
content={
|
content={
|
||||||
<AceEditor
|
<SQLEditor
|
||||||
mode="sql"
|
|
||||||
value={props.sqlExpression}
|
value={props.sqlExpression}
|
||||||
editorProps={{ $blockScrolling: true }}
|
editorProps={{ $blockScrolling: true }}
|
||||||
setOptions={{
|
setOptions={{
|
||||||
@@ -65,7 +53,6 @@ export const SQLPopover = (props: PopoverProps & { sqlExpression: string }) => {
|
|||||||
wrapEnabled
|
wrapEnabled
|
||||||
style={{
|
style={{
|
||||||
border: `1px solid ${theme.colorBorder}`,
|
border: `1px solid ${theme.colorBorder}`,
|
||||||
background: theme.colorPrimaryBg,
|
|
||||||
maxWidth: theme.sizeUnit * 100,
|
maxWidth: theme.sizeUnit * 100,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,11 +17,14 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export default function roundDecimal(number, precision) {
|
export default function roundDecimal(
|
||||||
let roundedNumber;
|
number: number,
|
||||||
let p = precision;
|
precision?: number,
|
||||||
|
): number {
|
||||||
|
let roundedNumber: number;
|
||||||
if (precision) {
|
if (precision) {
|
||||||
roundedNumber = Math.round(number * (p = 10 ** p)) / p;
|
const p = 10 ** precision;
|
||||||
|
roundedNumber = Math.round(number * p) / p;
|
||||||
} else {
|
} else {
|
||||||
roundedNumber = Math.round(number);
|
roundedNumber = Math.round(number);
|
||||||
}
|
}
|
||||||
@@ -258,20 +258,36 @@ export const dashboardWithFilter = {
|
|||||||
type: DASHBOARD_ROOT_TYPE,
|
type: DASHBOARD_ROOT_TYPE,
|
||||||
id: DASHBOARD_ROOT_ID,
|
id: DASHBOARD_ROOT_ID,
|
||||||
children: [DASHBOARD_GRID_ID],
|
children: [DASHBOARD_GRID_ID],
|
||||||
|
meta: {
|
||||||
|
chartId: 0,
|
||||||
|
height: 0,
|
||||||
|
uuid: '',
|
||||||
|
width: 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
[DASHBOARD_GRID_ID]: {
|
[DASHBOARD_GRID_ID]: {
|
||||||
type: DASHBOARD_GRID_TYPE,
|
type: DASHBOARD_GRID_TYPE,
|
||||||
id: DASHBOARD_GRID_ID,
|
id: DASHBOARD_GRID_ID,
|
||||||
children: ['ROW_ID'],
|
children: ['ROW_ID'],
|
||||||
meta: {},
|
meta: {
|
||||||
|
chartId: 0,
|
||||||
|
height: 0,
|
||||||
|
uuid: '',
|
||||||
|
width: 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
[DASHBOARD_HEADER_ID]: {
|
[DASHBOARD_HEADER_ID]: {
|
||||||
type: DASHBOARD_HEADER_TYPE,
|
type: DASHBOARD_HEADER_TYPE,
|
||||||
id: DASHBOARD_HEADER_ID,
|
id: DASHBOARD_HEADER_ID,
|
||||||
|
children: [],
|
||||||
meta: {
|
meta: {
|
||||||
text: 'New dashboard',
|
text: 'New dashboard',
|
||||||
|
chartId: 0,
|
||||||
|
height: 0,
|
||||||
|
uuid: '',
|
||||||
|
width: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -285,6 +301,7 @@ export const dashboardWithFilter = {
|
|||||||
...newComponentFactory(CHART_TYPE),
|
...newComponentFactory(CHART_TYPE),
|
||||||
id: 'FILTER_ID',
|
id: 'FILTER_ID',
|
||||||
meta: {
|
meta: {
|
||||||
|
...newComponentFactory(CHART_TYPE).meta,
|
||||||
chartId: filterId,
|
chartId: filterId,
|
||||||
width: 3,
|
width: 3,
|
||||||
height: 10,
|
height: 10,
|
||||||
@@ -296,6 +313,7 @@ export const dashboardWithFilter = {
|
|||||||
...newComponentFactory(CHART_TYPE),
|
...newComponentFactory(CHART_TYPE),
|
||||||
id: 'CHART_ID',
|
id: 'CHART_ID',
|
||||||
meta: {
|
meta: {
|
||||||
|
...newComponentFactory(CHART_TYPE).meta,
|
||||||
chartId,
|
chartId,
|
||||||
width: 3,
|
width: 3,
|
||||||
height: 10,
|
height: 10,
|
||||||
|
|||||||
@@ -17,8 +17,26 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { ADD_TOAST, REMOVE_TOAST } from './actions';
|
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) {
|
switch (action.type) {
|
||||||
case ADD_TOAST: {
|
case ADD_TOAST: {
|
||||||
const { payload: toast } = action;
|
const { payload: toast } = action;
|
||||||
@@ -33,8 +33,11 @@ interface ModalFormFieldProps {
|
|||||||
hasFeedback?: boolean;
|
hasFeedback?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledFieldContainer = styled.div<{ bottomSpacing: boolean }>`
|
const StyledFieldContainer = styled.div<{
|
||||||
${({ theme, bottomSpacing }) => css`
|
bottomSpacing: boolean;
|
||||||
|
hasError: boolean;
|
||||||
|
}>`
|
||||||
|
${({ theme, bottomSpacing, hasError }) => css`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-top: 0px;
|
margin-top: 0px;
|
||||||
margin-bottom: ${bottomSpacing ? theme.sizeUnit * 4 : 0}px;
|
margin-bottom: ${bottomSpacing ? theme.sizeUnit * 4 : 0}px;
|
||||||
@@ -48,7 +51,7 @@ const StyledFieldContainer = styled.div<{ bottomSpacing: boolean }>`
|
|||||||
|
|
||||||
.required {
|
.required {
|
||||||
margin-left: ${theme.sizeUnit / 2}px;
|
margin-left: ${theme.sizeUnit / 2}px;
|
||||||
color: ${theme.colorError};
|
color: ${hasError ? theme.colorError : theme.colorIcon};
|
||||||
}
|
}
|
||||||
|
|
||||||
.helper {
|
.helper {
|
||||||
@@ -128,8 +131,14 @@ export function ModalFormField({
|
|||||||
validateStatus,
|
validateStatus,
|
||||||
hasFeedback = false,
|
hasFeedback = false,
|
||||||
}: ModalFormFieldProps) {
|
}: ModalFormFieldProps) {
|
||||||
|
const hasError = !!(error || validateStatus === 'error');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledFieldContainer bottomSpacing={bottomSpacing} data-test={testId}>
|
<StyledFieldContainer
|
||||||
|
bottomSpacing={bottomSpacing}
|
||||||
|
hasError={hasError}
|
||||||
|
data-test={testId}
|
||||||
|
>
|
||||||
<div className="control-label">
|
<div className="control-label">
|
||||||
{label}
|
{label}
|
||||||
{tooltip && <InfoTooltip tooltip={tooltip} />}
|
{tooltip && <InfoTooltip tooltip={tooltip} />}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { useState } from 'react';
|
|||||||
import { Meta, StoryObj } from '@storybook/react';
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
import { Tag } from 'src/components/Tag';
|
import { Tag } from 'src/components/Tag';
|
||||||
import type { CheckableTagProps } 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 {
|
export default {
|
||||||
title: 'Components/Tag',
|
title: 'Components/Tag',
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { render, screen } from 'spec/helpers/testing-library';
|
import { render, screen } from 'spec/helpers/testing-library';
|
||||||
import TagType from 'src/types/TagType';
|
import type { TagType } from 'src/types/TagType';
|
||||||
import { Tag } from '.';
|
import { Tag } from '.';
|
||||||
|
|
||||||
const mockedProps: TagType = {
|
const mockedProps: TagType = {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import { styled } from '@superset-ui/core';
|
import { styled } from '@superset-ui/core';
|
||||||
import { Link } from 'react-router-dom';
|
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 { Tag as AntdTag } from '@superset-ui/core/components/Tag';
|
||||||
import { Tooltip } from '@superset-ui/core/components/Tooltip';
|
import { Tooltip } from '@superset-ui/core/components/Tooltip';
|
||||||
import type { TagProps } from 'antd/es';
|
import type { TagProps } from 'antd/es';
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
SupersetClient,
|
SupersetClient,
|
||||||
t,
|
t,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import Tag from 'src/types/TagType';
|
import type { TagType } from 'src/types/TagType';
|
||||||
|
|
||||||
import rison from 'rison';
|
import rison from 'rison';
|
||||||
import { cacheWrapper } from 'src/utils/cacheWrapper';
|
import { cacheWrapper } from 'src/utils/cacheWrapper';
|
||||||
@@ -43,7 +43,7 @@ type SelectTagsValue = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const tagToSelectOption = (
|
export const tagToSelectOption = (
|
||||||
tag: Tag & { table_name: string },
|
tag: TagType & { table_name: string },
|
||||||
): SelectTagsValue => ({
|
): SelectTagsValue => ({
|
||||||
value: tag.id,
|
value: tag.id,
|
||||||
label: tag.name,
|
label: tag.name,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { styled } from '@superset-ui/core';
|
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';
|
import { Tag } from 'src/components/Tag';
|
||||||
|
|
||||||
export type TagsListProps = {
|
export type TagsListProps = {
|
||||||
|
|||||||
@@ -26,9 +26,40 @@ import {
|
|||||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import { Slice } from '../types';
|
import { Slice } from '../types';
|
||||||
|
import { HYDRATE_DASHBOARD } from './hydrate';
|
||||||
|
|
||||||
const FETCH_SLICES_PAGE_SIZE = 200;
|
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(
|
export function getDatasourceParameter(
|
||||||
datasourceId: number,
|
datasourceId: number,
|
||||||
datasourceType: DatasourceType,
|
datasourceType: DatasourceType,
|
||||||
|
|||||||
@@ -170,7 +170,9 @@ const SliceAddedBadge: FC<{ placeholder?: HTMLDivElement }> = ({
|
|||||||
const AddSliceCard: FC<{
|
const AddSliceCard: FC<{
|
||||||
datasourceUrl?: string;
|
datasourceUrl?: string;
|
||||||
datasourceName?: string;
|
datasourceName?: string;
|
||||||
innerRef?: RefObject<HTMLDivElement>;
|
innerRef?:
|
||||||
|
| RefObject<HTMLDivElement>
|
||||||
|
| ((node: HTMLDivElement | null) => void);
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
lastModified?: string;
|
lastModified?: string;
|
||||||
sliceName: string;
|
sliceName: string;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { Dispatch } from 'redux';
|
|||||||
import { Slice } from 'src/dashboard/types';
|
import { Slice } from 'src/dashboard/types';
|
||||||
import { withTheme, Theme } from '@emotion/react';
|
import { withTheme, Theme } from '@emotion/react';
|
||||||
import { navigateTo } from 'src/utils/navigationUtils';
|
import { navigateTo } from 'src/utils/navigationUtils';
|
||||||
|
import type { ConnectDragSource } from 'react-dnd';
|
||||||
import AddSliceCard from './AddSliceCard';
|
import AddSliceCard from './AddSliceCard';
|
||||||
import AddSliceDragPreview from './dnd/AddSliceDragPreview';
|
import AddSliceDragPreview from './dnd/AddSliceDragPreview';
|
||||||
import { DragDroppable } from './dnd/DragDroppable';
|
import { DragDroppable } from './dnd/DragDroppable';
|
||||||
@@ -312,7 +313,7 @@ class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
|
|||||||
// actual style should be applied to nested AddSliceCard component
|
// actual style should be applied to nested AddSliceCard component
|
||||||
style={{}}
|
style={{}}
|
||||||
>
|
>
|
||||||
{({ dragSourceRef }) => (
|
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||||
<AddSliceCard
|
<AddSliceCard
|
||||||
innerRef={dragSourceRef}
|
innerRef={dragSourceRef}
|
||||||
style={style}
|
style={style}
|
||||||
|
|||||||
@@ -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 }),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -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 }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -22,6 +22,7 @@ import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
|
|||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import { shallowEqual, useSelector } from 'react-redux';
|
import { shallowEqual, useSelector } from 'react-redux';
|
||||||
import { ResizeCallback, ResizeStartCallback } from 're-resizable';
|
import { ResizeCallback, ResizeStartCallback } from 're-resizable';
|
||||||
|
import type { ConnectDragSource } from 'react-dnd';
|
||||||
import { Draggable } from '../../dnd/DragDroppable';
|
import { Draggable } from '../../dnd/DragDroppable';
|
||||||
import { COLUMN_TYPE, ROW_TYPE } from '../../../util/componentTypes';
|
import { COLUMN_TYPE, ROW_TYPE } from '../../../util/componentTypes';
|
||||||
import WithPopoverMenu from '../../menu/WithPopoverMenu';
|
import WithPopoverMenu from '../../menu/WithPopoverMenu';
|
||||||
@@ -119,7 +120,7 @@ const DynamicComponent: FC<DynamicComponentProps> = ({
|
|||||||
onDrop={handleComponentDrop}
|
onDrop={handleComponentDrop}
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
>
|
>
|
||||||
{({ dragSourceRef }) => (
|
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||||
<WithPopoverMenu
|
<WithPopoverMenu
|
||||||
menuItems={[
|
menuItems={[
|
||||||
<BackgroundStyleDropdown
|
<BackgroundStyleDropdown
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { PureComponent } from 'react';
|
|||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import { css, styled } from '@superset-ui/core';
|
import { css, styled } from '@superset-ui/core';
|
||||||
import { DragDroppable } from 'src/dashboard/components/dnd/DragDroppable';
|
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_COMPONENTS_SOURCE_ID } from 'src/dashboard/util/constants';
|
||||||
import { NEW_COMPONENT_SOURCE_TYPE } from 'src/dashboard/util/componentTypes';
|
import { NEW_COMPONENT_SOURCE_TYPE } from 'src/dashboard/util/componentTypes';
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ export default class DraggableNewComponent extends PureComponent<DraggableNewCom
|
|||||||
depth={0}
|
depth={0}
|
||||||
editMode
|
editMode
|
||||||
>
|
>
|
||||||
{({ dragSourceRef }) => (
|
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||||
<NewComponent ref={dragSourceRef} data-test="new-component">
|
<NewComponent ref={dragSourceRef} data-test="new-component">
|
||||||
<NewComponentPlaceholder
|
<NewComponentPlaceholder
|
||||||
className={cx('new-component-placeholder', className)}
|
className={cx('new-component-placeholder', className)}
|
||||||
|
|||||||
@@ -28,11 +28,38 @@ import {
|
|||||||
DASHBOARD_GRID_ID,
|
DASHBOARD_GRID_ID,
|
||||||
} from '../util/constants';
|
} 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]: {
|
[DASHBOARD_ROOT_ID]: {
|
||||||
type: DASHBOARD_ROOT_TYPE,
|
type: DASHBOARD_ROOT_TYPE,
|
||||||
id: DASHBOARD_ROOT_ID,
|
id: DASHBOARD_ROOT_ID,
|
||||||
children: [DASHBOARD_GRID_ID],
|
children: [DASHBOARD_GRID_ID],
|
||||||
|
parents: [],
|
||||||
|
meta: rootMeta,
|
||||||
},
|
},
|
||||||
|
|
||||||
[DASHBOARD_GRID_ID]: {
|
[DASHBOARD_GRID_ID]: {
|
||||||
@@ -40,14 +67,16 @@ export default {
|
|||||||
id: DASHBOARD_GRID_ID,
|
id: DASHBOARD_GRID_ID,
|
||||||
children: [],
|
children: [],
|
||||||
parents: [DASHBOARD_ROOT_ID],
|
parents: [DASHBOARD_ROOT_ID],
|
||||||
meta: {},
|
meta: gridMeta,
|
||||||
},
|
},
|
||||||
|
|
||||||
[DASHBOARD_HEADER_ID]: {
|
[DASHBOARD_HEADER_ID]: {
|
||||||
type: HEADER_TYPE,
|
type: HEADER_TYPE,
|
||||||
id: DASHBOARD_HEADER_ID,
|
id: DASHBOARD_HEADER_ID,
|
||||||
meta: {
|
children: [],
|
||||||
text: 'New dashboard',
|
parents: [],
|
||||||
},
|
meta: headerMeta,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default emptyDashboardLayout;
|
||||||
@@ -23,10 +23,12 @@ import {
|
|||||||
FETCH_ALL_SLICES_STARTED,
|
FETCH_ALL_SLICES_STARTED,
|
||||||
ADD_SLICES,
|
ADD_SLICES,
|
||||||
SET_SLICES,
|
SET_SLICES,
|
||||||
|
SliceEntitiesState,
|
||||||
|
SliceEntitiesActionPayload,
|
||||||
} from '../actions/sliceEntities';
|
} from '../actions/sliceEntities';
|
||||||
import { HYDRATE_DASHBOARD } from '../actions/hydrate';
|
import { HYDRATE_DASHBOARD } from '../actions/hydrate';
|
||||||
|
|
||||||
export const initSliceEntities = {
|
export const initSliceEntities: SliceEntitiesState = {
|
||||||
slices: {},
|
slices: {},
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
@@ -34,37 +36,34 @@ export const initSliceEntities = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function sliceEntitiesReducer(
|
export default function sliceEntitiesReducer(
|
||||||
state = initSliceEntities,
|
state: SliceEntitiesState = initSliceEntities,
|
||||||
action,
|
action: SliceEntitiesActionPayload,
|
||||||
) {
|
): SliceEntitiesState {
|
||||||
const actionHandlers = {
|
switch (action.type) {
|
||||||
[HYDRATE_DASHBOARD]() {
|
case HYDRATE_DASHBOARD:
|
||||||
return {
|
return {
|
||||||
...action.data.sliceEntities,
|
...action.data.sliceEntities,
|
||||||
};
|
};
|
||||||
},
|
case FETCH_ALL_SLICES_STARTED:
|
||||||
[FETCH_ALL_SLICES_STARTED]() {
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
};
|
};
|
||||||
},
|
case ADD_SLICES:
|
||||||
[ADD_SLICES]() {
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
slices: { ...state.slices, ...action.payload.slices },
|
slices: { ...state.slices, ...action.payload.slices },
|
||||||
lastUpdated: new Date().getTime(),
|
lastUpdated: new Date().getTime(),
|
||||||
};
|
};
|
||||||
},
|
case SET_SLICES:
|
||||||
[SET_SLICES]() {
|
|
||||||
return {
|
return {
|
||||||
|
...state,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
slices: { ...action.payload.slices },
|
slices: { ...action.payload.slices },
|
||||||
lastUpdated: new Date().getTime(),
|
lastUpdated: new Date().getTime(),
|
||||||
};
|
};
|
||||||
},
|
case FETCH_ALL_SLICES_FAILED:
|
||||||
[FETCH_ALL_SLICES_FAILED]() {
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -72,11 +71,7 @@ export default function sliceEntitiesReducer(
|
|||||||
errorMessage:
|
errorMessage:
|
||||||
action.payload.error || t('Could not fetch all saved charts'),
|
action.payload.error || t('Could not fetch all saved charts'),
|
||||||
};
|
};
|
||||||
},
|
default:
|
||||||
};
|
return state;
|
||||||
|
|
||||||
if (action.type in actionHandlers) {
|
|
||||||
return actionHandlers[action.type]();
|
|
||||||
}
|
}
|
||||||
return state;
|
|
||||||
}
|
}
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
211
superset-frontend/src/dashboard/util/activeDashboardFilters.ts
Normal file
211
superset-frontend/src/dashboard/util/activeDashboardFilters.ts
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,17 +16,41 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
import { DashboardLayout } from '../types';
|
||||||
import getFilterScopeNodesTree from './getFilterScopeNodesTree';
|
import getFilterScopeNodesTree from './getFilterScopeNodesTree';
|
||||||
import getFilterScopeParentNodes from './getFilterScopeParentNodes';
|
import getFilterScopeParentNodes from './getFilterScopeParentNodes';
|
||||||
import getKeyForFilterScopeTree from './getKeyForFilterScopeTree';
|
import getKeyForFilterScopeTree from './getKeyForFilterScopeTree';
|
||||||
import getSelectedChartIdForFilterScopeTree from './getSelectedChartIdForFilterScopeTree';
|
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({
|
export default function buildFilterScopeTreeEntry({
|
||||||
checkedFilterFields = [],
|
checkedFilterFields = [],
|
||||||
activeFilterField,
|
activeFilterField,
|
||||||
filterScopeMap = {},
|
filterScopeMap = {},
|
||||||
layout = {},
|
layout = {},
|
||||||
}) {
|
}: BuildFilterScopeTreeEntryProps): Record<string, FilterScopeTreeEntry> {
|
||||||
const key = getKeyForFilterScopeTree({
|
const key = getKeyForFilterScopeTree({
|
||||||
checkedFilterFields,
|
checkedFilterFields,
|
||||||
activeFilterField,
|
activeFilterField,
|
||||||
@@ -43,15 +67,15 @@ export default function buildFilterScopeTreeEntry({
|
|||||||
filterFields: editingList,
|
filterFields: editingList,
|
||||||
selectedChartId,
|
selectedChartId,
|
||||||
});
|
});
|
||||||
const checkedChartIdSet = new Set();
|
const checkedChartIdSet = new Set<string>();
|
||||||
editingList.forEach(filterField => {
|
editingList.forEach(filterField => {
|
||||||
(filterScopeMap[filterField].checked || []).forEach(chartId => {
|
(filterScopeMap[filterField]?.checked || []).forEach(chartId => {
|
||||||
checkedChartIdSet.add(`${chartId}:${filterField}`);
|
checkedChartIdSet.add(`${chartId}:${filterField}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const checked = [...checkedChartIdSet];
|
const checked = [...checkedChartIdSet];
|
||||||
const expanded = filterScopeMap[key]
|
const expanded = filterScopeMap[key]
|
||||||
? filterScopeMap[key].expanded
|
? filterScopeMap[key].expanded || []
|
||||||
: getFilterScopeParentNodes(nodes, 1);
|
: getFilterScopeParentNodes(nodes, 1);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -16,7 +16,10 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* 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 { NEW_COMPONENTS_SOURCE_ID } from 'src/dashboard/util/constants';
|
||||||
import {
|
import {
|
||||||
CHART_TYPE,
|
CHART_TYPE,
|
||||||
@@ -28,7 +31,7 @@ import {
|
|||||||
|
|
||||||
describe('dropOverflowsParent', () => {
|
describe('dropOverflowsParent', () => {
|
||||||
it('returns true if a parent does NOT have adequate width for child', () => {
|
it('returns true if a parent does NOT have adequate width for child', () => {
|
||||||
const dropResult = {
|
const dropResult: DropResult = {
|
||||||
source: { id: '_' },
|
source: { id: '_' },
|
||||||
destination: { id: 'a' },
|
destination: { id: 'a' },
|
||||||
dragging: { id: 'z' },
|
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', () => {
|
it('returns false if a parent DOES have adequate width for child', () => {
|
||||||
const dropResult = {
|
const dropResult: DropResult = {
|
||||||
source: { id: '_' },
|
source: { id: '_' },
|
||||||
destination: { id: 'a' },
|
destination: { id: 'a' },
|
||||||
dragging: { id: 'z' },
|
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', () => {
|
it('returns false if a child CAN shrink to available parent space', () => {
|
||||||
const dropResult = {
|
const dropResult: DropResult = {
|
||||||
source: { id: '_' },
|
source: { id: '_' },
|
||||||
destination: { id: 'a' },
|
destination: { id: 'a' },
|
||||||
dragging: { id: 'z' },
|
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', () => {
|
it('returns true if a child CANNOT shrink to available parent space', () => {
|
||||||
const dropResult = {
|
const dropResult: DropResult = {
|
||||||
source: { id: '_' },
|
source: { id: '_' },
|
||||||
destination: { id: 'a' },
|
destination: { id: 'a' },
|
||||||
dragging: { id: 'b' },
|
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', () => {
|
it('returns true if a column has children that CANNOT shrink to available parent space', () => {
|
||||||
const dropResult = {
|
const dropResult: DropResult = {
|
||||||
source: { id: '_' },
|
source: { id: '_' },
|
||||||
destination: { id: 'destination' },
|
destination: { id: 'destination' },
|
||||||
dragging: { id: 'dragging' },
|
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
|
// remove children
|
||||||
expect(
|
expect(
|
||||||
dropOverflowsParent(dropResult, {
|
dropOverflowsParent(dropResult, {
|
||||||
...layout,
|
...layout,
|
||||||
dragging: { ...layout.dragging, children: [] },
|
dragging: { ...layout.dragging, children: [] } as any,
|
||||||
}),
|
} as any),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work with new components that are not in the layout', () => {
|
it('should work with new components that are not in the layout', () => {
|
||||||
const dropResult = {
|
const dropResult: DropResult = {
|
||||||
source: { id: NEW_COMPONENTS_SOURCE_ID },
|
source: { id: NEW_COMPONENTS_SOURCE_ID },
|
||||||
destination: { id: 'a' },
|
destination: { id: 'a' },
|
||||||
dragging: { type: CHART_TYPE },
|
dragging: { type: CHART_TYPE },
|
||||||
@@ -212,15 +215,15 @@ describe('dropOverflowsParent', () => {
|
|||||||
a: {
|
a: {
|
||||||
id: 'a',
|
id: 'a',
|
||||||
type: ROW_TYPE,
|
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', () => {
|
it('source/destination without widths should not overflow parent', () => {
|
||||||
const dropResult = {
|
const dropResult: DropResult = {
|
||||||
source: { id: '_' },
|
source: { id: '_' },
|
||||||
destination: { id: 'tab' },
|
destination: { id: 'tab' },
|
||||||
dragging: { id: 'header' },
|
dragging: { id: 'header' },
|
||||||
@@ -237,6 +240,6 @@ describe('dropOverflowsParent', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(dropOverflowsParent(dropResult, layout)).toBe(false);
|
expect(dropOverflowsParent(dropResult, layout as any)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -16,9 +16,22 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
import type { ComponentType, Layout } from 'src/dashboard/types';
|
||||||
import getComponentWidthFromDrop from './getComponentWidthFromDrop';
|
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 });
|
const childWidth = getComponentWidthFromDrop({ dropResult, layout });
|
||||||
return typeof childWidth === 'number' && childWidth < 0;
|
return typeof childWidth === 'number' && childWidth < 0;
|
||||||
}
|
}
|
||||||
@@ -18,8 +18,11 @@
|
|||||||
*/
|
*/
|
||||||
import { TABS_TYPE } from './componentTypes';
|
import { TABS_TYPE } from './componentTypes';
|
||||||
import { DASHBOARD_ROOT_ID } from './constants';
|
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?
|
// DASHBOARD_GRID_TYPE or TABS_TYPE?
|
||||||
let parent = layout[DASHBOARD_ROOT_ID];
|
let parent = layout[DASHBOARD_ROOT_ID];
|
||||||
if (
|
if (
|
||||||
@@ -18,36 +18,55 @@
|
|||||||
*/
|
*/
|
||||||
import getChartIdsFromLayout from 'src/dashboard/util/getChartIdsFromLayout';
|
import getChartIdsFromLayout from 'src/dashboard/util/getChartIdsFromLayout';
|
||||||
import { ROW_TYPE, CHART_TYPE } from 'src/dashboard/util/componentTypes';
|
import { ROW_TYPE, CHART_TYPE } from 'src/dashboard/util/componentTypes';
|
||||||
|
import type { DashboardLayout } from '../types';
|
||||||
|
|
||||||
describe('getChartIdsFromLayout', () => {
|
describe('getChartIdsFromLayout', () => {
|
||||||
const mockLayout = {
|
const mockLayout: DashboardLayout = {
|
||||||
a: {
|
a: {
|
||||||
id: 'a',
|
id: 'a',
|
||||||
type: CHART_TYPE,
|
type: CHART_TYPE,
|
||||||
meta: { chartId: 'A' },
|
children: [],
|
||||||
|
meta: {
|
||||||
|
chartId: 123,
|
||||||
|
height: 400,
|
||||||
|
width: 400,
|
||||||
|
uuid: 'uuid-a',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
b: {
|
b: {
|
||||||
id: 'b',
|
id: 'b',
|
||||||
type: CHART_TYPE,
|
type: CHART_TYPE,
|
||||||
meta: { chartId: 'B' },
|
children: [],
|
||||||
|
meta: {
|
||||||
|
chartId: 456,
|
||||||
|
height: 400,
|
||||||
|
width: 400,
|
||||||
|
uuid: 'uuid-b',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
c: {
|
c: {
|
||||||
id: 'c',
|
id: 'c',
|
||||||
type: ROW_TYPE,
|
type: ROW_TYPE,
|
||||||
meta: { chartId: 'C' },
|
children: [],
|
||||||
|
meta: {
|
||||||
|
chartId: 789,
|
||||||
|
height: 400,
|
||||||
|
width: 400,
|
||||||
|
uuid: 'uuid-c',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
it('should return an array of chartIds', () => {
|
it('should return an array of chartIds', () => {
|
||||||
const result = getChartIdsFromLayout(mockLayout);
|
const result = getChartIdsFromLayout(mockLayout);
|
||||||
expect(Array.isArray(result)).toBe(true);
|
expect(Array.isArray(result)).toBe(true);
|
||||||
expect(result.includes('A')).toBe(true);
|
expect(result.includes(123)).toBe(true);
|
||||||
expect(result.includes('B')).toBe(true);
|
expect(result.includes(456)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return ids only from CHART_TYPE components', () => {
|
it('should return ids only from CHART_TYPE components', () => {
|
||||||
const result = getChartIdsFromLayout(mockLayout);
|
const result = getChartIdsFromLayout(mockLayout);
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
expect(result.includes('C')).toBe(false);
|
expect(result.includes(789)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,7 +16,17 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* 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();
|
const directPathToFilter = (tabsComponent.parents || []).slice();
|
||||||
directPathToFilter.push(tabsComponent.id);
|
directPathToFilter.push(tabsComponent.id);
|
||||||
directPathToFilter.push(tabsComponent.children[tabIndex]);
|
directPathToFilter.push(tabsComponent.children[tabIndex]);
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { DASHBOARD_ROOT_TYPE, DASHBOARD_GRID_TYPE } from './componentTypes';
|
import { DASHBOARD_ROOT_TYPE, DASHBOARD_GRID_TYPE } from './componentTypes';
|
||||||
|
import type { ComponentType } from '../types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DASHBOARD_GRID_ID,
|
DASHBOARD_GRID_ID,
|
||||||
@@ -24,7 +25,22 @@ import {
|
|||||||
DASHBOARD_VERSION_KEY,
|
DASHBOARD_VERSION_KEY,
|
||||||
} from './constants';
|
} 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 {
|
return {
|
||||||
[DASHBOARD_VERSION_KEY]: 'v2',
|
[DASHBOARD_VERSION_KEY]: 'v2',
|
||||||
[DASHBOARD_ROOT_ID]: {
|
[DASHBOARD_ROOT_ID]: {
|
||||||
@@ -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"]');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,10 +18,15 @@
|
|||||||
*/
|
*/
|
||||||
import { safeStringify } from '../../utils/safeStringify';
|
import { safeStringify } from '../../utils/safeStringify';
|
||||||
|
|
||||||
|
interface GetKeyForFilterScopeTreeProps {
|
||||||
|
activeFilterField?: string;
|
||||||
|
checkedFilterFields: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export default function getKeyForFilterScopeTree({
|
export default function getKeyForFilterScopeTree({
|
||||||
activeFilterField,
|
activeFilterField,
|
||||||
checkedFilterFields,
|
checkedFilterFields,
|
||||||
}) {
|
}: GetKeyForFilterScopeTreeProps): string {
|
||||||
return safeStringify(
|
return safeStringify(
|
||||||
activeFilterField ? [activeFilterField] : checkedFilterFields,
|
activeFilterField ? [activeFilterField] : checkedFilterFields,
|
||||||
);
|
);
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -18,8 +18,12 @@
|
|||||||
*/
|
*/
|
||||||
/* eslint-disable no-param-reassign */
|
/* eslint-disable no-param-reassign */
|
||||||
import { CHART_TYPE } from './componentTypes';
|
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(
|
return Object.values(layout).find(
|
||||||
currentComponent =>
|
currentComponent =>
|
||||||
currentComponent &&
|
currentComponent &&
|
||||||
@@ -18,7 +18,9 @@
|
|||||||
*/
|
*/
|
||||||
import { IN_COMPONENT_ELEMENT_TYPES } from './constants';
|
import { IN_COMPONENT_ELEMENT_TYPES } from './constants';
|
||||||
|
|
||||||
export default function getLeafComponentIdFromPath(directPathToChild = []) {
|
export default function getLeafComponentIdFromPath(
|
||||||
|
directPathToChild: string[] = [],
|
||||||
|
): string | null {
|
||||||
if (directPathToChild.length > 0) {
|
if (directPathToChild.length > 0) {
|
||||||
const currentPath = directPathToChild.slice();
|
const currentPath = directPathToChild.slice();
|
||||||
|
|
||||||
@@ -26,7 +28,10 @@ export default function getLeafComponentIdFromPath(directPathToChild = []) {
|
|||||||
const componentId = currentPath.pop();
|
const componentId = currentPath.pop();
|
||||||
const componentType = componentId && componentId.split('-')[0];
|
const componentType = componentId && componentId.split('-')[0];
|
||||||
|
|
||||||
if (!IN_COMPONENT_ELEMENT_TYPES.includes(componentType)) {
|
if (
|
||||||
|
componentType &&
|
||||||
|
!IN_COMPONENT_ELEMENT_TYPES.includes(componentType)
|
||||||
|
) {
|
||||||
return componentId;
|
return componentId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ import isDashboardEmpty from 'src/dashboard/util/isDashboardEmpty';
|
|||||||
import getEmptyLayout from 'src/dashboard/util/getEmptyLayout';
|
import getEmptyLayout from 'src/dashboard/util/getEmptyLayout';
|
||||||
|
|
||||||
describe('isDashboardEmpty', () => {
|
describe('isDashboardEmpty', () => {
|
||||||
const emptyLayout: object = getEmptyLayout();
|
const emptyLayout = getEmptyLayout();
|
||||||
const testLayout: object = {
|
const testLayout: object = {
|
||||||
...emptyLayout,
|
...emptyLayout,
|
||||||
'MARKDOWN-IhTGLhyiTd': {
|
'MARKDOWN-IhTGLhyiTd': {
|
||||||
|
|||||||
@@ -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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,18 +16,19 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { CHART_TYPE } from './componentTypes';
|
export interface ChartLoadTimestamps {
|
||||||
|
chartUpdateStartTime?: number;
|
||||||
export default function getChartIdsFromLayout(layout) {
|
chartUpdateEndTime?: number | null;
|
||||||
return Object.values(layout).reduce((chartIds, currentComponent) => {
|
// allow extra fields without narrowing
|
||||||
if (
|
[key: string]: unknown;
|
||||||
currentComponent &&
|
}
|
||||||
currentComponent.type === CHART_TYPE &&
|
|
||||||
currentComponent.meta &&
|
export default function isDashboardLoading(
|
||||||
currentComponent.meta.chartId
|
charts: Record<string, ChartLoadTimestamps>,
|
||||||
) {
|
): boolean {
|
||||||
chartIds.push(currentComponent.meta.chartId);
|
return Object.values(charts).some(chart => {
|
||||||
}
|
const start = chart.chartUpdateStartTime ?? 0;
|
||||||
return chartIds;
|
const end = chart.chartUpdateEndTime ?? 0;
|
||||||
}, []);
|
return start > end;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -16,17 +16,40 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* 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({
|
export default function isInDifferentFilterScopes({
|
||||||
dashboardFilters = {},
|
dashboardFilters = {},
|
||||||
source = [],
|
source = [],
|
||||||
destination = [],
|
destination = [],
|
||||||
}) {
|
}: IsInDifferentFilterScopesProps): boolean {
|
||||||
const sourceSet = new Set(source);
|
const sourceSet = new Set(source);
|
||||||
const destinationSet = new Set(destination);
|
const destinationSet = new Set(destination);
|
||||||
|
|
||||||
const allScopes = [].concat(
|
const allScopes = ([] as string[]).concat(
|
||||||
...Object.values(dashboardFilters).map(({ scopes }) =>
|
...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));
|
return allScopes.some(tab => destinationSet.has(tab) !== sourceSet.has(tab));
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,16 +16,36 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
import { ChartState } from 'src/explore/types';
|
||||||
|
import { Layout } from 'src/dashboard/types';
|
||||||
import findNonTabChildCharIds from './findNonTabChildChartIds';
|
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 });
|
const chartIds = findNonTabChildCharIds({ id, layout });
|
||||||
|
|
||||||
let minQueryStartTime = Infinity;
|
let minQueryStartTime = Infinity;
|
||||||
const didLoad = chartIds.every(chartId => {
|
const didLoad = chartIds.every((chartId: number) => {
|
||||||
const query = chartQueries[chartId] || {};
|
const query = chartQueries[chartId.toString()] || {};
|
||||||
minQueryStartTime = Math.min(query.chartUpdateStartTime, minQueryStartTime);
|
minQueryStartTime = Math.min(
|
||||||
return ['stopped', 'failed', 'rendered'].indexOf(query.chartStatus) > -1;
|
query.chartUpdateStartTime ?? Infinity,
|
||||||
|
minQueryStartTime,
|
||||||
|
);
|
||||||
|
return ['stopped', 'failed', 'rendered'].includes(query.chartStatus || '');
|
||||||
});
|
});
|
||||||
|
|
||||||
return { didLoad, minQueryStartTime };
|
return { didLoad, minQueryStartTime };
|
||||||
@@ -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: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,7 +16,29 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* 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 }) => {
|
return Object.values(dashboardFilters).reduce((map, { chartId, scopes }) => {
|
||||||
const scopesById = Object.keys(scopes).reduce(
|
const scopesById = Object.keys(scopes).reduce(
|
||||||
(scopesByColumn, column) => ({
|
(scopesByColumn, column) => ({
|
||||||
@@ -23,8 +23,20 @@ import {
|
|||||||
MARKDOWN_TYPE,
|
MARKDOWN_TYPE,
|
||||||
TAB_TYPE,
|
TAB_TYPE,
|
||||||
} from './componentTypes';
|
} 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]: {
|
[DASHBOARD_GRID_TYPE]: {
|
||||||
[CHART_TYPE]: true,
|
[CHART_TYPE]: true,
|
||||||
[COLUMN_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;
|
if (!parentType || !childType) return false;
|
||||||
|
|
||||||
const wrapChildLookup = typeToWrapChildLookup[parentType];
|
const wrapChildLookup = typeToWrapChildLookup[parentType as ParentTypes];
|
||||||
if (!wrapChildLookup) return false;
|
if (!wrapChildLookup) return false;
|
||||||
|
|
||||||
return Boolean(wrapChildLookup[childType]);
|
return Boolean(wrapChildLookup[childType as ChildTypes]);
|
||||||
}
|
}
|
||||||
@@ -58,6 +58,7 @@ export interface IDatasource {
|
|||||||
sql?: string | null;
|
sql?: string | null;
|
||||||
datasource_name?: string | null;
|
datasource_name?: string | null;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
|
catalog?: string | null;
|
||||||
schema?: string | null;
|
schema?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { Clauses, ExpressionTypes } from './types';
|
|
||||||
|
|
||||||
export default PropTypes.oneOfType([
|
|
||||||
PropTypes.shape({
|
|
||||||
expressionType: PropTypes.oneOf([ExpressionTypes.Simple]).isRequired,
|
|
||||||
clause: PropTypes.oneOf([Clauses.Having, Clauses.Where]).isRequired,
|
|
||||||
subject: PropTypes.string.isRequired,
|
|
||||||
comparator: PropTypes.oneOfType([
|
|
||||||
PropTypes.string,
|
|
||||||
PropTypes.arrayOf(PropTypes.string),
|
|
||||||
]).isRequired,
|
|
||||||
}),
|
|
||||||
PropTypes.shape({
|
|
||||||
expressionType: PropTypes.oneOf([ExpressionTypes.Sql]).isRequired,
|
|
||||||
clause: PropTypes.oneOf([Clauses.Where, Clauses.Having]).isRequired,
|
|
||||||
sqlExpression: PropTypes.string.isRequired,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>;
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -16,9 +16,10 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import PropTypes from 'prop-types';
|
import { Column } from '@superset-ui/core';
|
||||||
|
|
||||||
export default PropTypes.shape({
|
export type ColumnType = Pick<Column, 'column_name' | 'type'>;
|
||||||
column_name: PropTypes.string.isRequired,
|
|
||||||
type: PropTypes.string,
|
// For backward compatibility with PropTypes usage - create a placeholder object
|
||||||
});
|
const columnType = {} as any;
|
||||||
|
export default columnType;
|
||||||
@@ -26,3 +26,10 @@ export enum Clauses {
|
|||||||
Having = 'HAVING',
|
Having = 'HAVING',
|
||||||
Where = 'WHERE',
|
Where = 'WHERE',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-export AdhocFilter types for convenient access
|
||||||
|
export type {
|
||||||
|
AdhocFilterSimple,
|
||||||
|
AdhocFilterSql,
|
||||||
|
AdhocFilterType,
|
||||||
|
} from './adhocFilterType';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,9 +16,8 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
export default PropTypes.shape({
|
export type { AggregateOption } from './types';
|
||||||
column_name: PropTypes.string.isRequired,
|
|
||||||
type: PropTypes.string,
|
// For backward compatibility with PropTypes usage
|
||||||
});
|
export { AggregateOption as default } from './types';
|
||||||
@@ -16,10 +16,10 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import PropTypes from 'prop-types';
|
import { Column } from '@superset-ui/core';
|
||||||
|
|
||||||
export default PropTypes.shape({
|
export type ColumnType = Pick<Column, 'column_name' | 'type'>;
|
||||||
metric_name: PropTypes.string,
|
|
||||||
verbose_name: PropTypes.string,
|
// For backward compatibility with PropTypes usage - create a placeholder object
|
||||||
expression: PropTypes.string,
|
const columnType = {} as any;
|
||||||
});
|
export default columnType;
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -16,8 +16,7 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import PropTypes from 'prop-types';
|
export { savedMetricType } from './types';
|
||||||
|
|
||||||
export default PropTypes.shape({
|
// For backward compatibility with PropTypes usage
|
||||||
aggregate_name: PropTypes.string.isRequired,
|
export { savedMetricType as default } from './types';
|
||||||
});
|
|
||||||
@@ -21,3 +21,7 @@ export type savedMetricType = {
|
|||||||
verbose_name?: string;
|
verbose_name?: string;
|
||||||
expression: string;
|
expression: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface AggregateOption {
|
||||||
|
aggregate_name: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,9 +17,13 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { t, validateNonEmpty } from '@superset-ui/core';
|
import { t, validateNonEmpty } from '@superset-ui/core';
|
||||||
|
import type {
|
||||||
|
ControlPanelConfig,
|
||||||
|
ControlPanelState,
|
||||||
|
} from '@superset-ui/chart-controls';
|
||||||
import { formatSelectOptions } from 'src/explore/exploreUtils';
|
import { formatSelectOptions } from 'src/explore/exploreUtils';
|
||||||
|
|
||||||
export default {
|
const config: ControlPanelConfig = {
|
||||||
controlPanelSections: [
|
controlPanelSections: [
|
||||||
{
|
{
|
||||||
label: t('Code'),
|
label: t('Code'),
|
||||||
@@ -45,12 +49,15 @@ export default {
|
|||||||
type: 'TextAreaControl',
|
type: 'TextAreaControl',
|
||||||
label: t('Code'),
|
label: t('Code'),
|
||||||
description: t('Put your code here'),
|
description: t('Put your code here'),
|
||||||
mapStateToProps: state => ({
|
mapStateToProps: (state: Partial<ControlPanelState>) => {
|
||||||
language:
|
const languageValue = state.controls?.markup_type?.value;
|
||||||
state.controls && state.controls.markup_type
|
return {
|
||||||
? state.controls.markup_type.value
|
language:
|
||||||
: 'markdown',
|
typeof languageValue === 'string'
|
||||||
}),
|
? languageValue
|
||||||
|
: 'markdown',
|
||||||
|
};
|
||||||
|
},
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -74,3 +81,5 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -16,11 +16,20 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
import type { ControlPanelState, Dataset } from '@superset-ui/chart-controls';
|
||||||
|
|
||||||
|
interface TimeGrainOverrideState {
|
||||||
|
choices: [string, string][] | null;
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
default: null,
|
default: null,
|
||||||
mapStateToProps: state => ({
|
mapStateToProps: (state: ControlPanelState): TimeGrainOverrideState => ({
|
||||||
choices: state.datasource
|
choices:
|
||||||
? state.datasource.time_grain_sqla.filter(o => o[0] !== null)
|
state.datasource && 'time_grain_sqla' in state.datasource
|
||||||
: null,
|
? ((state.datasource as Dataset).time_grain_sqla?.filter(
|
||||||
|
(o: [string, string]) => o[0] !== null,
|
||||||
|
) ?? null)
|
||||||
|
: null,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
FeatureFlag,
|
FeatureFlag,
|
||||||
styled,
|
styled,
|
||||||
SupersetClient,
|
SupersetClient,
|
||||||
SupersetTheme,
|
|
||||||
t,
|
t,
|
||||||
VizType,
|
VizType,
|
||||||
useTheme,
|
useTheme,
|
||||||
@@ -41,7 +40,6 @@ import rison from 'rison';
|
|||||||
import { useSingleViewResource } from 'src/views/CRUD/hooks';
|
import { useSingleViewResource } from 'src/views/CRUD/hooks';
|
||||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||||
import Owner from 'src/types/Owner';
|
import Owner from 'src/types/Owner';
|
||||||
// import { Form as AntdForm } from 'src/components/Form';
|
|
||||||
import { propertyComparator } from '@superset-ui/core/components/Select/utils';
|
import { propertyComparator } from '@superset-ui/core/components/Select/utils';
|
||||||
import {
|
import {
|
||||||
AsyncSelect,
|
AsyncSelect,
|
||||||
@@ -266,11 +264,6 @@ export const StyledInputContainer = styled.div`
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.required {
|
|
||||||
margin-left: ${theme.sizeUnit / 2}px;
|
|
||||||
color: ${theme.colorError};
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-label {
|
.control-label {
|
||||||
margin-bottom: ${theme.sizeUnit * 2}px;
|
margin-bottom: ${theme.sizeUnit * 2}px;
|
||||||
color: ${theme.colorText};
|
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';
|
type NotificationAddStatus = 'active' | 'disabled' | 'hidden';
|
||||||
|
|
||||||
interface NotificationMethodAddProps {
|
interface NotificationMethodAddProps {
|
||||||
@@ -2042,6 +2031,15 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
|||||||
<ModalFormField
|
<ModalFormField
|
||||||
label={isReport ? t('Report name') : t('Alert name')}
|
label={isReport ? t('Report name') : t('Alert name')}
|
||||||
required
|
required
|
||||||
|
error={
|
||||||
|
validationStatus[Sections.General]?.hasErrors &&
|
||||||
|
!currentAlert?.name?.trim()
|
||||||
|
? t(
|
||||||
|
'%s name is required',
|
||||||
|
isReport ? t('Report') : t('Alert'),
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
name="name"
|
name="name"
|
||||||
@@ -2054,7 +2052,17 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
|||||||
onChange={onInputChange}
|
onChange={onInputChange}
|
||||||
/>
|
/>
|
||||||
</ModalFormField>
|
</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
|
<AsyncSelect
|
||||||
ariaLabel={t('Owners')}
|
ariaLabel={t('Owners')}
|
||||||
allowClear
|
allowClear
|
||||||
@@ -2115,40 +2123,46 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
|||||||
),
|
),
|
||||||
children: (
|
children: (
|
||||||
<div>
|
<div>
|
||||||
<StyledInputContainer>
|
<ModalFormField
|
||||||
<div className="control-label">
|
label={t('Database')}
|
||||||
{t('Database')}
|
required
|
||||||
<span className="required">*</span>
|
error={
|
||||||
</div>
|
validationStatus[Sections.Alert]?.hasErrors &&
|
||||||
<div className="input-container">
|
!currentAlert?.database
|
||||||
<AsyncSelect
|
? t('Database is required')
|
||||||
ariaLabel={t('Database')}
|
: undefined
|
||||||
name="source"
|
}
|
||||||
placeholder={t('Select database')}
|
>
|
||||||
value={
|
<AsyncSelect
|
||||||
currentAlert?.database?.label &&
|
ariaLabel={t('Database')}
|
||||||
currentAlert?.database?.value
|
name="source"
|
||||||
? {
|
placeholder={t('Select database')}
|
||||||
value: currentAlert.database.value,
|
value={
|
||||||
label: currentAlert.database.label,
|
currentAlert?.database?.label &&
|
||||||
}
|
currentAlert?.database?.value
|
||||||
: undefined
|
? {
|
||||||
}
|
value: currentAlert.database.value,
|
||||||
options={loadSourceOptions}
|
label: currentAlert.database.label,
|
||||||
onChange={onSourceChange}
|
}
|
||||||
/>
|
: undefined
|
||||||
</div>
|
}
|
||||||
</StyledInputContainer>
|
options={loadSourceOptions}
|
||||||
<StyledInputContainer>
|
onChange={onSourceChange}
|
||||||
<div className="control-label">
|
/>
|
||||||
{t('SQL Query')}
|
</ModalFormField>
|
||||||
<InfoTooltip
|
<ModalFormField
|
||||||
tooltip={t(
|
label={t('SQL Query')}
|
||||||
'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).',
|
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).',
|
||||||
<span className="required">*</span>
|
)}
|
||||||
</div>
|
error={
|
||||||
|
validationStatus[Sections.Alert]?.hasErrors &&
|
||||||
|
!currentAlert?.sql?.length
|
||||||
|
? t('SQL Query is required')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<TextAreaControl
|
<TextAreaControl
|
||||||
name="sql"
|
name="sql"
|
||||||
language="sql"
|
language="sql"
|
||||||
@@ -2160,57 +2174,60 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
|||||||
initialValue={resource?.sql}
|
initialValue={resource?.sql}
|
||||||
key={currentAlert?.id}
|
key={currentAlert?.id}
|
||||||
/>
|
/>
|
||||||
</StyledInputContainer>
|
</ModalFormField>
|
||||||
<div
|
<div
|
||||||
className="inline-container wrap"
|
className="inline-container wrap"
|
||||||
css={css`
|
css={css`
|
||||||
gap: ${theme.sizeUnit}px;
|
gap: ${theme.sizeUnit}px;
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<StyledInputContainer css={noMarginBottom}>
|
<ModalFormField
|
||||||
<div className="control-label" css={inputSpacer}>
|
label={t('Trigger Alert If...')}
|
||||||
{t('Trigger Alert If...')}
|
required
|
||||||
<span className="required">*</span>
|
error={
|
||||||
</div>
|
validationStatus[Sections.Alert]?.hasErrors &&
|
||||||
<div className="input-container">
|
!currentAlert?.validator_config_json?.op
|
||||||
<Select
|
? t('Condition is required')
|
||||||
ariaLabel={t('Condition')}
|
: undefined
|
||||||
onChange={onConditionChange}
|
}
|
||||||
placeholder={t('Condition')}
|
>
|
||||||
value={
|
<Select
|
||||||
currentAlert?.validator_config_json?.op ||
|
ariaLabel={t('Condition')}
|
||||||
undefined
|
onChange={onConditionChange}
|
||||||
}
|
placeholder={t('Condition')}
|
||||||
options={CONDITIONS}
|
value={
|
||||||
/>
|
currentAlert?.validator_config_json?.op ||
|
||||||
</div>
|
undefined
|
||||||
</StyledInputContainer>
|
}
|
||||||
<StyledInputContainer css={noMarginBottom}>
|
options={CONDITIONS}
|
||||||
<div className="control-label">
|
/>
|
||||||
{t('Value')}{' '}
|
</ModalFormField>
|
||||||
{!conditionNotNull && (
|
<ModalFormField
|
||||||
<span className="required">*</span>
|
label={t('Value')}
|
||||||
)}
|
required={!conditionNotNull}
|
||||||
</div>
|
error={
|
||||||
<div className="input-container">
|
validationStatus[Sections.Alert]?.hasErrors &&
|
||||||
<InputNumber
|
!conditionNotNull &&
|
||||||
disabled={conditionNotNull}
|
!currentAlert?.validator_config_json?.threshold
|
||||||
type="number"
|
? t('Value is required')
|
||||||
name="threshold"
|
: undefined
|
||||||
value={
|
}
|
||||||
currentAlert?.validator_config_json
|
>
|
||||||
?.threshold !== undefined &&
|
<InputNumber
|
||||||
!conditionNotNull
|
disabled={conditionNotNull}
|
||||||
? currentAlert.validator_config_json
|
type="number"
|
||||||
.threshold
|
name="threshold"
|
||||||
: ''
|
value={
|
||||||
}
|
currentAlert?.validator_config_json
|
||||||
min={0}
|
?.threshold !== undefined && !conditionNotNull
|
||||||
placeholder={t('Value')}
|
? currentAlert.validator_config_json.threshold
|
||||||
onChange={onThresholdChange}
|
: ''
|
||||||
/>
|
}
|
||||||
</div>
|
min={0}
|
||||||
</StyledInputContainer>
|
placeholder={t('Value')}
|
||||||
|
onChange={onThresholdChange}
|
||||||
|
/>
|
||||||
|
</ModalFormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -2468,7 +2485,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
|||||||
<InputNumber
|
<InputNumber
|
||||||
type="number"
|
type="number"
|
||||||
name="custom_width"
|
name="custom_width"
|
||||||
value={currentAlert?.custom_width || undefined}
|
value={currentAlert?.custom_width || 1600}
|
||||||
min={600}
|
min={600}
|
||||||
max={2400}
|
max={2400}
|
||||||
placeholder={t('Input custom width in pixels')}
|
placeholder={t('Input custom width in pixels')}
|
||||||
@@ -2511,66 +2528,66 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
|||||||
value={currentAlert?.crontab || ''}
|
value={currentAlert?.crontab || ''}
|
||||||
onChange={newVal => updateAlertState('crontab', newVal)}
|
onChange={newVal => updateAlertState('crontab', newVal)}
|
||||||
/>
|
/>
|
||||||
<StyledInputContainer>
|
<ModalFormField
|
||||||
<div className="control-label">
|
label={t('Timezone')}
|
||||||
{t('Timezone')} <span className="required">*</span>
|
required
|
||||||
</div>
|
error={
|
||||||
|
validationStatus[Sections.Schedule]?.hasErrors &&
|
||||||
|
!currentAlert?.timezone
|
||||||
|
? t('Timezone is required')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<TimezoneSelector
|
<TimezoneSelector
|
||||||
onTimezoneChange={onTimezoneChange}
|
onTimezoneChange={onTimezoneChange}
|
||||||
timezone={currentAlert?.timezone}
|
timezone={currentAlert?.timezone}
|
||||||
minWidth="100%"
|
minWidth="100%"
|
||||||
/>
|
/>
|
||||||
</StyledInputContainer>
|
</ModalFormField>
|
||||||
<StyledInputContainer>
|
<ModalFormField
|
||||||
<div className="control-label">
|
label={t('Log retention')}
|
||||||
{t('Log retention')}
|
required
|
||||||
<span className="required">*</span>
|
error={
|
||||||
</div>
|
validationStatus[Sections.Schedule]?.hasErrors &&
|
||||||
<div className="input-container">
|
!currentAlert?.log_retention
|
||||||
<Select
|
? t('Log retention is required')
|
||||||
ariaLabel={t('Log retention')}
|
: undefined
|
||||||
placeholder={t('Log retention')}
|
}
|
||||||
onChange={onLogRetentionChange}
|
>
|
||||||
value={currentAlert?.log_retention}
|
<Select
|
||||||
options={RETENTION_OPTIONS}
|
ariaLabel={t('Log retention')}
|
||||||
sortComparator={propertyComparator('value')}
|
placeholder={t('Log retention')}
|
||||||
/>
|
onChange={onLogRetentionChange}
|
||||||
</div>
|
value={currentAlert?.log_retention}
|
||||||
</StyledInputContainer>
|
options={RETENTION_OPTIONS}
|
||||||
<StyledInputContainer css={noMarginBottom}>
|
sortComparator={propertyComparator('value')}
|
||||||
{isReport ? (
|
/>
|
||||||
<>
|
</ModalFormField>
|
||||||
<div className="control-label">
|
<ModalFormField
|
||||||
{t('Working timeout')}
|
label={isReport ? t('Working timeout') : t('Grace period')}
|
||||||
<span className="required">*</span>
|
required={isReport}
|
||||||
</div>
|
error={
|
||||||
<div className="input-container">
|
validationStatus[Sections.Schedule]?.hasErrors &&
|
||||||
<NumberInput
|
isReport &&
|
||||||
min={1}
|
!currentAlert?.working_timeout
|
||||||
name="working_timeout"
|
? t('Working timeout is required')
|
||||||
value={currentAlert?.working_timeout || ''}
|
: undefined
|
||||||
placeholder={t('Time in seconds')}
|
}
|
||||||
onChange={onTimeoutVerifyChange}
|
bottomSpacing={false}
|
||||||
timeUnit={t('seconds')}
|
>
|
||||||
/>
|
<NumberInput
|
||||||
</div>
|
min={1}
|
||||||
</>
|
name={isReport ? 'working_timeout' : 'grace_period'}
|
||||||
) : (
|
value={
|
||||||
<>
|
isReport
|
||||||
<div className="control-label">{t('Grace period')}</div>
|
? currentAlert?.working_timeout || ''
|
||||||
<div className="input-container">
|
: currentAlert?.grace_period || ''
|
||||||
<NumberInput
|
}
|
||||||
min={1}
|
placeholder={t('Time in seconds')}
|
||||||
name="grace_period"
|
onChange={onTimeoutVerifyChange}
|
||||||
value={currentAlert?.grace_period || ''}
|
timeUnit={t('seconds')}
|
||||||
placeholder={t('Time in seconds')}
|
/>
|
||||||
onChange={onTimeoutVerifyChange}
|
</ModalFormField>
|
||||||
timeUnit={t('seconds')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</StyledInputContainer>
|
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
|
|||||||
import Owner from 'src/types/Owner';
|
import Owner from 'src/types/Owner';
|
||||||
import AlertReportModal from 'src/features/alerts/AlertReportModal';
|
import AlertReportModal from 'src/features/alerts/AlertReportModal';
|
||||||
import { AlertObject, AlertState } from 'src/features/alerts/types';
|
import { AlertObject, AlertState } from 'src/features/alerts/types';
|
||||||
|
import { useExecuteReportSchedule } from 'src/features/alerts/hooks/useExecuteReportSchedule';
|
||||||
import { QueryObjectColumns } from 'src/views/CRUD/types';
|
import { QueryObjectColumns } from 'src/views/CRUD/types';
|
||||||
import { Icons } from '@superset-ui/core/components/Icons';
|
import { Icons } from '@superset-ui/core/components/Icons';
|
||||||
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
|
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
|
||||||
@@ -157,12 +158,16 @@ function AlertList({
|
|||||||
addDangerToast,
|
addDangerToast,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Execute hook for Fire Now functionality
|
||||||
|
const { executeReport } = useExecuteReportSchedule();
|
||||||
|
|
||||||
const [alertModalOpen, setAlertModalOpen] = useState<boolean>(false);
|
const [alertModalOpen, setAlertModalOpen] = useState<boolean>(false);
|
||||||
const [currentAlert, setCurrentAlert] = useState<Partial<AlertObject> | null>(
|
const [currentAlert, setCurrentAlert] = useState<Partial<AlertObject> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [currentAlertDeleting, setCurrentAlertDeleting] =
|
const [currentAlertDeleting, setCurrentAlertDeleting] =
|
||||||
useState<AlertObject | null>(null);
|
useState<AlertObject | null>(null);
|
||||||
|
const [executingIds, setExecutingIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
function handleAlertEdit(alert: AlertObject | null) {
|
function handleAlertEdit(alert: AlertObject | null) {
|
||||||
@@ -246,6 +251,51 @@ function AlertList({
|
|||||||
[alerts, setResourceCollection, updateResource],
|
[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(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -397,6 +447,16 @@ function AlertList({
|
|||||||
onClick: handleEdit,
|
onClick: handleEdit,
|
||||||
}
|
}
|
||||||
: null,
|
: 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
|
allowEdit && canDelete
|
||||||
? {
|
? {
|
||||||
label: 'delete-action',
|
label: 'delete-action',
|
||||||
@@ -424,7 +484,14 @@ function AlertList({
|
|||||||
id: QueryObjectColumns.ChangedBy,
|
id: QueryObjectColumns.ChangedBy,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[canDelete, canEdit, isReportEnabled, toggleActive],
|
[
|
||||||
|
canDelete,
|
||||||
|
canEdit,
|
||||||
|
isReportEnabled,
|
||||||
|
toggleActive,
|
||||||
|
executingIds,
|
||||||
|
handleExecuteReport,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const subMenuButtons: SubMenuProps['buttons'] = [];
|
const subMenuButtons: SubMenuProps['buttons'] = [];
|
||||||
|
|||||||
@@ -39,5 +39,3 @@ export interface TagType {
|
|||||||
css?: SerializedStyles;
|
css?: SerializedStyles;
|
||||||
closable?: boolean;
|
closable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TagType;
|
|
||||||
|
|||||||
66
superset-frontend/src/utils/DebouncedMessageQueue.test.ts
Normal file
66
superset-frontend/src/utils/DebouncedMessageQueue.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,26 +18,45 @@
|
|||||||
*/
|
*/
|
||||||
import { debounce } from 'lodash';
|
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({
|
constructor({
|
||||||
callback = () => {},
|
callback = () => {},
|
||||||
sizeThreshold = 1000,
|
sizeThreshold = 1000,
|
||||||
delayThreshold = 1000,
|
delayThreshold = 1000,
|
||||||
}) {
|
}: DebouncedMessageQueueOptions<T> = {}) {
|
||||||
this.queue = [];
|
this.queue = [];
|
||||||
this.sizeThreshold = sizeThreshold;
|
this.sizeThreshold = sizeThreshold;
|
||||||
this.delayThreshold = delayThreshold;
|
this.delayThreshold = delayThreshold;
|
||||||
|
|
||||||
this.trigger = debounce(this.trigger.bind(this), this.delayThreshold);
|
|
||||||
this.callback = callback;
|
this.callback = callback;
|
||||||
|
|
||||||
|
this.trigger = debounce(
|
||||||
|
this.triggerInternal.bind(this),
|
||||||
|
this.delayThreshold,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
append(eventData) {
|
append(eventData: T): void {
|
||||||
this.queue.push(eventData);
|
this.queue.push(eventData);
|
||||||
this.trigger();
|
this.trigger();
|
||||||
}
|
}
|
||||||
|
|
||||||
trigger() {
|
private triggerInternal(): void {
|
||||||
if (this.queue.length > 0) {
|
if (this.queue.length > 0) {
|
||||||
const events = this.queue.splice(0, this.sizeThreshold);
|
const events = this.queue.splice(0, this.sizeThreshold);
|
||||||
this.callback.call(null, events);
|
this.callback.call(null, events);
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
190
superset-frontend/src/utils/datasourceUtils.test.ts
Normal file
190
superset-frontend/src/utils/datasourceUtils.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
57
superset-frontend/src/utils/datasourceUtils.ts
Normal file
57
superset-frontend/src/utils/datasourceUtils.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
101
superset-frontend/src/utils/getControlsForVizType.test.ts
Normal file
101
superset-frontend/src/utils/getControlsForVizType.test.ts
Normal 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',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
74
superset-frontend/src/utils/getControlsForVizType.ts
Normal file
74
superset-frontend/src/utils/getControlsForVizType.ts
Normal 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;
|
||||||
58
superset-frontend/src/utils/hostNamesConfig.test.ts
Normal file
58
superset-frontend/src/utils/hostNamesConfig.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
import { initFeatureFlags } from '@superset-ui/core';
|
import { initFeatureFlags } from '@superset-ui/core';
|
||||||
import getBootstrapData from './getBootstrapData';
|
import getBootstrapData from './getBootstrapData';
|
||||||
|
|
||||||
function getDomainsConfig() {
|
function getDomainsConfig(): string[] {
|
||||||
const appContainer = document.getElementById('app');
|
const appContainer = document.getElementById('app');
|
||||||
if (!appContainer) {
|
if (!appContainer) {
|
||||||
return [];
|
return [];
|
||||||
@@ -42,13 +42,15 @@ function getDomainsConfig() {
|
|||||||
initFeatureFlags(bootstrapData.common.feature_flags);
|
initFeatureFlags(bootstrapData.common.feature_flags);
|
||||||
|
|
||||||
if (bootstrapData?.common?.conf?.SUPERSET_WEBSERVER_DOMAINS) {
|
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);
|
availableDomains.add(hostName);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Array.from(availableDomains);
|
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;
|
||||||
129
superset-frontend/src/utils/reducerUtils.test.ts
Normal file
129
superset-frontend/src/utils/reducerUtils.test.ts
Normal 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);
|
||||||
|
});
|
||||||
107
superset-frontend/src/utils/reducerUtils.ts
Normal file
107
superset-frontend/src/utils/reducerUtils.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -309,3 +309,15 @@ class ReportScheduleForbiddenError(ForbiddenError):
|
|||||||
|
|
||||||
class ReportSchedulePruneLogError(CommandException):
|
class ReportSchedulePruneLogError(CommandException):
|
||||||
message = _("An error occurred while pruning logs ")
|
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."
|
||||||
|
)
|
||||||
|
|||||||
147
superset/commands/report/execute_now.py
Normal file
147
superset/commands/report/execute_now.py
Normal 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
|
||||||
@@ -29,13 +29,16 @@ from superset.charts.filters import ChartFilter
|
|||||||
from superset.commands.report.create import CreateReportScheduleCommand
|
from superset.commands.report.create import CreateReportScheduleCommand
|
||||||
from superset.commands.report.delete import DeleteReportScheduleCommand
|
from superset.commands.report.delete import DeleteReportScheduleCommand
|
||||||
from superset.commands.report.exceptions import (
|
from superset.commands.report.exceptions import (
|
||||||
|
ReportScheduleCeleryNotConfiguredError,
|
||||||
ReportScheduleCreateFailedError,
|
ReportScheduleCreateFailedError,
|
||||||
ReportScheduleDeleteFailedError,
|
ReportScheduleDeleteFailedError,
|
||||||
|
ReportScheduleExecuteNowFailedError,
|
||||||
ReportScheduleForbiddenError,
|
ReportScheduleForbiddenError,
|
||||||
ReportScheduleInvalidError,
|
ReportScheduleInvalidError,
|
||||||
ReportScheduleNotFoundError,
|
ReportScheduleNotFoundError,
|
||||||
ReportScheduleUpdateFailedError,
|
ReportScheduleUpdateFailedError,
|
||||||
)
|
)
|
||||||
|
from superset.commands.report.execute_now import ExecuteReportScheduleNowCommand
|
||||||
from superset.commands.report.update import UpdateReportScheduleCommand
|
from superset.commands.report.update import UpdateReportScheduleCommand
|
||||||
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
|
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
|
||||||
from superset.dashboards.filters import DashboardAccessFilter
|
from superset.dashboards.filters import DashboardAccessFilter
|
||||||
@@ -48,6 +51,7 @@ from superset.reports.schemas import (
|
|||||||
get_delete_ids_schema,
|
get_delete_ids_schema,
|
||||||
get_slack_channels_schema,
|
get_slack_channels_schema,
|
||||||
openapi_spec_methods_override,
|
openapi_spec_methods_override,
|
||||||
|
ReportScheduleExecuteResponseSchema,
|
||||||
ReportSchedulePostSchema,
|
ReportSchedulePostSchema,
|
||||||
ReportSchedulePutSchema,
|
ReportSchedulePutSchema,
|
||||||
)
|
)
|
||||||
@@ -76,6 +80,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
|
|||||||
RouteMethod.RELATED,
|
RouteMethod.RELATED,
|
||||||
"bulk_delete",
|
"bulk_delete",
|
||||||
"slack_channels", # not using RouteMethod since locally defined
|
"slack_channels", # not using RouteMethod since locally defined
|
||||||
|
"execute", # not using RouteMethod since locally defined
|
||||||
}
|
}
|
||||||
class_permission_name = "ReportSchedule"
|
class_permission_name = "ReportSchedule"
|
||||||
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
|
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||||
@@ -588,3 +593,77 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
|
|||||||
except SupersetException as ex:
|
except SupersetException as ex:
|
||||||
logger.error("Error fetching slack channels %s", str(ex))
|
logger.error("Error fetching slack channels %s", str(ex))
|
||||||
return self.response_422(message=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))
|
||||||
|
|||||||
@@ -413,3 +413,15 @@ class SlackChannelSchema(Schema):
|
|||||||
name = fields.String()
|
name = fields.String()
|
||||||
is_member = fields.Boolean()
|
is_member = fields.Boolean()
|
||||||
is_private = 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")
|
||||||
|
|||||||
@@ -2049,3 +2049,94 @@ class TestReportSchedulesApi(SupersetTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert json.loads(report_schedule.extra_json) == extra_json
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user