mirror of
https://github.com/apache/superset.git
synced 2026-04-12 20:57:55 +00:00
feat: AI-powered TypeScript migration framework with parallel processing (#35045)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me> Co-authored-by: Elizabeth Thompson <eschutho@gmail.com>
This commit is contained in:
committed by
GitHub
parent
076e477fd4
commit
ecb3ac68ff
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/`*
|
||||
@@ -17,11 +17,14 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export default function roundDecimal(number, precision) {
|
||||
let roundedNumber;
|
||||
let p = precision;
|
||||
export default function roundDecimal(
|
||||
number: number,
|
||||
precision?: number,
|
||||
): number {
|
||||
let roundedNumber: number;
|
||||
if (precision) {
|
||||
roundedNumber = Math.round(number * (p = 10 ** p)) / p;
|
||||
const p = 10 ** precision;
|
||||
roundedNumber = Math.round(number * p) / p;
|
||||
} else {
|
||||
roundedNumber = Math.round(number);
|
||||
}
|
||||
@@ -258,20 +258,36 @@ export const dashboardWithFilter = {
|
||||
type: DASHBOARD_ROOT_TYPE,
|
||||
id: DASHBOARD_ROOT_ID,
|
||||
children: [DASHBOARD_GRID_ID],
|
||||
meta: {
|
||||
chartId: 0,
|
||||
height: 0,
|
||||
uuid: '',
|
||||
width: 0,
|
||||
},
|
||||
},
|
||||
|
||||
[DASHBOARD_GRID_ID]: {
|
||||
type: DASHBOARD_GRID_TYPE,
|
||||
id: DASHBOARD_GRID_ID,
|
||||
children: ['ROW_ID'],
|
||||
meta: {},
|
||||
meta: {
|
||||
chartId: 0,
|
||||
height: 0,
|
||||
uuid: '',
|
||||
width: 0,
|
||||
},
|
||||
},
|
||||
|
||||
[DASHBOARD_HEADER_ID]: {
|
||||
type: DASHBOARD_HEADER_TYPE,
|
||||
id: DASHBOARD_HEADER_ID,
|
||||
children: [],
|
||||
meta: {
|
||||
text: 'New dashboard',
|
||||
chartId: 0,
|
||||
height: 0,
|
||||
uuid: '',
|
||||
width: 0,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -285,6 +301,7 @@ export const dashboardWithFilter = {
|
||||
...newComponentFactory(CHART_TYPE),
|
||||
id: 'FILTER_ID',
|
||||
meta: {
|
||||
...newComponentFactory(CHART_TYPE).meta,
|
||||
chartId: filterId,
|
||||
width: 3,
|
||||
height: 10,
|
||||
@@ -296,6 +313,7 @@ export const dashboardWithFilter = {
|
||||
...newComponentFactory(CHART_TYPE),
|
||||
id: 'CHART_ID',
|
||||
meta: {
|
||||
...newComponentFactory(CHART_TYPE).meta,
|
||||
chartId,
|
||||
width: 3,
|
||||
height: 10,
|
||||
|
||||
@@ -17,8 +17,26 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { ADD_TOAST, REMOVE_TOAST } from './actions';
|
||||
import { ToastMeta } from './types';
|
||||
|
||||
export default function messageToastsReducer(toasts = [], action) {
|
||||
interface AddToastAction {
|
||||
type: typeof ADD_TOAST;
|
||||
payload: ToastMeta;
|
||||
}
|
||||
|
||||
interface RemoveToastAction {
|
||||
type: typeof REMOVE_TOAST;
|
||||
payload: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
type ToastAction = AddToastAction | RemoveToastAction;
|
||||
|
||||
export default function messageToastsReducer(
|
||||
toasts: ToastMeta[] = [],
|
||||
action: ToastAction,
|
||||
): ToastMeta[] {
|
||||
switch (action.type) {
|
||||
case ADD_TOAST: {
|
||||
const { payload: toast } = action;
|
||||
@@ -20,7 +20,7 @@ import { useState } from 'react';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { Tag } from 'src/components/Tag';
|
||||
import type { CheckableTagProps } from 'src/components/Tag';
|
||||
import TagType from 'src/types/TagType';
|
||||
import type { TagType } from 'src/types/TagType';
|
||||
|
||||
export default {
|
||||
title: 'Components/Tag',
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import TagType from 'src/types/TagType';
|
||||
import type { TagType } from 'src/types/TagType';
|
||||
import { Tag } from '.';
|
||||
|
||||
const mockedProps: TagType = {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { styled } from '@superset-ui/core';
|
||||
import { Link } from 'react-router-dom';
|
||||
import TagType from 'src/types/TagType';
|
||||
import type { TagType } from 'src/types/TagType';
|
||||
import { Tag as AntdTag } from '@superset-ui/core/components/Tag';
|
||||
import { Tooltip } from '@superset-ui/core/components/Tooltip';
|
||||
import type { TagProps } from 'antd/es';
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
SupersetClient,
|
||||
t,
|
||||
} from '@superset-ui/core';
|
||||
import Tag from 'src/types/TagType';
|
||||
import type { TagType } from 'src/types/TagType';
|
||||
|
||||
import rison from 'rison';
|
||||
import { cacheWrapper } from 'src/utils/cacheWrapper';
|
||||
@@ -43,7 +43,7 @@ type SelectTagsValue = {
|
||||
};
|
||||
|
||||
export const tagToSelectOption = (
|
||||
tag: Tag & { table_name: string },
|
||||
tag: TagType & { table_name: string },
|
||||
): SelectTagsValue => ({
|
||||
value: tag.id,
|
||||
label: tag.name,
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { styled } from '@superset-ui/core';
|
||||
import TagType from 'src/types/TagType';
|
||||
import type { TagType } from 'src/types/TagType';
|
||||
import { Tag } from 'src/components/Tag';
|
||||
|
||||
export type TagsListProps = {
|
||||
|
||||
@@ -26,9 +26,40 @@ import {
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { Dispatch } from 'redux';
|
||||
import { Slice } from '../types';
|
||||
import { HYDRATE_DASHBOARD } from './hydrate';
|
||||
|
||||
const FETCH_SLICES_PAGE_SIZE = 200;
|
||||
|
||||
// State types
|
||||
export interface SliceEntitiesState {
|
||||
slices: { [id: number]: Slice };
|
||||
isLoading: boolean;
|
||||
errorMessage: string | null;
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
// Action types
|
||||
export type SliceEntitiesActionPayload =
|
||||
| {
|
||||
type: typeof ADD_SLICES;
|
||||
payload: { slices: { [id: number]: Slice } };
|
||||
}
|
||||
| {
|
||||
type: typeof SET_SLICES;
|
||||
payload: { slices: { [id: number]: Slice } };
|
||||
}
|
||||
| {
|
||||
type: typeof FETCH_ALL_SLICES_STARTED;
|
||||
}
|
||||
| {
|
||||
type: typeof FETCH_ALL_SLICES_FAILED;
|
||||
payload: { error: string };
|
||||
}
|
||||
| {
|
||||
type: typeof HYDRATE_DASHBOARD;
|
||||
data: { sliceEntities: SliceEntitiesState };
|
||||
};
|
||||
|
||||
export function getDatasourceParameter(
|
||||
datasourceId: number,
|
||||
datasourceType: DatasourceType,
|
||||
|
||||
@@ -170,7 +170,9 @@ const SliceAddedBadge: FC<{ placeholder?: HTMLDivElement }> = ({
|
||||
const AddSliceCard: FC<{
|
||||
datasourceUrl?: string;
|
||||
datasourceName?: string;
|
||||
innerRef?: RefObject<HTMLDivElement>;
|
||||
innerRef?:
|
||||
| RefObject<HTMLDivElement>
|
||||
| ((node: HTMLDivElement | null) => void);
|
||||
isSelected?: boolean;
|
||||
lastModified?: string;
|
||||
sliceName: string;
|
||||
|
||||
@@ -50,6 +50,7 @@ import { Dispatch } from 'redux';
|
||||
import { Slice } from 'src/dashboard/types';
|
||||
import { withTheme, Theme } from '@emotion/react';
|
||||
import { navigateTo } from 'src/utils/navigationUtils';
|
||||
import type { ConnectDragSource } from 'react-dnd';
|
||||
import AddSliceCard from './AddSliceCard';
|
||||
import AddSliceDragPreview from './dnd/AddSliceDragPreview';
|
||||
import { DragDroppable } from './dnd/DragDroppable';
|
||||
@@ -312,7 +313,7 @@ class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
|
||||
// actual style should be applied to nested AddSliceCard component
|
||||
style={{}}
|
||||
>
|
||||
{({ dragSourceRef }) => (
|
||||
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||
<AddSliceCard
|
||||
innerRef={dragSourceRef}
|
||||
style={style}
|
||||
|
||||
@@ -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 { shallowEqual, useSelector } from 'react-redux';
|
||||
import { ResizeCallback, ResizeStartCallback } from 're-resizable';
|
||||
import type { ConnectDragSource } from 'react-dnd';
|
||||
import { Draggable } from '../../dnd/DragDroppable';
|
||||
import { COLUMN_TYPE, ROW_TYPE } from '../../../util/componentTypes';
|
||||
import WithPopoverMenu from '../../menu/WithPopoverMenu';
|
||||
@@ -119,7 +120,7 @@ const DynamicComponent: FC<DynamicComponentProps> = ({
|
||||
onDrop={handleComponentDrop}
|
||||
editMode={editMode}
|
||||
>
|
||||
{({ dragSourceRef }) => (
|
||||
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||
<WithPopoverMenu
|
||||
menuItems={[
|
||||
<BackgroundStyleDropdown
|
||||
|
||||
@@ -20,6 +20,7 @@ import { PureComponent } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { css, styled } from '@superset-ui/core';
|
||||
import { DragDroppable } from 'src/dashboard/components/dnd/DragDroppable';
|
||||
import type { ConnectDragSource } from 'react-dnd';
|
||||
import { NEW_COMPONENTS_SOURCE_ID } from 'src/dashboard/util/constants';
|
||||
import { NEW_COMPONENT_SOURCE_TYPE } from 'src/dashboard/util/componentTypes';
|
||||
|
||||
@@ -81,7 +82,7 @@ export default class DraggableNewComponent extends PureComponent<DraggableNewCom
|
||||
depth={0}
|
||||
editMode
|
||||
>
|
||||
{({ dragSourceRef }) => (
|
||||
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||
<NewComponent ref={dragSourceRef} data-test="new-component">
|
||||
<NewComponentPlaceholder
|
||||
className={cx('new-component-placeholder', className)}
|
||||
|
||||
@@ -28,11 +28,38 @@ import {
|
||||
DASHBOARD_GRID_ID,
|
||||
} from '../util/constants';
|
||||
|
||||
export default {
|
||||
import type { DashboardLayout, LayoutItemMeta } from '../types';
|
||||
|
||||
// Create minimal meta objects that satisfy the LayoutItemMeta type requirements
|
||||
const rootMeta: LayoutItemMeta = {
|
||||
chartId: 0,
|
||||
height: 0,
|
||||
uuid: '',
|
||||
width: 0,
|
||||
};
|
||||
|
||||
const gridMeta: LayoutItemMeta = {
|
||||
chartId: 0,
|
||||
height: 0,
|
||||
uuid: '',
|
||||
width: 0,
|
||||
};
|
||||
|
||||
const headerMeta: LayoutItemMeta = {
|
||||
chartId: 0,
|
||||
height: 0,
|
||||
uuid: '',
|
||||
width: 0,
|
||||
text: 'New dashboard',
|
||||
};
|
||||
|
||||
const emptyDashboardLayout: DashboardLayout = {
|
||||
[DASHBOARD_ROOT_ID]: {
|
||||
type: DASHBOARD_ROOT_TYPE,
|
||||
id: DASHBOARD_ROOT_ID,
|
||||
children: [DASHBOARD_GRID_ID],
|
||||
parents: [],
|
||||
meta: rootMeta,
|
||||
},
|
||||
|
||||
[DASHBOARD_GRID_ID]: {
|
||||
@@ -40,14 +67,16 @@ export default {
|
||||
id: DASHBOARD_GRID_ID,
|
||||
children: [],
|
||||
parents: [DASHBOARD_ROOT_ID],
|
||||
meta: {},
|
||||
meta: gridMeta,
|
||||
},
|
||||
|
||||
[DASHBOARD_HEADER_ID]: {
|
||||
type: HEADER_TYPE,
|
||||
id: DASHBOARD_HEADER_ID,
|
||||
meta: {
|
||||
text: 'New dashboard',
|
||||
},
|
||||
children: [],
|
||||
parents: [],
|
||||
meta: headerMeta,
|
||||
},
|
||||
};
|
||||
|
||||
export default emptyDashboardLayout;
|
||||
@@ -23,10 +23,12 @@ import {
|
||||
FETCH_ALL_SLICES_STARTED,
|
||||
ADD_SLICES,
|
||||
SET_SLICES,
|
||||
SliceEntitiesState,
|
||||
SliceEntitiesActionPayload,
|
||||
} from '../actions/sliceEntities';
|
||||
import { HYDRATE_DASHBOARD } from '../actions/hydrate';
|
||||
|
||||
export const initSliceEntities = {
|
||||
export const initSliceEntities: SliceEntitiesState = {
|
||||
slices: {},
|
||||
isLoading: true,
|
||||
errorMessage: null,
|
||||
@@ -34,37 +36,34 @@ export const initSliceEntities = {
|
||||
};
|
||||
|
||||
export default function sliceEntitiesReducer(
|
||||
state = initSliceEntities,
|
||||
action,
|
||||
) {
|
||||
const actionHandlers = {
|
||||
[HYDRATE_DASHBOARD]() {
|
||||
state: SliceEntitiesState = initSliceEntities,
|
||||
action: SliceEntitiesActionPayload,
|
||||
): SliceEntitiesState {
|
||||
switch (action.type) {
|
||||
case HYDRATE_DASHBOARD:
|
||||
return {
|
||||
...action.data.sliceEntities,
|
||||
};
|
||||
},
|
||||
[FETCH_ALL_SLICES_STARTED]() {
|
||||
case FETCH_ALL_SLICES_STARTED:
|
||||
return {
|
||||
...state,
|
||||
isLoading: true,
|
||||
};
|
||||
},
|
||||
[ADD_SLICES]() {
|
||||
case ADD_SLICES:
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
slices: { ...state.slices, ...action.payload.slices },
|
||||
lastUpdated: new Date().getTime(),
|
||||
};
|
||||
},
|
||||
[SET_SLICES]() {
|
||||
case SET_SLICES:
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
slices: { ...action.payload.slices },
|
||||
lastUpdated: new Date().getTime(),
|
||||
};
|
||||
},
|
||||
[FETCH_ALL_SLICES_FAILED]() {
|
||||
case FETCH_ALL_SLICES_FAILED:
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
@@ -72,11 +71,7 @@ export default function sliceEntitiesReducer(
|
||||
errorMessage:
|
||||
action.payload.error || t('Could not fetch all saved charts'),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
if (action.type in actionHandlers) {
|
||||
return actionHandlers[action.type]();
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
@@ -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
|
||||
* under the License.
|
||||
*/
|
||||
import { DashboardLayout } from '../types';
|
||||
import getFilterScopeNodesTree from './getFilterScopeNodesTree';
|
||||
import getFilterScopeParentNodes from './getFilterScopeParentNodes';
|
||||
import getKeyForFilterScopeTree from './getKeyForFilterScopeTree';
|
||||
import getSelectedChartIdForFilterScopeTree from './getSelectedChartIdForFilterScopeTree';
|
||||
|
||||
interface FilterScopeMapItem {
|
||||
checked?: number[];
|
||||
expanded?: string[];
|
||||
}
|
||||
|
||||
interface FilterScopeMap {
|
||||
[key: string]: FilterScopeMapItem;
|
||||
}
|
||||
|
||||
interface FilterScopeTreeEntry {
|
||||
nodes: any[];
|
||||
nodesFiltered: any[];
|
||||
checked: string[];
|
||||
expanded: string[];
|
||||
}
|
||||
|
||||
interface BuildFilterScopeTreeEntryProps {
|
||||
checkedFilterFields?: string[];
|
||||
activeFilterField?: string;
|
||||
filterScopeMap?: FilterScopeMap;
|
||||
layout?: DashboardLayout;
|
||||
}
|
||||
|
||||
export default function buildFilterScopeTreeEntry({
|
||||
checkedFilterFields = [],
|
||||
activeFilterField,
|
||||
filterScopeMap = {},
|
||||
layout = {},
|
||||
}) {
|
||||
}: BuildFilterScopeTreeEntryProps): Record<string, FilterScopeTreeEntry> {
|
||||
const key = getKeyForFilterScopeTree({
|
||||
checkedFilterFields,
|
||||
activeFilterField,
|
||||
@@ -43,15 +67,15 @@ export default function buildFilterScopeTreeEntry({
|
||||
filterFields: editingList,
|
||||
selectedChartId,
|
||||
});
|
||||
const checkedChartIdSet = new Set();
|
||||
const checkedChartIdSet = new Set<string>();
|
||||
editingList.forEach(filterField => {
|
||||
(filterScopeMap[filterField].checked || []).forEach(chartId => {
|
||||
(filterScopeMap[filterField]?.checked || []).forEach(chartId => {
|
||||
checkedChartIdSet.add(`${chartId}:${filterField}`);
|
||||
});
|
||||
});
|
||||
const checked = [...checkedChartIdSet];
|
||||
const expanded = filterScopeMap[key]
|
||||
? filterScopeMap[key].expanded
|
||||
? filterScopeMap[key].expanded || []
|
||||
: getFilterScopeParentNodes(nodes, 1);
|
||||
|
||||
return {
|
||||
@@ -16,7 +16,10 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import dropOverflowsParent from 'src/dashboard/util/dropOverflowsParent';
|
||||
// Layout type not directly used in tests - using object shapes for test data
|
||||
import dropOverflowsParent, {
|
||||
type DropResult,
|
||||
} from 'src/dashboard/util/dropOverflowsParent';
|
||||
import { NEW_COMPONENTS_SOURCE_ID } from 'src/dashboard/util/constants';
|
||||
import {
|
||||
CHART_TYPE,
|
||||
@@ -28,7 +31,7 @@ import {
|
||||
|
||||
describe('dropOverflowsParent', () => {
|
||||
it('returns true if a parent does NOT have adequate width for child', () => {
|
||||
const dropResult = {
|
||||
const dropResult: DropResult = {
|
||||
source: { id: '_' },
|
||||
destination: { id: 'a' },
|
||||
dragging: { id: 'z' },
|
||||
@@ -56,11 +59,11 @@ describe('dropOverflowsParent', () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(dropOverflowsParent(dropResult, layout)).toBe(true);
|
||||
expect(dropOverflowsParent(dropResult, layout as any)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false if a parent DOES have adequate width for child', () => {
|
||||
const dropResult = {
|
||||
const dropResult: DropResult = {
|
||||
source: { id: '_' },
|
||||
destination: { id: 'a' },
|
||||
dragging: { id: 'z' },
|
||||
@@ -88,11 +91,11 @@ describe('dropOverflowsParent', () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(dropOverflowsParent(dropResult, layout)).toBe(false);
|
||||
expect(dropOverflowsParent(dropResult, layout as any)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false if a child CAN shrink to available parent space', () => {
|
||||
const dropResult = {
|
||||
const dropResult: DropResult = {
|
||||
source: { id: '_' },
|
||||
destination: { id: 'a' },
|
||||
dragging: { id: 'z' },
|
||||
@@ -120,11 +123,11 @@ describe('dropOverflowsParent', () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(dropOverflowsParent(dropResult, layout)).toBe(false);
|
||||
expect(dropOverflowsParent(dropResult, layout as any)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true if a child CANNOT shrink to available parent space', () => {
|
||||
const dropResult = {
|
||||
const dropResult: DropResult = {
|
||||
source: { id: '_' },
|
||||
destination: { id: 'a' },
|
||||
dragging: { id: 'b' },
|
||||
@@ -153,11 +156,11 @@ describe('dropOverflowsParent', () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(dropOverflowsParent(dropResult, layout)).toBe(true);
|
||||
expect(dropOverflowsParent(dropResult, layout as any)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true if a column has children that CANNOT shrink to available parent space', () => {
|
||||
const dropResult = {
|
||||
const dropResult: DropResult = {
|
||||
source: { id: '_' },
|
||||
destination: { id: 'destination' },
|
||||
dragging: { id: 'dragging' },
|
||||
@@ -191,18 +194,18 @@ describe('dropOverflowsParent', () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(dropOverflowsParent(dropResult, layout)).toBe(true);
|
||||
expect(dropOverflowsParent(dropResult, layout as any)).toBe(true);
|
||||
// remove children
|
||||
expect(
|
||||
dropOverflowsParent(dropResult, {
|
||||
...layout,
|
||||
dragging: { ...layout.dragging, children: [] },
|
||||
}),
|
||||
dragging: { ...layout.dragging, children: [] } as any,
|
||||
} as any),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should work with new components that are not in the layout', () => {
|
||||
const dropResult = {
|
||||
const dropResult: DropResult = {
|
||||
source: { id: NEW_COMPONENTS_SOURCE_ID },
|
||||
destination: { id: 'a' },
|
||||
dragging: { type: CHART_TYPE },
|
||||
@@ -212,15 +215,15 @@ describe('dropOverflowsParent', () => {
|
||||
a: {
|
||||
id: 'a',
|
||||
type: ROW_TYPE,
|
||||
children: [],
|
||||
children: [] as any,
|
||||
},
|
||||
};
|
||||
|
||||
expect(dropOverflowsParent(dropResult, layout)).toBe(false);
|
||||
expect(dropOverflowsParent(dropResult, layout as any)).toBe(false);
|
||||
});
|
||||
|
||||
it('source/destination without widths should not overflow parent', () => {
|
||||
const dropResult = {
|
||||
const dropResult: DropResult = {
|
||||
source: { id: '_' },
|
||||
destination: { id: 'tab' },
|
||||
dragging: { id: 'header' },
|
||||
@@ -237,6 +240,6 @@ describe('dropOverflowsParent', () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(dropOverflowsParent(dropResult, layout)).toBe(false);
|
||||
expect(dropOverflowsParent(dropResult, layout as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -16,9 +16,22 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { ComponentType, Layout } from 'src/dashboard/types';
|
||||
import getComponentWidthFromDrop from './getComponentWidthFromDrop';
|
||||
|
||||
export default function doesChildOverflowParent(dropResult, layout) {
|
||||
export interface DropResult {
|
||||
source: { id: string };
|
||||
destination: { id: string };
|
||||
dragging: {
|
||||
id?: string;
|
||||
type?: ComponentType;
|
||||
};
|
||||
}
|
||||
|
||||
export default function doesChildOverflowParent(
|
||||
dropResult: DropResult,
|
||||
layout: Layout,
|
||||
): boolean {
|
||||
const childWidth = getComponentWidthFromDrop({ dropResult, layout });
|
||||
return typeof childWidth === 'number' && childWidth < 0;
|
||||
}
|
||||
@@ -18,8 +18,11 @@
|
||||
*/
|
||||
import { TABS_TYPE } from './componentTypes';
|
||||
import { DASHBOARD_ROOT_ID } from './constants';
|
||||
import { DashboardLayout } from '../types';
|
||||
|
||||
export default function findFirstParentContainerId(layout = {}) {
|
||||
export default function findFirstParentContainerId(
|
||||
layout: DashboardLayout = {},
|
||||
): string {
|
||||
// DASHBOARD_GRID_TYPE or TABS_TYPE?
|
||||
let parent = layout[DASHBOARD_ROOT_ID];
|
||||
if (
|
||||
@@ -18,36 +18,55 @@
|
||||
*/
|
||||
import getChartIdsFromLayout from 'src/dashboard/util/getChartIdsFromLayout';
|
||||
import { ROW_TYPE, CHART_TYPE } from 'src/dashboard/util/componentTypes';
|
||||
import type { DashboardLayout } from '../types';
|
||||
|
||||
describe('getChartIdsFromLayout', () => {
|
||||
const mockLayout = {
|
||||
const mockLayout: DashboardLayout = {
|
||||
a: {
|
||||
id: 'a',
|
||||
type: CHART_TYPE,
|
||||
meta: { chartId: 'A' },
|
||||
children: [],
|
||||
meta: {
|
||||
chartId: 123,
|
||||
height: 400,
|
||||
width: 400,
|
||||
uuid: 'uuid-a',
|
||||
},
|
||||
},
|
||||
b: {
|
||||
id: 'b',
|
||||
type: CHART_TYPE,
|
||||
meta: { chartId: 'B' },
|
||||
children: [],
|
||||
meta: {
|
||||
chartId: 456,
|
||||
height: 400,
|
||||
width: 400,
|
||||
uuid: 'uuid-b',
|
||||
},
|
||||
},
|
||||
c: {
|
||||
id: 'c',
|
||||
type: ROW_TYPE,
|
||||
meta: { chartId: 'C' },
|
||||
children: [],
|
||||
meta: {
|
||||
chartId: 789,
|
||||
height: 400,
|
||||
width: 400,
|
||||
uuid: 'uuid-c',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('should return an array of chartIds', () => {
|
||||
const result = getChartIdsFromLayout(mockLayout);
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.includes('A')).toBe(true);
|
||||
expect(result.includes('B')).toBe(true);
|
||||
expect(result.includes(123)).toBe(true);
|
||||
expect(result.includes(456)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return ids only from CHART_TYPE components', () => {
|
||||
const result = getChartIdsFromLayout(mockLayout);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.includes('C')).toBe(false);
|
||||
expect(result.includes(789)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
* under the License.
|
||||
*/
|
||||
export default function getDirectPathToTabIndex(tabsComponent, tabIndex) {
|
||||
export interface TabsComponentLike {
|
||||
id: string;
|
||||
parents?: string[];
|
||||
children: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export default function getDirectPathToTabIndex(
|
||||
tabsComponent: TabsComponentLike,
|
||||
tabIndex: number,
|
||||
): string[] {
|
||||
const directPathToFilter = (tabsComponent.parents || []).slice();
|
||||
directPathToFilter.push(tabsComponent.id);
|
||||
directPathToFilter.push(tabsComponent.children[tabIndex]);
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { DASHBOARD_ROOT_TYPE, DASHBOARD_GRID_TYPE } from './componentTypes';
|
||||
import type { ComponentType } from '../types';
|
||||
|
||||
import {
|
||||
DASHBOARD_GRID_ID,
|
||||
@@ -24,7 +25,22 @@ import {
|
||||
DASHBOARD_VERSION_KEY,
|
||||
} from './constants';
|
||||
|
||||
export default function getEmptyLayout() {
|
||||
// Basic layout item for empty dashboard (simplified version without meta)
|
||||
interface BasicLayoutItem {
|
||||
type: ComponentType;
|
||||
id: string;
|
||||
children: string[];
|
||||
parents?: string[];
|
||||
}
|
||||
|
||||
// Empty layout structure
|
||||
type EmptyLayout = {
|
||||
[DASHBOARD_VERSION_KEY]: string;
|
||||
[DASHBOARD_ROOT_ID]: BasicLayoutItem;
|
||||
[DASHBOARD_GRID_ID]: BasicLayoutItem;
|
||||
};
|
||||
|
||||
export default function getEmptyLayout(): EmptyLayout {
|
||||
return {
|
||||
[DASHBOARD_VERSION_KEY]: 'v2',
|
||||
[DASHBOARD_ROOT_ID]: {
|
||||
@@ -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';
|
||||
|
||||
interface GetKeyForFilterScopeTreeProps {
|
||||
activeFilterField?: string;
|
||||
checkedFilterFields: string[];
|
||||
}
|
||||
|
||||
export default function getKeyForFilterScopeTree({
|
||||
activeFilterField,
|
||||
checkedFilterFields,
|
||||
}) {
|
||||
}: GetKeyForFilterScopeTreeProps): string {
|
||||
return safeStringify(
|
||||
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 */
|
||||
import { CHART_TYPE } from './componentTypes';
|
||||
import type { DashboardLayout, LayoutItem } from '../types';
|
||||
|
||||
export default function getLayoutComponentFromChartId(layout, chartId) {
|
||||
export default function getLayoutComponentFromChartId(
|
||||
layout: DashboardLayout,
|
||||
chartId: number,
|
||||
): LayoutItem | undefined {
|
||||
return Object.values(layout).find(
|
||||
currentComponent =>
|
||||
currentComponent &&
|
||||
@@ -18,7 +18,9 @@
|
||||
*/
|
||||
import { IN_COMPONENT_ELEMENT_TYPES } from './constants';
|
||||
|
||||
export default function getLeafComponentIdFromPath(directPathToChild = []) {
|
||||
export default function getLeafComponentIdFromPath(
|
||||
directPathToChild: string[] = [],
|
||||
): string | null {
|
||||
if (directPathToChild.length > 0) {
|
||||
const currentPath = directPathToChild.slice();
|
||||
|
||||
@@ -26,7 +28,10 @@ export default function getLeafComponentIdFromPath(directPathToChild = []) {
|
||||
const componentId = currentPath.pop();
|
||||
const componentType = componentId && componentId.split('-')[0];
|
||||
|
||||
if (!IN_COMPONENT_ELEMENT_TYPES.includes(componentType)) {
|
||||
if (
|
||||
componentType &&
|
||||
!IN_COMPONENT_ELEMENT_TYPES.includes(componentType)
|
||||
) {
|
||||
return componentId;
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import isDashboardEmpty from 'src/dashboard/util/isDashboardEmpty';
|
||||
import getEmptyLayout from 'src/dashboard/util/getEmptyLayout';
|
||||
|
||||
describe('isDashboardEmpty', () => {
|
||||
const emptyLayout: object = getEmptyLayout();
|
||||
const emptyLayout = getEmptyLayout();
|
||||
const testLayout: object = {
|
||||
...emptyLayout,
|
||||
'MARKDOWN-IhTGLhyiTd': {
|
||||
|
||||
@@ -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
|
||||
* under the License.
|
||||
*/
|
||||
import { CHART_TYPE } from './componentTypes';
|
||||
|
||||
export default function getChartIdsFromLayout(layout) {
|
||||
return Object.values(layout).reduce((chartIds, currentComponent) => {
|
||||
if (
|
||||
currentComponent &&
|
||||
currentComponent.type === CHART_TYPE &&
|
||||
currentComponent.meta &&
|
||||
currentComponent.meta.chartId
|
||||
) {
|
||||
chartIds.push(currentComponent.meta.chartId);
|
||||
}
|
||||
return chartIds;
|
||||
}, []);
|
||||
export interface ChartLoadTimestamps {
|
||||
chartUpdateStartTime?: number;
|
||||
chartUpdateEndTime?: number | null;
|
||||
// allow extra fields without narrowing
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export default function isDashboardLoading(
|
||||
charts: Record<string, ChartLoadTimestamps>,
|
||||
): boolean {
|
||||
return Object.values(charts).some(chart => {
|
||||
const start = chart.chartUpdateStartTime ?? 0;
|
||||
const end = chart.chartUpdateEndTime ?? 0;
|
||||
return start > end;
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
// Dashboard filter structure based on the actual usage pattern
|
||||
interface DashboardFilterColumn {
|
||||
scope: string[];
|
||||
}
|
||||
|
||||
interface DashboardFilter {
|
||||
chartId: number;
|
||||
scopes: Record<string, DashboardFilterColumn>;
|
||||
}
|
||||
|
||||
interface DashboardFilters {
|
||||
[filterId: string]: DashboardFilter;
|
||||
}
|
||||
|
||||
interface IsInDifferentFilterScopesProps {
|
||||
dashboardFilters?: DashboardFilters;
|
||||
source?: string[];
|
||||
destination?: string[];
|
||||
}
|
||||
|
||||
export default function isInDifferentFilterScopes({
|
||||
dashboardFilters = {},
|
||||
source = [],
|
||||
destination = [],
|
||||
}) {
|
||||
}: IsInDifferentFilterScopesProps): boolean {
|
||||
const sourceSet = new Set(source);
|
||||
const destinationSet = new Set(destination);
|
||||
|
||||
const allScopes = [].concat(
|
||||
const allScopes = ([] as string[]).concat(
|
||||
...Object.values(dashboardFilters).map(({ scopes }) =>
|
||||
[].concat(...Object.values(scopes).map(({ scope }) => scope)),
|
||||
([] as string[]).concat(
|
||||
...Object.values(scopes).map(({ scope }) => scope),
|
||||
),
|
||||
),
|
||||
);
|
||||
return allScopes.some(tab => destinationSet.has(tab) !== sourceSet.has(tab));
|
||||
@@ -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
|
||||
* under the License.
|
||||
*/
|
||||
import { ChartState } from 'src/explore/types';
|
||||
import { Layout } from 'src/dashboard/types';
|
||||
import findNonTabChildCharIds from './findNonTabChildChartIds';
|
||||
|
||||
export default function childChartsDidLoad({ chartQueries, layout, id }) {
|
||||
interface ChildChartsDidLoadParams {
|
||||
chartQueries: Record<string, Partial<ChartState>>;
|
||||
layout: Layout;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface ChildChartsDidLoadResult {
|
||||
didLoad: boolean;
|
||||
minQueryStartTime: number;
|
||||
}
|
||||
|
||||
export default function childChartsDidLoad({
|
||||
chartQueries,
|
||||
layout,
|
||||
id,
|
||||
}: ChildChartsDidLoadParams): ChildChartsDidLoadResult {
|
||||
const chartIds = findNonTabChildCharIds({ id, layout });
|
||||
|
||||
let minQueryStartTime = Infinity;
|
||||
const didLoad = chartIds.every(chartId => {
|
||||
const query = chartQueries[chartId] || {};
|
||||
minQueryStartTime = Math.min(query.chartUpdateStartTime, minQueryStartTime);
|
||||
return ['stopped', 'failed', 'rendered'].indexOf(query.chartStatus) > -1;
|
||||
const didLoad = chartIds.every((chartId: number) => {
|
||||
const query = chartQueries[chartId.toString()] || {};
|
||||
minQueryStartTime = Math.min(
|
||||
query.chartUpdateStartTime ?? Infinity,
|
||||
minQueryStartTime,
|
||||
);
|
||||
return ['stopped', 'failed', 'rendered'].includes(query.chartStatus || '');
|
||||
});
|
||||
|
||||
return { didLoad, minQueryStartTime };
|
||||
@@ -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
|
||||
* under the License.
|
||||
*/
|
||||
export default function serializeFilterScopes(dashboardFilters) {
|
||||
import { JsonObject } from '@superset-ui/core';
|
||||
|
||||
interface DashboardFilterScope {
|
||||
scope: string[] | JsonObject;
|
||||
immune?: string[];
|
||||
}
|
||||
|
||||
interface DashboardFilter {
|
||||
chartId: number | string;
|
||||
scopes: Record<string, DashboardFilterScope>;
|
||||
}
|
||||
|
||||
interface DashboardFilters {
|
||||
[filterId: string]: DashboardFilter;
|
||||
}
|
||||
|
||||
interface SerializedFilterScopes {
|
||||
[chartId: string]: Record<string, DashboardFilterScope>;
|
||||
}
|
||||
|
||||
export default function serializeFilterScopes(
|
||||
dashboardFilters: DashboardFilters,
|
||||
): SerializedFilterScopes {
|
||||
return Object.values(dashboardFilters).reduce((map, { chartId, scopes }) => {
|
||||
const scopesById = Object.keys(scopes).reduce(
|
||||
(scopesByColumn, column) => ({
|
||||
@@ -23,8 +23,20 @@ import {
|
||||
MARKDOWN_TYPE,
|
||||
TAB_TYPE,
|
||||
} from './componentTypes';
|
||||
import { ComponentType } from '../types';
|
||||
|
||||
const typeToWrapChildLookup = {
|
||||
interface WrapChildParams {
|
||||
parentType: ComponentType | undefined | null;
|
||||
childType: ComponentType | undefined | null;
|
||||
}
|
||||
|
||||
type ParentTypes = typeof DASHBOARD_GRID_TYPE | typeof TAB_TYPE;
|
||||
type ChildTypes = typeof CHART_TYPE | typeof COLUMN_TYPE | typeof MARKDOWN_TYPE;
|
||||
|
||||
const typeToWrapChildLookup: Record<
|
||||
ParentTypes,
|
||||
Record<ChildTypes, boolean>
|
||||
> = {
|
||||
[DASHBOARD_GRID_TYPE]: {
|
||||
[CHART_TYPE]: true,
|
||||
[COLUMN_TYPE]: true,
|
||||
@@ -38,11 +50,14 @@ const typeToWrapChildLookup = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function shouldWrapChildInRow({ parentType, childType }) {
|
||||
export default function shouldWrapChildInRow({
|
||||
parentType,
|
||||
childType,
|
||||
}: WrapChildParams): boolean {
|
||||
if (!parentType || !childType) return false;
|
||||
|
||||
const wrapChildLookup = typeToWrapChildLookup[parentType];
|
||||
const wrapChildLookup = typeToWrapChildLookup[parentType as ParentTypes];
|
||||
if (!wrapChildLookup) return false;
|
||||
|
||||
return Boolean(wrapChildLookup[childType]);
|
||||
return Boolean(wrapChildLookup[childType as ChildTypes]);
|
||||
}
|
||||
@@ -58,6 +58,7 @@ export interface IDatasource {
|
||||
sql?: string | null;
|
||||
datasource_name?: string | null;
|
||||
name?: string | null;
|
||||
catalog?: 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
|
||||
* under the License.
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import { Column } from '@superset-ui/core';
|
||||
|
||||
export default PropTypes.shape({
|
||||
column_name: PropTypes.string.isRequired,
|
||||
type: PropTypes.string,
|
||||
});
|
||||
export type ColumnType = Pick<Column, 'column_name' | 'type'>;
|
||||
|
||||
// For backward compatibility with PropTypes usage - create a placeholder object
|
||||
const columnType = {} as any;
|
||||
export default columnType;
|
||||
@@ -26,3 +26,10 @@ export enum Clauses {
|
||||
Having = 'HAVING',
|
||||
Where = 'WHERE',
|
||||
}
|
||||
|
||||
// Re-export AdhocFilter types for convenient access
|
||||
export type {
|
||||
AdhocFilterSimple,
|
||||
AdhocFilterSql,
|
||||
AdhocFilterType,
|
||||
} from './adhocFilterType';
|
||||
|
||||
@@ -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
|
||||
* under the License.
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default PropTypes.shape({
|
||||
column_name: PropTypes.string.isRequired,
|
||||
type: PropTypes.string,
|
||||
});
|
||||
export type { AggregateOption } from './types';
|
||||
|
||||
// For backward compatibility with PropTypes usage
|
||||
export { AggregateOption as default } from './types';
|
||||
@@ -16,10 +16,10 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import { Column } from '@superset-ui/core';
|
||||
|
||||
export default PropTypes.shape({
|
||||
metric_name: PropTypes.string,
|
||||
verbose_name: PropTypes.string,
|
||||
expression: PropTypes.string,
|
||||
});
|
||||
export type ColumnType = Pick<Column, 'column_name' | 'type'>;
|
||||
|
||||
// For backward compatibility with PropTypes usage - create a placeholder object
|
||||
const columnType = {} as any;
|
||||
export default columnType;
|
||||
@@ -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
|
||||
* under the License.
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
export { savedMetricType } from './types';
|
||||
|
||||
export default PropTypes.shape({
|
||||
aggregate_name: PropTypes.string.isRequired,
|
||||
});
|
||||
// For backward compatibility with PropTypes usage
|
||||
export { savedMetricType as default } from './types';
|
||||
@@ -21,3 +21,7 @@ export type savedMetricType = {
|
||||
verbose_name?: 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.
|
||||
*/
|
||||
import { t, validateNonEmpty } from '@superset-ui/core';
|
||||
import type {
|
||||
ControlPanelConfig,
|
||||
ControlPanelState,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { formatSelectOptions } from 'src/explore/exploreUtils';
|
||||
|
||||
export default {
|
||||
const config: ControlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: t('Code'),
|
||||
@@ -45,12 +49,15 @@ export default {
|
||||
type: 'TextAreaControl',
|
||||
label: t('Code'),
|
||||
description: t('Put your code here'),
|
||||
mapStateToProps: state => ({
|
||||
language:
|
||||
state.controls && state.controls.markup_type
|
||||
? state.controls.markup_type.value
|
||||
: 'markdown',
|
||||
}),
|
||||
mapStateToProps: (state: Partial<ControlPanelState>) => {
|
||||
const languageValue = state.controls?.markup_type?.value;
|
||||
return {
|
||||
language:
|
||||
typeof languageValue === 'string'
|
||||
? languageValue
|
||||
: 'markdown',
|
||||
};
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
@@ -74,3 +81,5 @@ export default {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -16,11 +16,20 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { ControlPanelState, Dataset } from '@superset-ui/chart-controls';
|
||||
|
||||
interface TimeGrainOverrideState {
|
||||
choices: [string, string][] | null;
|
||||
}
|
||||
|
||||
export default {
|
||||
default: null,
|
||||
mapStateToProps: state => ({
|
||||
choices: state.datasource
|
||||
? state.datasource.time_grain_sqla.filter(o => o[0] !== null)
|
||||
: null,
|
||||
mapStateToProps: (state: ControlPanelState): TimeGrainOverrideState => ({
|
||||
choices:
|
||||
state.datasource && 'time_grain_sqla' in state.datasource
|
||||
? ((state.datasource as Dataset).time_grain_sqla?.filter(
|
||||
(o: [string, string]) => o[0] !== null,
|
||||
) ?? null)
|
||||
: null,
|
||||
}),
|
||||
};
|
||||
@@ -39,5 +39,3 @@ export interface TagType {
|
||||
css?: SerializedStyles;
|
||||
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';
|
||||
|
||||
class DebouncedMessageQueue {
|
||||
export interface DebouncedMessageQueueOptions<T> {
|
||||
callback?: (events: T[]) => void;
|
||||
sizeThreshold?: number;
|
||||
delayThreshold?: number;
|
||||
}
|
||||
|
||||
class DebouncedMessageQueue<T = Record<string, unknown>> {
|
||||
private queue: T[];
|
||||
|
||||
private readonly sizeThreshold: number;
|
||||
|
||||
private readonly delayThreshold: number;
|
||||
|
||||
private readonly callback: (events: T[]) => void;
|
||||
|
||||
public readonly trigger: () => void;
|
||||
|
||||
constructor({
|
||||
callback = () => {},
|
||||
sizeThreshold = 1000,
|
||||
delayThreshold = 1000,
|
||||
}) {
|
||||
}: DebouncedMessageQueueOptions<T> = {}) {
|
||||
this.queue = [];
|
||||
this.sizeThreshold = sizeThreshold;
|
||||
this.delayThreshold = delayThreshold;
|
||||
|
||||
this.trigger = debounce(this.trigger.bind(this), this.delayThreshold);
|
||||
this.callback = callback;
|
||||
|
||||
this.trigger = debounce(
|
||||
this.triggerInternal.bind(this),
|
||||
this.delayThreshold,
|
||||
);
|
||||
}
|
||||
|
||||
append(eventData) {
|
||||
append(eventData: T): void {
|
||||
this.queue.push(eventData);
|
||||
this.trigger();
|
||||
}
|
||||
|
||||
trigger() {
|
||||
private triggerInternal(): void {
|
||||
if (this.queue.length > 0) {
|
||||
const events = this.queue.splice(0, this.sizeThreshold);
|
||||
this.callback.call(null, events);
|
||||
@@ -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 getBootstrapData from './getBootstrapData';
|
||||
|
||||
function getDomainsConfig() {
|
||||
function getDomainsConfig(): string[] {
|
||||
const appContainer = document.getElementById('app');
|
||||
if (!appContainer) {
|
||||
return [];
|
||||
@@ -42,13 +42,15 @@ function getDomainsConfig() {
|
||||
initFeatureFlags(bootstrapData.common.feature_flags);
|
||||
|
||||
if (bootstrapData?.common?.conf?.SUPERSET_WEBSERVER_DOMAINS) {
|
||||
bootstrapData.common.conf.SUPERSET_WEBSERVER_DOMAINS.forEach(hostName => {
|
||||
const domains = bootstrapData.common.conf
|
||||
.SUPERSET_WEBSERVER_DOMAINS as string[];
|
||||
domains.forEach((hostName: string) => {
|
||||
availableDomains.add(hostName);
|
||||
});
|
||||
}
|
||||
return Array.from(availableDomains);
|
||||
}
|
||||
|
||||
export const availableDomains = getDomainsConfig();
|
||||
export const availableDomains: string[] = getDomainsConfig();
|
||||
|
||||
export const allowCrossDomain = availableDomains.length > 1;
|
||||
export const allowCrossDomain: boolean = availableDomains.length > 1;
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user