Compare commits

...

71 Commits

Author SHA1 Message Date
Mehmet Salih Yavuz
7f41b6b699 Merge branch 'master' into msyavuz/refactor/typescript-migration 2025-09-21 19:40:26 +03:00
Maxime Beauchemin
ecb3ac68ff feat: AI-powered TypeScript migration framework with parallel processing (#35045)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
Co-authored-by: Elizabeth Thompson <eschutho@gmail.com>
2025-09-20 15:47:42 -07:00
Mehmet Salih Yavuz
076e477fd4 fix(SQLPopover): Use correct component (#35212) 2025-09-20 12:12:37 +03:00
SBIN2010
1e4bc6ee78 fix: bug in tooltip timeseries chart in calculated total with annotation layer (#35179) 2025-09-19 10:30:42 -07:00
Pat Buxton
db178cf527 fix: Bump pandas to 2.1.4 for python 3.12 (#34999) 2025-09-19 09:18:00 -07:00
Alexandru Soare
5901320933 feat(database): Adding per-user caching option in Security tab (#34842) 2025-09-19 19:15:31 +03:00
SBIN2010
23bb4f88c0 fix(Funnel): onInit overridden row_limit to default value on save chart (#35076) 2025-09-19 09:13:45 -07:00
Levis Mbote
4130b92966 fix(gantt-chart): fix Y-axis label visibility in dark theme (#35189) 2025-09-19 12:33:53 +03:00
Mehmet Salih Yavuz
38297edc6b chore(matrixify): Remove leftover option (#35195) 2025-09-19 00:47:47 +03:00
sha174n
0c8f326258 docs: Add security warning for ENABLE_TEMPLATE_PROCESSING (#35192) 2025-09-18 17:36:41 -04:00
Joe Li
127f6b3d66 fix(tests): migrate Cypress control tests to React Testing Library (#35181)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-18 14:23:33 -07:00
Mehmet Salih Yavuz
7609c33745 fix(frontend): resolve ESLint no-param-reassign errors
Fixed parameter reassignment violations in data conversion functions by using object spread syntax instead of mutating the accumulator object.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 23:56:00 +03:00
Mehmet Salih Yavuz
cd16218fbf fix: lint 2025-09-18 23:41:42 +03:00
Mehmet Salih Yavuz
bed45e42ac fix(frontend): resolve TypeScript compilation errors from migration
Fixed all TypeScript errors introduced by the JS-to-TS migration:

- utils/common.ts: Fixed SupersetApiResult import, date formatting types, and boolean return type
- utils/reducerUtils.test.ts: Added proper interface compatibility and type assertions
- middleware/loggerMiddleware.ts: Removed unused types and added proper parameter typing
- explore components: Fixed type compatibility across DataTableControl, DataTablesPane, and related components
- explore/actions/hydrateExplore.ts: Fixed ExplorePageState compatibility
- explore/controlUtils/standardizedFormData.ts: Fixed controls property typing

All files now pass TypeScript compilation with strict type safety.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 23:32:30 +03:00
Mehmet Salih Yavuz
8286a1f2a5 refactor(frontend): migrate 5 core JavaScript files to TypeScript
Migrated the following files from JavaScript to TypeScript:
- src/utils/common.js -> common.ts
- src/utils/reducerUtils.js -> reducerUtils.ts
- src/middleware/loggerMiddleware.js -> loggerMiddleware.ts
- src/explore/store.js -> store.ts
- src/dashboard/util/newComponentFactory.js -> newComponentFactory.ts

All migrations include proper type definitions, interfaces, and follow
Superset's TypeScript conventions with no 'any' types.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 23:19:56 +03:00
Maxime Beauchemin
ea519a77b5 fix: only block showtime for unauthorized users on push (#35184)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-18 12:28:28 -07:00
Michael S. Molina
6cb3ef9f5d chore: TypeScript Configuration Modernization and Cleanup (#35159) 2025-09-18 16:27:57 -03:00
Kamil Gabryjelski
a889ae75fc chore: Bump ag grid to 34.2.0 (#35193) 2025-09-18 19:09:22 +02:00
Mehmet Salih Yavuz
b60be9655f feat(TimeTable): add other sparkline type options (#35180) 2025-09-18 16:41:05 +03:00
lc-4918
fd6da21ce0 chore(i18n): update French translations (#35070)
Co-authored-by: l.clement <l.clement@altereo.fr>
2025-09-17 21:05:15 -07:00
marun
1bf112a57a fix(CrudThemeProvider): Optimized theme loading logic (#35155) 2025-09-17 20:49:26 -07:00
marun
1f530d45cb fix(embedded): resolve theme context error in Loading component (#35168)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-17 20:44:04 -07:00
Joe Li
1187902e68 feat(playwright): Add Playwright CI Integration for Cypress Migration (SIP-178) (#35110)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-17 17:13:47 -07:00
Mehmet Salih Yavuz
f570786f44 fix: skip the e2e test while migrating 2025-09-17 23:56:31 +03:00
Mehmet Salih Yavuz
f9b399328d fix: update tests 2025-09-17 23:24:01 +03:00
Mehmet Salih Yavuz
7e222d54b6 fix: ci 2025-09-17 22:58:34 +03:00
Maxime Beauchemin
ad3eff9e90 feat(matrixify): replace single toggle with separate horizontal/vertical layout controls (#35067)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
2025-09-17 22:57:06 +03:00
Mehmet Salih Yavuz
f3da8510d0 fix: ci 2025-09-17 22:46:56 +03:00
Mehmet Salih Yavuz
0dc2a02d2e Update superset-frontend/src/dashboard/util/logging/childChartsDidLoad.ts
Co-authored-by: Elizabeth Thompson <eschutho@gmail.com>
2025-09-17 22:03:04 +03:00
Mehmet Salih Yavuz
10055ed4c7 Merge branch 'master' into js-to-ts 2025-09-17 21:59:00 +03:00
Mehmet Salih Yavuz
e9a2fa6c63 feat: migrate 5 JS files to TypeScript with proper typing
- Migrate MessageToasts/reducers.js with Redux action types
- Migrate dragDroppableConfig.js with comprehensive drag/drop interfaces
- Migrate shouldWrapChildInRow.js with parameter interfaces
- Migrate activeDashboardFilters.js with filter system types
- Migrate emptyDashboardLayout.js with DashboardLayout types
- Fix mock data and drag handler types in related components

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 19:40:16 +03:00
SBIN2010
3e554674ff feat(waterfall): add changes label series and grouping customize settings (#34847) 2025-09-17 08:24:45 -03:00
Amin Ghadersohi
dced2f8564 feat: Add BaseDAO improvements and test reorganization (#35018)
Co-authored-by: bito-code-review[bot] <188872107+bito-code-review[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-16 18:15:16 -07:00
Maxime Beauchemin
05c6a1bf20 fix(viz): resolve dark mode compatibility issues in BigNumber and Heatmap (#35151)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-16 10:21:47 -07:00
SBIN2010
c193d6d6a1 fix: import bug template params (#35144) 2025-09-16 10:21:29 -07:00
Joe Li
fb840b8e71 fix(deck.gl): restore legend display for Polygon charts with linear palette and fixed color schemes (#35142)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-16 20:20:42 +03:00
Maxime Beauchemin
d0cc6f115b feat: add optional garbage collection after requests (#35061)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-16 09:23:39 -07:00
Hugh A. Miles II
966e231f94 feat: Add Dashboard Filter Support for Alert Reports (#32196)
Co-authored-by: Elizabeth Thompson <eschutho@gmail.com>
Co-authored-by: Hugh A Miles II <hugh@Mac.home>
2025-09-16 10:52:28 -04:00
Richard Fogaca Nienkotter
a66737cb05 feat(custom-tooltip): custom tooltip on deck.gl charts (#34276) 2025-09-16 17:11:19 +03:00
Michael S. Molina
bc6859a99d refactor: Organizes the src/core folder (#35119) 2025-09-16 08:21:16 -03:00
Mehmet Salih Yavuz
3e491be312 refactor: migrate 5 dashboard utility files to TypeScript (#35147)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-16 12:12:43 +03:00
Elizabeth Thompson
133e686224 docs(llms): add PR template guidelines to LLMS.md (#35148)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-15 15:57:16 -07:00
Elizabeth Thompson
7d0a472d1e feat: Add comprehensive dark mode support for chart thumbnails and examples (#35111)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-15 13:53:44 -07:00
Maxime Beauchemin
c2534f9155 feat: Add ECharts options overrides to theme system (#34876)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-15 13:52:38 -07:00
Maxime Beauchemin
088ecdd0bf refactor: Enable G logging rules and comprehensive ruff improvements (#35081)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-15 12:42:49 -07:00
Maxime Beauchemin
e1a2e4843a chore: standardize webpack dev server host configuration and enable LAN access (#35066)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-15 10:10:05 -07:00
dependabot[bot]
15e8ffee1e chore(deps): bump axios from 1.11.0 to 1.12.0 in /docs (#35109) 2025-09-14 07:50:16 +07:00
Elizabeth Thompson
19ddcb7e5c fix: Remove emotion-rgba from dependencies and codebase (#35124) 2025-09-13 13:33:20 +03:00
Gabriel Torres Ruiz
36daa2dc3f fix(ListView): implement AntD pagination for ListView component (#35057) 2025-09-12 14:15:05 -07:00
Gabriel Torres Ruiz
7fd5a7668b fix(charts): fix legend theming and hollow symbols in dark mode (#35123) 2025-09-12 23:33:29 +03:00
Mehmet Salih Yavuz
95333e34b1 fix(theming): Lighter text colors on dark mode (#35114) 2025-09-12 15:52:54 +03:00
Daniel Vaz Gaspar
a9fb853e3e fix: Bump FAB to 5.X (#33055)
Co-authored-by: Joe Li <joe@preset.io>
2025-09-12 09:21:37 +01:00
Rafael Benitez
dea9068647 fix(DashboardEditor): CSS template selector UI in dashboard properties modal restored (#35106) 2025-09-11 15:34:16 -07:00
Elizabeth Thompson
3416bd1479 chore: add codeowners (#35107) 2025-09-11 14:07:21 -07:00
Michael S. Molina
e729b2dbb4 fix: SQL Lab tab events (#35105) 2025-09-11 17:53:51 -03:00
Maxime Beauchemin
d1ee1307ff feat: migrate 3 dashboard utility files to TypeScript
- migrate getChartIdsFromLayout.js to TypeScript with proper DashboardLayout typing
- migrate isInDifferentFilterScopes.js to TypeScript with filter scope interfaces
- migrate serializeFilterScopes.js to TypeScript with serialization types
- create comprehensive test files for isInDifferentFilterScopes and serializeFilterScopes
- enhance existing getChartIdsFromLayout test with proper TypeScript compliance
- all migrations use component-colocated types following established patterns
- zero any types introduced, full backward compatibility maintained

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 13:16:51 -07:00
Maxime Beauchemin
a3d28f6615 fix build 2025-09-08 12:17:48 -07:00
Maxime Beauchemin
f95efae874 fix a few minimal issues 2025-09-08 11:32:24 -07:00
Maxime Beauchemin
14e0d220e7 feat: migrate getControlsForVizType.js to TypeScript
- Added proper parameter and return type annotations
- Migrated existing test file to TypeScript
- Used existing ControlMap types, proper JsonObject typing
- Agent validation: EASY - simple utility, existing types worked perfectly
2025-09-08 09:34:32 -07:00
Maxime Beauchemin
f802e3a454 feat: migrate reducerUtils.js to TypeScript
- Added proper TypeScript interfaces with generics
- Created comprehensive test suite (8 passing tests)
- Zero any types, full type safety for Redux utilities
- Agent validation: EASY - pure utility functions, no dependencies
2025-09-08 09:33:42 -07:00
Maxime Beauchemin
58e493d471 docs: enforce single-file TypeScript validation strategy
Critical framework update addressing tsc multi-file compilation issues:

## Core Issue Identified
TypeScript's tsc has documented problems with multi-file compilation in complex projects,
similar to issues addressed by packages like tsc-multi.

## Framework Changes
1. **Single-File Validation Mandate**: Always validate TypeScript files individually
2. **Avoid Project-Wide Validation**: Never use npm run type during parallel execution
3. **One-File-At-A-Time Pattern**: npx tscw with single file arguments only
4. **Downstream Validation**: Check each importing file individually

## Why This Matters
- **Reliability**: Multi-file tsc can produce false positives/negatives
- **Parallel Safety**: Prevents conflicts during concurrent agent execution
- **Authoritative Results**: Single-file validation is trustworthy
- **Known TypeScript Limitation**: Works around documented tsc issues

This ensures our AI agents get reliable TypeScript validation results during parallel migrations.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 17:02:16 -07:00
Maxime Beauchemin
c453757c48 docs: enhance AI migration framework with downstream validation
Critical framework improvements based on real-world type debugging experience
2025-09-07 17:00:17 -07:00
Maxime Beauchemin
e416fece27 feat: migrate 5 utility files from JS to TypeScript in parallel
Successfully migrated 5 small leaf-node utility files to TypeScript:

## Files Migrated
1. **dropOverflowsParent.js** (24 lines) → .ts + comprehensive test suite
2. **datasourceUtils.js** (27 lines) → .ts + 8 test cases
3. **getKeyForFilterScopeTree.js** (28 lines) → .ts + 6 test cases
4. **getLayoutComponentFromChartId.js** (30 lines) → .ts + 5 test cases
5. **childChartsDidLoad.js** (32 lines) → .ts + 10 test cases

## Quality Metrics
-  Zero `any` types across all files
-  Comprehensive test coverage (30+ new test cases)
-  TypeScript compilation passes
-  All tests pass
-  Git history preserved via `git mv`

## Key Improvements
- **Type Safety**: Proper TypeScript interfaces and type guards
- **Test Coverage**: Created test files for previously untested utilities
- **DRY Types**: Flexible interface design for datasourceUtils compatibility
- **Real-world Debugging**: Solved complex type compatibility issues

## Migration Strategy Validation
- **Parallel Processing**: 5 files migrated simultaneously by agents
- **Atomic Units**: Each agent handled core file + tests + mocks
- **Integration Success**: All migrations required minimal coordinator fixes

Progress: 17/219 files migrated (7.8% complete)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 16:49:28 -07:00
Maxime Beauchemin
6b65ab7a29 feat(js-to-ts): migrate 3 utility files to TypeScript with comprehensive tests
Migrate small leaf node utility files to TypeScript:
- getDirectPathToTabIndex.js → .ts (35 lines) - Dashboard tab path utility
- isDashboardLoading.js → .ts (34 lines) - Chart loading state checker
- Separator.js → .ts (85 lines) - Control panel configuration for separator widget

Key improvements:
- Proper TypeScript interfaces (TabsComponentLike, ChartLoadTimestamps)
- Zero `any` types throughout all migrations
- Comprehensive test coverage with 8 total test cases
- Type-safe control panel configuration with ControlPanelState
- All TypeScript compilation and ESLint validation passes

Technical notes:
- Used proper optional property handling with nullish coalescing (??)
- Created focused interfaces avoiding over-broad typing
- Test files include edge cases and error scenarios

Progress: 12/219 files migrated (5.5%)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 16:03:46 -07:00
Maxime Beauchemin
1f1b0389ce docs(js-to-ts): enhance AGENT.md with PropTypes auto-generation and ESLint best practices 2025-09-07 15:54:03 -07:00
Maxime Beauchemin
519835e1a4 feat(js-to-ts): batch migration of 5 leaf files with PropTypes auto-generation
Migrate 5 small leaf files (23-54 lines) to TypeScript with zero dependencies:
- aggregateOptionType.js → .ts (23 lines, MetricControl)
- columnType.js → .ts (24 lines, MetricControl & FilterControl)
- savedMetricType.js → .ts (25 lines, MetricControl)
- adhocFilterType.js → .ts (37 lines, FilterControl)
- hostNamesConfig.js → .ts (54 lines, utils)

Key improvements:
- Implement elegant PropTypes auto-generation using babel-plugin-typescript-to-proptypes
- Remove 18+ lines of manual PropTypes duplication in adhocFilterType
- Create 4 comprehensive test files for previously untested utilities
- Zero `any` types across all migrations
- 100% automatic integration rate maintained

Enhanced AGENT.md with:
- PropTypes auto-generation patterns and migration strategy
- ESLint --fix flag recommendation for automatic formatting
- Type consolidation best practices

Progress: 9/219 files migrated (4.1%), all with proper TypeScript types

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 15:53:16 -07:00
Maxime Beauchemin
8cbb61dd7e feat(coordination): complete documentation cleanup and size-based prioritization
Documentation Structure Improvements:
- Update ROOT/PROJECT.md with correct file references
- Remove coordinator references from AGENT.md (agents don't need coordinator context)
- Fix all cross-references between files
- Clean architecture: agents see only technical instructions

Coordinator Workflow Enhancements:
- Add size-prioritized file analysis commands to COORDINATOR.md
- Implement smallest-files-first strategy (<50, 50-200, 200+ lines)
- Add systematic leaf analysis with line counts and dependency checking
- Benefits: faster wins, easier validation, better success rate

File Priority Analysis Results:
 hostNamesConfig.js (54 lines, 0 JS deps) - Next smallest leaf candidate
 Separator.js (76 lines, 0 JS deps) - Second smallest leaf candidate

Ready for next migration with improved systematic approach.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 15:20:48 -07:00
Maxime Beauchemin
960a31f211 feat(migration): complete DebouncedMessageQueue TypeScript migration
- Migrate DebouncedMessageQueue.js to TypeScript with proper generics
- Add DebouncedMessageQueueOptions interface for type-safe configuration
- Implement proper class properties with private/readonly modifiers
- CREATE missing test file: DebouncedMessageQueue.test.ts
- All TypeScript compilation and tests pass
- Improve js-to-ts command with test creation requirements

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 14:58:22 -07:00
Maxime Beauchemin
a1242bd80e feat(migration): improve js-to-ts command and migrate timeGrainSqlaAnimationOverrides
Script improvements:
- Add ESLint validation step for each migrated file
- Clarify TypeScript compilation commands for per-file validation
- Update success report format to include validation steps

Migration completed:
- Convert timeGrainSqlaAnimationOverrides.js to TypeScript
- Add proper ControlPanelState and Dataset types from @superset-ui/chart-controls
- Implement TimeGrainOverrideState interface for return type
- Use type guards and proper casting for type safety

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 14:49:57 -07:00
Maxime Beauchemin
291e07c345 feat(typescript): migrate roundDecimal utility to TypeScript
- Convert roundDecimal.js and test file from JavaScript to TypeScript
- Add proper type annotations: (number: number, precision?: number): number
- Refactor precision calculation to avoid TypeScript compilation issues
- Maintain all existing functionality and test coverage
- Use git mv to preserve file history

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 14:35:58 -07:00
Maxime Beauchemin
7c745ac622 refactor: clarify atomic migration strategy for core files + tests/mocks
- Coordinators now target only core files (no tests/mocks)
- Agents migrate core file + all related tests/mocks atomically as one unit
- Updated commands and documentation to reflect atomic migration workflow
- Clear separation of concerns: coordinators identify, agents execute atomically

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 14:23:44 -07:00
651 changed files with 21276 additions and 5429 deletions

View File

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

View File

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

View File

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

View File

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

14
.github/CODEOWNERS vendored
View File

@@ -33,10 +33,10 @@
# Notify PMC members of changes to extension-related files
/superset-core/ @michael-s-molina @villebro
/superset-extensions-cli/ @michael-s-molina @villebro
/superset/core/ @michael-s-molina @villebro
/superset/extensions/ @michael-s-molina @villebro
/superset-frontend/src/packages/superset-core/ @michael-s-molina @villebro
/superset-frontend/src/core/ @michael-s-molina @villebro
/superset-frontend/src/extensions/ @michael-s-molina @villebro
/superset-core/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
/superset-extensions-cli/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
/superset/core/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
/superset/extensions/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
/superset-frontend/src/packages/superset-core/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
/superset-frontend/src/core/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
/superset-frontend/src/extensions/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje

View File

@@ -182,6 +182,76 @@ cypress-run-all() {
kill $flaskProcessId
}
playwright-install() {
cd "$GITHUB_WORKSPACE/superset-frontend"
say "::group::Install Playwright browsers"
npx playwright install --with-deps chromium
# Create output directories for test results and debugging
mkdir -p playwright-results
mkdir -p test-results
say "::endgroup::"
}
playwright-run() {
local APP_ROOT=$1
# Start Flask from the project root (same as Cypress)
cd "$GITHUB_WORKSPACE"
local flasklog="${HOME}/flask-playwright.log"
local port=8081
PLAYWRIGHT_BASE_URL="http://localhost:${port}"
if [ -n "$APP_ROOT" ]; then
export SUPERSET_APP_ROOT=$APP_ROOT
PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL}${APP_ROOT}/
fi
export PLAYWRIGHT_BASE_URL
nohup flask run --no-debugger -p $port >"$flasklog" 2>&1 </dev/null &
local flaskProcessId=$!
# Ensure cleanup on exit
trap "kill $flaskProcessId 2>/dev/null || true" EXIT
# Wait for server to be ready with health check
local timeout=60
say "Waiting for Flask server to start on port $port..."
while [ $timeout -gt 0 ]; do
if curl -f ${PLAYWRIGHT_BASE_URL}/health >/dev/null 2>&1; then
say "Flask server is ready"
break
fi
sleep 1
timeout=$((timeout - 1))
done
if [ $timeout -eq 0 ]; then
echo "::error::Flask server failed to start within 60 seconds"
echo "::group::Flask startup log"
cat "$flasklog"
echo "::endgroup::"
return 1
fi
# Change to frontend directory for Playwright execution
cd "$GITHUB_WORKSPACE/superset-frontend"
say "::group::Run Playwright tests"
echo "Running Playwright with baseURL: ${PLAYWRIGHT_BASE_URL}"
npx playwright test auth/login --reporter=github --output=playwright-results
local status=$?
say "::endgroup::"
# After job is done, print out Flask log for debugging
echo "::group::Flask log for Playwright run"
cat "$flasklog"
echo "::endgroup::"
# make sure the program exits
kill $flaskProcessId
return $status
}
eyes-storybook-dependencies() {
say "::group::install eyes-storyook dependencies"
sudo apt-get update -y && sudo apt-get -y install gconf-service ca-certificates libxshmfence-dev fonts-liberation libappindicator3-1 libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libgconf-2-4 libglib2.0-0 libgdk-pixbuf2.0-0 libgtk-3-0 libnspr4 libnss3 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release xdg-utils libappindicator1

View File

@@ -61,17 +61,8 @@ jobs:
console.log(`📊 Permission level for ${actor}: ${permission.permission}`);
const authorized = ['write', 'admin'].includes(permission.permission);
if (!authorized) {
console.log(`🚨 Unauthorized user ${actor} - skipping all operations`);
core.setOutput('authorized', 'false');
return;
}
console.log(`✅ Authorized maintainer: ${actor}`);
core.setOutput('authorized', 'true');
// If this is a synchronize event, check if Showtime is active and set blocked label
if (context.eventName === 'pull_request_target' && context.payload.action === 'synchronize') {
// If this is a synchronize event from unauthorized user, check if Showtime is active and set blocked label
if (!authorized && context.eventName === 'pull_request_target' && context.payload.action === 'synchronize') {
console.log(`🔒 Synchronize event detected - checking if Showtime is active`);
// Check if PR has any circus tent labels (Showtime is in use)
@@ -99,6 +90,15 @@ jobs:
}
}
if (!authorized) {
console.log(`🚨 Unauthorized user ${actor} - skipping all operations`);
core.setOutput('authorized', 'false');
return;
}
console.log(`✅ Authorized maintainer: ${actor}`);
core.setOutput('authorized', 'true');
- name: Install Superset Showtime
if: steps.auth.outputs.authorized == 'true'
run: |

View File

@@ -143,7 +143,7 @@ jobs:
- name: tsc
run: |
docker run --rm $TAG bash -c \
"npm run type"
"npm run plugins:build && npm run type"
validate-frontend:
needs: frontend-build

View File

@@ -0,0 +1,141 @@
name: Playwright E2E Tests
on:
push:
branches:
- "master"
- "[0-9].[0-9]*"
pull_request:
types: [synchronize, opened, reopened, ready_for_review]
workflow_dispatch:
inputs:
ref:
description: 'The branch or tag to checkout'
required: false
default: ''
pr_id:
description: 'The pull request ID to checkout'
required: false
default: ''
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
jobs:
playwright-tests:
runs-on: ubuntu-22.04
# Allow workflow to succeed even if tests fail during shadow mode
continue-on-error: true
permissions:
contents: read
pull-requests: read
strategy:
fail-fast: false
matrix:
browser: ["chromium"]
app_root: ["", "/app/prefix"]
env:
SUPERSET_ENV: development
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset
PYTHONPATH: ${{ github.workspace }}
REDIS_PORT: 16379
GITHUB_TOKEN: ${{ github.token }}
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: superset
POSTGRES_PASSWORD: superset
ports:
- 15432:5432
redis:
image: redis:7-alpine
ports:
- 16379:6379
steps:
# -------------------------------------------------------
# Conditional checkout based on context (same as Cypress workflow)
- name: Checkout for push or pull_request event
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: actions/checkout@v5
with:
persist-credentials: false
submodules: recursive
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Checkout using ref (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
uses: actions/checkout@v5
with:
persist-credentials: false
ref: ${{ github.event.inputs.ref }}
submodules: recursive
- name: Checkout using PR ID (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
uses: actions/checkout@v5
with:
persist-credentials: false
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
submodules: recursive
# -------------------------------------------------------
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Python
uses: ./.github/actions/setup-backend/
if: steps.check.outputs.python || steps.check.outputs.frontend
- name: Setup postgres
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: setup-postgres
- name: Import test data
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: testdata
- name: Setup Node.js
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@v4
with:
node-version-file: './superset-frontend/.nvmrc'
- name: Install npm dependencies
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: npm-install
- name: Build javascript packages
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: build-instrumented-assets
- name: Install Playwright
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: playwright-install
- name: Run Playwright
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
env:
NODE_OPTIONS: "--max-old-space-size=4096"
with:
run: playwright-run ${{ matrix.app_root }}
- name: Set safe app root
if: failure()
id: set-safe-app-root
run: |
APP_ROOT="${{ matrix.app_root }}"
SAFE_APP_ROOT=${APP_ROOT//\//_}
echo "safe_app_root=$SAFE_APP_ROOT" >> $GITHUB_OUTPUT
- name: Upload Playwright Artifacts
uses: actions/upload-artifact@v4
if: failure()
with:
path: |
${{ github.workspace }}/superset-frontend/playwright-results/
${{ github.workspace }}/superset-frontend/test-results/
name: playwright-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}

30
LLMS.md
View File

@@ -15,8 +15,9 @@ Apache Superset is a data visualization platform with Flask/Python backend and R
### Testing Strategy Migration
- **Prefer unit tests** over integration tests
- **Prefer integration tests** over Cypress end-to-end tests
- **Cypress is last resort** - Actively moving away from Cypress
- **Prefer integration tests** over end-to-end tests
- **Use Playwright for E2E tests** - Migrating from Cypress
- **Cypress is deprecated** - Will be removed once migration is completed
- **Use Jest + React Testing Library** for component testing
- **Use `test()` instead of `describe()`** - Follow [avoid nesting when testing](https://kentcdodds.com/blog/avoid-nesting-when-youre-testing) principles
@@ -107,6 +108,18 @@ superset/
npm run test # All tests
npm run test -- filename.test.tsx # Single file
# E2E Tests (Playwright - NEW)
npm run playwright:test # All Playwright tests
npm run playwright:ui # Interactive UI mode
npm run playwright:headed # See browser during tests
npx playwright test tests/auth/login.spec.ts # Single file
npm run playwright:debug tests/auth/login.spec.ts # Debug specific file
# E2E Tests (Cypress - DEPRECATED)
cd superset-frontend/cypress-base
npm run cypress-run-chrome # All Cypress tests (headless)
npm run cypress-debug # Interactive Cypress UI
# Backend
pytest # All tests
pytest tests/unit_tests/specific_test.py # Single file
@@ -136,6 +149,19 @@ curl -f http://localhost:8088/health || echo "❌ Setup required - see https://s
- **Use negation operator**: `~Model.field` instead of `== False` to avoid ruff E712 errors
- **Example**: `~Model.is_active` instead of `Model.is_active == False`
## Pull Request Guidelines
**When creating pull requests:**
1. **Read the current PR template**: Always check `.github/PULL_REQUEST_TEMPLATE.md` for the latest format
2. **Use the template sections**: Include all sections from the template (SUMMARY, BEFORE/AFTER, TESTING INSTRUCTIONS, ADDITIONAL INFORMATION)
3. **Follow PR title conventions**: Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
- Format: `type(scope): description`
- Example: `fix(dashboard): load charts correctly`
- Types: `fix`, `feat`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`
**Important**: Always reference the actual template file at `.github/PULL_REQUEST_TEMPLATE.md` instead of using cached content, as the template may be updated over time.
## Pre-commit Validation
**Use pre-commit hooks for quality validation:**

View File

@@ -23,7 +23,8 @@ This file documents any backwards-incompatible changes in Superset and
assists people when migrating to a new version.
## Next
- [35062](https://github.com/apache/superset/pull/35062): Changed the function signature of `setupExtensions` to `setupCodeOverrides` with options as arguments.
- [33055](https://github.com/apache/superset/pull/33055): Upgrades Flask-AppBuilder to 5.0.0. The AUTH_OID authentication type has been deprecated and is no longer available as an option in Flask-AppBuilder. OpenID (OID) is considered a deprecated authentication protocol - if you are using AUTH_OID, you will need to migrate to an alternative authentication method such as OAuth, LDAP, or database authentication before upgrading.
- [35062](https://github.com/apache/superset/pull/35062): Changed the function signature of `setupExtensions` to `setupCodeOverrides` with options as arguments.
- [34871](https://github.com/apache/superset/pull/34871): Fixed Jest test hanging issue from Ant Design v5 upgrade. MessageChannel is now mocked in test environment to prevent rc-overflow from causing Jest to hang. Test environment only - no production impact.
- [34782](https://github.com/apache/superset/pull/34782): Dataset exports now include the dataset ID in their file name (similar to charts and dashboards). If managing assets as code, make sure to rename existing dataset YAMLs to include the ID (and avoid duplicated files).
- [34536](https://github.com/apache/superset/pull/34536): The `ENVIRONMENT_TAG_CONFIG` color values have changed to support only Ant Design semantic colors. Update your `superset_config.py`:

View File

@@ -162,8 +162,11 @@ services:
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
# configuring the dev-server to use the host.docker.internal to connect to the backend
superset: "http://superset-light:8088"
# Webpack dev server configuration
WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-127.0.0.1}"
WEBPACK_DEVSERVER_PORT: "${WEBPACK_DEVSERVER_PORT:-9000}"
ports:
- "127.0.0.1:${NODE_PORT:-9001}:9000" # Parameterized port
- "${NODE_PORT:-9001}:9000" # Parameterized port, accessible on all interfaces
command: ["/app/docker/docker-frontend.sh"]
env_file:
- path: docker/.env # default

View File

@@ -138,7 +138,7 @@ try:
from superset_config_docker import * # noqa: F403
logger.info(
f"Loaded your Docker configuration at [{superset_config_docker.__file__}]"
"Loaded your Docker configuration at [%s]", superset_config_docker.__file__
)
except ImportError:
logger.info("Using default Docker config...")

View File

@@ -363,110 +363,6 @@ CUSTOM_SECURITY_MANAGER = CustomSsoSecurityManager
]
```
### Keycloak-Specific Configuration using Flask-OIDC
If you are using Keycloak as OpenID Connect 1.0 Provider, the above configuration based on [`Authlib`](https://authlib.org/) might not work. In this case using [`Flask-OIDC`](https://pypi.org/project/flask-oidc/) is a viable option.
Make sure the pip package [`Flask-OIDC`](https://pypi.org/project/flask-oidc/) is installed on the webserver. This was successfully tested using version 2.2.0. This package requires [`Flask-OpenID`](https://pypi.org/project/Flask-OpenID/) as a dependency.
The following code defines a new security manager. Add it to a new file named `keycloak_security_manager.py`, placed in the same directory as your `superset_config.py` file.
```python
from flask_appbuilder.security.manager import AUTH_OID
from superset.security import SupersetSecurityManager
from flask_oidc import OpenIDConnect
from flask_appbuilder.security.views import AuthOIDView
from flask_login import login_user
from urllib.parse import quote
from flask_appbuilder.views import ModelView, SimpleFormView, expose
from flask import (
redirect,
request
)
import logging
class OIDCSecurityManager(SupersetSecurityManager):
def __init__(self, appbuilder):
super(OIDCSecurityManager, self).__init__(appbuilder)
if self.auth_type == AUTH_OID:
self.oid = OpenIDConnect(self.appbuilder.get_app)
self.authoidview = AuthOIDCView
class AuthOIDCView(AuthOIDView):
@expose('/login/', methods=['GET', 'POST'])
def login(self, flag=True):
sm = self.appbuilder.sm
oidc = sm.oid
@self.appbuilder.sm.oid.require_login
def handle_login():
user = sm.auth_user_oid(oidc.user_getfield('email'))
if user is None:
info = oidc.user_getinfo(['preferred_username', 'given_name', 'family_name', 'email'])
user = sm.add_user(info.get('preferred_username'), info.get('given_name'), info.get('family_name'),
info.get('email'), sm.find_role('Gamma'))
login_user(user, remember=False)
return redirect(self.appbuilder.get_url_for_index)
return handle_login()
@expose('/logout/', methods=['GET', 'POST'])
def logout(self):
oidc = self.appbuilder.sm.oid
oidc.logout()
super(AuthOIDCView, self).logout()
redirect_url = request.url_root.strip('/') + self.appbuilder.get_url_for_login
return redirect(
oidc.client_secrets.get('issuer') + '/protocol/openid-connect/logout?redirect_uri=' + quote(redirect_url))
```
Then add to your `superset_config.py` file:
```python
from keycloak_security_manager import OIDCSecurityManager
from flask_appbuilder.security.manager import AUTH_OID, AUTH_REMOTE_USER, AUTH_DB, AUTH_LDAP, AUTH_OAUTH
import os
AUTH_TYPE = AUTH_OID
SECRET_KEY: 'SomethingNotEntirelySecret'
OIDC_CLIENT_SECRETS = '/path/to/client_secret.json'
OIDC_ID_TOKEN_COOKIE_SECURE = False
OIDC_OPENID_REALM: '<myRealm>'
OIDC_INTROSPECTION_AUTH_METHOD: 'client_secret_post'
CUSTOM_SECURITY_MANAGER = OIDCSecurityManager
# Will allow user self registration, allowing to create Flask users from Authorized User
AUTH_USER_REGISTRATION = True
# The default user self registration role
AUTH_USER_REGISTRATION_ROLE = 'Public'
```
Store your client-specific OpenID information in a file called `client_secret.json`. Create this file in the same directory as `superset_config.py`:
```json
{
"<myOpenIDProvider>": {
"issuer": "https://<myKeycloakDomain>/realms/<myRealm>",
"auth_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/auth",
"client_id": "https://<myKeycloakDomain>",
"client_secret": "<myClientSecret>",
"redirect_uris": [
"https://<SupersetWebserver>/oauth-authorized/<myOpenIDProvider>"
],
"userinfo_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/userinfo",
"token_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/token",
"token_introspection_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/token/introspect"
}
}
```
## LDAP Authentication
FAB supports authenticating user credentials against an LDAP server.

View File

@@ -10,8 +10,15 @@ version: 1
## Jinja Templates
SQL Lab and Explore supports [Jinja templating](https://jinja.palletsprojects.com/en/2.11.x/) in queries.
To enable templating, the `ENABLE_TEMPLATE_PROCESSING` [feature flag](/docs/configuration/configuring-superset#feature-flags) needs to be enabled in
`superset_config.py`. When templating is enabled, python code can be embedded in virtual datasets and
To enable templating, the `ENABLE_TEMPLATE_PROCESSING` [feature flag](/docs/configuration/configuring-superset#feature-flags) needs to be enabled in `superset_config.py`.
> #### ⚠️ Security Warning
>
> While powerful, this feature executes template code on the server. Within the Superset security model, this is **intended functionality**, as users with permissions to edit charts and virtual datasets are considered **trusted users**.
>
> If you grant these permissions to untrusted users, this feature can be exploited as a **Server-Side Template Injection (SSTI)** vulnerability. Do not enable `ENABLE_TEMPLATE_PROCESSING` unless you fully understand and accept the associated security risks.
When templating is enabled, python code can be embedded in virtual datasets and
in Custom SQL in the filter and metric controls in Explore. By default, the following variables are
made available in the Jinja context:

View File

@@ -165,6 +165,206 @@ Or in the CRUD interface theme JSON:
This feature works with the stock Docker image - no custom build required!
## ECharts Configuration Overrides
:::note
Available since Superset 6.0
:::
Superset provides fine-grained control over ECharts visualizations through theme-level configuration overrides. This allows you to customize the appearance and behavior of all ECharts-based charts without modifying individual chart configurations.
### Global ECharts Overrides
Apply settings to all ECharts visualizations using `echartsOptionsOverrides`:
```python
THEME_DEFAULT = {
"token": {
"colorPrimary": "#2893B3",
# ... other Ant Design tokens
},
"echartsOptionsOverrides": {
"grid": {
"left": "10%",
"right": "10%",
"top": "15%",
"bottom": "15%"
},
"tooltip": {
"backgroundColor": "rgba(0, 0, 0, 0.8)",
"borderColor": "#ccc",
"textStyle": {
"color": "#fff"
}
},
"legend": {
"textStyle": {
"fontSize": 14,
"fontWeight": "bold"
}
}
}
}
```
### Chart-Specific Overrides
Target specific chart types using `echartsOptionsOverridesByChartType`:
```python
THEME_DEFAULT = {
"token": {
"colorPrimary": "#2893B3",
# ... other tokens
},
"echartsOptionsOverridesByChartType": {
"echarts_pie": {
"legend": {
"orient": "vertical",
"right": 10,
"top": "center"
}
},
"echarts_timeseries": {
"xAxis": {
"axisLabel": {
"rotate": 45,
"fontSize": 12
}
},
"dataZoom": [{
"type": "slider",
"show": True,
"start": 0,
"end": 100
}]
},
"echarts_bubble": {
"grid": {
"left": "15%",
"bottom": "20%"
}
}
}
}
```
### UI Configuration
You can also configure ECharts overrides through the theme CRUD interface:
```json
{
"token": {
"colorPrimary": "#2893B3"
},
"echartsOptionsOverrides": {
"grid": {
"left": "10%",
"right": "10%"
},
"tooltip": {
"backgroundColor": "rgba(0, 0, 0, 0.8)"
}
},
"echartsOptionsOverridesByChartType": {
"echarts_pie": {
"legend": {
"orient": "vertical",
"right": 10
}
}
}
}
```
### Override Precedence
The system applies overrides in the following order (last wins):
1. **Base ECharts theme** - Default Superset styling
2. **Plugin options** - Chart-specific configurations
3. **Global overrides** - `echartsOptionsOverrides`
4. **Chart-specific overrides** - `echartsOptionsOverridesByChartType[chartType]`
This ensures chart-specific overrides take precedence over global ones.
### Common Chart Types
Available chart types for `echartsOptionsOverridesByChartType`:
- `echarts_timeseries` - Time series/line charts
- `echarts_pie` - Pie and donut charts
- `echarts_bubble` - Bubble/scatter charts
- `echarts_funnel` - Funnel charts
- `echarts_gauge` - Gauge charts
- `echarts_radar` - Radar charts
- `echarts_boxplot` - Box plot charts
- `echarts_treemap` - Treemap charts
- `echarts_sunburst` - Sunburst charts
- `echarts_graph` - Network/graph charts
- `echarts_sankey` - Sankey diagrams
- `echarts_heatmap` - Heatmaps
- `echarts_mixed_timeseries` - Mixed time series
### Best Practices
1. **Start with global overrides** for consistent styling across all charts
2. **Use chart-specific overrides** for unique requirements per visualization type
3. **Test thoroughly** as overrides use deep merge - nested objects are combined, but arrays are completely replaced
4. **Document your overrides** to help team members understand custom styling
5. **Consider performance** - complex overrides may impact chart rendering speed
### Example: Corporate Branding
```python
# Complete corporate theme with ECharts customization
THEME_DEFAULT = {
"token": {
"colorPrimary": "#1B4D3E",
"fontFamily": "Corporate Sans, Arial, sans-serif"
},
"echartsOptionsOverrides": {
"grid": {
"left": "8%",
"right": "8%",
"top": "12%",
"bottom": "12%"
},
"textStyle": {
"fontFamily": "Corporate Sans, Arial, sans-serif"
},
"title": {
"textStyle": {
"color": "#1B4D3E",
"fontSize": 18,
"fontWeight": "bold"
}
}
},
"echartsOptionsOverridesByChartType": {
"echarts_timeseries": {
"xAxis": {
"axisLabel": {
"color": "#666",
"fontSize": 11
}
}
},
"echarts_pie": {
"legend": {
"textStyle": {
"fontSize": 12
},
"itemGap": 20
}
}
}
}
```
This feature provides powerful theming capabilities while maintaining the flexibility of ECharts' extensive configuration options.
## Advanced Features
- **System Themes**: Manage system-wide default and dark themes via UI or configuration

View File

@@ -631,7 +631,7 @@ can find all of the workflows and other assets under the `.github/` folder. This
- running the backend unit test suites (`tests/`)
- running the frontend test suites (`superset-frontend/src/**.*.test.*`)
- running our Cypress end-to-end tests (`superset-frontend/cypress-base/`)
- running our Playwright end-to-end tests (`superset-frontend/playwright/`) and legacy Cypress tests (`superset-frontend/cypress-base/`)
- linting the codebase, including all Python, Typescript and Javascript, yaml and beyond
- checking for all sorts of other rules conventions

View File

@@ -225,21 +225,57 @@ npm run test -- path/to/file.js
### E2E Integration Testing
For E2E testing, we recommend that you use a `docker compose` backend
**Note: We are migrating from Cypress to Playwright. Use Playwright for new tests.**
#### Playwright (Recommended - NEW)
For E2E testing with Playwright, use the same `docker compose` backend:
```bash
CYPRESS_CONFIG=true docker compose up --build
```
`docker compose` will get to work and expose a Cypress-ready Superset app.
This app uses a different database schema (`superset_cypress`) to keep it isolated from
your other dev environment(s), a specific set of examples, and a set of configurations that
aligns with the expectations within the end-to-end tests. Also note that it's served on a
different port than the default port for the backend (`8088`).
Now in another terminal, let's get ready to execute some Cypress commands. First, tell cypress
to connect to the Cypress-ready Superset backend.
The backend setup is identical - this exposes a test-ready Superset app on port 8081 with isolated database schema (`superset_cypress`), test data, and configurations.
Now in another terminal, run Playwright tests:
```bash
# Navigate to frontend directory (Playwright config is here)
cd superset-frontend
# Run all Playwright tests
npm run playwright:test
# or: npx playwright test
# Run with interactive UI for debugging
npm run playwright:ui
# or: npx playwright test --ui
# Run in headed mode (see browser)
npm run playwright:headed
# or: npx playwright test --headed
# Run specific test file
npx playwright test tests/auth/login.spec.ts
# Run with debug mode (step through tests)
npm run playwright:debug tests/auth/login.spec.ts
# or: npx playwright test --debug tests/auth/login.spec.ts
# Generate test report
npx playwright show-report
```
Configuration is in `superset-frontend/playwright.config.ts`. Base URL is automatically set to `http://localhost:8088` but will use `PLAYWRIGHT_BASE_URL` if provided.
#### Cypress (DEPRECATED - will be removed in Phase 5)
:::warning
Cypress is being phased out in favor of Playwright. Use Playwright for all new tests.
:::
```bash
# Set base URL for Cypress
CYPRESS_BASE_URL=http://localhost:8081
```

View File

@@ -363,110 +363,6 @@ CUSTOM_SECURITY_MANAGER = CustomSsoSecurityManager
]
```
### Keycloak-Specific Configuration using Flask-OIDC
If you are using Keycloak as OpenID Connect 1.0 Provider, the above configuration based on [`Authlib`](https://authlib.org/) might not work. In this case using [`Flask-OIDC`](https://pypi.org/project/flask-oidc/) is a viable option.
Make sure the pip package [`Flask-OIDC`](https://pypi.org/project/flask-oidc/) is installed on the webserver. This was successfully tested using version 2.2.0. This package requires [`Flask-OpenID`](https://pypi.org/project/Flask-OpenID/) as a dependency.
The following code defines a new security manager. Add it to a new file named `keycloak_security_manager.py`, placed in the same directory as your `superset_config.py` file.
```python
from flask_appbuilder.security.manager import AUTH_OID
from superset.security import SupersetSecurityManager
from flask_oidc import OpenIDConnect
from flask_appbuilder.security.views import AuthOIDView
from flask_login import login_user
from urllib.parse import quote
from flask_appbuilder.views import ModelView, SimpleFormView, expose
from flask import (
redirect,
request
)
import logging
class OIDCSecurityManager(SupersetSecurityManager):
def __init__(self, appbuilder):
super(OIDCSecurityManager, self).__init__(appbuilder)
if self.auth_type == AUTH_OID:
self.oid = OpenIDConnect(self.appbuilder.get_app)
self.authoidview = AuthOIDCView
class AuthOIDCView(AuthOIDView):
@expose('/login/', methods=['GET', 'POST'])
def login(self, flag=True):
sm = self.appbuilder.sm
oidc = sm.oid
@self.appbuilder.sm.oid.require_login
def handle_login():
user = sm.auth_user_oid(oidc.user_getfield('email'))
if user is None:
info = oidc.user_getinfo(['preferred_username', 'given_name', 'family_name', 'email'])
user = sm.add_user(info.get('preferred_username'), info.get('given_name'), info.get('family_name'),
info.get('email'), sm.find_role('Gamma'))
login_user(user, remember=False)
return redirect(self.appbuilder.get_url_for_index)
return handle_login()
@expose('/logout/', methods=['GET', 'POST'])
def logout(self):
oidc = self.appbuilder.sm.oid
oidc.logout()
super(AuthOIDCView, self).logout()
redirect_url = request.url_root.strip('/') + self.appbuilder.get_url_for_login
return redirect(
oidc.client_secrets.get('issuer') + '/protocol/openid-connect/logout?redirect_uri=' + quote(redirect_url))
```
Then add to your `superset_config.py` file:
```python
from keycloak_security_manager import OIDCSecurityManager
from flask_appbuilder.security.manager import AUTH_OID, AUTH_REMOTE_USER, AUTH_DB, AUTH_LDAP, AUTH_OAUTH
import os
AUTH_TYPE = AUTH_OID
SECRET_KEY: 'SomethingNotEntirelySecret'
OIDC_CLIENT_SECRETS = '/path/to/client_secret.json'
OIDC_ID_TOKEN_COOKIE_SECURE = False
OIDC_OPENID_REALM: '<myRealm>'
OIDC_INTROSPECTION_AUTH_METHOD: 'client_secret_post'
CUSTOM_SECURITY_MANAGER = OIDCSecurityManager
# Will allow user self registration, allowing to create Flask users from Authorized User
AUTH_USER_REGISTRATION = True
# The default user self registration role
AUTH_USER_REGISTRATION_ROLE = 'Public'
```
Store your client-specific OpenID information in a file called `client_secret.json`. Create this file in the same directory as `superset_config.py`:
```json
{
"<myOpenIDProvider>": {
"issuer": "https://<myKeycloakDomain>/realms/<myRealm>",
"auth_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/auth",
"client_id": "https://<myKeycloakDomain>",
"client_secret": "<myClientSecret>",
"redirect_uris": [
"https://<SupersetWebserver>/oauth-authorized/<myOpenIDProvider>"
],
"userinfo_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/userinfo",
"token_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/token",
"token_introspection_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/token/introspect"
}
}
```
## LDAP Authentication
FAB supports authenticating user credentials against an LDAP server.

View File

@@ -4766,9 +4766,9 @@ available-typed-arrays@^1.0.7:
possible-typed-array-names "^1.0.0"
axios@^1.9.0:
version "1.11.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.11.0.tgz#c2ec219e35e414c025b2095e8b8280278478fdb6"
integrity sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==
version "1.12.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.12.0.tgz#11248459be05a5ee493485628fa0e4323d0abfc3"
integrity sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==
dependencies:
follow-redirects "^1.15.6"
form-data "^4.0.4"

View File

@@ -48,7 +48,7 @@ dependencies = [
"cryptography>=42.0.4, <45.0.0",
"deprecation>=2.1.0, <2.2.0",
"flask>=2.2.5, <3.0.0",
"flask-appbuilder>=4.8.1, <5.0.0",
"flask-appbuilder>=5.0.0,<6",
"flask-caching>=2.1.0, <3",
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",
@@ -76,7 +76,7 @@ dependencies = [
"packaging",
# --------------------------
# pandas and related (wanting pandas[performance] without numba as it's 100+MB and not needed)
"pandas[excel]>=2.0.3, <2.1",
"pandas[excel]>=2.0.3, <2.2",
"bottleneck", # recommended performance dependency for pandas, see https://pandas.pydata.org/docs/getting_started/install.html#performance-dependencies-recommended
# --------------------------
"parsedatetime",
@@ -84,6 +84,7 @@ dependencies = [
"pgsanity",
"Pillow>=11.0.0, <12",
"polyline>=2.0.0, <3.0",
"pydantic>=2.8.0",
"pyparsing>=3.0.6, <4",
"python-dateutil",
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies
@@ -313,6 +314,7 @@ select = [
"E",
"F",
"F",
"G",
"I",
"N",
"PT",
@@ -328,6 +330,7 @@ ignore = [
"PT006",
"T201",
"N999",
"G201",
]
extend-select = ["I"]

View File

@@ -6,6 +6,8 @@ alembic==1.15.2
# via flask-migrate
amqp==5.3.1
# via kombu
annotated-types==0.7.0
# via pydantic
apispec==6.6.1
# via
# -r requirements/base.in
@@ -114,11 +116,11 @@ flask==2.3.3
# flask-session
# flask-sqlalchemy
# flask-wtf
flask-appbuilder==4.8.1
flask-appbuilder==5.0.0
# via
# apache-superset (pyproject.toml)
# apache-superset-core
flask-babel==2.0.0
flask-babel==3.1.0
# via flask-appbuilder
flask-caching==2.3.1
# via apache-superset (pyproject.toml)
@@ -158,6 +160,7 @@ greenlet==3.1.1
# via
# apache-superset (pyproject.toml)
# shillelagh
# sqlalchemy
gunicorn==23.0.0
# via apache-superset (pyproject.toml)
h11==0.16.0
@@ -264,7 +267,7 @@ packaging==25.0
# limits
# marshmallow
# shillelagh
pandas==2.0.3
pandas==2.1.4
# via apache-superset (pyproject.toml)
paramiko==3.5.1
# via
@@ -296,6 +299,10 @@ pyasn1-modules==0.4.2
# via google-auth
pycparser==2.22
# via cffi
pydantic==2.11.7
# via apache-superset (pyproject.toml)
pydantic-core==2.33.2
# via pydantic
pygments==2.19.1
# via rich
pyjwt==2.10.1
@@ -406,10 +413,15 @@ typing-extensions==4.14.0
# alembic
# cattrs
# limits
# pydantic
# pydantic-core
# pyopenssl
# referencing
# selenium
# shillelagh
# typing-inspection
typing-inspection==0.4.1
# via pydantic
tzdata==2025.2
# via
# kombu

View File

@@ -18,6 +18,10 @@ amqp==5.3.1
# via
# -c requirements/base-constraint.txt
# kombu
annotated-types==0.7.0
# via
# -c requirements/base-constraint.txt
# pydantic
apispec==6.6.1
# via
# -c requirements/base-constraint.txt
@@ -208,12 +212,12 @@ flask==2.3.3
# flask-sqlalchemy
# flask-testing
# flask-wtf
flask-appbuilder==4.8.1
flask-appbuilder==5.0.0
# via
# -c requirements/base-constraint.txt
# apache-superset
# apache-superset-core
flask-babel==2.0.0
flask-babel==3.1.0
# via
# -c requirements/base-constraint.txt
# flask-appbuilder
@@ -327,6 +331,7 @@ greenlet==3.1.1
# apache-superset
# gevent
# shillelagh
# sqlalchemy
grpcio==1.71.0
# via
# apache-superset
@@ -532,7 +537,7 @@ packaging==25.0
# pytest
# shillelagh
# sqlalchemy-bigquery
pandas==2.0.3
pandas==2.1.4
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -632,6 +637,14 @@ pycparser==2.22
# via
# -c requirements/base-constraint.txt
# cffi
pydantic==2.11.7
# via
# -c requirements/base-constraint.txt
# apache-superset
pydantic-core==2.33.2
# via
# -c requirements/base-constraint.txt
# pydantic
pydata-google-auth==1.9.0
# via pandas-gbq
pydruid==0.6.9
@@ -875,10 +888,17 @@ typing-extensions==4.14.0
# apache-superset
# cattrs
# limits
# pydantic
# pydantic-core
# pyopenssl
# referencing
# selenium
# shillelagh
# typing-inspection
typing-inspection==0.4.1
# via
# -c requirements/base-constraint.txt
# pydantic
tzdata==2025.2
# via
# -c requirements/base-constraint.txt

View File

@@ -42,7 +42,7 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
"flask-appbuilder>=4.5.3, <5.0.0",
"flask-appbuilder>=5.0.0,<6",
]
[project.urls]

View File

@@ -160,7 +160,9 @@ def test_validate_npm_handles_file_not_found_exception(mock_run, mock_which):
def test_validate_npm_does_not_catch_other_subprocess_exceptions(
mock_run, mock_which, exception_type
):
"""Test validate_npm does not catch OSError and PermissionError (they propagate up)."""
"""
Test validate_npm does not catch OSError and PermissionError (they propagate up).
"""
mock_which.return_value = "/usr/bin/npm"
mock_run.side_effect = exception_type("Test error")

View File

@@ -323,6 +323,7 @@ module.exports = {
'*.stories.tsx',
'*.stories.jsx',
'fixtures.*',
'playwright/**/*',
],
excludedFiles: 'cypress-base/cypress/**/*',
plugins: ['jest', 'jest-dom', 'no-only-tests', 'testing-library'],
@@ -397,6 +398,13 @@ module.exports = {
'react/no-void-elements': 0,
},
},
{
files: ['playwright/**/*'],
rules: {
'import/no-unresolved': 0, // Playwright is not installed in main build
'import/no-extraneous-dependencies': 0, // Playwright is not installed in main build
},
},
],
// eslint-disable-next-line no-dupe-keys
rules: {

View File

@@ -175,7 +175,7 @@ describe('Charts list', () => {
interceptDelete();
cy.getBySel('sort-header').contains('Name').click();
// Modal closes immediatly without this
// Modal closes immediately without this
cy.wait(2000);
cy.getBySel('table-row').eq(0).contains('3 - Sample chart');

View File

@@ -1,766 +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.
*/
// eslint-disable-next-line import/no-extraneous-dependencies
import { Interception } from 'cypress/types/net-stubbing';
import { waitForChartLoad } from 'cypress/utils';
import { SUPPORTED_CHARTS_DASHBOARD } from 'cypress/utils/urls';
import {
openTopLevelTab,
SUPPORTED_TIER1_CHARTS,
SUPPORTED_TIER2_CHARTS,
} from './utils';
import {
interceptExploreJson,
interceptV1ChartData,
interceptFormDataKey,
} from '../explore/utils';
const interceptDrillInfo = () => {
cy.intercept('GET', '**/api/v1/dataset/*/drill_info/*', {
statusCode: 200,
body: {
result: {
id: 1,
changed_on_humanized: '2 days ago',
created_on_humanized: 'a week ago',
table_name: 'birth_names',
changed_by: {
first_name: 'Admin',
last_name: 'User',
},
created_by: {
first_name: 'Admin',
last_name: 'User',
},
owners: [
{
first_name: 'Admin',
last_name: 'User',
},
],
columns: [
{
column_name: 'gender',
verbose_name: null,
},
{
column_name: 'state',
verbose_name: null,
},
{
column_name: 'name',
verbose_name: null,
},
{
column_name: 'ds',
verbose_name: null,
},
],
},
},
}).as('drillInfo');
};
const closeModal = () => {
cy.get('body').then($body => {
if ($body.find('[data-test="close-drill-by-modal"]').length) {
cy.getBySel('close-drill-by-modal').click({ force: true });
}
});
};
const openTableContextMenu = (
cellContent: string,
tableSelector = "[data-test-viz-type='table']",
) => {
cy.get(tableSelector).scrollIntoView();
cy.get(tableSelector).contains(cellContent).first().rightclick();
};
const drillBy = (targetDrillByColumn: string, isLegacy = false) => {
if (isLegacy) {
interceptExploreJson('legacyData');
} else {
interceptV1ChartData();
}
cy.get('.ant-dropdown:not(.ant-dropdown-hidden)', { timeout: 15000 })
.should('be.visible')
.find("[role='menu'] [role='menuitem']")
.contains(/^Drill by$/)
.trigger('mouseover', { force: true });
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
{ timeout: 15000 },
)
.should('be.visible')
.find('[role="menuitem"]')
.contains(new RegExp(`^${targetDrillByColumn}$`))
.click();
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
).trigger('mouseout', { clientX: 0, clientY: 0, force: true });
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
).should('not.exist');
if (isLegacy) {
return cy.wait('@legacyData');
}
return cy.wait('@v1Data');
};
const verifyExpectedFormData = (
interceptedRequest: Interception,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expectedFormData: Record<string, any>,
) => {
const actualFormData = interceptedRequest.request.body?.form_data;
Object.entries(expectedFormData).forEach(([key, val]) => {
expect(actualFormData?.[key]).to.eql(val);
});
};
const testEchart = (
vizType: string,
chartName: string,
drillClickCoordinates: [[number, number], [number, number]],
furtherDrillDimension = 'name',
) => {
cy.get(`[data-test-viz-type='${vizType}'] canvas`).then($canvas => {
// click 'boy'
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger(
'mouseover',
drillClickCoordinates[0][0],
drillClickCoordinates[0][1],
);
cy.wrap($canvas).rightclick(
drillClickCoordinates[0][0],
drillClickCoordinates[0][1],
);
drillBy('state').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: ['state'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.getBySel(`"Drill by: ${chartName}-modal"`).as('drillByModal');
cy.get('@drillByModal')
.find('.draggable-trigger')
.should('contain', chartName);
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'state');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
// further drill
cy.get(`[data-test="drill-by-chart"] canvas`).then($canvas => {
// click 'other'
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger(
'mouseover',
drillClickCoordinates[1][0],
drillClickCoordinates[1][1],
);
cy.wrap($canvas).rightclick(
drillClickCoordinates[1][0],
drillClickCoordinates[1][1],
);
drillBy(furtherDrillDimension).then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: [furtherDrillDimension],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
{
clause: 'WHERE',
comparator: 'other',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'state',
},
],
});
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
// undo - back to drill by state
interceptV1ChartData('drillByUndo');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'state (other)')
.and('contain', furtherDrillDimension)
.contains('state (other)')
.click();
cy.wait('@drillByUndo').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: ['state'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('not.contain', 'state (other)')
.and('not.contain', furtherDrillDimension)
.and('contain', 'state');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
});
});
};
describe('Drill by modal', () => {
beforeEach(() => {
closeModal();
});
before(() => {
interceptDrillInfo();
cy.visit(SUPPORTED_CHARTS_DASHBOARD);
});
describe('Modal actions + Table', () => {
before(() => {
closeModal();
interceptDrillInfo();
openTopLevelTab('Tier 1');
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
});
it.only('opens the modal from the context menu', () => {
openTableContextMenu('boy');
drillBy('state').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: ['state'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.getBySel('"Drill by: Table-modal"').as('drillByModal');
cy.get('@drillByModal')
.find('.draggable-trigger')
.should('contain', 'Drill by: Table');
cy.get('@drillByModal')
.find('[data-test="metadata-bar"]')
.should('be.visible');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'state');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('contain', 'state')
.and('contain', 'sum__num');
// further drilling
openTableContextMenu('CA', '[data-test="drill-by-chart"]');
drillBy('name').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: ['name'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
{
clause: 'WHERE',
comparator: 'CA',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'state',
},
],
});
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('not.contain', 'state')
.and('contain', 'name')
.and('contain', 'sum__num');
// undo - back to drill by state
interceptV1ChartData('drillByUndo');
interceptFormDataKey();
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'state (CA)')
.and('contain', 'name')
.contains('state (CA)')
.click();
cy.wait('@drillByUndo').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: ['state'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('not.contain', 'name')
.and('contain', 'state')
.and('contain', 'sum__num');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('not.contain', 'state (CA)')
.and('not.contain', 'name')
.and('contain', 'state');
cy.get('@drillByModal')
.find('[data-test="drill-by-display-toggle"]')
.contains('Table')
.click();
cy.getBySel('drill-by-chart').should('not.exist');
cy.get('@drillByModal')
.find('[data-test="drill-by-results-table"]')
.should('be.visible');
cy.wait('@formDataKey').then(intercept => {
cy.get('@drillByModal')
.contains('Edit chart')
.should('have.attr', 'href')
.and(
'contain',
`/explore/?form_data_key=${intercept.response?.body?.key}`,
);
});
});
});
describe('Tier 1 charts', () => {
before(() => {
closeModal();
interceptDrillInfo();
openTopLevelTab('Tier 1');
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
});
it('Pivot Table', () => {
openTableContextMenu('boy', "[data-test-viz-type='pivot_table_v2']");
drillBy('name').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupbyRows: ['state'],
groupbyColumns: ['name'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.getBySel('"Drill by: Pivot Table-modal"').as('drillByModal');
cy.get('@drillByModal')
.find('.draggable-trigger')
.should('contain', 'Drill by: Pivot Table');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'name');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('contain', 'state')
.and('contain', 'name')
.and('contain', 'sum__num')
.and('not.contain', 'Gender');
openTableContextMenu('CA', '[data-test="drill-by-chart"]');
drillBy('ds').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupbyColumns: ['name'],
groupbyRows: ['ds'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
{
clause: 'WHERE',
comparator: 'CA',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'state',
},
],
});
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('contain', 'name')
.and('contain', 'ds')
.and('contain', 'sum__num')
.and('not.contain', 'state');
interceptV1ChartData('drillByUndo');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'name (CA)')
.and('contain', 'ds')
.contains('name (CA)')
.click();
cy.wait('@drillByUndo').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupbyRows: ['state'],
groupbyColumns: ['name'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('not.contain', 'ds')
.and('contain', 'state')
.and('contain', 'name')
.and('contain', 'sum__num');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('not.contain', 'name (CA)')
.and('not.contain', 'ds')
.and('contain', 'name');
});
it('Line chart', () => {
testEchart('echarts_timeseries_line', 'Line Chart', [
[85, 93],
[85, 93],
]);
});
it('Area Chart', () => {
testEchart('echarts_area', 'Area Chart', [
[85, 93],
[85, 93],
]);
});
it('Scatter Chart', () => {
testEchart('echarts_timeseries_scatter', 'Scatter Chart', [
[85, 93],
[85, 93],
]);
});
it.skip('Bar Chart', () => {
testEchart('echarts_timeseries_bar', 'Bar Chart', [
[85, 94],
[490, 68],
]);
});
it('Pie Chart', () => {
testEchart('pie', 'Pie Chart', [
[243, 167],
[534, 248],
]);
});
});
describe('Tier 2 charts', () => {
before(() => {
closeModal();
interceptDrillInfo();
openTopLevelTab('Tier 2');
SUPPORTED_TIER2_CHARTS.forEach(waitForChartLoad);
});
it('Box Plot Chart', () => {
testEchart(
'box_plot',
'Box Plot Chart',
[
[139, 277],
[787, 441],
],
'ds',
);
});
it('Generic Chart', () => {
testEchart('echarts_timeseries', 'Generic Chart', [
[85, 93],
[85, 93],
]);
});
it('Smooth Line Chart', () => {
testEchart('echarts_timeseries_smooth', 'Smooth Line Chart', [
[85, 93],
[85, 93],
]);
});
it('Step Line Chart', () => {
testEchart('echarts_timeseries_step', 'Step Line Chart', [
[85, 93],
[85, 93],
]);
});
it('Funnel Chart', () => {
testEchart('funnel', 'Funnel Chart', [
[154, 80],
[421, 39],
]);
});
it('Gauge Chart', () => {
testEchart('gauge_chart', 'Gauge Chart', [
[151, 95],
[300, 143],
]);
});
it.skip('Radar Chart', () => {
testEchart('radar', 'Radar Chart', [
[182, 49],
[423, 91],
]);
});
it('Treemap V2 Chart', () => {
testEchart('treemap_v2', 'Treemap V2 Chart', [
[145, 84],
[220, 105],
]);
});
it.skip('Mixed Chart', () => {
cy.get('[data-test-viz-type="mixed_timeseries"] canvas').then($canvas => {
// click 'boy'
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger('mouseover', 85, 93);
cy.wrap($canvas).rightclick(85, 93);
drillBy('name').then(intercepted => {
const { queries } = intercepted.request.body;
expect(queries[0].columns).to.eql(['name']);
expect(queries[0].filters).to.eql([
{ col: 'gender', op: '==', val: 'boy' },
]);
expect(queries[1].columns).to.eql(['state']);
expect(queries[1].filters).to.eql([]);
});
cy.getBySel('"Drill by: Mixed Chart-modal"').as('drillByModal');
cy.get('@drillByModal')
.find('.draggable-trigger')
.should('contain', 'Mixed Chart');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'name');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
// further drill
cy.get(`[data-test="drill-by-chart"] canvas`).then($canvas => {
// click second query
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger('mouseover', 261, 114);
cy.wrap($canvas).rightclick(261, 114);
drillBy('ds').then(intercepted => {
const { queries } = intercepted.request.body;
expect(queries[0].columns).to.eql(['name']);
expect(queries[0].filters).to.eql([
{ col: 'gender', op: '==', val: 'boy' },
]);
expect(queries[1].columns).to.eql(['ds']);
expect(queries[1].filters).to.eql([
{ col: 'state', op: '==', val: 'other' },
]);
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
// undo - back to drill by state
interceptV1ChartData('drillByUndo');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'name (other)')
.and('contain', 'ds')
.contains('name (other)')
.click();
cy.wait('@drillByUndo').then(intercepted => {
const { queries } = intercepted.request.body;
expect(queries[0].columns).to.eql(['name']);
expect(queries[0].filters).to.eql([
{ col: 'gender', op: '==', val: 'boy' },
]);
expect(queries[1].columns).to.eql(['state']);
expect(queries[1].filters).to.eql([]);
});
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('not.contain', 'name (other)')
.and('not.contain', 'ds')
.and('contain', 'name');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
});
});
});
});
});

View File

@@ -1,186 +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.
*/
// ***********************************************
// Tests for setting controls in the UI
// ***********************************************
import { interceptChart, setSelectSearchInput } from 'cypress/utils';
describe('Datasource control', () => {
const newMetricName = `abc${Date.now()}`;
it('should allow edit dataset', () => {
interceptChart({ legacy: false }).as('chartData');
cy.visitChartByName('Num Births Trend');
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[data-test="datasource-menu-trigger"]').click();
cy.get('[data-test="edit-dataset"]').click();
cy.get('[data-test="edit-dataset-tabs"]').within(() => {
cy.contains('Metrics').click();
});
// create new metric
cy.get('[data-test="crud-add-table-item"]', { timeout: 10000 }).click();
cy.wait(1000);
cy.get('.ant-table-body [data-test="textarea-editable-title-input"]')
.first()
.click();
cy.get('.ant-table-body [data-test="textarea-editable-title-input"]')
.first()
.focus();
cy.focused().clear({ force: true });
cy.focused().type(`${newMetricName}{enter}`, { force: true });
cy.get('[data-test="datasource-modal-save"]').click();
cy.get('.ant-modal-confirm-btns button').contains('OK').click();
// select new metric
cy.get('[data-test=metrics]')
.contains('Drop columns/metrics here or click')
.click();
cy.get('input[aria-label="Select saved metrics"]')
.should('exist')
.then($input => {
setSelectSearchInput($input, newMetricName);
});
// delete metric
cy.get('[data-test="datasource-menu-trigger"]').click();
cy.get('[data-test="edit-dataset"]').click();
cy.get('.ant-modal-content').within(() => {
cy.get('[data-test="collection-tab-Metrics"]')
.contains('Metrics')
.click();
});
cy.get(`[data-test="textarea-editable-title-input"]`)
.contains(newMetricName)
.closest('tr')
.find('[data-test="crud-delete-icon"]')
.click();
cy.get('[data-test="datasource-modal-save"]').click();
cy.get('.ant-modal-confirm-btns button').contains('OK').click();
cy.get('[data-test="metrics"]').contains(newMetricName).should('not.exist');
});
});
describe('Color scheme control', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('chartData');
cy.visitChartByName('Num Births Trend');
cy.verifySliceSuccess({ waitAlias: '@chartData' });
});
it('should show color options with and without tooltips', () => {
cy.get('#controlSections-tab-CUSTOMIZE').click();
cy.get('.ant-select-selection-item .color-scheme-label').contains(
'Superset Colors',
);
cy.get('.ant-select-selection-item .color-scheme-label').trigger(
'mouseover',
);
cy.get('.color-scheme-tooltip').should('be.visible');
cy.get('.color-scheme-tooltip').contains('Superset Colors');
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
cy.get('.color-scheme-label')
.contains('Superset Colors')
.trigger('mouseover');
cy.get('.color-scheme-label')
.contains('Superset Colors')
.trigger('mouseout');
cy.focused().type('lyftColors');
cy.getBySel('lyftColors').should('exist');
cy.getBySel('lyftColors').trigger('mouseover', { force: true });
cy.get('.color-scheme-tooltip').should('not.be.visible');
});
});
describe('VizType control', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('tableChartData');
interceptChart({ legacy: false }).as('bigNumberChartData');
});
it('Can change vizType', () => {
cy.visitChartByName('Daily Totals');
cy.verifySliceSuccess({ waitAlias: '@tableChartData' });
cy.contains('View all charts').click();
cy.get('.ant-modal-content').within(() => {
cy.get('button').contains('KPI').click(); // change categories
cy.get('[role="button"]').contains('Big Number').click();
cy.get('button').contains('Select').click();
});
cy.get('button[data-test="run-query-button"]').click();
cy.verifySliceSuccess({
waitAlias: '@bigNumberChartData',
});
});
});
describe('Test datatable', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('tableChartData');
interceptChart({ legacy: false }).as('lineChartData');
cy.visitChartByName('Daily Totals');
});
it('Data Pane opens and loads results', () => {
cy.contains('Results').click();
cy.get('[data-test="row-count-label"]').contains('26 rows');
cy.get('.ant-empty-description').should('not.exist');
});
it('Datapane loads view samples', () => {
cy.intercept(
'**/datasource/samples?force=false&datasource_type=table&datasource_id=*',
).as('Samples');
cy.contains('Samples').click();
cy.wait('@Samples');
cy.get('.ant-tabs-tab-active').contains('Samples');
cy.get('[data-test="row-count-label"]').contains('1k rows');
cy.get('.ant-empty-description').should('not.exist');
});
});
describe('Groupby control', () => {
it('Set groupby', () => {
interceptChart({ legacy: false }).as('chartData');
cy.visitChartByName('Num Births Trend');
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[data-test=groupby]')
.contains('Drop columns here or click')
.click();
cy.get('[id="adhoc-metric-edit-tabs-tab-simple"]').click();
cy.get('input[aria-label="Column"]').click();
cy.get('input[aria-label="Column"]').type('state{enter}');
cy.get('[data-test="ColumnEdit#save"]').contains('Save').click();
cy.get('button[data-test="run-query-button"]').click();
cy.verifySliceSuccess({ waitAlias: '@chartData' });
});
});

View File

@@ -33,6 +33,7 @@ module.exports = {
'^@superset-ui/([^/]+)$': '<rootDir>/node_modules/@superset-ui/$1/src',
// mapping @apache-superset/core to local package
'^@apache-superset/core$': '<rootDir>/packages/superset-core/src',
'^@apache-superset/core/(.*)$': '<rootDir>/packages/superset-core/src/$1',
},
testEnvironment: '<rootDir>/spec/helpers/jsDomWithFetchAPI.ts',
modulePathIgnorePatterns: ['<rootDir>/packages/generator-superset'],

View File

@@ -54,6 +54,8 @@
"@visx/scale": "^3.5.0",
"@visx/tooltip": "^3.0.0",
"@visx/xychart": "^3.5.1",
"ag-grid-community": "34.2.0",
"ag-grid-react": "34.2.0",
"antd": "^5.24.6",
"chrono-node": "^2.7.8",
"classnames": "^2.2.5",
@@ -64,7 +66,6 @@
"dom-to-image-more": "^3.6.0",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
"emotion-rgba": "0.0.12",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
"fast-glob": "^3.3.2",
"fs-extra": "^11.2.0",
@@ -160,6 +161,7 @@
"@hot-loader/react-dom": "^17.0.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.49.1",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",
"@storybook/addon-essentials": "8.1.11",
@@ -191,7 +193,6 @@
"@types/react-resizable": "^3.0.8",
"@types/react-router-dom": "^5.3.3",
"@types/react-transition-group": "^4.4.12",
"@types/react-ultimate-pagination": "^1.2.4",
"@types/react-virtualized-auto-sizer": "^1.0.8",
"@types/react-window": "^1.8.8",
"@types/redux-localstorage": "^1.0.8",
@@ -10111,6 +10112,22 @@
"url": "https://opencollective.com/unts"
}
},
"node_modules/@playwright/test": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
"integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@pnpm/config.env-replace": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz",
@@ -16078,16 +16095,6 @@
"@types/react": "*"
}
},
"node_modules/@types/react-ultimate-pagination": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@types/react-ultimate-pagination/-/react-ultimate-pagination-1.2.4.tgz",
"integrity": "sha512-1y9jLt3KEFGzFD+99qVpJUI/Eu4cEx48sClB957eGoepWRLVVi+r1UBj0157Mg7HYZcIF4I1/qGZYaBBQWhaqg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-virtualized-auto-sizer": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.8.tgz",
@@ -18708,27 +18715,27 @@
}
},
"node_modules/ag-charts-types": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-12.0.2.tgz",
"integrity": "sha512-AWM1Y+XW+9VMmV3AbzdVEnreh/I2C9Pmqpc2iLmtId3Xbvmv7O56DqnuDb9EXjK5uPxmyUerTP+utL13UGcztw==",
"version": "12.2.0",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-12.2.0.tgz",
"integrity": "sha512-d2qQrQirt9wP36YW5HPuOvXsiajyiFnr1CTsoCbs02bavPDz7Lk2jHp64+waM4YKgXb3GN7gafbBI9Qgk33BmQ==",
"license": "MIT"
},
"node_modules/ag-grid-community": {
"version": "34.0.2",
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-34.0.2.tgz",
"integrity": "sha512-hVJp5vrmwHRB10YjfSOVni5YJkO/v+asLjT72S4YnIFSx8lAgyPmByNJgtojk1aJ5h6Up93jTEmGDJeuKiWWLA==",
"version": "34.2.0",
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-34.2.0.tgz",
"integrity": "sha512-peS7THEMYwpIrwLQHmkRxw/TlOnddD/F5A88RqlBxf8j+WqVYRWMOOhU5TqymGcha7z2oZ8IoL9ROl3gvtdEjg==",
"license": "MIT",
"dependencies": {
"ag-charts-types": "12.0.2"
"ag-charts-types": "12.2.0"
}
},
"node_modules/ag-grid-react": {
"version": "34.0.2",
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-34.0.2.tgz",
"integrity": "sha512-1KBXkTvwtZiYVlSuDzBkiqfHjZgsATOmpLZdAtdmsCSOOOEWai0F9zHHgBuHfyciAE4nrbQWfojkx8IdnwsKFw==",
"version": "34.2.0",
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-34.2.0.tgz",
"integrity": "sha512-dLKFw6hz75S0HLuZvtcwjm+gyiI4gXVzHEu7lWNafWAX0mb8DhogEOP5wbzAlsN6iCfi7bK/cgZImZFjenlqwg==",
"license": "MIT",
"dependencies": {
"ag-grid-community": "34.0.2",
"ag-grid-community": "34.2.0",
"prop-types": "^15.8.1"
},
"peerDependencies": {
@@ -24477,12 +24484,6 @@
"node": ">= 4"
}
},
"node_modules/emotion-rgba": {
"version": "0.0.12",
"resolved": "https://registry.npmjs.org/emotion-rgba/-/emotion-rgba-0.0.12.tgz",
"integrity": "sha512-lvtZ52BWisYDtis+HctQMkxcHwmFbzTiZhgMJGFfWXLsBYEzthfKE7nlysOiUwmmAdTM/8YBAPfwQ4MEDwiaWw==",
"license": "MIT"
},
"node_modules/encodable": {
"version": "0.7.8",
"resolved": "https://registry.npmjs.org/encodable/-/encodable-0.7.8.tgz",
@@ -45535,6 +45536,53 @@
"dev": true,
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
"integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
"integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/png-async": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/png-async/-/png-async-0.9.4.tgz",
@@ -60642,7 +60690,7 @@
},
"packages/superset-core": {
"name": "@apache-superset/core",
"version": "0.0.1-rc2",
"version": "0.0.1-rc4",
"license": "ISC",
"devDependencies": {
"@babel/cli": "^7.26.4",
@@ -60652,7 +60700,8 @@
"@babel/preset-typescript": "^7.26.0",
"@types/react": "^17.0.83",
"install": "^0.13.0",
"npm": "^11.1.0"
"npm": "^11.1.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"antd": "^5.24.6",
@@ -63338,10 +63387,10 @@
"version": "0.20.3",
"license": "Apache-2.0",
"dependencies": {
"@apache-superset/core": "*",
"@react-icons/all-files": "^4.1.0",
"@types/react": "*",
"lodash": "^4.17.21",
"prop-types": "^15.8.1"
"lodash": "^4.17.21"
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
@@ -63366,14 +63415,15 @@
"license": "Apache-2.0",
"dependencies": {
"@ant-design/icons": "^5.2.6",
"@apache-superset/core": "*",
"@babel/runtime": "^7.28.2",
"@fontsource/fira-code": "^5.2.6",
"@fontsource/inter": "^5.2.6",
"@types/json-bigint": "^1.0.4",
"@visx/responsive": "^3.12.0",
"ace-builds": "^1.43.1",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "34.0.2",
"ag-grid-community": "34.2.0",
"ag-grid-react": "34.2.0",
"brace": "^0.11.1",
"classnames": "^2.2.5",
"core-js": "^3.38.1",
@@ -65244,6 +65294,8 @@
"d3-array": "^1.2.4",
"d3-color": "^1.4.1",
"d3-scale": "^3.0.0",
"dayjs": "^1.11.13",
"handlebars": "^4.7.8",
"lodash": "^4.17.21",
"mousetrap": "^1.6.5",
"ngeohash": "^0.6.3",
@@ -65410,6 +65462,7 @@
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/dom": "^8.20.1",
@@ -65461,6 +65514,7 @@
"lodash": "^4.17.21"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"echarts": "*",
@@ -66638,6 +66692,7 @@
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"lodash": "^4.17.11",
@@ -67769,6 +67824,7 @@
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/dom": "^8.20.1",

View File

@@ -63,6 +63,11 @@
"plugins:release-conventional": "npm run prune && npm run plugins:build && lerna publish --conventional-commits --create-release github --yes",
"plugins:release-from-tag": "npm run prune && npm run plugins:build && lerna publish from-package --yes",
"plugins:storybook": "cd packages/superset-ui-demo && npm run storybook",
"playwright:test": "playwright test",
"playwright:ui": "playwright test --ui",
"playwright:headed": "playwright test --headed",
"playwright:debug": "playwright test --debug",
"playwright:report": "playwright show-report",
"prettier": "npm run _prettier -- --write",
"prettier-check": "npm run _prettier -- --check",
"prod": "npm run build",
@@ -122,6 +127,8 @@
"@visx/scale": "^3.5.0",
"@visx/tooltip": "^3.0.0",
"@visx/xychart": "^3.5.1",
"ag-grid-community": "34.2.0",
"ag-grid-react": "34.2.0",
"antd": "^5.24.6",
"chrono-node": "^2.7.8",
"classnames": "^2.2.5",
@@ -132,7 +139,6 @@
"dom-to-image-more": "^3.6.0",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
"emotion-rgba": "0.0.12",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
"fast-glob": "^3.3.2",
"fs-extra": "^11.2.0",
@@ -228,6 +234,7 @@
"@hot-loader/react-dom": "^17.0.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.49.1",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",
"@storybook/addon-essentials": "8.1.11",
@@ -259,7 +266,6 @@
"@types/react-resizable": "^3.0.8",
"@types/react-router-dom": "^5.3.3",
"@types/react-transition-group": "^4.4.12",
"@types/react-ultimate-pagination": "^1.2.4",
"@types/react-virtualized-auto-sizer": "^1.0.8",
"@types/react-window": "^1.8.8",
"@types/redux-localstorage": "^1.0.8",

View File

@@ -22,19 +22,6 @@ To add the package to Superset, go to the `superset-frontend` subdirectory in yo
npm i -S ../../<%= packageName %>
```
If your Superset plugin exists in the `superset-frontend` directory and you wish to resolve TypeScript errors about `@superset-ui/core` not being resolved correctly, add the following to your `tsconfig.json` file:
```
"references": [
{
"path": "../../packages/superset-ui-chart-controls"
},
{
"path": "../../packages/superset-ui-core"
}
]
```
You may also wish to add the following to the `include` array in `tsconfig.json` to make Superset types available to your plugin:
```

View File

@@ -1,44 +1,19 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationDir": "lib",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": false,
"jsx": "react",
"lib": [
"dom",
"esnext"
],
"module": "esnext",
"moduleResolution": "node",
"noEmitOnError": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"outDir": "lib",
"pretty": true,
"removeComments": false,
"strict": true,
"target": "es2015",
"useDefineForClassFields": false,
"composite": true,
"declarationMap": true,
"rootDir": "src",
"skipLibCheck": true,
"emitDeclarationOnly": true,
"resolveJsonModule": true,
"types": ["jest"],
"typeRoots": [
"./node_modules/@types"
]
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
"exclude": [
"lib",
"test"
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.test.*",
"src/**/*.stories.*"
],
"include": [
"src/**/*",
"types/**/*"
"references": [
{ "path": "../../packages/superset-core" },
{ "path": "../../packages/superset-ui-core" },
{ "path": "../../packages/superset-ui-chart-controls" }
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "@apache-superset/core",
"version": "0.0.1-rc3",
"version": "0.0.1-rc4",
"description": "This package contains UI elements, APIs, and utility functions used by Superset.",
"sideEffects": false,
"main": "lib/index.js",

View File

@@ -1,19 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationDir": "lib",
"outDir": "lib",
"strict": true,
"rootDir": "src",
"jsx": "preserve",
"baseUrl": ".",
"module": "esnext",
"moduleResolution": "node",
"skipLibCheck": true,
"target": "es2020",
"esModuleInterop": true
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*.ts*"],
"exclude": ["lib"]
"include": ["src/**/*", "types/**/*"],
"exclude": ["src/**/*.test.*", "src/**/*.stories.*"]
}

View File

@@ -24,10 +24,10 @@
"lib"
],
"dependencies": {
"@apache-superset/core": "*",
"@react-icons/all-files": "^4.1.0",
"@types/react": "*",
"lodash": "^4.17.21",
"prop-types": "^15.8.1"
"lodash": "^4.17.21"
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",

View File

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

View File

@@ -20,20 +20,32 @@ import { t } from '@superset-ui/core';
import { ControlPanelSectionConfig } from '../types';
export const matrixifyEnableSection: ControlPanelSectionConfig = {
label: t('Enable matrixify'),
label: t('Matrixify'),
expanded: true,
controlSetRows: [
[
{
name: 'matrixify_enabled',
name: 'matrixify_enable_horizontal_layout',
config: {
type: 'CheckboxControl',
label: t('Enable matrixify'),
label: t('Enable horizontal layout (columns)'),
description: t(
'Create matrix columns by placing charts side-by-side',
),
default: false,
renderTrigger: true,
},
},
],
[
{
name: 'matrixify_enable_vertical_layout',
config: {
type: 'CheckboxControl',
label: t('Enable vertical layout (rows)'),
description: t('Create matrix rows by stacking charts vertically'),
default: false,
renderTrigger: true,
description: t(
'Transform this chart into a matrix/grid of charts based on dimensions or metrics',
),
},
},
],
@@ -42,9 +54,11 @@ export const matrixifyEnableSection: ControlPanelSectionConfig = {
};
export const matrixifySection: ControlPanelSectionConfig = {
label: t('Matrixify'),
label: t('Cell layout & styling'),
expanded: false,
visibility: ({ controls }) => controls?.matrixify_enabled?.value === true,
visibility: ({ controls }) =>
controls?.matrixify_enable_vertical_layout?.value === true ||
controls?.matrixify_enable_horizontal_layout?.value === true,
controlSetRows: [
[
{
@@ -105,9 +119,10 @@ export const matrixifySection: ControlPanelSectionConfig = {
};
export const matrixifyRowSection: ControlPanelSectionConfig = {
label: t('Vertical layout'),
label: t('Vertical layout (rows)'),
expanded: false,
visibility: ({ controls }) => controls?.matrixify_enabled?.value === true,
visibility: ({ controls }) =>
controls?.matrixify_enable_vertical_layout?.value === true,
controlSetRows: [
['matrixify_show_row_labels'],
['matrixify_mode_rows'],
@@ -118,13 +133,14 @@ export const matrixifyRowSection: ControlPanelSectionConfig = {
['matrixify_topn_metric_rows'],
['matrixify_topn_order_rows'],
],
tabOverride: 'data',
tabOverride: 'matrixify',
};
export const matrixifyColumnSection: ControlPanelSectionConfig = {
label: t('Horizontal layout'),
label: t('Horizontal layout (columns)'),
expanded: false,
visibility: ({ controls }) => controls?.matrixify_enabled?.value === true,
visibility: ({ controls }) =>
controls?.matrixify_enable_horizontal_layout?.value === true,
controlSetRows: [
['matrixify_show_column_headers'],
['matrixify_mode_columns'],
@@ -135,5 +151,5 @@ export const matrixifyColumnSection: ControlPanelSectionConfig = {
['matrixify_topn_metric_columns'],
['matrixify_topn_order_columns'],
],
tabOverride: 'data',
tabOverride: 'matrixify',
};

View File

@@ -18,21 +18,49 @@
* under the License.
*/
import { t } from '@superset-ui/core';
import { t, validateNonEmpty } from '@superset-ui/core';
import { SharedControlConfig } from '../types';
import { dndAdhocMetricControl } from './dndControls';
import { defineSavedMetrics } from '../utils';
/**
* Matrixify control definitions
* Controls for transforming charts into matrix/grid layouts
*/
// Utility function to check if matrixify controls should be visible
const isMatrixifyVisible = (
controls: any,
axis: 'rows' | 'columns',
mode?: 'metrics' | 'dimensions',
selectionMode?: 'members' | 'topn',
) => {
const layoutControl = `matrixify_enable_${axis === 'rows' ? 'vertical' : 'horizontal'}_layout`;
const modeControl = `matrixify_mode_${axis}`;
const selectionModeControl = `matrixify_dimension_selection_mode_${axis}`;
const isLayoutEnabled = controls?.[layoutControl]?.value === true;
if (!isLayoutEnabled) return false;
if (mode) {
const isModeMatch = controls?.[modeControl]?.value === mode;
if (!isModeMatch) return false;
if (selectionMode && mode === 'dimensions') {
return controls?.[selectionModeControl]?.value === selectionMode;
}
}
return true;
};
// Initialize the controls object that will be populated dynamically
const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
// Dynamically add axis-specific controls (rows and columns)
['columns', 'rows'].forEach(axisParam => {
const axis = axisParam; // Capture the value in a local variable
(['columns', 'rows'] as const).forEach(axisParam => {
const axis: 'rows' | 'columns' = axisParam;
matrixifyControls[`matrixify_mode_${axis}`] = {
type: 'RadioButtonControl',
@@ -43,17 +71,18 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
['dimensions', t('Dimension members')],
],
renderTrigger: true,
tabOverride: 'matrixify',
visibility: ({ controls }) => isMatrixifyVisible(controls, axis),
};
matrixifyControls[`matrixify_${axis}`] = {
...dndAdhocMetricControl,
label: t(`Metrics`),
multi: true,
validators: [], // Not required
// description: t(`Select metrics for ${axis}`),
validators: [], // No validation - rely on visibility
renderTrigger: true,
visibility: ({ controls }) =>
controls?.[`matrixify_mode_${axis}`]?.value === 'metrics',
tabOverride: 'matrixify',
visibility: ({ controls }) => isMatrixifyVisible(controls, axis, 'metrics'),
};
// Combined dimension and values control
@@ -62,8 +91,9 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
label: t(`Dimension selection`),
description: t(`Select dimension and values`),
default: { dimension: '', values: [] },
validators: [], // Not required
validators: [], // No validation - rely on visibility
renderTrigger: true,
tabOverride: 'matrixify',
shouldMapStateToProps: (prevState, state) => {
// Recalculate when any relevant form_data field changes
const fieldsToCheck = [
@@ -82,24 +112,40 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
const getValue = (key: string, defaultValue?: any) =>
form_data?.[key] ?? controls?.[key]?.value ?? defaultValue;
const selectionMode = getValue(
`matrixify_dimension_selection_mode_${axis}`,
'members',
);
const isVisible = isMatrixifyVisible(controls, axis, 'dimensions');
// Validate dimension is selected when visible
const dimensionValidator = (value: any) => {
if (!value?.dimension) {
return t('Dimension is required');
}
return false;
};
// Additional validation for topN mode
const validators = isVisible
? [dimensionValidator, validateNonEmpty]
: [];
return {
datasource,
selectionMode: getValue(
`matrixify_dimension_selection_mode_${axis}`,
'members',
),
selectionMode,
topNMetric: getValue(`matrixify_topn_metric_${axis}`),
topNValue: getValue(`matrixify_topn_value_${axis}`),
topNOrder: getValue(`matrixify_topn_order_${axis}`),
formData: form_data,
validators,
};
},
visibility: ({ controls }) =>
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions',
isMatrixifyVisible(controls, axis, 'dimensions'),
};
// Dimension picker for TopN mode (just dimension, no values)
// NOTE: This is now handled by matrixify_dimension control, so hiding it
matrixifyControls[`matrixify_topn_dimension_${axis}`] = {
type: 'SelectControl',
label: t('Dimension'),
@@ -127,33 +173,67 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
['topn', t('Top n')],
],
renderTrigger: true,
tabOverride: 'matrixify',
visibility: ({ controls }) =>
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions',
isMatrixifyVisible(controls, axis, 'dimensions'),
};
// TopN controls
matrixifyControls[`matrixify_topn_value_${axis}`] = {
type: 'TextControl',
type: 'NumberControl',
label: t(`Number of top values`),
description: t(`How many top values to select`),
default: 10,
isInt: true,
validators: [],
renderTrigger: true,
tabOverride: 'matrixify',
visibility: ({ controls }) =>
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions' &&
controls?.[`matrixify_dimension_selection_mode_${axis}`]?.value ===
isMatrixifyVisible(controls, axis, 'dimensions', 'topn'),
mapStateToProps: ({ controls }) => {
const isVisible = isMatrixifyVisible(
controls,
axis,
'dimensions',
'topn',
);
return {
validators: isVisible ? [validateNonEmpty] : [],
};
},
};
matrixifyControls[`matrixify_topn_metric_${axis}`] = {
...dndAdhocMetricControl,
label: t(`Metric for ordering`),
multi: false,
validators: [], // Not required
validators: [],
description: t(`Metric to use for ordering Top N values`),
tabOverride: 'matrixify',
visibility: ({ controls }) =>
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions' &&
controls?.[`matrixify_dimension_selection_mode_${axis}`]?.value ===
isMatrixifyVisible(controls, axis, 'dimensions', 'topn'),
mapStateToProps: (state, controlState) => {
const { controls, datasource } = state;
const isVisible = isMatrixifyVisible(
controls,
axis,
'dimensions',
'topn',
);
const originalProps =
dndAdhocMetricControl.mapStateToProps?.(state, controlState) || {};
return {
...originalProps,
columns: datasource?.columns || [],
savedMetrics: defineSavedMetrics(datasource),
datasource,
datasourceType: datasource?.type,
validators: isVisible ? [validateNonEmpty] : [],
};
},
};
matrixifyControls[`matrixify_topn_order_${axis}`] = {
@@ -164,10 +244,10 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
['asc', t('Ascending')],
['desc', t('Descending')],
],
renderTrigger: true,
tabOverride: 'matrixify',
visibility: ({ controls }) =>
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions' &&
controls?.[`matrixify_dimension_selection_mode_${axis}`]?.value ===
'topn',
isMatrixifyVisible(controls, axis, 'dimensions', 'topn'),
};
});
@@ -213,15 +293,22 @@ matrixifyControls.matrixify_charts_per_row = {
!controls?.matrixify_fit_columns_dynamically?.value,
};
// Main enable control
matrixifyControls.matrixify_enabled = {
matrixifyControls.matrixify_enable_vertical_layout = {
type: 'CheckboxControl',
label: t('Enable matrixify'),
description: t(
'Transform this chart into a matrix/grid of charts based on dimensions or metrics',
),
label: t('Enable vertical layout (rows)'),
description: t('Create matrix rows by stacking charts vertically'),
default: false,
renderTrigger: true,
tabOverride: 'matrixify',
};
matrixifyControls.matrixify_enable_horizontal_layout = {
type: 'CheckboxControl',
label: t('Enable horizontal layout (columns)'),
description: t('Create matrix columns by placing charts side-by-side'),
default: false,
renderTrigger: true,
tabOverride: 'matrixify',
};
// Cell title control for Matrixify
@@ -234,8 +321,8 @@ matrixifyControls.matrixify_cell_title_template = {
default: '',
renderTrigger: true,
visibility: ({ controls }) =>
(controls?.matrixify_mode_rows?.value ||
controls?.matrixify_mode_columns?.value) !== undefined,
controls?.matrixify_enable_vertical_layout?.value === true ||
controls?.matrixify_enable_horizontal_layout?.value === true,
};
// Matrix display controls
@@ -245,9 +332,9 @@ matrixifyControls.matrixify_show_row_labels = {
description: t('Display labels for each row on the left side of the matrix'),
default: true,
renderTrigger: true,
tabOverride: 'matrixify',
visibility: ({ controls }) =>
(controls?.matrixify_mode_rows?.value ||
controls?.matrixify_mode_columns?.value) !== undefined,
controls?.matrixify_enable_vertical_layout?.value === true,
};
matrixifyControls.matrixify_show_column_headers = {
@@ -256,9 +343,9 @@ matrixifyControls.matrixify_show_column_headers = {
description: t('Display headers for each column at the top of the matrix'),
default: true,
renderTrigger: true,
tabOverride: 'matrixify',
visibility: ({ controls }) =>
(controls?.matrixify_mode_rows?.value ||
controls?.matrixify_mode_columns?.value) !== undefined,
controls?.matrixify_enable_horizontal_layout?.value === true,
};
export { matrixifyControls };

View File

@@ -17,11 +17,7 @@
* under the License.
*/
import { ensureIsArray, GenericDataType, ValueOf } from '@superset-ui/core';
import {
ControlPanelState,
isDataset,
isQueryResponse,
} from '@superset-ui/chart-controls';
import { ControlPanelState, isDataset, isQueryResponse } from '../types';
export function checkColumnType(
columnName: string,

View File

@@ -2,18 +2,8 @@
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": false,
"noEmit": true,
"rootDir": "."
},
"extends": "../../../tsconfig.json",
"include": [
"**/*",
"../types/**/*",
"../../../types/**/*"
],
"references": [
{
"path": ".."
}
]
"include": ["**/*", "../types/**/*", "../../../types/**/*"]
}

View File

@@ -1,22 +1,13 @@
{
"compilerOptions": {
"declarationDir": "lib",
"outDir": "lib",
"rootDir": "src"
},
"exclude": [
"lib",
"test"
],
"extends": "../../tsconfig.json",
"include": [
"src/**/*",
"types/**/*",
"../../types/**/*"
],
"compilerOptions": {
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["src/**/*.test.*", "src/**/*.stories.*"],
"references": [
{
"path": "../superset-ui-core"
}
{ "path": "../superset-core" },
{ "path": "../superset-ui-core" }
]
}

View File

@@ -24,14 +24,15 @@
"lib"
],
"dependencies": {
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@babel/runtime": "^7.28.2",
"@fontsource/fira-code": "^5.2.6",
"@fontsource/inter": "^5.2.6",
"@types/json-bigint": "^1.0.4",
"ace-builds": "^1.43.1",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "34.0.2",
"ag-grid-community": "34.2.0",
"ag-grid-react": "34.2.0",
"brace": "^0.11.1",
"classnames": "^2.2.5",
"csstype": "^3.1.3",

View File

@@ -276,10 +276,10 @@ export function generateMatrixifyGrid(
const cellFormData = generateCellFormData(
formData,
rowCount > 1 ? config.rows : null,
colCount > 1 ? config.columns : null,
rowCount > 1 ? row : null,
colCount > 1 ? col : null,
rowCount > 0 ? config.rows : null,
colCount > 0 ? config.columns : null,
rowCount > 0 ? row : null,
colCount > 0 ? col : null,
);
// Generate title using template if provided

View File

@@ -74,7 +74,8 @@ test('should create single group when fitting columns dynamically', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_fit_columns_dynamically: true,
matrixify_charts_per_row: 3,
matrixify_show_row_labels: true,
@@ -123,7 +124,8 @@ test('should create multiple groups when not fitting columns dynamically', () =>
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_fit_columns_dynamically: false,
matrixify_charts_per_row: 3,
matrixify_show_row_labels: true,
@@ -158,7 +160,8 @@ test('should handle exact division of columns', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_fit_columns_dynamically: false,
matrixify_charts_per_row: 2,
matrixify_show_row_labels: true,
@@ -186,7 +189,8 @@ test('should handle case where charts_per_row exceeds total columns', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_fit_columns_dynamically: false,
matrixify_charts_per_row: 5,
matrixify_show_row_labels: true,
@@ -216,7 +220,8 @@ test('should show headers for each group when wrapping occurs', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_fit_columns_dynamically: false,
matrixify_charts_per_row: 2,
matrixify_show_row_labels: true,
@@ -250,7 +255,8 @@ test('should show headers only on first row when not wrapping', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_fit_columns_dynamically: true, // No wrapping
matrixify_show_row_labels: true,
matrixify_show_column_headers: true,
@@ -279,7 +285,8 @@ test('should hide headers when disabled', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_show_row_labels: false,
matrixify_show_column_headers: false,
};
@@ -306,7 +313,8 @@ test('should place cells correctly in wrapped layout', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_fit_columns_dynamically: false,
matrixify_charts_per_row: 2,
matrixify_show_row_labels: true,
@@ -336,7 +344,8 @@ test('should handle null grid gracefully', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
};
const { container } = renderWithTheme(
@@ -357,7 +366,8 @@ test('should handle empty grid gracefully', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
};
const { container } = renderWithTheme(
@@ -381,7 +391,8 @@ test('should use default values for missing configuration', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
// Missing optional configurations
};

View File

@@ -128,9 +128,13 @@ function MatrixifyGridRenderer({
[formData],
);
// Determine layout parameters
const showRowLabels = formData.matrixify_show_row_labels ?? true;
const showColumnHeaders = formData.matrixify_show_column_headers ?? true;
// Determine layout parameters - only show headers/labels if layout is enabled
const showRowLabels =
formData.matrixify_enable_vertical_layout === true &&
(formData.matrixify_show_row_labels ?? true);
const showColumnHeaders =
formData.matrixify_enable_horizontal_layout === true &&
(formData.matrixify_show_column_headers ?? true);
const rowHeight = formData.matrixify_row_height || DEFAULT_ROW_HEIGHT;
const fitColumnsDynamically =
formData.matrixify_fit_columns_dynamically ?? true;

View File

@@ -26,6 +26,7 @@ interface LookupTable {
export interface ExampleImage {
url: string;
urlDark?: string;
caption?: string;
}
@@ -38,6 +39,7 @@ export interface ChartMetadataConfig {
enableNoResults?: boolean;
supportedAnnotationTypes?: string[];
thumbnail: string;
thumbnailDark?: string;
useLegacyApi?: boolean;
behaviors?: Behavior[];
exampleGallery?: ExampleImage[];
@@ -71,6 +73,8 @@ export default class ChartMetadata {
thumbnail: string;
thumbnailDark?: string;
useLegacyApi: boolean;
behaviors: Behavior[];
@@ -107,6 +111,7 @@ export default class ChartMetadata {
description = '',
supportedAnnotationTypes = [],
thumbnail,
thumbnailDark,
useLegacyApi = false,
behaviors = [],
datasourceCount = 1,
@@ -138,6 +143,7 @@ export default class ChartMetadata {
);
this.supportedAnnotationTypes = supportedAnnotationTypes;
this.thumbnail = thumbnail;
this.thumbnailDark = thumbnailDark;
this.useLegacyApi = useLegacyApi;
this.behaviors = behaviors;
this.datasourceCount = datasourceCount;

View File

@@ -37,10 +37,11 @@ test('isMatrixifyEnabled should return false when no matrixify configuration exi
expect(isMatrixifyEnabled(formData)).toBe(false);
});
test('isMatrixifyEnabled should return false when matrixify_enabled is false', () => {
test('isMatrixifyEnabled should return false when layout controls are false', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: false,
matrixify_enable_vertical_layout: false,
matrixify_enable_horizontal_layout: false,
matrixify_mode_rows: 'metrics',
matrixify_rows: [createMetric('Revenue')],
} as MatrixifyFormData;
@@ -51,7 +52,7 @@ test('isMatrixifyEnabled should return false when matrixify_enabled is false', (
test('isMatrixifyEnabled should return true for valid metrics mode configuration', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'metrics',
matrixify_mode_columns: 'metrics',
matrixify_rows: [createMetric('Revenue')],
@@ -64,7 +65,7 @@ test('isMatrixifyEnabled should return true for valid metrics mode configuration
test('isMatrixifyEnabled should return true for valid dimensions mode configuration', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'dimensions',
matrixify_dimension_rows: { dimension: 'country', values: ['USA'] },
@@ -77,7 +78,7 @@ test('isMatrixifyEnabled should return true for valid dimensions mode configurat
test('isMatrixifyEnabled should return true for mixed mode configuration', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'metrics',
matrixify_mode_columns: 'dimensions',
matrixify_rows: [createMetric('Revenue')],
@@ -90,7 +91,7 @@ test('isMatrixifyEnabled should return true for mixed mode configuration', () =>
test('isMatrixifyEnabled should return true for topn dimension selection mode', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'dimensions',
matrixify_dimension_rows: {
@@ -109,7 +110,7 @@ test('isMatrixifyEnabled should return true for topn dimension selection mode',
test('isMatrixifyEnabled should return false when both axes have empty metrics arrays', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'metrics',
matrixify_mode_columns: 'metrics',
matrixify_rows: [],
@@ -122,7 +123,7 @@ test('isMatrixifyEnabled should return false when both axes have empty metrics a
test('isMatrixifyEnabled should return false when both dimensions have empty values and no topn mode', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'dimensions',
matrixify_dimension_rows: { dimension: 'country', values: [] },
@@ -140,7 +141,7 @@ test('getMatrixifyConfig should return null when no matrixify configuration exis
test('getMatrixifyConfig should return valid config for metrics mode', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'metrics',
matrixify_mode_columns: 'metrics',
matrixify_rows: [createMetric('Revenue')],
@@ -158,7 +159,7 @@ test('getMatrixifyConfig should return valid config for metrics mode', () => {
test('getMatrixifyConfig should return valid config for dimensions mode', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'dimensions',
matrixify_dimension_rows: { dimension: 'country', values: ['USA'] },
@@ -182,7 +183,7 @@ test('getMatrixifyConfig should return valid config for dimensions mode', () =>
test('getMatrixifyConfig should handle topn selection mode', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'dimensions',
matrixify_dimension_rows: {
@@ -203,7 +204,8 @@ test('getMatrixifyConfig should handle topn selection mode', () => {
test('getMatrixifyValidationErrors should return empty array when matrixify is not enabled', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: false,
matrixify_enable_vertical_layout: false,
matrixify_enable_horizontal_layout: false,
} as MatrixifyFormData;
expect(getMatrixifyValidationErrors(formData)).toEqual([]);
@@ -212,7 +214,7 @@ test('getMatrixifyValidationErrors should return empty array when matrixify is n
test('getMatrixifyValidationErrors should return empty array when properly configured', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'metrics',
matrixify_mode_columns: 'metrics',
matrixify_rows: [createMetric('Revenue')],
@@ -225,7 +227,7 @@ test('getMatrixifyValidationErrors should return empty array when properly confi
test('getMatrixifyValidationErrors should return error when enabled but no configuration exists', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
} as MatrixifyFormData;
const errors = getMatrixifyValidationErrors(formData);
@@ -235,7 +237,7 @@ test('getMatrixifyValidationErrors should return error when enabled but no confi
test('getMatrixifyValidationErrors should return error when metrics mode has no metrics', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'metrics',
matrixify_rows: [],
matrixify_columns: [],
@@ -261,7 +263,7 @@ test('should handle empty form data object', () => {
test('should handle partial configuration with one axis only', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'metrics',
matrixify_rows: [createMetric('Revenue')],
// No columns configuration

View File

@@ -96,8 +96,9 @@ export interface MatrixifyAxisConfig {
* Complete Matrixify configuration in form data
*/
export interface MatrixifyFormData {
// Enable/disable matrixify functionality
matrixify_enabled?: boolean;
// Layout enable controls
matrixify_enable_vertical_layout?: boolean;
matrixify_enable_horizontal_layout?: boolean;
// Row axis configuration
matrixify_mode_rows?: MatrixifyMode;
@@ -177,8 +178,12 @@ export function getMatrixifyConfig(
* Check if Matrixify is enabled and properly configured
*/
export function isMatrixifyEnabled(formData: MatrixifyFormData): boolean {
// First check if matrixify is explicitly enabled via checkbox
if (!formData.matrixify_enabled) {
// Check if either vertical or horizontal layout is enabled
const hasVerticalLayout = formData.matrixify_enable_vertical_layout === true;
const hasHorizontalLayout =
formData.matrixify_enable_horizontal_layout === true;
if (!hasVerticalLayout && !hasHorizontalLayout) {
return false;
}
@@ -216,7 +221,11 @@ export function getMatrixifyValidationErrors(
const errors: string[] = [];
// Only validate if matrixify is enabled
if (!formData.matrixify_enabled) {
const hasVerticalLayout = formData.matrixify_enable_vertical_layout === true;
const hasHorizontalLayout =
formData.matrixify_enable_horizontal_layout === true;
if (!hasVerticalLayout && !hasHorizontalLayout) {
return errors;
}

View File

@@ -123,21 +123,33 @@ export function AsyncAceEditor(
const cssWorkerUrlPromise = import(
'ace-builds/src-min-noconflict/worker-css'
);
const javascriptWorkerUrlPromise = import(
'ace-builds/src-min-noconflict/worker-javascript'
);
const htmlWorkerUrlPromise = import(
'ace-builds/src-min-noconflict/worker-html'
);
const acequirePromise = import('ace-builds/src-min-noconflict/ace');
const [
{ default: ReactAceEditor },
{ config },
{ default: cssWorkerUrl },
{ default: javascriptWorkerUrl },
{ default: htmlWorkerUrl },
{ require: acequire },
] = await Promise.all([
reactAcePromise,
aceBuildsConfigPromise,
cssWorkerUrlPromise,
javascriptWorkerUrlPromise,
htmlWorkerUrlPromise,
acequirePromise,
]);
config.setModuleUrl('ace/mode/css_worker', cssWorkerUrl);
config.setModuleUrl('ace/mode/javascript_worker', javascriptWorkerUrl);
config.setModuleUrl('ace/mode/html_worker', htmlWorkerUrl);
await Promise.all(aceModules.map(x => aceModuleLoaders[x]()));

View File

@@ -0,0 +1,106 @@
/* eslint-disable import/first */
/**
* 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 { FC } from 'react';
import AceEditor, { IAceEditorProps } from 'react-ace';
import ace from 'ace-builds/src-noconflict/ace';
// Disable workers to avoid localhost loading issues
ace.config.set('useWorker', false);
// Import required modes and themes after ace is loaded
import 'ace-builds/src-min-noconflict/mode-handlebars';
import 'ace-builds/src-min-noconflict/mode-css';
import 'ace-builds/src-min-noconflict/mode-json';
import 'ace-builds/src-min-noconflict/mode-sql';
import 'ace-builds/src-min-noconflict/mode-markdown';
import 'ace-builds/src-min-noconflict/mode-javascript';
import 'ace-builds/src-min-noconflict/mode-html';
import 'ace-builds/src-noconflict/theme-github';
import 'ace-builds/src-noconflict/theme-monokai';
export type CodeEditorMode =
| 'handlebars'
| 'css'
| 'json'
| 'sql'
| 'markdown'
| 'javascript'
| 'html';
export type CodeEditorTheme = 'light' | 'dark';
export interface CodeEditorProps
extends Omit<IAceEditorProps, 'mode' | 'theme'> {
mode?: CodeEditorMode;
theme?: CodeEditorTheme;
name?: string;
}
export const CodeEditor: FC<CodeEditorProps> = ({
mode = 'handlebars',
theme = 'dark',
name,
width = '100%',
height = '300px',
value,
fontSize = 14,
showPrintMargin = true,
focus = true,
wrapEnabled = true,
highlightActiveLine = true,
editorProps = { $blockScrolling: true },
setOptions,
...rest
}: CodeEditorProps) => {
const editorName = name || Math.random().toString(36).substring(7);
const aceTheme = theme === 'light' ? 'github' : 'monokai';
return (
<AceEditor
mode={mode}
theme={aceTheme}
name={editorName}
height={height}
width={width}
value={value}
fontSize={fontSize}
showPrintMargin={showPrintMargin}
focus={focus}
editorProps={editorProps}
wrapEnabled={wrapEnabled}
highlightActiveLine={highlightActiveLine}
setOptions={{
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
enableSnippets: true,
showLineNumbers: true,
tabSize: 2,
showGutter: true,
fontFamily:
'Menlo, Consolas, Courier New, Ubuntu Mono, source-code-pro, Lucida Console, monospace',
...setOptions,
}}
{...rest}
/>
);
};
export default CodeEditor;

View File

@@ -60,7 +60,7 @@ const EmptyStateContainer = styled.div`
flex-direction: column;
width: 100%;
height: 100%;
color: ${theme.colorTextQuaternary};
color: ${theme.colorTextTertiary};
align-items: center;
justify-content: center;
padding: ${theme.sizeUnit * 4}px;
@@ -84,7 +84,7 @@ const EmptyStateContainer = styled.div`
const Title = styled.p<{ size: EmptyStateSize }>`
${({ theme, size }) => css`
font-size: ${size === 'large' ? theme.fontSizeLG : theme.fontSize}px;
color: ${theme.colorTextQuaternary};
color: ${theme.colorTextTertiary};
margin-top: ${size === 'large' ? theme.sizeUnit * 4 : theme.sizeUnit * 2}px;
font-weight: ${theme.fontWeightStrong};
`}
@@ -93,7 +93,7 @@ const Title = styled.p<{ size: EmptyStateSize }>`
const Description = styled.p<{ size: EmptyStateSize }>`
${({ theme, size }) => css`
font-size: ${size === 'large' ? theme.fontSize : theme.fontSizeSM}px;
color: ${theme.colorTextQuaternary};
color: ${theme.colorTextTertiary};
margin-top: ${theme.sizeUnit * 2}px;
`}
`;

View File

@@ -19,8 +19,10 @@
import { useEffect, useState, FunctionComponent } from 'react';
import { t, styled, css, useTheme } from '@superset-ui/core';
import dayjs from 'dayjs';
import { Dayjs } from 'dayjs';
import { extendedDayjs } from '../../utils/dates';
import 'dayjs/plugin/updateLocale';
import 'dayjs/plugin/calendar';
import { Icons } from '../Icons';
import type { LastUpdatedProps } from './types';
@@ -46,9 +48,7 @@ export const LastUpdated: FunctionComponent<LastUpdatedProps> = ({
update,
}) => {
const theme = useTheme();
const [timeSince, setTimeSince] = useState<dayjs.Dayjs>(
extendedDayjs(updatedAt),
);
const [timeSince, setTimeSince] = useState<Dayjs>(extendedDayjs(updatedAt));
useEffect(() => {
setTimeSince(() => extendedDayjs(updatedAt));

View File

@@ -1,37 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { Ellipsis } from './Ellipsis';
test('Ellipsis - click when the button is enabled', async () => {
const click = jest.fn();
render(<Ellipsis onClick={click} />);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(1);
});
test('Ellipsis - click when the button is disabled', async () => {
const click = jest.fn();
render(<Ellipsis onClick={click} disabled />);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(0);
});

View File

@@ -1,38 +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 classNames from 'classnames';
import { PaginationButtonProps } from './types';
export function Ellipsis({ disabled, onClick }: PaginationButtonProps) {
return (
<li className={classNames({ disabled })}>
<span
role="button"
tabIndex={disabled ? -1 : 0}
onClick={e => {
e.preventDefault();
if (!disabled) onClick(e);
}}
>
</span>
</li>
);
}

View File

@@ -1,47 +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 { render, screen, userEvent } from '@superset-ui/core/spec';
import { Item } from './Item';
test('Item - click when the item is not active', async () => {
const click = jest.fn();
render(
<Item onClick={click}>
<div data-test="test" />
</Item>,
);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('test')).toBeInTheDocument();
});
test('Item - click when the item is active', async () => {
const click = jest.fn();
render(
<Item onClick={click} active>
<div data-test="test" />
</Item>,
);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(0);
expect(screen.getByTestId('test')).toBeInTheDocument();
});

View File

@@ -1,45 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode } from 'react';
import classNames from 'classnames';
import { PaginationButtonProps } from './types';
interface PaginationItemButton extends PaginationButtonProps {
active?: boolean;
children: ReactNode;
}
export function Item({ active, children, onClick }: PaginationItemButton) {
return (
<li className={classNames({ active })}>
<span
role="button"
tabIndex={0}
aria-current={active ? 'page' : undefined}
onClick={e => {
e.preventDefault();
if (!active) onClick(e);
}}
>
{children}
</span>
</li>
);
}

View File

@@ -1,38 +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 classNames from 'classnames';
import { PaginationButtonProps } from './types';
export function Next({ disabled, onClick }: PaginationButtonProps) {
return (
<li className={classNames({ disabled })}>
<span
role="button"
tabIndex={disabled ? -1 : 0}
onClick={e => {
e.preventDefault();
if (!disabled) onClick(e);
}}
>
»
</span>
</li>
);
}

View File

@@ -1,37 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { Prev } from './Prev';
test('Prev - click when the button is enabled', async () => {
const click = jest.fn();
render(<Prev onClick={click} />);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(1);
});
test('Prev - click when the button is disabled', async () => {
const click = jest.fn();
render(<Prev onClick={click} disabled />);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(0);
});

View File

@@ -1,75 +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 { render, screen, cleanup } from '@superset-ui/core/spec';
import Wrapper from './Wrapper';
// Add cleanup after each test
afterEach(async () => {
cleanup();
// Wait for any pending effects to complete
await new Promise(resolve => setTimeout(resolve, 0));
});
jest.mock('./Next', () => ({
Next: () => <div data-test="next" />,
}));
jest.mock('./Prev', () => ({
Prev: () => <div data-test="prev" />,
}));
jest.mock('./Item', () => ({
Item: () => <div data-test="item" />,
}));
jest.mock('./Ellipsis', () => ({
Ellipsis: () => <div data-test="ellipsis" />,
}));
test('Pagination rendering correctly', async () => {
render(
<Wrapper>
<li data-test="test" />
</Wrapper>,
);
expect(screen.getByRole('navigation')).toBeInTheDocument();
expect(screen.getByTestId('test')).toBeInTheDocument();
});
test('Next attribute', async () => {
render(<Wrapper.Next onClick={jest.fn()} />);
expect(screen.getByTestId('next')).toBeInTheDocument();
});
test('Prev attribute', async () => {
render(<Wrapper.Next onClick={jest.fn()} />);
expect(screen.getByTestId('next')).toBeInTheDocument();
});
test('Item attribute', async () => {
render(
<Wrapper.Item onClick={jest.fn()}>
<></>
</Wrapper.Item>,
);
expect(screen.getByTestId('item')).toBeInTheDocument();
});
test('Ellipsis attribute', async () => {
render(<Wrapper.Ellipsis onClick={jest.fn()} />);
expect(screen.getByTestId('ellipsis')).toBeInTheDocument();
});

View File

@@ -1,90 +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 { styled } from '@superset-ui/core';
import { Next } from './Next';
import { Prev } from './Prev';
import { Item } from './Item';
import { Ellipsis } from './Ellipsis';
interface PaginationProps {
children: JSX.Element | JSX.Element[];
}
const PaginationList = styled.ul`
${({ theme }) => `
display: inline-block;
padding: ${theme.sizeUnit * 3}px;
li {
display: inline;
margin: 0 4px;
> span {
padding: 8px 12px;
text-decoration: none;
background-color: ${theme.colorBgContainer};
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadius}px;
color: ${theme.colorText};
&:hover,
&:focus {
z-index: 2;
color: ${theme.colorText};
background-color: ${theme.colorBgLayout};
}
}
&.disabled {
span {
background-color: transparent;
cursor: default;
&:focus {
outline: none;
}
}
}
&.active {
span {
z-index: 3;
color: ${theme.colorBgLayout};
cursor: default;
background-color: ${theme.colorPrimary};
&:focus {
outline: none;
}
}
}
}
`}
`;
function Pagination({ children }: PaginationProps) {
return <PaginationList role="navigation">{children}</PaginationList>;
}
Pagination.Next = Next;
Pagination.Prev = Prev;
Pagination.Item = Item;
Pagination.Ellipsis = Ellipsis;
export default Pagination;

View File

@@ -1,47 +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 Pagination from '@superset-ui/core/components/Pagination/Wrapper';
import {
createUltimatePagination,
ITEM_TYPES,
} from 'react-ultimate-pagination';
const ListViewPagination = createUltimatePagination({
WrapperComponent: Pagination,
itemTypeToComponent: {
[ITEM_TYPES.PAGE]: ({ value, isActive, onClick }) => (
<Pagination.Item active={isActive} onClick={onClick}>
{value}
</Pagination.Item>
),
[ITEM_TYPES.ELLIPSIS]: ({ isActive, onClick }) => (
<Pagination.Ellipsis disabled={isActive} onClick={onClick} />
),
[ITEM_TYPES.PREVIOUS_PAGE_LINK]: ({ isActive, onClick }) => (
<Pagination.Prev disabled={isActive} onClick={onClick} />
),
[ITEM_TYPES.NEXT_PAGE_LINK]: ({ isActive, onClick }) => (
<Pagination.Next disabled={isActive} onClick={onClick} />
),
[ITEM_TYPES.FIRST_PAGE_LINK]: () => null,
[ITEM_TYPES.LAST_PAGE_LINK]: () => null,
},
});
export default ListViewPagination;

View File

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

View File

@@ -100,3 +100,109 @@ test('Should the loading-indicator be visible during loading', () => {
expect(screen.getByTestId('loading-indicator')).toBeVisible();
});
test('Pagination controls should be rendered when pageSize is provided', () => {
const paginationProps = {
...defaultProps,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange: jest.fn(),
};
render(<TableCollection {...paginationProps} />);
expect(screen.getByRole('list')).toBeInTheDocument();
});
test('Pagination should call onPageChange when page is changed', async () => {
const onPageChange = jest.fn();
const paginationProps = {
...defaultProps,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange,
};
const { rerender } = render(<TableCollection {...paginationProps} />);
// Simulate pagination change
await screen.findByTitle('Next Page');
// Verify onPageChange would be called with correct arguments
// The actual AntD pagination will handle the click internally
expect(onPageChange).toBeDefined();
// Verify that re-rendering with new pageIndex works
rerender(<TableCollection {...paginationProps} pageIndex={1} />);
expect(screen.getByRole('list')).toBeInTheDocument();
});
test('Pagination callback should be stable across re-renders', () => {
const onPageChange = jest.fn();
const paginationProps = {
...defaultProps,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange,
};
const { rerender } = render(<TableCollection {...paginationProps} />);
// Re-render with same props
rerender(<TableCollection {...paginationProps} />);
// onPageChange should not have been called during re-render
expect(onPageChange).not.toHaveBeenCalled();
});
test('Should display correct page info when showRowCount is true', () => {
const paginationProps = {
...defaultProps,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange: jest.fn(),
showRowCount: true,
};
render(<TableCollection {...paginationProps} />);
// AntD pagination shows page info
expect(screen.getByText('1-2 of 3')).toBeInTheDocument();
});
test('Should not display page info when showRowCount is false', () => {
const paginationProps = {
...defaultProps,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange: jest.fn(),
showRowCount: false,
};
render(<TableCollection {...paginationProps} />);
// Page info should not be shown
expect(screen.queryByText('1-2 of 3')).not.toBeInTheDocument();
});
test('Bulk selection should work with pagination', () => {
const toggleRowSelected = jest.fn();
const toggleAllRowsSelected = jest.fn();
const selectionProps = {
...defaultProps,
bulkSelectEnabled: true,
selectedFlatRows: [],
toggleRowSelected,
toggleAllRowsSelected,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange: jest.fn(),
};
render(<TableCollection {...selectionProps} />);
// Check that selection checkboxes are rendered
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes.length).toBeGreaterThan(0);
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { HTMLAttributes, memo, useMemo } from 'react';
import { HTMLAttributes, memo, useMemo, useCallback } from 'react';
import {
ColumnInstance,
HeaderGroup,
@@ -47,15 +47,25 @@ interface TableCollectionProps<T extends object> {
toggleAllRowsSelected?: (value?: boolean) => void;
sticky?: boolean;
size?: TableSize;
pageIndex?: number;
pageSize?: number;
totalCount?: number;
onPageChange?: (page: number, pageSize: number) => void;
isPaginationSticky?: boolean;
showRowCount?: boolean;
}
const StyledTable = styled(Table)`
${({ theme }) => `
const StyledTable = styled(Table)<{
isPaginationSticky?: boolean;
showRowCount?: boolean;
}>`
${({ theme, isPaginationSticky, showRowCount }) => `
th.ant-column-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
opacity: 0;
font-size: ${theme.fontSizeXL}px;
@@ -72,15 +82,18 @@ const StyledTable = styled(Table)`
}
}
}
.ant-table-column-title {
line-height: initial;
}
.ant-table-row:hover {
.actions {
opacity: 1;
transition: opacity ease-in ${theme.motionDurationMid};
}
}
.ant-table-cell {
max-width: 320px;
font-feature-settings: 'tnum' 1;
@@ -91,10 +104,37 @@ const StyledTable = styled(Table)`
padding-left: ${theme.sizeUnit * 4}px;
white-space: nowrap;
}
.ant-table-placeholder .ant-table-cell {
border-bottom: 0;
}
&.ant-table-wrapper .ant-table-pagination.ant-pagination {
display: flex;
justify-content: center;
margin: ${showRowCount ? theme.sizeUnit * 4 : 0}px 0 ${showRowCount ? theme.sizeUnit * 14 : 0}px 0;
position: relative;
.ant-pagination-total-text {
color: ${theme.colorTextBase};
margin-inline-end: 0;
position: absolute;
top: ${theme.sizeUnit * 12}px;
}
${
isPaginationSticky &&
`
position: sticky;
bottom: 0;
left: 0;
z-index: 1;
background-color: ${theme.colorBgElevated};
padding: ${theme.sizeUnit * 2}px 0;
`
}
}
// Hotfix - antd doesn't apply background color to overflowing cells
& table {
background-color: ${theme.colorBgContainer};
@@ -116,13 +156,22 @@ function TableCollection<T extends object>({
prepareRow,
sticky,
size = TableSize.Middle,
pageIndex = 0,
pageSize = 25,
totalCount = 0,
onPageChange,
isPaginationSticky = false,
showRowCount = true,
}: TableCollectionProps<T>) {
const mappedColumns = mapColumns<T>(
columns,
headerGroups,
columnsForWrapText,
const mappedColumns = useMemo(
() => mapColumns<T>(columns, headerGroups, columnsForWrapText),
[columns, headerGroups, columnsForWrapText],
);
const mappedRows = useMemo(
() => mapRows(rows, prepareRow),
[rows, prepareRow],
);
const mappedRows = mapRows(rows, prepareRow);
const selectedRowKeys = useMemo(
() => selectedFlatRows?.map(row => row.id) || [],
@@ -147,6 +196,68 @@ function TableCollection<T extends object>({
toggleRowSelected,
toggleAllRowsSelected,
]);
const handlePaginationChange = useCallback(
(page: number, size: number) => {
const validPage = Math.max(0, (page || 1) - 1);
const validSize = size || pageSize;
onPageChange?.(validPage, validSize);
},
[pageSize, onPageChange],
);
const showTotalFunc = useCallback(
(total: number, range: [number, number]) =>
`${range[0]}-${range[1]} of ${total}`,
[],
);
const handleTableChange = useCallback(
(_pagination: any, _filters: any, sorter: SorterResult) => {
if (sorter && sorter.field) {
setSortBy?.([
{
id: sorter.field,
desc: sorter.order === 'descend',
},
] as SortingRule<T>[]);
}
},
[setSortBy],
);
const paginationConfig = useMemo(() => {
if (totalCount === 0) return false;
const config: any = {
pageSize,
size: 'default' as const,
showSizeChanger: false,
showQuickJumper: false,
align: 'center' as const,
showTotal: showRowCount ? showTotalFunc : undefined,
};
if (onPageChange) {
config.current = pageIndex + 1;
config.total = totalCount;
config.onChange = handlePaginationChange;
} else {
if (pageIndex > 0) config.defaultCurrent = pageIndex + 1;
config.total = totalCount;
}
return config;
}, [
pageSize,
totalCount,
showRowCount,
showTotalFunc,
pageIndex,
handlePaginationChange,
onPageChange,
]);
return (
<StyledTable
loading={loading}
@@ -155,12 +266,15 @@ function TableCollection<T extends object>({
data={mappedRows}
size={size}
data-test="listview-table"
pagination={false}
pagination={paginationConfig}
scroll={{ x: 'max-content' }}
tableLayout="auto"
rowKey="rowId"
rowSelection={rowSelection}
locale={{ emptyText: null }}
sortDirections={['ascend', 'descend', 'ascend']}
isPaginationSticky={isPaginationSticky}
showRowCount={showRowCount}
components={{
header: {
cell: (props: HTMLAttributes<HTMLTableCellElement>) => (
@@ -176,14 +290,7 @@ function TableCollection<T extends object>({
),
},
}}
onChange={(_pagination, _filters, sorter: SorterResult) => {
setSortBy?.([
{
id: sorter.field,
desc: sorter.order === 'descend',
},
] as SortingRule<T>[]);
}}
onChange={handleTableChange}
/>
);
}

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { render, screen, userEvent, waitFor } from '@superset-ui/core/spec';
import { TableView, TableViewProps } from '.';
const mockedProps: TableViewProps = {
@@ -30,6 +30,7 @@ const mockedProps: TableViewProps = {
{
accessor: 'age',
Header: 'Age',
sortable: true,
id: 'age',
},
{
@@ -78,10 +79,10 @@ test('should render the cells', () => {
test('should render the pagination', () => {
render(<TableView {...mockedProps} />);
expect(screen.getByRole('navigation')).toBeInTheDocument();
expect(screen.getAllByRole('button')).toHaveLength(4);
expect(screen.getByText('«')).toBeInTheDocument();
expect(screen.getByText('»')).toBeInTheDocument();
expect(screen.getByRole('list')).toBeInTheDocument();
expect(screen.getAllByRole('button')).toHaveLength(2);
expect(screen.getByTitle('Previous Page')).toBeInTheDocument();
expect(screen.getByTitle('Next Page')).toBeInTheDocument();
});
test('should show the row count by default', () => {
@@ -104,45 +105,63 @@ test('should NOT render the pagination when disabled', () => {
withPagination: false,
};
render(<TableView {...withoutPaginationProps} />);
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
expect(screen.queryByRole('list')).not.toBeInTheDocument();
});
test('should NOT render the pagination when fewer rows than page size', () => {
test('should render the pagination even when fewer rows than page size', () => {
const withoutPaginationProps = {
...mockedProps,
pageSize: 3,
};
render(<TableView {...withoutPaginationProps} />);
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
expect(screen.getByRole('list')).toBeInTheDocument();
});
test('should change page when « and » buttons are clicked', async () => {
test('should change page when pagination is clicked', async () => {
render(<TableView {...mockedProps} />);
const nextBtn = screen.getByText('»');
const prevBtn = screen.getByText('«');
await userEvent.click(nextBtn);
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.getByText('321')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('Kate')).toBeInTheDocument();
expect(screen.queryByText('Emily')).not.toBeInTheDocument();
await userEvent.click(prevBtn);
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.getByText('123')).toBeInTheDocument();
expect(screen.getByText('27')).toBeInTheDocument();
expect(screen.getByText('Emily')).toBeInTheDocument();
expect(screen.queryByText('Kate')).not.toBeInTheDocument();
const page2 = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2);
await waitFor(() => {
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.getByText('321')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('Kate')).toBeInTheDocument();
expect(screen.queryByText('Emily')).not.toBeInTheDocument();
});
const page1 = screen.getByRole('listitem', { name: '1' });
await userEvent.click(page1);
await waitFor(() => {
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.getByText('123')).toBeInTheDocument();
expect(screen.getByText('27')).toBeInTheDocument();
expect(screen.getByText('Emily')).toBeInTheDocument();
expect(screen.queryByText('Kate')).not.toBeInTheDocument();
});
});
test('should sort by age', async () => {
render(<TableView {...mockedProps} />);
await userEvent.click(screen.getAllByTestId('sort-header')[1]);
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('10');
await waitFor(() => {
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('10');
});
await userEvent.click(screen.getAllByTestId('sort-header')[1]);
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('27');
await waitFor(() => {
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('27');
});
});
test('should sort by initialSortBy DESC', () => {
@@ -208,3 +227,146 @@ test('should render the right wrap content text by columnsForWrapText', () => {
'ant-table-cell-ellipsis',
);
});
test('should handle server-side pagination', async () => {
const onServerPagination = jest.fn();
const serverPaginationProps = {
...mockedProps,
serverPagination: true,
onServerPagination,
totalCount: 10,
pageSize: 2,
};
render(<TableView {...serverPaginationProps} />);
// Click next page
const page2 = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2);
await waitFor(() => {
expect(onServerPagination).toHaveBeenCalledWith({
pageIndex: 1,
});
});
});
test('should handle server-side sorting', async () => {
const onServerPagination = jest.fn();
const serverPaginationProps = {
...mockedProps,
serverPagination: true,
onServerPagination,
};
render(<TableView {...serverPaginationProps} />);
// Click on sortable column
await userEvent.click(screen.getAllByTestId('sort-header')[0]);
await waitFor(() => {
expect(onServerPagination).toHaveBeenCalledWith({
pageIndex: 0,
sortBy: [{ id: 'id', desc: false }],
});
});
});
test('pagination callbacks should be stable across re-renders', () => {
const onServerPagination = jest.fn();
const serverPaginationProps = {
...mockedProps,
serverPagination: true,
onServerPagination,
totalCount: 10,
pageSize: 2,
};
const { rerender } = render(<TableView {...serverPaginationProps} />);
// Re-render with same props
rerender(<TableView {...serverPaginationProps} />);
// onServerPagination should not have been called during re-render
expect(onServerPagination).not.toHaveBeenCalled();
});
test('should scroll to top when scrollTopOnPagination is true', async () => {
const scrollToSpy = jest
.spyOn(window, 'scrollTo')
.mockImplementation(() => {});
const scrollProps = {
...mockedProps,
scrollTopOnPagination: true,
pageSize: 1,
};
render(<TableView {...scrollProps} />);
// Click next page
const page2 = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2);
await waitFor(() => {
expect(scrollToSpy).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
});
scrollToSpy.mockRestore();
});
test('should NOT scroll to top when scrollTopOnPagination is false', async () => {
const scrollToSpy = jest
.spyOn(window, 'scrollTo')
.mockImplementation(() => {});
const scrollProps = {
...mockedProps,
scrollTopOnPagination: false,
pageSize: 1,
};
render(<TableView {...scrollProps} />);
// Click next page
const page2 = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2);
await waitFor(() => {
expect(screen.getByText('321')).toBeInTheDocument();
});
expect(scrollToSpy).not.toHaveBeenCalled();
scrollToSpy.mockRestore();
});
test('should handle totalCount of 0 correctly', () => {
const emptyProps = {
...mockedProps,
data: [],
totalCount: 0,
};
render(<TableView {...emptyProps} />);
// Pagination should not be shown when totalCount is 0
expect(screen.queryByRole('list')).not.toBeInTheDocument();
});
test('should handle large datasets with pagination', () => {
const largeDataset = Array.from({ length: 100 }, (_, i) => ({
id: i,
age: 20 + i,
name: `Person ${i}`,
}));
const largeDataProps = {
...mockedProps,
data: largeDataset,
pageSize: 10,
};
render(<TableView {...largeDataProps} />);
// Should show only first page (10 items)
expect(screen.getAllByTestId('table-row')).toHaveLength(10);
// Should show pagination with correct page count
expect(screen.getByRole('list')).toBeInTheDocument();
expect(screen.getByText('1-10 of 100')).toBeInTheDocument();
});

View File

@@ -16,16 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import { memo, useEffect, useRef } from 'react';
import { memo, useEffect, useRef, useMemo, useCallback } from 'react';
import { isEqual } from 'lodash';
import { styled, t } from '@superset-ui/core';
import { styled } from '@superset-ui/core';
import { useFilters, usePagination, useSortBy, useTable } from 'react-table';
import { Empty } from '@superset-ui/core/components';
import Pagination from '@superset-ui/core/components/Pagination';
import TableCollection from '@superset-ui/core/components/TableCollection';
import { TableSize } from '@superset-ui/core/components/Table';
import { SortByType, ServerPagination } from './types';
const NOOP_SERVER_PAGINATION = () => {};
const DEFAULT_PAGE_SIZE = 10;
export enum EmptyWrapperType {
@@ -96,29 +97,6 @@ const TableViewStyles = styled.div<{
}
`;
const PaginationStyles = styled.div<{
isPaginationSticky?: boolean;
}>`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: ${({ theme }) => theme.colorBgElevated};
${({ isPaginationSticky }) =>
isPaginationSticky &&
`
position: sticky;
bottom: 0;
left: 0;
`};
.row-count-container {
margin-top: ${({ theme }) => theme.sizeUnit * 2}px;
color: ${({ theme }) => theme.colorText};
}
`;
const RawTableView = ({
columns,
data,
@@ -133,16 +111,21 @@ const RawTableView = ({
showRowCount = true,
serverPagination = false,
columnsForWrapText,
onServerPagination = () => {},
scrollTopOnPagination = false,
onServerPagination = NOOP_SERVER_PAGINATION,
scrollTopOnPagination = true,
size = TableSize.Middle,
...props
}: TableViewProps) => {
const initialState = {
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
pageIndex: initialPageIndex ?? 0,
sortBy: initialSortBy,
};
const tableRef = useRef<HTMLTableElement>(null);
const initialState = useMemo(
() => ({
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
pageIndex: initialPageIndex ?? 0,
sortBy: initialSortBy,
}),
[initialPageSize, initialPageIndex, initialSortBy],
);
const {
getTableProps,
@@ -151,10 +134,9 @@ const RawTableView = ({
page,
rows,
prepareRow,
pageCount,
gotoPage,
setSortBy,
state: { pageIndex, pageSize, sortBy },
state: { pageIndex, sortBy },
} = useTable(
{
columns,
@@ -162,36 +144,94 @@ const RawTableView = ({
initialState,
manualPagination: serverPagination,
manualSortBy: serverPagination,
pageCount: Math.ceil(totalCount / initialState.pageSize),
pageCount: serverPagination
? Math.ceil(totalCount / initialState.pageSize)
: undefined,
autoResetSortBy: false,
},
useFilters,
useSortBy,
usePagination,
...(withPagination ? [usePagination] : []),
);
const content = withPagination ? page : rows;
let EmptyWrapperComponent;
switch (emptyWrapperType) {
case EmptyWrapperType.Small:
EmptyWrapperComponent = ({ children }: any) => <>{children}</>;
break;
case EmptyWrapperType.Default:
default:
EmptyWrapperComponent = ({ children }: any) => (
<EmptyWrapper>{children}</EmptyWrapper>
);
}
const isEmpty = !loading && content.length === 0;
const hasPagination = pageCount > 1 && withPagination;
const tableRef = useRef<HTMLTableElement>(null);
const handleGotoPage = (p: number) => {
if (scrollTopOnPagination) {
tableRef?.current?.scroll(0, 0);
const EmptyWrapperComponent = useMemo(() => {
switch (emptyWrapperType) {
case EmptyWrapperType.Small:
return ({ children }: any) => <>{children}</>;
case EmptyWrapperType.Default:
default:
return ({ children }: any) => <EmptyWrapper>{children}</EmptyWrapper>;
}
gotoPage(p);
};
}, [emptyWrapperType]);
const content = useMemo(
() => (withPagination ? page : rows),
[withPagination, page, rows],
);
const isEmpty = useMemo(
() => !loading && content.length === 0,
[loading, content.length],
);
const handleScrollToTop = useCallback(() => {
if (scrollTopOnPagination) {
if (tableRef?.current) {
if (typeof tableRef.current.scrollTo === 'function') {
tableRef.current.scrollTo({ top: 0, behavior: 'smooth' });
} else if (typeof tableRef.current.scroll === 'function') {
tableRef.current.scroll(0, 0);
}
}
if (typeof window !== 'undefined' && window.scrollTo)
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, [scrollTopOnPagination]);
const handlePageChange = useCallback(
(p: number) => {
if (scrollTopOnPagination) handleScrollToTop();
gotoPage(p);
},
[scrollTopOnPagination, handleScrollToTop, gotoPage],
);
const paginationProps = useMemo(() => {
if (!withPagination) {
return {
pageIndex: 0,
pageSize: data.length,
totalCount: 0,
onPageChange: undefined,
};
}
if (serverPagination) {
return {
pageIndex,
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
totalCount,
onPageChange: handlePageChange,
};
}
return {
pageIndex,
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
totalCount: data.length,
onPageChange: handlePageChange,
};
}, [
withPagination,
serverPagination,
pageIndex,
initialPageSize,
totalCount,
data.length,
handlePageChange,
]);
useEffect(() => {
if (serverPagination && pageIndex !== initialState.pageIndex) {
@@ -199,7 +239,7 @@ const RawTableView = ({
pageIndex,
});
}
}, [pageIndex]);
}, [initialState.pageIndex, onServerPagination, pageIndex, serverPagination]);
useEffect(() => {
if (serverPagination && !isEqual(sortBy, initialState.sortBy)) {
@@ -208,61 +248,38 @@ const RawTableView = ({
sortBy,
});
}
}, [sortBy]);
}, [initialState.sortBy, onServerPagination, serverPagination, sortBy]);
return (
<>
<TableViewStyles {...props} ref={tableRef}>
<TableCollection
getTableProps={getTableProps}
getTableBodyProps={getTableBodyProps}
prepareRow={prepareRow}
headerGroups={headerGroups}
rows={content}
columns={columns}
loading={loading}
setSortBy={setSortBy}
size={size}
columnsForWrapText={columnsForWrapText}
/>
{isEmpty && (
<EmptyWrapperComponent>
{noDataText ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={noDataText}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</EmptyWrapperComponent>
)}
</TableViewStyles>
{hasPagination && (
<PaginationStyles
className="pagination-container"
isPaginationSticky={props.isPaginationSticky}
>
<Pagination
totalPages={pageCount || 0}
currentPage={pageCount ? pageIndex + 1 : 0}
onChange={(p: number) => handleGotoPage(p - 1)}
hideFirstAndLastPageLinks
/>
{showRowCount && (
<div className="row-count-container">
{!loading &&
t(
'%s-%s of %s',
pageSize * pageIndex + (page.length && 1),
pageSize * pageIndex + page.length,
totalCount,
)}
</div>
<TableViewStyles {...props} ref={tableRef}>
<TableCollection
getTableProps={getTableProps}
getTableBodyProps={getTableBodyProps}
prepareRow={prepareRow}
headerGroups={headerGroups}
rows={content}
columns={columns}
loading={loading}
setSortBy={setSortBy}
size={size}
columnsForWrapText={columnsForWrapText}
isPaginationSticky={props.isPaginationSticky}
showRowCount={showRowCount}
{...paginationProps}
/>
{isEmpty && (
<EmptyWrapperComponent>
{noDataText ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={noDataText}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</PaginationStyles>
</EmptyWrapperComponent>
)}
</>
</TableViewStyles>
);
};

View File

@@ -181,3 +181,9 @@ export {
type ThemedAgGridReactProps,
setupAGGridModules,
} from './ThemedAgGridReact';
export {
CodeEditor,
type CodeEditorProps,
type CodeEditorMode,
type CodeEditorTheme,
} from './CodeEditor';

View File

@@ -60,10 +60,7 @@ export function useTheme() {
const styled: CreateStyled = emotionStyled;
// launching in in dark mode for now while iterating
const themeObject: Theme = Theme.fromConfig({
algorithm: ThemeAlgorithm.DEFAULT,
});
const themeObject: Theme = Theme.fromConfig();
const { theme } = themeObject;
const supersetTheme = theme;

View File

@@ -127,6 +127,15 @@ export interface SupersetSpecificTokens {
// Spinner-related
brandSpinnerUrl?: string;
brandSpinnerSvg?: string;
// ECharts-related
/** Global ECharts configuration overrides applied to all chart types */
echartsOptionsOverrides?: any;
/** Chart-specific ECharts configuration overrides keyed by viz_type */
echartsOptionsOverridesByChartType?: {
[chartType: string]: any;
};
}
/**

View File

@@ -43,6 +43,7 @@ dayjs.updateLocale('en', {
});
export const extendedDayjs = dayjs;
export type { Dayjs };
export const fDuration = function (
t1: number,

View File

@@ -26,6 +26,7 @@ export enum FeatureFlag {
AlertReports = 'ALERT_REPORTS',
AlertReportTabs = 'ALERT_REPORT_TABS',
AlertReportSlackV2 = 'ALERT_REPORT_SLACK_V2',
AlertReportsFilter = 'ALERT_REPORTS_FILTER',
AllowFullCsvExport = 'ALLOW_FULL_CSV_EXPORT',
AvoidColorsCollision = 'AVOID_COLORS_COLLISION',
ChartPluginsExperimental = 'CHART_PLUGINS_EXPERIMENTAL',

View File

@@ -33,3 +33,4 @@ export * from './random';
export * from './typedMemo';
export * from './html';
export * from './tooltip';
export * from './merge';

View File

@@ -0,0 +1,61 @@
/**
* 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 { mergeReplaceArrays } from './merge';
describe('lodash utilities', () => {
describe('mergeReplaceArrays', () => {
it('should merge objects and replace arrays', () => {
const obj1 = { a: [1, 2], b: { c: 3 } };
const obj2 = { a: [4, 5], b: { d: 6 } };
const result = mergeReplaceArrays(obj1, obj2);
expect(result).toEqual({
a: [4, 5], // array replaced
b: { c: 3, d: 6 }, // objects merged
});
});
it('should handle precedence with multiple sources', () => {
const base = { x: { y: 1 }, z: [1] };
const override1 = { x: { y: 2 }, z: [2, 3] };
const override2 = { x: { y: 3 }, z: [4] };
const result = mergeReplaceArrays(base, override1, override2);
expect(result).toEqual({
x: { y: 3 }, // last wins
z: [4], // array replaced by last
});
});
it('should handle empty and null values', () => {
const base = { a: [1], b: { x: 1 } };
const override = { a: [], b: { x: null } };
const result = mergeReplaceArrays(base, override);
expect(result).toEqual({
a: [], // empty array replaces
b: { x: null }, // null overrides
});
});
});
});

View File

@@ -0,0 +1,52 @@
/**
* 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 { mergeWith } from 'lodash';
/**
* Merges objects using lodash.mergeWith, but replaces arrays instead of concatenating them.
* This is useful for configuration objects where you want to completely override array values
* rather than merge them by index.
*
* @example
* const obj1 = { a: [1, 2], b: { c: 3 } };
* const obj2 = { a: [4, 5], b: { d: 6 } };
* mergeReplaceArrays(obj1, obj2);
* // Result: { a: [4, 5], b: { c: 3, d: 6 } }
*
* @example
* // ECharts configuration merging
* const baseConfig = { series: [{ type: 'line' }], grid: { left: '10%' } };
* const overrides = { series: [{ type: 'bar' }], grid: { right: '10%' } };
* mergeReplaceArrays(baseConfig, overrides);
* // Result: { series: [{ type: 'bar' }], grid: { left: '10%', right: '10%' } }
*
* @param sources - Objects to merge (rightmost wins for arrays, deep merge for objects)
* @returns Merged object with arrays replaced, not concatenated
*/
export function mergeReplaceArrays<T = any>(...sources: any[]): T {
const replaceArrays = (objValue: any, srcValue: any) => {
if (Array.isArray(srcValue)) {
return srcValue; // Replace arrays entirely
}
return undefined; // Let lodash handle object merging for non-arrays
};
return mergeWith({}, ...sources, replaceArrays);
}

View File

@@ -2,14 +2,8 @@
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": false,
"noEmit": true,
"rootDir": "."
},
"extends": "../../../tsconfig.json",
"include": ["**/*", "../types/**/*", "../../../types/**/*"],
"references": [
{
"path": ".."
}
]
"include": ["**/*", "../types/**/*", "../../../types/**/*"]
}

View File

@@ -1,24 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"declarationDir": "lib",
"outDir": "lib",
"rootDir": "src",
"baseUrl": ".",
"paths": {
"src/*": ["./src/*"],
"@superset-ui/core": ["src"],
"@superset-ui/core/*": ["src/*"]
}
"baseUrl": "../..",
"outDir": "lib"
},
"exclude": [
"lib",
"test"
],
"include": [
"src/**/*",
"spec/**/*",
"types/**/*"
],
"references": []
"include": ["src/**/*", "types/**/*"],
"exclude": ["src/**/*.test.*", "src/**/*.stories.*"],
"references": [{ "path": "../superset-core" }]
}

View File

@@ -17,4 +17,6 @@
* under the License.
*/
declare module 'ace-builds/src-min-noconflict/worker-css';
declare module 'ace-builds/src-min-noconflict/worker-javascript';
declare module 'ace-builds/src-min-noconflict/worker-html';
declare module 'ace-builds/src-min-noconflict/ace';

View File

@@ -19,3 +19,5 @@
declare module '*.gif';
declare module '*.svg';
declare module '*.png';
declare module '*.jpg';
declare module '*.jpeg';

View File

@@ -1,18 +1,9 @@
{
"compilerOptions": {
"declarationDir": "lib",
"outDir": "lib",
"rootDir": "src"
},
"exclude": [
"lib",
"src/**/*.test.ts"
],
"extends": "../../tsconfig.json",
"include": [
"src/**/*",
"types/**/*",
"../../types/**/*"
],
"references": []
"compilerOptions": {
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["src/**/*.test.*", "src/**/*.stories.*"]
}

View File

@@ -0,0 +1,90 @@
/**
* 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.
*/
/// <reference types="node" />
// eslint-disable-next-line import/no-extraneous-dependencies
import { defineConfig } from '@playwright/test';
export default defineConfig({
// Test directory
testDir: './playwright/tests',
// Timeout settings
timeout: 30000,
expect: { timeout: 8000 },
// Parallel execution
fullyParallel: true,
workers: process.env.CI ? 2 : 1,
// Retry logic - 2 retries in CI, 0 locally
retries: process.env.CI ? 2 : 0,
// Reporter configuration - multiple reporters for better visibility
reporter: process.env.CI
? [
['github'], // GitHub Actions annotations
['list'], // Detailed output with summary table
['html', { outputFolder: 'playwright-report', open: 'never' }], // Interactive report
['json', { outputFile: 'test-results/results.json' }], // Machine-readable
]
: [
['list'], // Shows summary table locally
['html', { outputFolder: 'playwright-report', open: 'on-failure' }], // Auto-open on failure
],
// Global test setup
use: {
// Use environment variable for base URL in CI, default to localhost:8088 for local
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088',
// Browser settings
headless: !!process.env.CI,
viewport: { width: 1280, height: 1024 },
// Screenshots and videos on failure
screenshot: 'only-on-failure',
video: 'retain-on-failure',
// Trace collection for debugging
trace: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: {
browserName: 'chromium',
testIdAttribute: 'data-test',
},
},
],
// Web server setup - disabled in CI (Flask started separately in workflow)
webServer: process.env.CI
? undefined
: {
command: 'curl -f http://localhost:8088/health',
url: 'http://localhost:8088/health',
reuseExistingServer: true,
timeout: 5000,
},
});

View File

@@ -0,0 +1,218 @@
<!--
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.
-->
# Playwright E2E Tests for Superset
This directory contains Playwright end-to-end tests for Apache Superset, designed as a replacement for the existing Cypress tests during the migration to Playwright.
## Architecture
```
playwright/
├── components/core/ # Reusable UI components
├── pages/ # Page Object Models
├── tests/ # Test files organized by feature
├── utils/ # Shared constants and utilities
└── README.md # This file
```
## Design Principles
We follow **YAGNI** (You Aren't Gonna Need It), **DRY** (Don't Repeat Yourself), and **KISS** (Keep It Simple, Stupid) principles:
- Build only what's needed now
- Reuse existing patterns and components
- Keep solutions simple and maintainable
## Component Architecture
### Core Components (`components/core/`)
Reusable UI interaction classes for common elements:
- **Form**: Container with properly scoped child element access
- **Input**: Supports `fill()`, `type()`, and `pressSequentially()` methods
- **Button**: Standard click, hover, focus interactions
**Usage Example:**
```typescript
import { Form } from '../components/core';
const loginForm = new Form(page, '[data-test="login-form"]');
const usernameInput = loginForm.getInput('[data-test="username-input"]');
await usernameInput.fill('admin');
```
### Page Objects (`pages/`)
Each page object encapsulates:
- **Actions**: What you can do on the page
- **Queries**: Information you can get from the page
- **Selectors**: Centralized in private static SELECTORS constant
- **NO Assertions**: Keep assertions in test files
**Page Object Pattern:**
```typescript
export class AuthPage {
// Selectors centralized in the page object
private static readonly SELECTORS = {
LOGIN_FORM: '[data-test="login-form"]',
USERNAME_INPUT: '[data-test="username-input"]',
} as const;
// Actions - what you can do
async loginWithCredentials(username: string, password: string) { }
// Queries - information you can get
async getCurrentUrl(): Promise<string> { }
// NO assertions - those belong in tests
}
```
### Tests (`tests/`)
Organized by feature/area (auth, dashboard, charts, etc.):
- Use page objects for actions
- Keep assertions in test files
- Import shared constants from `utils/`
**Test Pattern:**
```typescript
import { test, expect } from '@playwright/test';
import { AuthPage } from '../../pages/AuthPage';
import { LOGIN } from '../../utils/urls';
test('should login with correct credentials', async ({ page }) => {
const authPage = new AuthPage(page);
await authPage.goto();
await authPage.loginWithCredentials('admin', 'general');
// Assertions belong in tests, not page objects
expect(await authPage.getCurrentUrl()).not.toContain(LOGIN);
});
```
### Utilities (`utils/`)
Shared constants and utilities:
- **urls.ts**: URL paths and request patterns
- Keep flat exports (no premature namespacing)
## Contributing Guidelines
### Adding New Tests
1. **Check existing components** before creating new ones
2. **Use page objects** for page interactions
3. **Keep assertions in tests**, not page objects
4. **Follow naming conventions**: `feature.spec.ts`
### Adding New Components
1. **Follow YAGNI**: Only build what's immediately needed
2. **Use Locator-based scoping** for proper element isolation
3. **Support both string selectors and Locator objects** via constructor overloads
4. **Add to `components/core/index.ts`** for easy importing
### Adding New Page Objects
1. **Centralize selectors** in private static SELECTORS constant
2. **Import shared constants** from `utils/urls.ts`
3. **Actions and queries only** - no assertions
4. **Use existing components** for DOM interactions
## Running Tests
```bash
# Run all tests
npm run playwright:test
# or: npx playwright test
# Run specific test file
npx playwright test tests/auth/login.spec.ts
# Run with UI mode for debugging
npm run playwright:ui
# or: npx playwright test --ui
# Run in headed mode (see browser)
npm run playwright:headed
# or: npx playwright test --headed
# Debug specific test file
npm run playwright:debug tests/auth/login.spec.ts
# or: npx playwright test --debug tests/auth/login.spec.ts
```
## Test Reports
Playwright generates multiple reports for better visibility:
```bash
# View interactive HTML report (opens automatically on failure)
npm run playwright:report
# or: npx playwright show-report
# View test trace for debugging failures
npx playwright show-trace test-results/[test-name]/trace.zip
```
### Report Types
- **List Reporter**: Shows progress and summary table in terminal
- **HTML Report**: Interactive web interface with screenshots, videos, and traces
- **JSON Report**: Machine-readable format in `test-results/results.json`
- **GitHub Actions**: Annotations in CI for failed tests
### Debugging Failed Tests
When tests fail, Playwright automatically captures:
- **Screenshots** at the point of failure
- **Videos** of the entire test run
- **Traces** with timeline and network activity
- **Error context** with detailed debugging information
All debugging artifacts are available in the HTML report for easy analysis.
## Configuration
- **Config**: `playwright.config.ts` - matches Cypress settings
- **Base URL**: `http://localhost:8088` (assumes Superset running)
- **Browsers**: Chrome only for Phase 1 (YAGNI)
- **Retries**: 2 in CI, 0 locally (matches Cypress)
## Migration from Cypress
When porting Cypress tests:
1. **Port the logic**, not the implementation
2. **Use page objects** instead of inline selectors
3. **Replace `cy.intercept/cy.wait`** with `page.waitForRequest()`
4. **Use shared constants** from `utils/urls.ts`
5. **Follow the established patterns** shown in `tests/auth/login.spec.ts`
## Best Practices
- **Centralize selectors** in page objects
- **Centralize URLs** in `utils/urls.ts`
- **Use meaningful test descriptions**
- **Keep page objects action-focused**
- **Put assertions in tests, not page objects**
- **Follow the existing patterns** for consistency

View File

@@ -0,0 +1,119 @@
/**
* 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 { Locator, Page } from '@playwright/test';
export class Button {
private readonly locator: Locator;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Gets the button element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Clicks the button
* @param options - Optional click options
*/
async click(options?: {
timeout?: number;
force?: boolean;
delay?: number;
button?: 'left' | 'right' | 'middle';
}): Promise<void> {
await this.element.click(options);
}
/**
* Gets the button text content
*/
async getText(): Promise<string> {
return (await this.element.textContent()) ?? '';
}
/**
* Gets a specific attribute value from the button
* @param attribute - The attribute name to retrieve
*/
async getAttribute(attribute: string): Promise<string | null> {
return this.element.getAttribute(attribute);
}
/**
* Checks if the button is visible
*/
async isVisible(): Promise<boolean> {
return this.element.isVisible();
}
/**
* Checks if the button is enabled
*/
async isEnabled(): Promise<boolean> {
return this.element.isEnabled();
}
/**
* Checks if the button is disabled
*/
async isDisabled(): Promise<boolean> {
return this.element.isDisabled();
}
/**
* Hovers over the button
* @param options - Optional hover options
*/
async hover(options?: { timeout?: number; force?: boolean }): Promise<void> {
await this.element.hover(options);
}
/**
* Focuses on the button
*/
async focus(): Promise<void> {
await this.element.focus();
}
/**
* Double clicks the button
* @param options - Optional click options
*/
async doubleClick(options?: {
timeout?: number;
force?: boolean;
delay?: number;
}): Promise<void> {
await this.element.dblclick(options);
}
}

View File

@@ -0,0 +1,110 @@
/**
* 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 { Locator, Page } from '@playwright/test';
import { Input } from './Input';
import { Button } from './Button';
export class Form {
private readonly page: Page;
private readonly locator: Locator;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
this.page = page;
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Gets the form element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Gets an input field within the form (properly scoped)
* @param inputSelector - Selector for the input field
*/
getInput(inputSelector: string): Input {
const scopedLocator = this.locator.locator(inputSelector);
return new Input(this.page, scopedLocator);
}
/**
* Gets a button within the form (properly scoped)
* @param buttonSelector - Selector for the button
*/
getButton(buttonSelector: string): Button {
const scopedLocator = this.locator.locator(buttonSelector);
return new Button(this.page, scopedLocator);
}
/**
* Checks if the form is visible
*/
async isVisible(): Promise<boolean> {
return this.locator.isVisible();
}
/**
* Submits the form (triggers submit event)
*/
async submit(): Promise<void> {
await this.locator.evaluate((form: HTMLElement) => {
if (form instanceof HTMLFormElement) {
form.submit();
}
});
}
/**
* Waits for the form to be visible
* @param options - Optional wait options
*/
async waitForVisible(options?: { timeout?: number }): Promise<void> {
await this.locator.waitFor({ state: 'visible', ...options });
}
/**
* Gets all form data as key-value pairs
* Useful for validation and debugging
*/
async getFormData(): Promise<Record<string, string>> {
return this.locator.evaluate((form: HTMLElement) => {
if (form instanceof HTMLFormElement) {
const formData = new FormData(form);
const result: Record<string, string> = {};
formData.forEach((value, key) => {
result[key] = value.toString();
});
return result;
}
return {};
});
}
}

View File

@@ -0,0 +1,111 @@
/**
* 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 { Locator, Page } from '@playwright/test';
export class Input {
private readonly locator: Locator;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Gets the input element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Fast fill - clears the input and sets the value directly
* @param value - The value to fill
* @param options - Optional fill options
*/
async fill(
value: string,
options?: { timeout?: number; force?: boolean },
): Promise<void> {
await this.element.fill(value, options);
}
/**
* Types text character by character (simulates real typing)
* @param text - The text to type
* @param options - Optional typing options
*/
async type(text: string, options?: { delay?: number }): Promise<void> {
await this.element.type(text, options);
}
/**
* Types text sequentially with more control over timing
* @param text - The text to type
* @param options - Optional sequential typing options
*/
async pressSequentially(
text: string,
options?: { delay?: number },
): Promise<void> {
await this.element.pressSequentially(text, options);
}
/**
* Gets the current value of the input
*/
async getValue(): Promise<string> {
return this.element.inputValue();
}
/**
* Clears the input field
*/
async clear(): Promise<void> {
await this.element.clear();
}
/**
* Checks if the input is visible
*/
async isVisible(): Promise<boolean> {
return this.element.isVisible();
}
/**
* Checks if the input is enabled
*/
async isEnabled(): Promise<boolean> {
return this.element.isEnabled();
}
/**
* Focuses on the input field
*/
async focus(): Promise<void> {
await this.element.focus();
}
}

View File

@@ -16,9 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import PropTypes from 'prop-types';
export default PropTypes.shape({
column_name: PropTypes.string.isRequired,
type: PropTypes.string,
});
// Core Playwright Components for Superset
export { Button } from './Button';
export { Form } from './Form';
export { Input } from './Input';

View File

@@ -0,0 +1,122 @@
/**
* 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 { Page, Response } from '@playwright/test';
import { Form } from '../components/core';
import { URL } from '../utils/urls';
export class AuthPage {
private readonly page: Page;
private readonly loginForm: Form;
// Selectors specific to the auth/login page
private static readonly SELECTORS = {
LOGIN_FORM: '[data-test="login-form"]',
USERNAME_INPUT: '[data-test="username-input"]',
PASSWORD_INPUT: '[data-test="password-input"]',
LOGIN_BUTTON: '[data-test="login-button"]',
ERROR_SELECTORS: [
'[role="alert"]',
'.ant-form-item-explain-error',
'.ant-form-item-explain.ant-form-item-explain-error',
'.alert-danger',
],
} as const;
constructor(page: Page) {
this.page = page;
this.loginForm = new Form(page, AuthPage.SELECTORS.LOGIN_FORM);
}
/**
* Navigate to the login page
*/
async goto(): Promise<void> {
await this.page.goto(URL.LOGIN);
}
/**
* Wait for login form to be visible
*/
async waitForLoginForm(): Promise<void> {
await this.loginForm.waitForVisible({ timeout: 5000 });
}
/**
* Login with provided credentials
* @param username - Username to enter
* @param password - Password to enter
*/
async loginWithCredentials(
username: string,
password: string,
): Promise<void> {
const usernameInput = this.loginForm.getInput(
AuthPage.SELECTORS.USERNAME_INPUT,
);
const passwordInput = this.loginForm.getInput(
AuthPage.SELECTORS.PASSWORD_INPUT,
);
const loginButton = this.loginForm.getButton(
AuthPage.SELECTORS.LOGIN_BUTTON,
);
await usernameInput.fill(username);
await passwordInput.fill(password);
await loginButton.click();
}
/**
* Get current page URL
*/
async getCurrentUrl(): Promise<string> {
return this.page.url();
}
/**
* Get the session cookie specifically
*/
async getSessionCookie(): Promise<{ name: string; value: string } | null> {
const cookies = await this.page.context().cookies();
return cookies.find((c: any) => c.name === 'session') || null;
}
/**
* Check if login form has validation errors
*/
async hasLoginError(): Promise<boolean> {
const visibilityPromises = AuthPage.SELECTORS.ERROR_SELECTORS.map(
selector => this.page.locator(selector).isVisible(),
);
const visibilityResults = await Promise.all(visibilityPromises);
return visibilityResults.some((isVisible: any) => isVisible);
}
/**
* Wait for a login request to be made and return the response
*/
async waitForLoginRequest(): Promise<Response> {
return this.page.waitForResponse(
(response: any) =>
response.url().includes('/login/') &&
response.request().method() === 'POST',
);
}
}

View File

@@ -0,0 +1,88 @@
/**
* 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 { test, expect } from '@playwright/test';
import { AuthPage } from '../../pages/AuthPage';
import { URL } from '../../utils/urls';
test.describe('Login view', () => {
let authPage: AuthPage;
test.beforeEach(async ({ page }: any) => {
authPage = new AuthPage(page);
await authPage.goto();
await authPage.waitForLoginForm();
});
test('should redirect to login with incorrect username and password', async ({
page,
}: any) => {
// Setup request interception before login attempt
const loginRequestPromise = authPage.waitForLoginRequest();
// Attempt login with incorrect credentials
await authPage.loginWithCredentials('admin', 'wrongpassword');
// Wait for login request and verify response
const loginResponse = await loginRequestPromise;
// Failed login returns 401 Unauthorized or 302 redirect to login
expect([401, 302]).toContain(loginResponse.status());
// Wait for redirect to complete before checking URL
await page.waitForURL((url: any) => url.pathname.endsWith('login/'), {
timeout: 10000,
});
// Verify we stay on login page
const currentUrl = await authPage.getCurrentUrl();
expect(currentUrl).toContain(URL.LOGIN);
// Verify error message is shown
const hasError = await authPage.hasLoginError();
expect(hasError).toBe(true);
});
test('should login with correct username and password', async ({
page,
}: any) => {
// Setup request interception before login attempt
const loginRequestPromise = authPage.waitForLoginRequest();
// Login with correct credentials
await authPage.loginWithCredentials('admin', 'general');
// Wait for login request and verify response
const loginResponse = await loginRequestPromise;
// Successful login returns 302 redirect
expect(loginResponse.status()).toBe(302);
// Wait for successful redirect to welcome page
await page.waitForURL(
(url: any) => url.pathname.endsWith('superset/welcome/'),
{
timeout: 10000,
},
);
// Verify specific session cookie exists
const sessionCookie = await authPage.getSessionCookie();
expect(sessionCookie).not.toBeNull();
expect(sessionCookie?.value).toBeTruthy();
});
});

View File

@@ -16,8 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import PropTypes from 'prop-types';
export default PropTypes.shape({
aggregate_name: PropTypes.string.isRequired,
});
export const URL = {
LOGIN: 'login/',
WELCOME: 'superset/welcome/',
} as const;

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -19,8 +19,10 @@
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import transformProps from './transformProps';
import example from './images/example.jpg';
import exampleDark from './images/example-dark.jpg';
import controlPanel from './controlPanel';
import thumbnail from './images/thumbnail.png';
import thumbnailDark from './images/thumbnail-dark.png';
const metadata = new ChartMetadata({
category: t('Correlation'),
@@ -28,7 +30,7 @@ const metadata = new ChartMetadata({
description: t(
"Visualizes how a metric has changed over a time using a color scale and a calendar view. Gray values are used to indicate missing values and the linear color scheme is used to encode the magnitude of each day's value.",
),
exampleGallery: [{ url: example }],
exampleGallery: [{ url: example, urlDark: exampleDark }],
name: t('Calendar Heatmap'),
tags: [
t('Business'),
@@ -39,6 +41,7 @@ const metadata = new ChartMetadata({
t('Trend'),
],
thumbnail,
thumbnailDark,
useLegacyApi: true,
});

View File

@@ -1,14 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib",
"baseUrl": "."
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["lib", "test"],
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
"exclude": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.test.*",
"src/**/*.stories.*"
],
"references": [
{ "path": "../../packages/superset-core" },
{ "path": "../../packages/superset-ui-core" },
{ "path": "../../packages/superset-ui-chart-controls" }
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -19,7 +19,9 @@
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import transformProps from './transformProps';
import example from './images/chord.jpg';
import exampleDark from './images/chord-dark.jpg';
import thumbnail from './images/thumbnail.png';
import thumbnailDark from './images/thumbnail-dark.png';
import controlPanel from './controlPanel';
const metadata = new ChartMetadata({
@@ -29,11 +31,16 @@ const metadata = new ChartMetadata({
'Showcases the flow or link between categories using thickness of chords. The value and corresponding thickness can be different for each side.',
),
exampleGallery: [
{ url: example, caption: t('Relationships between community channels') },
{
url: example,
urlDark: exampleDark,
caption: t('Relationships between community channels'),
},
],
name: t('Chord Diagram'),
tags: [t('Circular'), t('Legacy'), t('Proportional'), t('Relational')],
thumbnail,
thumbnailDark,
useLegacyApi: true,
});

View File

@@ -1,14 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib",
"baseUrl": "."
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["lib", "test"],
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
"exclude": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.test.*",
"src/**/*.stories.*"
],
"references": [
{ "path": "../../packages/superset-core" },
{ "path": "../../packages/superset-ui-core" },
{ "path": "../../packages/superset-ui-chart-controls" }
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Some files were not shown because too many files have changed in this diff Show More