mirror of
https://github.com/apache/superset.git
synced 2026-08-01 11:32:27 +00:00
Compare commits
90 Commits
fix-docker
...
feat/granu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1917ed0895 | ||
|
|
052724a578 | ||
|
|
4b81028f3b | ||
|
|
b704532120 | ||
|
|
cb8956b19b | ||
|
|
90873458f5 | ||
|
|
9e5233eda2 | ||
|
|
eb77167bae | ||
|
|
f0af6cc812 | ||
|
|
01ac966b83 | ||
|
|
97e5f0631d | ||
|
|
b7acb7984f | ||
|
|
d3919cf24f | ||
|
|
27889651b3 | ||
|
|
361fe6fe89 | ||
|
|
8506d70242 | ||
|
|
00a53eec2d | ||
|
|
5040db859c | ||
|
|
ef4f7afa90 | ||
|
|
47db185e3b | ||
|
|
2e463078a2 | ||
|
|
4f42928b34 | ||
|
|
75fa474fce | ||
|
|
fd8c21591a | ||
|
|
4147d877fc | ||
|
|
a9dca529c1 | ||
|
|
20f1918dd6 | ||
|
|
c09a4f6f47 | ||
|
|
4e4fa53c8d | ||
|
|
07ff82f189 | ||
|
|
b7b9bfd3fe | ||
|
|
b968d1095c | ||
|
|
e10237fcc1 | ||
|
|
92438322c0 | ||
|
|
f96e90b979 | ||
|
|
b464979db1 | ||
|
|
45f883c9cd | ||
|
|
8fd3401077 | ||
|
|
89a98ab9a4 | ||
|
|
2dfc770b0f | ||
|
|
6b7b23ed78 | ||
|
|
5ac5480f35 | ||
|
|
76889c1a69 | ||
|
|
569606635b | ||
|
|
66264856a7 | ||
|
|
3eb860a663 | ||
|
|
a44980da65 | ||
|
|
7112bce961 | ||
|
|
568486a304 | ||
|
|
fea135b46c | ||
|
|
601fcb3382 | ||
|
|
0d7cc88b2b | ||
|
|
32ee160c75 | ||
|
|
5914e83436 | ||
|
|
0b5e4dd5de | ||
|
|
3a565a6c16 | ||
|
|
f60c82e4a6 | ||
|
|
91131d5996 | ||
|
|
4b0d497513 | ||
|
|
86f690d17f | ||
|
|
e9b494163b | ||
|
|
be404f9b84 | ||
|
|
11257c0536 | ||
|
|
f2b6c395cd | ||
|
|
2d35ed2391 | ||
|
|
bd65469091 | ||
|
|
a6a66ca483 | ||
|
|
4a7cdccdad | ||
|
|
61bd8f0cf2 | ||
|
|
ae10e105c2 | ||
|
|
901dca58f7 | ||
|
|
d95a3d8426 | ||
|
|
70b95ca1b9 | ||
|
|
004f02746f | ||
|
|
5d20dc57d7 | ||
|
|
05c2354997 | ||
|
|
6043e7e7e3 | ||
|
|
1ee14c5993 | ||
|
|
9764a84402 | ||
|
|
570cc3e5f8 | ||
|
|
66519c3a85 | ||
|
|
1f43138888 | ||
|
|
652d029a2d | ||
|
|
e67b1f5326 | ||
|
|
fa79a467e4 | ||
|
|
2cce0308d4 | ||
|
|
c7fd1a2f65 | ||
|
|
ab4f646ef6 | ||
|
|
d6029f5c8a | ||
|
|
c16e8f747c |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -20,7 +20,7 @@
|
||||
|
||||
# Notify PMC members of changes to GitHub Actions
|
||||
|
||||
/.github/ @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @craig-rueda @kgabryje @dpgaspar @sadpandajoe
|
||||
/.github/ @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @craig-rueda @kgabryje @dpgaspar @sadpandajoe @hainenber
|
||||
|
||||
# Notify PMC members of changes to required GitHub Actions
|
||||
|
||||
|
||||
2
.github/workflows/ephemeral-env-pr-close.yml
vendored
2
.github/workflows/ephemeral-env-pr-close.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
|
||||
4
.github/workflows/ephemeral-env.yml
vendored
4
.github/workflows/ephemeral-env.yml
vendored
@@ -189,7 +189,7 @@ jobs:
|
||||
--extra-flags "--build-arg INCLUDE_CHROMIUM=false"
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
@@ -225,7 +225,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
|
||||
4
.github/workflows/superset-docs-deploy.yml
vendored
4
.github/workflows/superset-docs-deploy.yml
vendored
@@ -68,7 +68,7 @@ jobs:
|
||||
yarn install --check-cache
|
||||
- name: Download database diagnostics (if triggered by integration tests)
|
||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success'
|
||||
uses: dawidd6/action-download-artifact@v12
|
||||
uses: dawidd6/action-download-artifact@v14
|
||||
continue-on-error: true
|
||||
with:
|
||||
workflow: superset-python-integrationtest.yml
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
path: docs/src/data/
|
||||
- name: Try to download latest diagnostics (for push/dispatch triggers)
|
||||
if: github.event_name != 'workflow_run'
|
||||
uses: dawidd6/action-download-artifact@v12
|
||||
uses: dawidd6/action-download-artifact@v14
|
||||
continue-on-error: true
|
||||
with:
|
||||
workflow: superset-python-integrationtest.yml
|
||||
|
||||
4
.github/workflows/superset-docs-verify.yml
vendored
4
.github/workflows/superset-docs-verify.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
# Do not bump this linkinator-action version without opening
|
||||
# an ASF Infra ticket to allow the new version first!
|
||||
- uses: JustinBeckwith/linkinator-action@af984b9f30f63e796ae2ea5be5e07cb587f1bbd9 # v2.3
|
||||
- uses: JustinBeckwith/linkinator-action@f62ba0c110a76effb2ee6022cc6ce4ab161085e3 # v2.4
|
||||
continue-on-error: true # This will make the job advisory (non-blocking, no red X)
|
||||
with:
|
||||
paths: "**/*.md, **/*.mdx"
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
run: |
|
||||
yarn install --check-cache
|
||||
- name: Download database diagnostics from integration tests
|
||||
uses: dawidd6/action-download-artifact@v12
|
||||
uses: dawidd6/action-download-artifact@v14
|
||||
with:
|
||||
workflow: superset-python-integrationtest.yml
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
@@ -27,6 +27,7 @@ repos:
|
||||
args: [--check-untyped-defs]
|
||||
exclude: ^superset-extensions-cli/
|
||||
additional_dependencies: [
|
||||
types-cachetools,
|
||||
types-simplejson,
|
||||
types-python-dateutil,
|
||||
types-requests,
|
||||
|
||||
@@ -430,6 +430,11 @@ categories:
|
||||
url: https://brandct.cn/
|
||||
contributors: ["@wenbinye"]
|
||||
|
||||
- name: XNET
|
||||
url: https://xnetmobile.com/
|
||||
logo: xnet.png
|
||||
contributors: ["@deuspt"]
|
||||
|
||||
- name: Zeta
|
||||
url: https://www.zeta.tech/
|
||||
contributors: ["@shaikidris"]
|
||||
|
||||
15
UPDATING.md
15
UPDATING.md
@@ -24,6 +24,21 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### WebSocket config for GAQ with Docker
|
||||
|
||||
[35896](https://github.com/apache/superset/pull/35896) and [37624](https://github.com/apache/superset/pull/37624) updated documentation on how to run and configure Superset with Docker. Specifically for the WebSocket configuration, a new `docker/superset-websocket/config.example.json` was added to the repo, so that users could copy it to create a `docker/superset-websocket/config.json` file. The existing `docker/superset-websocket/config.json` was removed and git-ignored, so if you're using GAQ / WebSocket make sure to:
|
||||
- Stash/backup your existing `config.json` file, to re-apply it after (will get git-ignored going forward)
|
||||
- Update the `volumes` configuration for the `superset-websocket` service in your `docker-compose.override.yml` file, to include the `docker/superset-websocket/config.json` file. For example:
|
||||
``` yaml
|
||||
services:
|
||||
superset-websocket:
|
||||
volumes:
|
||||
- ./superset-websocket:/home/superset-websocket
|
||||
- /home/superset-websocket/node_modules
|
||||
- /home/superset-websocket/dist
|
||||
- ./docker/superset-websocket/config.json:/home/superset-websocket/config.json:ro
|
||||
```
|
||||
|
||||
### Example Data Loading Improvements
|
||||
|
||||
#### New Directory Structure
|
||||
|
||||
@@ -28,11 +28,11 @@ if [ "$BUILD_SUPERSET_FRONTEND_IN_DOCKER" = "true" ]; then
|
||||
cd /app/superset-frontend
|
||||
|
||||
if [ "$NPM_RUN_PRUNE" = "true" ]; then
|
||||
echo "Running `npm run prune`"
|
||||
echo "Running \"npm run prune\""
|
||||
npm run prune
|
||||
fi
|
||||
|
||||
echo "Running `npm install`"
|
||||
echo "Running \"npm install\""
|
||||
npm install
|
||||
|
||||
echo "Start webpack dev server"
|
||||
|
||||
@@ -105,7 +105,7 @@ class CeleryConfig:
|
||||
|
||||
CELERY_CONFIG = CeleryConfig
|
||||
|
||||
FEATURE_FLAGS = {"ALERT_REPORTS": True}
|
||||
FEATURE_FLAGS = {"ALERT_REPORTS": True, "DATASET_FOLDERS": True}
|
||||
ALERT_REPORTS_NOTIFICATION_DRY_RUN = True
|
||||
WEBDRIVER_BASEURL = f"http://superset_app{os.environ.get('SUPERSET_APP_ROOT', '/')}/" # When using docker compose baseurl should be http://superset_nginx{ENV{BASEPATH}}/ # noqa: E501
|
||||
# The base URL for the email report hyperlinks.
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"port": 8080,
|
||||
"logLevel": "info",
|
||||
"logToFile": false,
|
||||
"logFilename": "app.log",
|
||||
"statsd": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 8125,
|
||||
"globalTags": []
|
||||
},
|
||||
"redis": {
|
||||
"port": 6379,
|
||||
"host": "127.0.0.1",
|
||||
"password": "",
|
||||
"db": 0,
|
||||
"ssl": false
|
||||
},
|
||||
"redisStreamPrefix": "async-events-",
|
||||
"jwtAlgorithms": ["HS256"],
|
||||
"jwtSecret": "CHANGE-ME-IN-PRODUCTION-GOTTA-BE-LONG-AND-SECRET",
|
||||
"jwtCookieName": "async-token"
|
||||
}
|
||||
672
docs/GRANULAR_THEMING_PLAN.md
Normal file
672
docs/GRANULAR_THEMING_PLAN.md
Normal file
@@ -0,0 +1,672 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# Granular Theming Feature Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Extend Superset's theming system to support theme application at granular levels beyond instance and dashboard. This includes charts, dashboard components (Markdown, Rows, Columns, Tabs), and improved UI consistency across all dashboard elements.
|
||||
|
||||
## Current State
|
||||
|
||||
### Theme Hierarchy (Existing)
|
||||
1. **Instance Level** - Global theme via ThemeController, affects entire Superset
|
||||
2. **Dashboard Level** - Override via Dashboard Properties modal, stored in `theme_id`
|
||||
|
||||
### Current Limitations
|
||||
- Charts inherit dashboard/instance theme - no per-chart override
|
||||
- Dashboard components (Markdown, Row, Column, Tab) have no theme controls
|
||||
- Inconsistent UI patterns across components:
|
||||
- Charts have `SliceHeaderControls` menu (vertical dots)
|
||||
- Markdown has `MarkdownModeDropdown` (Edit/Preview toggle) - no standard menu
|
||||
- Row/Column have `BackgroundStyleDropdown` via gear icon
|
||||
- Tabs have editable title only
|
||||
|
||||
---
|
||||
|
||||
## Proposed Theme Hierarchy
|
||||
|
||||
```
|
||||
Instance Theme (global default)
|
||||
└── Dashboard Theme (override)
|
||||
├── Chart Theme (per-chart override)
|
||||
├── Markdown Theme (per-component override)
|
||||
├── Row Theme (per-component override)
|
||||
├── Column Theme (per-component override)
|
||||
└── Tab Theme (per-component override)
|
||||
```
|
||||
|
||||
Each level inherits from parent and can selectively override tokens.
|
||||
|
||||
---
|
||||
|
||||
## Feature Breakdown
|
||||
|
||||
### Phase 1: Chart-Level Theming
|
||||
|
||||
#### 1.1 Chart Theme in Explore View
|
||||
**Location:** Chart controls panel (alongside color palette picker)
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Add `ThemeControl` component to explore controls
|
||||
- [ ] Create theme selector UI (dropdown with theme previews)
|
||||
- [ ] Store theme selection in chart `form_data` / `params`
|
||||
- [ ] Update chart rendering to apply theme override
|
||||
- [ ] Add to relevant viz control panels (sections.tsx)
|
||||
|
||||
**Files to modify:**
|
||||
- `superset-frontend/src/explore/components/controls/` - New ThemeControl
|
||||
- `superset-frontend/src/explore/controlPanels/sections.tsx` - Add to panels
|
||||
- `superset-frontend/packages/superset-ui-core/src/chart/` - Theme application
|
||||
|
||||
#### 1.2 Chart Theme in Dashboard Context
|
||||
**Location:** SliceHeaderControls menu (top-right vertical dots)
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Add "Apply Theme" menu item to SliceHeaderControls
|
||||
- [ ] Create theme selection modal/popover
|
||||
- [ ] Store per-chart theme in dashboard layout metadata
|
||||
- [ ] Update chart rendering in dashboard to check for theme override
|
||||
- [ ] Handle theme inheritance (chart theme → dashboard theme → instance theme)
|
||||
|
||||
**Files to modify:**
|
||||
- `superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx`
|
||||
- `superset-frontend/src/dashboard/reducers/` - Store chart themes
|
||||
- `superset-frontend/src/dashboard/actions/` - Theme actions
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: UI Consistency Improvements
|
||||
|
||||
#### 2.1 Standardized Component Menu
|
||||
Create a consistent menu pattern for ALL dashboard components.
|
||||
|
||||
**Proposed Pattern:**
|
||||
- All components get a `ComponentHeaderControls` menu (vertical dots like charts)
|
||||
- Menu appears on hover/focus in both view and edit modes
|
||||
- Contains context-appropriate actions
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Create generic `ComponentHeaderControls` component
|
||||
- [ ] Define standard menu structure with extensible items
|
||||
- [ ] Implement for each component type
|
||||
|
||||
#### 2.2 Markdown Component Menu
|
||||
**Current:** `MarkdownModeDropdown` (Edit/Preview) at top of card in edit mode
|
||||
|
||||
**Proposed:** Standard vertical dots menu with:
|
||||
- Edit content (opens editor modal or inline)
|
||||
- Apply theme
|
||||
- View source (preview mode)
|
||||
- Delete component
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Add `ComponentHeaderControls` to Markdown
|
||||
- [ ] Move Edit/Preview toggle into menu or make it a toggle button
|
||||
- [ ] Remove old `MarkdownModeDropdown` UI
|
||||
- [ ] Add theme selection option
|
||||
|
||||
**Files to modify:**
|
||||
- `superset-frontend/src/dashboard/components/gridComponents/Markdown/`
|
||||
- `superset-frontend/src/dashboard/components/menu/MarkdownModeDropdown.tsx` - Deprecate
|
||||
|
||||
#### 2.3 Row/Column Component Menu
|
||||
**Current:** Gear icon for `BackgroundStyleDropdown` (Transparent/Solid)
|
||||
|
||||
**Proposed:** Standard vertical dots menu with:
|
||||
- Background style (submenu or modal)
|
||||
- Apply theme
|
||||
- Delete component
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Add `ComponentHeaderControls` to Row/Column
|
||||
- [ ] Move background style into menu
|
||||
- [ ] Remove standalone gear icon
|
||||
- [ ] Add theme selection option
|
||||
|
||||
**Files to modify:**
|
||||
- `superset-frontend/src/dashboard/components/gridComponents/Row/`
|
||||
- `superset-frontend/src/dashboard/components/gridComponents/Column/`
|
||||
- `superset-frontend/src/dashboard/components/menu/BackgroundStyleDropdown.tsx`
|
||||
|
||||
#### 2.4 Tab Component Menu
|
||||
**Current:** Editable title only
|
||||
|
||||
**Proposed:** Standard vertical dots menu on each tab with:
|
||||
- Rename tab
|
||||
- Apply theme (to tab content area)
|
||||
- Delete tab
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Add `ComponentHeaderControls` to Tab headers
|
||||
- [ ] Integrate with existing editable title
|
||||
- [ ] Add theme selection option
|
||||
|
||||
**Files to modify:**
|
||||
- `superset-frontend/src/dashboard/components/gridComponents/Tab/`
|
||||
- `superset-frontend/src/dashboard/components/gridComponents/Tabs/`
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Theme Storage & API
|
||||
|
||||
#### 3.1 Backend Storage
|
||||
**Tasks:**
|
||||
- [ ] Extend dashboard `json_metadata` schema to include component themes
|
||||
- [ ] Add chart theme field to chart model or store in dashboard layout
|
||||
- [ ] Create API endpoints for component theme CRUD (or use existing dashboard save)
|
||||
|
||||
**Schema proposal:**
|
||||
```json
|
||||
{
|
||||
"json_metadata": {
|
||||
"theme_id": 123,
|
||||
"component_themes": {
|
||||
"CHART-abc123": { "theme_id": 456 },
|
||||
"MARKDOWN-def456": { "theme_id": 789 },
|
||||
"ROW-ghi789": { "theme_id": 101 }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 Frontend State Management
|
||||
**Tasks:**
|
||||
- [ ] Add component themes to dashboard Redux state
|
||||
- [ ] Create actions for setting/clearing component themes
|
||||
- [ ] Update selectors to resolve theme hierarchy
|
||||
- [ ] Cache resolved themes for performance
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Theme Application & Rendering
|
||||
|
||||
#### 4.1 Theme Resolution Logic
|
||||
|
||||
Full cascade order: **Instance → Dashboard → Tab → Row/Column → Chart/Component**
|
||||
|
||||
Each level overrides the one above it. Components inherit from their structural parent.
|
||||
|
||||
```typescript
|
||||
function resolveComponentTheme(
|
||||
componentId: string,
|
||||
parentChain: string[] // [dashboardId, tabId?, rowId?, columnId?]
|
||||
): Theme {
|
||||
const instanceTheme = getInstanceTheme();
|
||||
const dashboardTheme = getDashboardTheme();
|
||||
|
||||
// Walk up the parent chain, collecting theme overrides
|
||||
let mergedTheme = mergeThemes(instanceTheme, dashboardTheme);
|
||||
|
||||
for (const parentId of parentChain) {
|
||||
const parentTheme = getComponentTheme(parentId);
|
||||
if (parentTheme) {
|
||||
mergedTheme = mergeThemes(mergedTheme, parentTheme);
|
||||
}
|
||||
}
|
||||
|
||||
// Finally apply this component's theme
|
||||
const componentTheme = getComponentTheme(componentId);
|
||||
return componentTheme
|
||||
? mergeThemes(mergedTheme, componentTheme)
|
||||
: mergedTheme;
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2 Theme Provider Hierarchy
|
||||
**Tasks:**
|
||||
- [ ] Create `ComponentThemeProvider` wrapper
|
||||
- [ ] Wrap each dashboard component with theme context
|
||||
- [ ] Ensure proper theme inheritance and override behavior
|
||||
- [ ] Handle theme changes without full re-render
|
||||
|
||||
---
|
||||
|
||||
## UI/UX Design Considerations
|
||||
|
||||
### Menu Placement
|
||||
- **Charts:** Keep existing top-right position (SliceHeaderControls)
|
||||
- **Markdown/Row/Column:** Top-right corner, visible on hover
|
||||
- **Tabs:** In tab header, right side of tab label
|
||||
|
||||
### Theme Selector UI
|
||||
|
||||
**Decision:** Modal with live preview rendering
|
||||
|
||||
The theme selector will open a modal that shows:
|
||||
- List of available themes (left panel)
|
||||
- Live preview of the component with selected theme applied (right panel)
|
||||
- Apply / Cancel buttons
|
||||
|
||||
This provides the best UX for understanding theme impact before committing.
|
||||
|
||||
**Implementation approach:**
|
||||
- Render component in isolated ThemeProvider within modal
|
||||
- Pass temporary theme to preview without affecting dashboard state
|
||||
- On "Apply", persist theme selection to dashboard metadata
|
||||
|
||||
### Visual Indicators
|
||||
- Show subtle indicator when component has theme override (e.g., small theme icon)
|
||||
- Tooltip showing "Custom theme: [Theme Name]"
|
||||
- In edit mode, highlight themed components differently
|
||||
|
||||
---
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
### Performance
|
||||
- Theme objects can be large - cache aggressively
|
||||
- Use React.memo and useMemo for theme-dependent components
|
||||
- Consider lazy loading theme data for components not in viewport
|
||||
|
||||
### Backwards Compatibility
|
||||
- Components without theme override continue to inherit from parent
|
||||
- Existing dashboards work unchanged
|
||||
- Migration not required - additive feature
|
||||
|
||||
### Theme Merging Strategy
|
||||
- Deep merge with component theme taking precedence
|
||||
- Only override specified tokens, inherit rest
|
||||
- Handle algorithm (light/dark) inheritance carefully
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Phase 1.2** - Chart theme in dashboard (SliceHeaderControls) - Most impactful, builds on existing menu
|
||||
2. **Phase 2.1** - Create generic ComponentHeaderControls - Foundation for other components
|
||||
3. **Phase 2.2** - Markdown menu - Good test case, simplest component
|
||||
4. **Phase 2.3** - Row/Column menu - Similar pattern, removes old UI
|
||||
5. **Phase 2.4** - Tab menu - More complex due to tab structure
|
||||
6. **Phase 1.1** - Chart theme in Explore - Can be done in parallel
|
||||
7. **Phase 3 & 4** - Storage and rendering - Continuous throughout
|
||||
|
||||
---
|
||||
|
||||
## Decisions Made
|
||||
|
||||
1. **Theme scope for Tabs:** Theme applies to tab **contents** (the content area), which can be overridden by components within the tab.
|
||||
|
||||
2. **Nested theming:** Full cascade - each level overrides the one above:
|
||||
```
|
||||
Instance → Dashboard → Tab → Row/Column → Chart/Component
|
||||
```
|
||||
A Chart theme overrides the Row theme it sits in.
|
||||
|
||||
3. **Color scheme vs Theme:** Keep **separate**:
|
||||
- **Color Scheme (palette):** Categorical/sequential colors for data visualization
|
||||
- **Theme:** UI elements - axes, fonts, backgrounds, borders, etc.
|
||||
|
||||
Both controls will exist side-by-side in chart controls.
|
||||
|
||||
4. **Theme selector UI:** Modal with live preview rendering.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Undo/Redo:** Should theme changes be undoable in dashboard edit mode?
|
||||
2. **Theme inheritance indicator:** How to show which theme a component is inheriting from?
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- [ ] Users can apply themes to individual charts in dashboards
|
||||
- [ ] All dashboard components have consistent menu UI
|
||||
- [ ] Old gear icon and mode dropdown removed
|
||||
- [ ] Theme inheritance works correctly across all levels
|
||||
- [ ] No performance regression on dashboard load
|
||||
- [ ] Backwards compatible with existing dashboards
|
||||
|
||||
---
|
||||
|
||||
## Future Possibilities
|
||||
|
||||
Once granular theming is in place, this opens doors for:
|
||||
|
||||
1. **ECharts Token Overrides** - The theme system already supports `echartsOverrides` for per-chart-type styling. Future work could expose UI for:
|
||||
- Rounded corners on bar charts
|
||||
- Custom line styles
|
||||
- Shadow effects
|
||||
- Animation settings
|
||||
|
||||
2. **Theme Templates** - Pre-built component theme combinations (e.g., "Dark Card", "Glassmorphism", "Minimal")
|
||||
|
||||
3. **Theme Export/Import** - Share component themes across dashboards
|
||||
|
||||
4. **Conditional Theming** - Apply themes based on data values or user context
|
||||
|
||||
---
|
||||
|
||||
## Implementation Log
|
||||
|
||||
_Ongoing notes as we implement..._
|
||||
|
||||
### Session 1 - Planning
|
||||
- Created initial plan document
|
||||
- Established theme hierarchy: Instance → Dashboard → Tab → Row/Column → Chart/Component
|
||||
- Decided on modal with live preview for theme selection
|
||||
- Clarified color scheme (data) vs theme (UI) separation
|
||||
|
||||
### Session 1 - Implementation Started
|
||||
- Created `ComponentHeaderControls` component at:
|
||||
`src/dashboard/components/menu/ComponentHeaderControls/index.tsx`
|
||||
- Generic vertical dots menu matching SliceHeaderControls pattern
|
||||
- Uses NoAnimationDropdown + Menu from @superset-ui/core
|
||||
- Configurable menu items, edit mode visibility
|
||||
- Exports `ComponentMenuKeys` enum for standard actions
|
||||
|
||||
- Created `useComponentMenuItems` hook at:
|
||||
`src/dashboard/components/menu/ComponentHeaderControls/useComponentMenuItems.tsx`
|
||||
- Builds standard menu items (theme, delete)
|
||||
- Supports custom items before/after standard items
|
||||
- Shows "Change theme (name)" when theme applied
|
||||
|
||||
**Next Steps:**
|
||||
1. ~~Integrate ComponentHeaderControls into Markdown component~~ DONE
|
||||
2. Test with simple Edit/Preview + Theme + Delete menu
|
||||
3. ~~Remove old MarkdownModeDropdown~~ DONE
|
||||
|
||||
### Session 1 - Markdown Integration
|
||||
- Integrated `ComponentHeaderControls` into Markdown component
|
||||
- Replaced old UI elements:
|
||||
- Removed `DeleteComponentButton` from HoverMenu (now in ComponentHeaderControls)
|
||||
- Removed `MarkdownModeDropdown` from `WithPopoverMenu.menuItems`
|
||||
- New menu includes: Edit/Preview toggle, Apply Theme, Delete
|
||||
- Uses existing `HoverMenu position="top"` for proper CSS integration
|
||||
- Menu shows on hover in edit mode (leverages existing DashboardWrapper CSS)
|
||||
|
||||
**Files modified:**
|
||||
- `src/dashboard/components/gridComponents/Markdown/Markdown.jsx`
|
||||
|
||||
**Status:** Completed - all tests pass
|
||||
|
||||
### Session 2 - CSS Fix and Test Updates
|
||||
- Fixed CSS visibility issue: replaced custom `MarkdownControlsWrapper` with `HoverMenu` component
|
||||
- The custom wrapper's CSS selectors weren't being triggered by the existing DashboardWrapper CSS rules
|
||||
- Using `HoverMenu position="top"` integrates with existing CSS that shows hover menus:
|
||||
```css
|
||||
div:hover > .hover-menu-container .hover-menu { opacity: 1; }
|
||||
```
|
||||
- Updated tests to work with new menu pattern:
|
||||
- Tests now click "More Options" button to open dropdown
|
||||
- Tests look for "Preview" option (not "Edit") when in edit mode
|
||||
- All 16 Markdown tests passing
|
||||
|
||||
**Files modified:**
|
||||
- `src/dashboard/components/gridComponents/Markdown/Markdown.jsx`
|
||||
- `src/dashboard/components/gridComponents/Markdown/Markdown.test.tsx`
|
||||
|
||||
**Status:** Phase 2.2 complete, ready for Phase 2.3 (Row/Column)
|
||||
|
||||
### Session 2 - ThemeSelectorModal Implementation
|
||||
- Created `ThemeSelectorModal` component at:
|
||||
`src/dashboard/components/menu/ThemeSelectorModal/index.tsx`
|
||||
- Fetches themes from `/api/v1/theme/` API
|
||||
- Shows dropdown with theme names and badges (Default, Dark)
|
||||
- Apply/Cancel buttons
|
||||
- Stores selected theme ID in component metadata
|
||||
|
||||
- Wired up ThemeSelectorModal to Markdown component:
|
||||
- Added `isThemeSelectorOpen` state
|
||||
- Added `handleOpenThemeSelector`, `handleCloseThemeSelector`, `handleApplyTheme` methods
|
||||
- `handleApplyTheme` stores `theme_id` in component.meta via `updateComponents`
|
||||
- Modal opens when clicking "Apply theme" menu item
|
||||
|
||||
**Files created:**
|
||||
- `src/dashboard/components/menu/ThemeSelectorModal/index.tsx`
|
||||
|
||||
**Files modified:**
|
||||
- `src/dashboard/components/gridComponents/Markdown/Markdown.jsx`
|
||||
|
||||
**Status:** ThemeSelectorModal complete, all tests pass
|
||||
|
||||
**Note:** Theme selection is stored in component metadata (client-side).
|
||||
Backend persistence (Phase 3) will save this to dashboard `json_metadata.component_themes`.
|
||||
|
||||
### Session 3 - Bug Fix: setState Race Condition
|
||||
|
||||
**Problem:** Clicking "Apply theme" menu item didn't open the modal. Debug logging showed:
|
||||
- `handleOpenThemeSelector` was called and set `isThemeSelectorOpen: true`
|
||||
- But the setState callback showed `isThemeSelectorOpen: false`
|
||||
- ThemeSelectorModal received `show: false`
|
||||
|
||||
**Root Cause:** `handleChangeEditorMode` used a non-functional setState pattern:
|
||||
```javascript
|
||||
const nextState = { ...this.state, editorMode: mode }; // Reads stale state
|
||||
this.setState(nextState); // Overwrites pending isThemeSelectorOpen: true
|
||||
```
|
||||
|
||||
When the dropdown menu closed, it triggered `handleChangeFocus → handleChangeEditorMode`,
|
||||
which copied the old state (before React applied the pending `isThemeSelectorOpen: true`)
|
||||
and overwrote it.
|
||||
|
||||
**Fix:** Changed to functional setState:
|
||||
```javascript
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
editorMode: mode,
|
||||
...(mode === 'preview' ? { hasError: false } : {}),
|
||||
}));
|
||||
```
|
||||
|
||||
**Status:** Modal now opens correctly. Theme selection is stored in component metadata.
|
||||
|
||||
**Next Steps:**
|
||||
1. ~~Phase 2.3: Add ComponentHeaderControls to Row/Column~~ DONE
|
||||
2. Phase 2.4: Add ComponentHeaderControls to Tabs
|
||||
3. Phase 1.2: Add theme selector to SliceHeaderControls (charts)
|
||||
4. Phase 3: Backend persistence
|
||||
5. Phase 4: Theme rendering/application
|
||||
|
||||
### Session 3 - Phase 2.3: Row/Column Integration
|
||||
|
||||
Updated Row and Column components to use `ComponentHeaderControls`:
|
||||
|
||||
**Row.tsx changes:**
|
||||
- Replaced `DeleteComponentButton` and gear `IconButton` with `ComponentHeaderControls`
|
||||
- Removed `BackgroundStyleDropdown` from `WithPopoverMenu.menuItems`
|
||||
- Added menu items: Background toggle (Transparent/Solid), Apply theme, Delete
|
||||
- Added `ThemeSelectorModal` integration
|
||||
- Fixed `backgroundStyle` variable ordering (moved before hooks that use it)
|
||||
|
||||
**Column.jsx changes:**
|
||||
- Same pattern as Row
|
||||
- Replaced old UI elements with `ComponentHeaderControls`
|
||||
- Added theme selector modal integration
|
||||
|
||||
**Test updates:**
|
||||
- Updated Row.test.tsx and Column.test.jsx to use new menu pattern
|
||||
- Removed mocks for `DeleteComponentButton`
|
||||
- Tests now click "More Options" menu button and select "Delete" from dropdown
|
||||
|
||||
**Files modified:**
|
||||
- `src/dashboard/components/gridComponents/Row/Row.tsx`
|
||||
- `src/dashboard/components/gridComponents/Row/Row.test.tsx`
|
||||
- `src/dashboard/components/gridComponents/Column/Column.jsx`
|
||||
- `src/dashboard/components/gridComponents/Column/Column.test.jsx`
|
||||
|
||||
**Status:** All tests pass (Row, Column, Markdown)
|
||||
|
||||
### Session 4 - Phase 2.4: Tabs Integration
|
||||
|
||||
Updated Tabs and TabsRenderer components to use `ComponentHeaderControls`:
|
||||
|
||||
**TabsRenderer.tsx changes:**
|
||||
- Replaced `DeleteComponentButton` with `ComponentHeaderControls`
|
||||
- Added `handleOpenThemeSelector` prop to interface
|
||||
- Added `handleMenuClick` callback for menu actions
|
||||
- Added menu items: Apply theme, Delete
|
||||
- Menu appears in HoverMenu alongside DragHandle
|
||||
|
||||
**Tabs.jsx changes:**
|
||||
- Added `ThemeSelectorModal` import and integration
|
||||
- Added `isThemeSelectorOpen` state
|
||||
- Added `handleOpenThemeSelector`, `handleCloseThemeSelector`, `handleApplyTheme` handlers
|
||||
- Passed `handleOpenThemeSelector` to TabsRenderer
|
||||
- Theme stored in `tabsComponent.meta.theme_id`
|
||||
|
||||
**Test updates:**
|
||||
- Updated TabsRenderer.test.tsx to include `handleOpenThemeSelector` in props
|
||||
- Updated Tabs.test.tsx:
|
||||
- Removed `DeleteComponentButton` mock (no longer used)
|
||||
- Updated tests to check for `ComponentHeaderControls` via `[aria-label="More Options"]`
|
||||
- Updated delete test to use menu pattern (click menu button, then "Delete" option)
|
||||
|
||||
**Files modified:**
|
||||
- `src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx`
|
||||
- `src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.test.tsx`
|
||||
- `src/dashboard/components/gridComponents/Tabs/Tabs.jsx`
|
||||
- `src/dashboard/components/gridComponents/Tabs/Tabs.test.tsx`
|
||||
|
||||
**Status:** All tests pass (108 tests across 7 test suites)
|
||||
|
||||
**Phase 2 Complete!** All dashboard components now have consistent `ComponentHeaderControls` menu:
|
||||
- Markdown: Edit/Preview toggle, Apply theme, Delete
|
||||
- Row: Background toggle, Apply theme, Delete
|
||||
- Column: Background toggle, Apply theme, Delete
|
||||
- Tabs: Apply theme, Delete
|
||||
|
||||
**Next Steps:**
|
||||
1. ~~Phase 1.2: Add theme selector to SliceHeaderControls (charts)~~ DONE
|
||||
2. Phase 3: Backend persistence
|
||||
3. Phase 4: Theme rendering/application
|
||||
|
||||
### Session 4 - Phase 1.2: SliceHeaderControls (Chart) Theme Integration
|
||||
|
||||
Added theme selector to chart menu in dashboard view:
|
||||
|
||||
**types.ts changes:**
|
||||
- Added `currentThemeId?: number | null` prop
|
||||
- Added `onApplyTheme?: (themeId: number | null) => void` callback prop
|
||||
|
||||
**dashboard/types.ts changes:**
|
||||
- Added `ApplyTheme = 'apply_theme'` to MenuKeys enum
|
||||
|
||||
**SliceHeaderControls/index.tsx changes:**
|
||||
- Added `isThemeSelectorOpen` state
|
||||
- Added `handleCloseThemeSelector` and `handleApplyTheme` handlers
|
||||
- Added `ApplyTheme` case to handleMenuClick switch
|
||||
- Added "Apply theme" menu item (only shown if `onApplyTheme` prop is provided)
|
||||
- Added `ThemeSelectorModal` rendering
|
||||
|
||||
**Design notes:**
|
||||
- Theme selector only appears if parent passes `onApplyTheme` callback
|
||||
- This allows gradual adoption - parent components can enable theming when ready
|
||||
- Charts in dashboard view can have themes applied once parent wiring is complete
|
||||
|
||||
**Files modified:**
|
||||
- `src/dashboard/types.ts`
|
||||
- `src/dashboard/components/SliceHeaderControls/types.ts`
|
||||
- `src/dashboard/components/SliceHeaderControls/index.tsx`
|
||||
|
||||
**Status:** All 33 tests pass
|
||||
|
||||
**Remaining Work:**
|
||||
1. ~~Wire up `onApplyTheme` callback in parent component (ChartHolder/SliceHeader)~~ DONE
|
||||
2. Phase 3: Backend persistence for component themes
|
||||
3. Phase 4: Theme rendering/application
|
||||
|
||||
### Session 5 - Phase 4.2: ComponentThemeProvider Implementation
|
||||
|
||||
Created `ComponentThemeProvider` for component-level theme application:
|
||||
|
||||
**ComponentThemeProvider/index.tsx:**
|
||||
- Wrapper component that loads and applies theme for dashboard components
|
||||
- Fetches theme from ThemeController via `createTheme(themeId, parentThemeConfig)`
|
||||
- Merges component theme with parent theme for proper inheritance
|
||||
- Error boundary catches missing ThemeProvider (e.g., in tests) and gracefully falls back
|
||||
- Uses ref for parent theme config to avoid infinite re-render loops
|
||||
|
||||
**Integration:**
|
||||
- Wrapped children of Row, Column, Tabs, Markdown, ChartHolder with ComponentThemeProvider
|
||||
- Components pass `themeId={component.meta?.theme_id}` to the provider
|
||||
- Theme inheritance works: Instance → Dashboard → Tab → Row/Column → Chart/Component
|
||||
|
||||
**useComponentTheme hook:**
|
||||
- Helper hook to get theme info for UI display
|
||||
- Returns `{ themeId, themeName, hasTheme }`
|
||||
|
||||
**Files created:**
|
||||
- `src/dashboard/components/ComponentThemeProvider/index.tsx`
|
||||
- `src/dashboard/components/ComponentThemeProvider/ComponentThemeProvider.test.tsx`
|
||||
|
||||
**Status:** Theme application working. Themes now visually apply to components.
|
||||
|
||||
### Session 5 - ChartHolder/SliceHeader Wiring Complete
|
||||
|
||||
Wired up `onApplyTheme` from SliceHeaderControls through the component hierarchy:
|
||||
|
||||
**ChartHolder.tsx changes:**
|
||||
- Added `handleApplyTheme` callback that updates component.meta.theme_id
|
||||
- Uses `getComponentById` pattern to avoid stale closures
|
||||
- Passes `onApplyTheme` and `currentThemeId` to Chart component
|
||||
- Wrapped Chart with `ComponentThemeProvider`
|
||||
|
||||
**Chart.jsx and SliceHeader changes:**
|
||||
- Props flow: ChartHolder → Chart → SliceHeader → SliceHeaderControls
|
||||
- `onApplyTheme` and `currentThemeId` passed down the hierarchy
|
||||
- Theme modal now accessible from chart's menu
|
||||
|
||||
**Files modified:**
|
||||
- `src/dashboard/components/gridComponents/ChartHolder/ChartHolder.tsx`
|
||||
- `src/dashboard/components/gridComponents/Chart/Chart.jsx`
|
||||
- `src/dashboard/components/SliceHeader/index.tsx`
|
||||
|
||||
### Session 5 - Stability Fixes for handleApplyTheme
|
||||
|
||||
Fixed re-render issues caused by unstable callback dependencies:
|
||||
|
||||
**Problem:** `handleApplyTheme` callbacks in Row, Column, Tabs had the full component object
|
||||
or `props` in their dependency arrays, causing unnecessary re-renders.
|
||||
|
||||
**Solution:** Applied ref pattern to all components:
|
||||
1. Create ref: `const componentRef = useRef(component)`
|
||||
2. Keep ref updated: `useEffect(() => { componentRef.current = component }, [component])`
|
||||
3. Use ref in callback: `const currentComponent = componentRef.current`
|
||||
4. Remove component from dependencies
|
||||
|
||||
**Files modified:**
|
||||
- `src/dashboard/components/gridComponents/Row/Row.tsx`
|
||||
- `src/dashboard/components/gridComponents/Column/Column.jsx`
|
||||
- `src/dashboard/components/gridComponents/Tabs/Tabs.jsx`
|
||||
- `src/dashboard/components/gridComponents/ChartHolder/ChartHolder.tsx`
|
||||
|
||||
**ComponentThemeProvider re-render fix:**
|
||||
- Fixed infinite re-render loop caused by `parentThemeConfig` in useEffect dependencies
|
||||
- Changed to use ref pattern for parent theme config
|
||||
- Simplified `useComponentTheme` hook to avoid setState in effect
|
||||
|
||||
**Status:** All re-render issues resolved. Dashboard loads cleanly without excessive renders.
|
||||
|
||||
### Current Status Summary
|
||||
|
||||
**Completed:**
|
||||
- Phase 1.2: Chart theme in dashboard (SliceHeaderControls) ✅
|
||||
- Phase 2.1: ComponentHeaderControls component ✅
|
||||
- Phase 2.2: Markdown menu integration ✅
|
||||
- Phase 2.3: Row/Column menu integration ✅
|
||||
- Phase 2.4: Tabs menu integration ✅
|
||||
- Phase 4.2: ComponentThemeProvider and theme application ✅
|
||||
|
||||
**In Progress:**
|
||||
- Phase 3: Backend persistence (themes stored in component.meta, needs API save)
|
||||
|
||||
**Not Started:**
|
||||
- Phase 1.1: Chart theme in Explore view
|
||||
- Theme visual indicators in edit mode
|
||||
@@ -171,9 +171,11 @@ const config: Config = {
|
||||
url: 'https://superset.apache.org',
|
||||
baseUrl: '/',
|
||||
onBrokenLinks: 'warn',
|
||||
onBrokenMarkdownLinks: 'throw',
|
||||
markdown: {
|
||||
mermaid: true,
|
||||
hooks: {
|
||||
onBrokenMarkdownLinks: 'throw',
|
||||
},
|
||||
},
|
||||
favicon: '/img/favicon.ico',
|
||||
organizationName: 'apache',
|
||||
@@ -186,14 +188,6 @@ const config: Config = {
|
||||
],
|
||||
plugins: [
|
||||
require.resolve('./src/webpack.extend.ts'),
|
||||
[
|
||||
'docusaurus-plugin-less',
|
||||
{
|
||||
lessOptions: {
|
||||
javascriptEnabled: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
...dynamicPlugins,
|
||||
[
|
||||
'docusaurus-plugin-openapi-docs',
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
# Yarn version
|
||||
YARN_VERSION = "1.22.22"
|
||||
# Increase heap size for webpack bundling of Superset UI components
|
||||
NODE_OPTIONS = "--max-old-space-size=4096"
|
||||
NODE_OPTIONS = "--max-old-space-size=8192"
|
||||
|
||||
# Deploy preview settings
|
||||
[context.deploy-preview]
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"_init": "cat src/intro_header.txt ../README.md > docs/intro.md",
|
||||
"start": "yarn run _init && yarn run generate:all && NODE_ENV=development docusaurus start",
|
||||
"start": "yarn run _init && yarn run generate:all && NODE_OPTIONS='--max-old-space-size=8192' NODE_ENV=development docusaurus start",
|
||||
"start:quick": "yarn run _init && NODE_OPTIONS='--max-old-space-size=8192' NODE_ENV=development docusaurus start",
|
||||
"stop": "pkill -f 'docusaurus start' || pkill -f 'docusaurus serve' || echo 'No docusaurus server running'",
|
||||
"build": "yarn run _init && yarn run generate:all && DEBUG=docusaurus:* docusaurus build",
|
||||
"build": "yarn run _init && yarn run generate:all && NODE_OPTIONS='--max-old-space-size=8192' DEBUG=docusaurus:* docusaurus build",
|
||||
"generate:api-docs": "python3 scripts/fix-openapi-spec.py && docusaurus gen-api-docs superset && node scripts/convert-api-sidebar.mjs && node scripts/generate-api-index.mjs && node scripts/generate-api-tag-pages.mjs",
|
||||
"clean:api-docs": "docusaurus clean-api-docs superset",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
@@ -22,7 +23,7 @@
|
||||
"generate:superset-components": "node scripts/generate-superset-components.mjs",
|
||||
"generate:database-docs": "node scripts/generate-database-docs.mjs",
|
||||
"gen-db-docs": "node scripts/generate-database-docs.mjs",
|
||||
"generate:all": "yarn run generate:extension-components && yarn run generate:superset-components && yarn run generate:database-docs && yarn run generate:api-docs",
|
||||
"generate:all": "yarn run generate:extension-components & yarn run generate:superset-components & yarn run generate:database-docs & wait && yarn run generate:api-docs",
|
||||
"lint:db-metadata": "python3 ../superset/db_engine_specs/lint_metadata.py",
|
||||
"lint:db-metadata:report": "python3 ../superset/db_engine_specs/lint_metadata.py --markdown -o ../superset/db_engine_specs/METADATA_STATUS.md",
|
||||
"update:readme-db-logos": "node scripts/generate-database-docs.mjs --update-readme",
|
||||
@@ -38,15 +39,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@docusaurus/core": "3.9.2",
|
||||
"@docusaurus/plugin-client-redirects": "3.9.2",
|
||||
"@docusaurus/preset-classic": "3.9.2",
|
||||
"@docusaurus/theme-live-codeblock": "^3.9.2",
|
||||
"@docusaurus/theme-mermaid": "^3.9.2",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/core": "^11.0.0",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
@@ -55,28 +52,26 @@
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@storybook/addon-docs": "^8.6.15",
|
||||
"@storybook/blocks": "^8.6.11",
|
||||
"@storybook/channels": "^8.6.11",
|
||||
"@storybook/client-logger": "^8.6.11",
|
||||
"@storybook/components": "^8.6.11",
|
||||
"@storybook/core": "^8.6.11",
|
||||
"@storybook/core-events": "^8.6.11",
|
||||
"@storybook/blocks": "^8.6.15",
|
||||
"@storybook/channels": "^8.6.15",
|
||||
"@storybook/client-logger": "^8.6.15",
|
||||
"@storybook/components": "^8.6.15",
|
||||
"@storybook/core": "^8.6.15",
|
||||
"@storybook/core-events": "^8.6.15",
|
||||
"@storybook/csf": "^0.1.13",
|
||||
"@storybook/docs-tools": "^8.6.11",
|
||||
"@storybook/preview-api": "^8.6.11",
|
||||
"@storybook/theming": "^8.6.11",
|
||||
"@storybook/docs-tools": "^8.6.15",
|
||||
"@storybook/preview-api": "^8.6.15",
|
||||
"@storybook/theming": "^8.6.15",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"antd": "^6.2.2",
|
||||
"babel-loader": "^9.2.1",
|
||||
"caniuse-lite": "^1.0.30001766",
|
||||
"docusaurus-plugin-less": "^2.0.2",
|
||||
"@swc/core": "^1.15.11",
|
||||
"antd": "^6.2.3",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001768",
|
||||
"docusaurus-plugin-openapi-docs": "^4.6.0",
|
||||
"docusaurus-theme-openapi-docs": "^4.6.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"json-bigint": "^1.0.0",
|
||||
"less": "^4.5.1",
|
||||
"less-loader": "^12.3.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -88,8 +83,8 @@
|
||||
"reselect": "^5.1.1",
|
||||
"storybook": "^8.6.15",
|
||||
"swagger-ui-react": "^5.31.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"tinycolor2": "^1.4.2",
|
||||
"ts-loader": "^9.5.4",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -104,11 +99,11 @@
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.2.0",
|
||||
"globals": "^17.3.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"webpack": "^5.104.1"
|
||||
"webpack": "^5.105.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -124,7 +119,8 @@
|
||||
},
|
||||
"resolutions": {
|
||||
"react-redux": "^9.2.0",
|
||||
"@reduxjs/toolkit": "^2.5.0"
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"baseline-browser-mapping": "^2.9.19"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"generated": "2026-01-27T23:17:43.310Z",
|
||||
"generated": "2026-01-31T10:47:01.730Z",
|
||||
"statistics": {
|
||||
"totalDatabases": 68,
|
||||
"withDocumentation": 68,
|
||||
"withConnectionString": 68,
|
||||
"withDrivers": 35,
|
||||
"totalDatabases": 70,
|
||||
"withDocumentation": 70,
|
||||
"withConnectionString": 70,
|
||||
"withDrivers": 36,
|
||||
"withAuthMethods": 4,
|
||||
"supportsJoins": 64,
|
||||
"supportsSubqueries": 65,
|
||||
"supportsJoins": 66,
|
||||
"supportsSubqueries": 67,
|
||||
"supportsDynamicSchema": 15,
|
||||
"supportsCatalog": 9,
|
||||
"averageScore": 33,
|
||||
"averageScore": 32,
|
||||
"maxScore": 201,
|
||||
"byCategory": {
|
||||
"Other Databases": [
|
||||
@@ -109,6 +109,8 @@
|
||||
"Traditional RDBMS": [
|
||||
"Aurora MySQL (Data API)",
|
||||
"Aurora PostgreSQL (Data API)",
|
||||
"Aurora MySQL",
|
||||
"Aurora PostgreSQL",
|
||||
"CockroachDB",
|
||||
"Cloudflare D1",
|
||||
"IBM Db2",
|
||||
@@ -133,6 +135,8 @@
|
||||
"Open Source": [
|
||||
"Aurora MySQL (Data API)",
|
||||
"Aurora PostgreSQL (Data API)",
|
||||
"Aurora MySQL",
|
||||
"Aurora PostgreSQL",
|
||||
"ClickHouse",
|
||||
"CockroachDB",
|
||||
"Couchbase",
|
||||
@@ -490,6 +494,132 @@
|
||||
"query_cost_estimation": false,
|
||||
"sql_validation": false
|
||||
},
|
||||
"Aurora MySQL": {
|
||||
"engine": "aurora_mysql",
|
||||
"engine_name": "Aurora MySQL",
|
||||
"module": "aurora",
|
||||
"documentation": {
|
||||
"description": "MySQL is a popular open-source relational database.",
|
||||
"logo": "mysql.png",
|
||||
"homepage_url": "https://www.mysql.com/",
|
||||
"categories": [
|
||||
"TRADITIONAL_RDBMS",
|
||||
"OPEN_SOURCE"
|
||||
],
|
||||
"pypi_packages": [
|
||||
"mysqlclient"
|
||||
],
|
||||
"connection_string": "mysql://{username}:{password}@{host}/{database}",
|
||||
"default_port": 3306,
|
||||
"parameters": {
|
||||
"username": "Database username",
|
||||
"password": "Database password",
|
||||
"host": "localhost, 127.0.0.1, IP address, or hostname",
|
||||
"database": "Database name"
|
||||
},
|
||||
"host_examples": [
|
||||
{
|
||||
"platform": "Localhost",
|
||||
"host": "localhost or 127.0.0.1"
|
||||
},
|
||||
{
|
||||
"platform": "Docker on Linux",
|
||||
"host": "172.18.0.1"
|
||||
},
|
||||
{
|
||||
"platform": "Docker on macOS",
|
||||
"host": "docker.for.mac.host.internal"
|
||||
},
|
||||
{
|
||||
"platform": "On-premise",
|
||||
"host": "IP address or hostname"
|
||||
}
|
||||
],
|
||||
"drivers": [
|
||||
{
|
||||
"name": "mysqlclient",
|
||||
"pypi_package": "mysqlclient",
|
||||
"connection_string": "mysql://{username}:{password}@{host}/{database}",
|
||||
"is_recommended": true,
|
||||
"notes": "Recommended driver. May fail with caching_sha2_password auth."
|
||||
},
|
||||
{
|
||||
"name": "mysql-connector-python",
|
||||
"pypi_package": "mysql-connector-python",
|
||||
"connection_string": "mysql+mysqlconnector://{username}:{password}@{host}/{database}",
|
||||
"is_recommended": false,
|
||||
"notes": "Required for newer MySQL databases using caching_sha2_password authentication."
|
||||
}
|
||||
]
|
||||
},
|
||||
"time_grains": {},
|
||||
"score": 0,
|
||||
"max_score": 0,
|
||||
"joins": true,
|
||||
"subqueries": true,
|
||||
"supports_dynamic_schema": false,
|
||||
"supports_catalog": false,
|
||||
"supports_dynamic_catalog": false,
|
||||
"ssh_tunneling": false,
|
||||
"query_cancelation": false,
|
||||
"supports_file_upload": false,
|
||||
"user_impersonation": false,
|
||||
"query_cost_estimation": false,
|
||||
"sql_validation": false
|
||||
},
|
||||
"Aurora PostgreSQL": {
|
||||
"engine": "aurora_postgresql",
|
||||
"engine_name": "Aurora PostgreSQL",
|
||||
"module": "aurora",
|
||||
"documentation": {
|
||||
"description": "PostgreSQL is an advanced open-source relational database.",
|
||||
"logo": "postgresql.svg",
|
||||
"homepage_url": "https://www.postgresql.org/",
|
||||
"categories": [
|
||||
"TRADITIONAL_RDBMS",
|
||||
"OPEN_SOURCE"
|
||||
],
|
||||
"pypi_packages": [
|
||||
"psycopg2"
|
||||
],
|
||||
"connection_string": "postgresql://{username}:{password}@{host}:{port}/{database}",
|
||||
"default_port": 5432,
|
||||
"parameters": {
|
||||
"username": "Database username",
|
||||
"password": "Database password",
|
||||
"host": "For localhost: localhost or 127.0.0.1. For AWS: endpoint URL",
|
||||
"port": "Default 5432",
|
||||
"database": "Database name"
|
||||
},
|
||||
"notes": "The psycopg2 library comes bundled with Superset Docker images.",
|
||||
"connection_examples": [
|
||||
{
|
||||
"description": "Basic connection",
|
||||
"connection_string": "postgresql://{username}:{password}@{host}:{port}/{database}"
|
||||
},
|
||||
{
|
||||
"description": "With SSL required",
|
||||
"connection_string": "postgresql://{username}:{password}@{host}:{port}/{database}?sslmode=require"
|
||||
}
|
||||
],
|
||||
"docs_url": "https://www.postgresql.org/docs/",
|
||||
"sqlalchemy_docs_url": "https://docs.sqlalchemy.org/en/13/dialects/postgresql.html"
|
||||
},
|
||||
"time_grains": {},
|
||||
"score": 0,
|
||||
"max_score": 0,
|
||||
"joins": true,
|
||||
"subqueries": true,
|
||||
"supports_dynamic_schema": false,
|
||||
"supports_catalog": false,
|
||||
"supports_dynamic_catalog": false,
|
||||
"ssh_tunneling": false,
|
||||
"query_cancelation": false,
|
||||
"supports_file_upload": false,
|
||||
"user_impersonation": false,
|
||||
"query_cost_estimation": false,
|
||||
"sql_validation": false
|
||||
},
|
||||
"Google BigQuery": {
|
||||
"engine": "google_bigquery",
|
||||
"engine_name": "Google BigQuery",
|
||||
|
||||
@@ -28,7 +28,7 @@ import databaseData from '../data/databases.json';
|
||||
import BlurredSection from '../components/BlurredSection';
|
||||
import DataSet from '../../../RESOURCES/INTHEWILD.yaml';
|
||||
import type { DatabaseData } from '../components/databases/types';
|
||||
import '../styles/main.less';
|
||||
import '../styles/main.css';
|
||||
|
||||
// Build database list from databases.json (databases with logos)
|
||||
// Deduplicate by logo filename to avoid showing the same logo twice
|
||||
@@ -795,7 +795,7 @@ export default function Home(): JSX.Element {
|
||||
</StyledIntegrations>
|
||||
</BlurredSection>
|
||||
{/* Only show carousel when we have enough logos (>10) for a good display */}
|
||||
{companiesWithLogos.length > 10 && (
|
||||
{companiesWithLogos.length > 7 && (
|
||||
<BlurredSection>
|
||||
<div style={{ padding: '0 20px' }}>
|
||||
<SectionHeader
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
@import 'antd-theme.less';
|
||||
|
||||
body {
|
||||
font-family: var(--ifm-font-family-base);
|
||||
@@ -81,26 +80,29 @@ a > span > svg {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
&::before {
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(180deg, #11b0d8 0%, #116f86 100%);
|
||||
content: '';
|
||||
display: block;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
z-index: -1;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
&:hover {
|
||||
color: #ffffff;
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.default-button-theme::before {
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(180deg, #11b0d8 0%, #116f86 100%);
|
||||
content: '';
|
||||
display: block;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
z-index: -1;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.default-button-theme:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.default-button-theme:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
@@ -109,32 +111,32 @@ a > span > svg {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.get-started-button {
|
||||
border-radius: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
width: 142px;
|
||||
padding: 7px 0;
|
||||
margin-right: 20px;
|
||||
}
|
||||
.navbar .get-started-button {
|
||||
border-radius: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
width: 142px;
|
||||
padding: 7px 0;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.github-button {
|
||||
background-image: url('/img/github.png');
|
||||
background-size: contain;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.navbar .github-button {
|
||||
background-image: url('/img/github.png');
|
||||
background-size: contain;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.navbar--dark {
|
||||
background-color: transparent;
|
||||
border-bottom: 1px solid rgba(24, 115, 132, 0.4);
|
||||
}
|
||||
|
||||
.github-button {
|
||||
background-image: url('/img/github-dark.png');
|
||||
}
|
||||
.navbar--dark .github-button {
|
||||
background-image: url('/img/github-dark.png');
|
||||
}
|
||||
|
||||
.navbar__logo {
|
||||
@@ -153,11 +155,11 @@ a > span > svg {
|
||||
.navbar {
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.get-started-button,
|
||||
.github-button {
|
||||
display: none;
|
||||
}
|
||||
.navbar .get-started-button,
|
||||
.navbar .github-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar__items {
|
||||
@@ -186,20 +188,20 @@ a > span > svg {
|
||||
--docsearch-searchbox-background: var(--ifm-navbar-background-color);
|
||||
border: 1px solid #187384;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&.DocSearch-Button {
|
||||
width: 225px;
|
||||
}
|
||||
.navbar .DocSearch.DocSearch-Button {
|
||||
width: 225px;
|
||||
}
|
||||
|
||||
.DocSearch-Search-Icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.navbar .DocSearch .DocSearch-Search-Icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.DocSearch-Button-Key,
|
||||
.DocSearch-Button-Placeholder {
|
||||
display: none;
|
||||
}
|
||||
.navbar .DocSearch .DocSearch-Button-Key,
|
||||
.navbar .DocSearch .DocSearch-Button-Placeholder {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar--dark .DocSearch {
|
||||
@@ -232,25 +234,25 @@ a > span > svg {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.footer__ci-services span {
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transition: opacity 0.2s;
|
||||
.footer__ci-services a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
.footer__ci-services a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
img {
|
||||
height: 28px;
|
||||
}
|
||||
.footer__ci-services img {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.footer__divider {
|
||||
@@ -268,13 +270,13 @@ a > span > svg {
|
||||
.footer__ci-services {
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
.footer__ci-services span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
img {
|
||||
height: 22px;
|
||||
}
|
||||
.footer__ci-services img {
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
@@ -67,8 +67,8 @@ export default function webpackExtendPlugin(): Plugin<void> {
|
||||
use: 'js-yaml-loader',
|
||||
});
|
||||
|
||||
// Add babel-loader rule for superset-frontend files
|
||||
// This ensures Emotion CSS-in-JS is processed correctly for SSG
|
||||
// Add swc-loader rule for superset-frontend files
|
||||
// SWC is a Rust-based transpiler that's significantly faster than babel
|
||||
const supersetFrontendPath = path.resolve(
|
||||
__dirname,
|
||||
'../../superset-frontend',
|
||||
@@ -76,26 +76,37 @@ export default function webpackExtendPlugin(): Plugin<void> {
|
||||
config.module?.rules?.push({
|
||||
test: /\.(tsx?|jsx?)$/,
|
||||
include: supersetFrontendPath,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
loader: 'swc-loader',
|
||||
options: {
|
||||
presets: [
|
||||
[
|
||||
'@babel/preset-react',
|
||||
{
|
||||
// Ignore superset-frontend/.swcrc which references plugins not
|
||||
// installed in the docs workspace (e.g. @swc/plugin-emotion)
|
||||
swcrc: false,
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: 'typescript',
|
||||
tsx: true,
|
||||
},
|
||||
transform: {
|
||||
react: {
|
||||
runtime: 'automatic',
|
||||
importSource: '@emotion/react',
|
||||
},
|
||||
],
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
plugins: ['@emotion/babel-plugin'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
devtool: isDev ? 'eval-source-map' : config.devtool,
|
||||
devtool: isDev ? false : config.devtool,
|
||||
cache: {
|
||||
type: 'filesystem',
|
||||
buildDependencies: {
|
||||
config: [__filename],
|
||||
},
|
||||
},
|
||||
...(isDev && {
|
||||
optimization: {
|
||||
...config.optimization,
|
||||
@@ -208,8 +219,6 @@ export default function webpackExtendPlugin(): Plugin<void> {
|
||||
),
|
||||
},
|
||||
},
|
||||
// We're removing the ts-loader rule that was processing superset-frontend files
|
||||
// This will prevent TypeScript errors from files outside the docs directory
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
13
docs/static/feature-flags.json
vendored
13
docs/static/feature-flags.json
vendored
@@ -114,6 +114,12 @@
|
||||
"lifecycle": "testing",
|
||||
"description": "Allow users to export full CSV of table viz type. Warning: Could cause server memory/compute issues with large datasets."
|
||||
},
|
||||
{
|
||||
"name": "AWS_DATABASE_IAM_AUTH",
|
||||
"default": false,
|
||||
"lifecycle": "testing",
|
||||
"description": "Enable AWS IAM authentication for database connections (Aurora, Redshift). Allows cross-account role assumption via STS AssumeRole. Security note: When enabled, ensure Superset's IAM role has restricted sts:AssumeRole permissions to prevent unauthorized access."
|
||||
},
|
||||
{
|
||||
"name": "CACHE_IMPERSONATION",
|
||||
"default": false,
|
||||
@@ -241,6 +247,13 @@
|
||||
"description": "Enables dashboard virtualization for improved performance",
|
||||
"category": "path_to_deprecation"
|
||||
},
|
||||
{
|
||||
"name": "DASHBOARD_VIRTUALIZATION_DEFER_DATA",
|
||||
"default": false,
|
||||
"lifecycle": "stable",
|
||||
"description": "Supports simultaneous data and dashboard virtualization for backend performance",
|
||||
"category": "runtime_config"
|
||||
},
|
||||
{
|
||||
"name": "DATAPANEL_CLOSED_BY_DEFAULT",
|
||||
"default": false,
|
||||
|
||||
BIN
docs/static/img/databases/alloydb.png
vendored
Normal file
BIN
docs/static/img/databases/alloydb.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
57
docs/static/img/databases/apache-iotdb.svg
vendored
Normal file
57
docs/static/img/databases/apache-iotdb.svg
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 24.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1"
|
||||
id="Õ_xBA__x2264__x201E__1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 107.7 107.7"
|
||||
style="enable-background:new 0 0 107.7 107.7;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#9E2878;}
|
||||
</style>
|
||||
<g>
|
||||
<g id="g1133" transform="translate(-244.51235,-228.78793)">
|
||||
<path id="path1119" class="st0" d="M340.8,253.8c2.6-1,5.5,0.4,6.2,3c0.7,2.6-1.1,5.7-3.7,6.4c-3.2,0.6-6.2-1.4-9.3,0.2
|
||||
c-3.1,1.7-4,5.2-4.7,8.3c-1.4,5-8.5,7.3-12.1,4.2c-3.3-2.4-3.4-7.8-0.2-11c2.2-2.5,5.9-3.3,8.8-2c2.7,1.2,6,1.7,8.6-0.4
|
||||
C337.5,260.1,336.7,255.1,340.8,253.8L340.8,253.8z"/>
|
||||
<path id="path1121" class="st0" d="M280.5,244.7c4.2-2.2,9.5,1.5,8.2,6.1c-1.4,5.4-0.7,11.5,2.9,15.5c3.4,4,9.8,4.8,14.6,1.9
|
||||
c3.7-2.1,6-5.8,7.4-9.6c1-3.1,0.6-6.2,1.1-9.3c1-3.8,5.8-6,9.1-4.2c3.2,1.4,4,5.9,1.7,8.8c-1.4,2.2-4.2,2.7-6.3,3.8
|
||||
c-3.6,1.9-6.5,4.9-8.4,8.6c-1.2,2.1-1.1,4.5-1.7,6.7c-0.9,1.9-3,2.7-4.8,2.9c-4.8,0.6-9.5,3-13,6.7c-1.7,1.7-3.1,4.2-5.6,4.2
|
||||
c-2.7-0.2-4.7-2.6-7.4-2.7c-4.9-0.7-10.2,0.7-14.4,4c-3.7,3.1-9.4,0.8-9.6-3.7c-0.9-5.1,6.2-9.5,10.1-6.2c4.3,4,10.8,5.6,16.9,3.7
|
||||
c5.6-1.9,9.7-8.3,8.6-14c-0.9-4.9-4.2-9.3-8.6-11.4c-1.7-0.8-3.6-1.6-4.2-3.5C275.6,250.2,277.3,246.2,280.5,244.7L280.5,244.7z"
|
||||
/>
|
||||
<path id="path1123" class="st0" d="M277.8,235.9c2.2-0.8,4.2,1.3,3.3,3.4c-0.6,2.1-3.7,2.7-4.8,1.1
|
||||
C275,238.9,276,236.4,277.8,235.9z"/>
|
||||
<path id="path1125" class="st0" d="M246.9,278.8c2.2-0.8,4.2,1.2,3.3,3.4c-0.6,2.1-3.7,2.7-4.8,1C244,281.9,245,279.3,246.9,278.8
|
||||
L246.9,278.8z"/>
|
||||
<path id="path1127" class="st0" d="M328.2,236.2c2.2-0.7,4.2,1.3,3.3,3.5c-0.6,2-3.7,2.7-4.8,1
|
||||
C325.4,239.2,326.3,236.8,328.2,236.2z"/>
|
||||
<path id="path1129" class="st0" d="M253.6,257.7c0.4-3.7,5.5-5.9,8.1-3.6c1.9,1.1,1.6,3.6,2.2,5.4c0.4,2.4,2.7,4.3,5.2,4.3
|
||||
c3.2,0.3,6.4-2.3,9.5-1.2c4.8,1.2,6.5,7.5,3.2,11.5c-2.9,4.1-9.3,4.5-12,0.7c-2.4-2.8-0.5-7.1-2.7-10.1c-1.7-2.9-5.4-2.7-8.3-1.9
|
||||
C255.8,263.8,253,260.7,253.6,257.7L253.6,257.7z"/>
|
||||
<path id="path1131" class="st0" d="M300.8,230c3.3-1.9,7.5,0.9,6.7,4.6c0,2.3-2.2,3.6-3.5,5.2c-1.9,1.9-2.3,4.9-1.2,7
|
||||
c1.3,2.9,4.9,4,5.5,7.2c1.2,4.8-3.3,10.1-8.2,9.8c-4.8,0.1-8.3-5-6.3-9.5c1.2-3.8,5.8-4.9,7.2-8.5c1.7-3.2-0.4-6.1-2.4-8.1
|
||||
C296.7,235.6,297.9,231.4,300.8,230z"/>
|
||||
</g>
|
||||
<g id="g1149" transform="translate(-244.51235,-228.78793)">
|
||||
<path id="path1135" class="st0" d="M256,311.5c-2.6,1-5.5-0.4-6.2-3c-0.7-2.6,1.1-5.7,3.7-6.4c3.2-0.6,6.2,1.4,9.3-0.2
|
||||
c3.1-1.6,4-5.1,4.7-8.2c1.4-5,8.5-7.3,12.1-4.2c3.3,2.4,3.4,7.8,0.2,10.9c-2.2,2.5-5.9,3.3-8.8,2c-2.7-1.2-6-1.7-8.6,0.4
|
||||
C259.1,305,259.9,310.2,256,311.5L256,311.5z"/>
|
||||
<path id="path1137" class="st0" d="M316.1,320.5c-4.2,2.2-9.5-1.5-8.2-6c1.4-5.4,0.7-11.6-2.9-15.6c-3.4-4-9.8-4.7-14.6-1.9
|
||||
c-3.7,2-6,5.7-7.4,9.5c-1,3.1-0.6,6.2-1.1,9.3c-1,3.8-5.8,6-9.1,4.3c-3.2-1.4-4-5.9-1.7-8.9c1.4-2.2,4.2-2.7,6.3-3.8
|
||||
c3.6-1.9,6.5-4.9,8.4-8.6c1.2-2,1.1-4.5,1.7-6.6c0.9-1.9,3-2.7,4.8-2.9c4.8-0.6,9.5-3,13-6.7c1.7-1.6,3.1-4.2,5.6-4.1
|
||||
c2.7,0.1,4.7,2.5,7.4,2.7c4.9,0.6,10.2-0.8,14.4-4c3.7-3.2,9.4-0.9,9.6,3.6c0.9,5.1-6.2,9.5-10.1,6.2c-4.3-4-10.8-5.6-16.9-3.7
|
||||
c-5.6,1.9-9.7,8.3-8.6,14c0.9,4.8,4.2,9.3,8.6,11.3c1.7,0.8,3.6,1.6,4.2,3.5C321.2,314.9,319.4,319.1,316.1,320.5L316.1,320.5z"/>
|
||||
<path id="path1139" class="st0" d="M318.9,329.4c-2.2,0.8-4.2-1.3-3.3-3.4c0.6-2.1,3.7-2.7,4.8-1.1
|
||||
C321.7,326.3,320.7,328.8,318.9,329.4z"/>
|
||||
<path id="path1141" class="st0" d="M349.9,286.5c-2.2,0.7-4.2-1.3-3.3-3.5c0.6-2.1,3.7-2.7,4.8-1
|
||||
C352.8,283.5,351.8,285.9,349.9,286.5z"/>
|
||||
<path id="path1143" class="st0" d="M268.5,329c-2.2,0.7-4.2-1.3-3.3-3.5c0.6-2,3.7-2.7,4.8-1C271.3,325.9,270.3,328.5,268.5,329z"
|
||||
/>
|
||||
<path id="path1145" class="st0" d="M343.1,307.4c-0.4,3.7-5.5,5.9-8.1,3.6c-1.9-1.1-1.6-3.6-2.2-5.4c-0.4-2.4-2.7-4.3-5.2-4.3
|
||||
c-3.2-0.3-6.4,2.3-9.5,1.2c-4.8-1.2-6.5-7.5-3.2-11.5c2.9-4.1,9.3-4.5,12-0.7c2.4,2.8,0.5,7,2.7,10c1.7,2.9,5.4,2.7,8.3,1.9
|
||||
C341,301.4,343.8,304.4,343.1,307.4L343.1,307.4z"/>
|
||||
<path id="path1147" class="st0" d="M295.8,335.2c-3.3,2-7.5-0.9-6.7-4.6c0-2.3,2.2-3.6,3.5-5.2c1.9-1.9,2.3-4.9,1.2-7
|
||||
c-1.3-2.9-4.9-4-5.5-7.2c-1.2-4.8,3.3-10.1,8.2-9.8c4.8-0.1,8.3,5,6.3,9.6c-1.2,3.7-5.8,4.8-7.2,8.4c-1.7,3.2,0.4,6.1,2.4,8.2
|
||||
C300,329.6,298.8,333.8,295.8,335.2L295.8,335.2z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
BIN
docs/static/img/databases/apache-phoenix.png
vendored
Normal file
BIN
docs/static/img/databases/apache-phoenix.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
docs/static/img/databases/neon.png
vendored
Normal file
BIN
docs/static/img/databases/neon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
1
docs/static/img/databases/supabase.svg
vendored
Normal file
1
docs/static/img/databases/supabase.svg
vendored
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.7 KiB |
BIN
docs/static/img/logos/xnet.png
vendored
Normal file
BIN
docs/static/img/logos/xnet.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
675
docs/yarn.lock
675
docs/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -174,7 +174,7 @@ oracle = ["cx-Oracle>8.0.0, <8.1"]
|
||||
parseable = ["sqlalchemy-parseable>=0.1.3,<0.2.0"]
|
||||
pinot = ["pinotdb>=5.0.0, <6.0.0"]
|
||||
playwright = ["playwright>=1.37.0, <2"]
|
||||
postgres = ["psycopg2-binary==2.9.6"]
|
||||
postgres = ["psycopg2-binary==2.9.9"]
|
||||
presto = ["pyhive[presto]>=0.6.5"]
|
||||
trino = ["trino>=0.328.0"]
|
||||
prophet = ["prophet>=1.1.6, <2"]
|
||||
@@ -204,6 +204,7 @@ ydb = ["ydb-sqlalchemy>=0.1.2"]
|
||||
development = [
|
||||
# no bounds for apache-superset-extensions-cli until a stable version
|
||||
"apache-superset-extensions-cli",
|
||||
"boto3",
|
||||
"docker",
|
||||
"flask-testing",
|
||||
"freezegun",
|
||||
@@ -437,6 +438,7 @@ authorized_licenses = [
|
||||
"apache software",
|
||||
"apache software, bsd",
|
||||
"bsd",
|
||||
"bsd-2-clause",
|
||||
"bsd-3-clause",
|
||||
"isc license (iscl)",
|
||||
"isc license",
|
||||
|
||||
@@ -16,8 +16,14 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
#
|
||||
urllib3>=2.6.0,<3.0.0
|
||||
werkzeug>=3.0.1
|
||||
# Security: CVE-2026-21441 - decompression bomb bypass on redirects
|
||||
urllib3>=2.6.3,<3.0.0
|
||||
# Security: GHSA-87hc-h4r5-73f7 - Windows path traversal fix
|
||||
werkzeug>=3.1.5,<4.0.0
|
||||
# Security: CVE-2025-68146 - TOCTOU symlink vulnerability
|
||||
filelock>=3.20.3,<4.0.0
|
||||
# Security: decompression bomb fix (required by aiohttp 3.13.3)
|
||||
brotli>=1.2.0,<2.0.0
|
||||
numexpr>=2.9.0
|
||||
|
||||
# 5.0.0 has a sensitive deprecation used in other libs
|
||||
|
||||
@@ -36,8 +36,10 @@ blinker==1.9.0
|
||||
# via flask
|
||||
bottleneck==1.5.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
brotli==1.1.0
|
||||
# via flask-compress
|
||||
brotli==1.2.0
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# flask-compress
|
||||
cachelib==0.13.0
|
||||
# via
|
||||
# flask-caching
|
||||
@@ -101,6 +103,8 @@ email-validator==2.2.0
|
||||
# via flask-appbuilder
|
||||
et-xmlfile==2.0.0
|
||||
# via openpyxl
|
||||
filelock==3.20.3
|
||||
# via -r requirements/base.in
|
||||
flask==2.3.3
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
@@ -289,7 +293,7 @@ prompt-toolkit==3.0.51
|
||||
# via click-repl
|
||||
pyarrow==16.1.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
pyasn1==0.6.1
|
||||
pyasn1==0.6.2
|
||||
# via
|
||||
# pyasn1-modules
|
||||
# rsa
|
||||
@@ -436,7 +440,7 @@ tzdata==2025.2
|
||||
# pandas
|
||||
url-normalize==2.2.1
|
||||
# via requests-cache
|
||||
urllib3==2.6.0
|
||||
urllib3==2.6.3
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# requests
|
||||
@@ -453,7 +457,7 @@ wcwidth==0.2.13
|
||||
# via prompt-toolkit
|
||||
websocket-client==1.8.0
|
||||
# via selenium
|
||||
werkzeug==3.1.3
|
||||
werkzeug==3.1.5
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# flask
|
||||
|
||||
@@ -76,11 +76,17 @@ blinker==1.9.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask
|
||||
boto3==1.42.39
|
||||
# via apache-superset
|
||||
botocore==1.42.39
|
||||
# via
|
||||
# boto3
|
||||
# s3transfer
|
||||
bottleneck==1.5.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
brotli==1.1.0
|
||||
brotli==1.2.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-compress
|
||||
@@ -235,8 +241,10 @@ fakeredis==2.32.1
|
||||
# via pydocket
|
||||
fastmcp==2.14.3
|
||||
# via apache-superset
|
||||
filelock==3.12.2
|
||||
# via virtualenv
|
||||
filelock==3.20.3
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# virtualenv
|
||||
flask==2.3.3
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -458,6 +466,10 @@ jinja2==3.1.6
|
||||
# apache-superset-extensions-cli
|
||||
# flask
|
||||
# flask-babel
|
||||
jmespath==1.1.0
|
||||
# via
|
||||
# boto3
|
||||
# botocore
|
||||
jsonpath-ng==1.7.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -700,7 +712,7 @@ protobuf==4.25.5
|
||||
# proto-plus
|
||||
psutil==6.1.0
|
||||
# via apache-superset
|
||||
psycopg2-binary==2.9.6
|
||||
psycopg2-binary==2.9.9
|
||||
# via apache-superset
|
||||
py-key-value-aio==0.3.0
|
||||
# via
|
||||
@@ -714,7 +726,7 @@ pyarrow==16.1.0
|
||||
# apache-superset
|
||||
# db-dtypes
|
||||
# pandas-gbq
|
||||
pyasn1==0.6.1
|
||||
pyasn1==0.6.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# pyasn1-modules
|
||||
@@ -810,6 +822,7 @@ python-dateutil==2.9.0.post0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
# botocore
|
||||
# celery
|
||||
# croniter
|
||||
# flask-appbuilder
|
||||
@@ -913,6 +926,8 @@ rsa==4.9.1
|
||||
# google-auth
|
||||
ruff==0.9.7
|
||||
# via apache-superset
|
||||
s3transfer==0.16.0
|
||||
# via boto3
|
||||
secretstorage==3.5.0
|
||||
# via keyring
|
||||
selenium==4.32.0
|
||||
@@ -1061,9 +1076,10 @@ url-normalize==2.2.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# requests-cache
|
||||
urllib3==2.6.0
|
||||
urllib3==2.6.3
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# botocore
|
||||
# docker
|
||||
# requests
|
||||
# requests-cache
|
||||
@@ -1095,7 +1111,7 @@ websocket-client==1.8.0
|
||||
# selenium
|
||||
websockets==15.0.1
|
||||
# via fastmcp
|
||||
werkzeug==3.1.3
|
||||
werkzeug==3.1.5
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask
|
||||
|
||||
1114
superset-embedded-sdk/package-lock.json
generated
1114
superset-embedded-sdk/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -59,7 +59,7 @@ module.exports = {
|
||||
],
|
||||
coverageReporters: ['lcov', 'json-summary', 'html', 'text'],
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!d3-(array|interpolate|color|time|scale|time-format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|@rjsf/*.|sinon|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|react-error-boundary|react-json-tree|react-base16-styling|lodash-es)',
|
||||
'node_modules/(?!d3-(array|interpolate|color|time|scale|time-format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|sinon|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|react-error-boundary|react-json-tree|react-base16-styling|lodash-es)',
|
||||
],
|
||||
preset: 'ts-jest',
|
||||
transform: {
|
||||
|
||||
956
superset-frontend/package-lock.json
generated
956
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -161,7 +161,7 @@
|
||||
"geostyler-openlayers-parser": "^4.3.0",
|
||||
"geostyler-style": "7.5.0",
|
||||
"geostyler-wfs-parser": "^2.0.3",
|
||||
"googleapis": "^170.1.0",
|
||||
"googleapis": "^171.2.0",
|
||||
"immer": "^11.1.3",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
@@ -171,7 +171,7 @@
|
||||
"json-stringify-pretty-compact": "^2.0.0",
|
||||
"lodash": "^4.17.23",
|
||||
"mapbox-gl": "^3.18.1",
|
||||
"markdown-to-jsx": "^9.6.1",
|
||||
"markdown-to-jsx": "^9.7.2",
|
||||
"match-sorter": "^6.3.4",
|
||||
"memoize-one": "^5.2.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
@@ -179,9 +179,10 @@
|
||||
"nanoid": "^5.1.6",
|
||||
"ol": "^7.5.2",
|
||||
"prop-types": "^15.8.1",
|
||||
"query-string": "6.14.1",
|
||||
"query-string": "9.3.1",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^17.0.2",
|
||||
"react-arborist": "^3.4.3",
|
||||
"react-checkbox-tree": "^1.8.0",
|
||||
"react-diff-viewer-continued": "^3.4.0",
|
||||
"react-dnd": "^11.1.3",
|
||||
@@ -216,27 +217,28 @@
|
||||
"urijs": "^1.19.8",
|
||||
"use-event-callback": "^0.1.0",
|
||||
"use-immer": "^0.11.0",
|
||||
"use-query-params": "^1.1.9",
|
||||
"use-query-params": "^2.2.2",
|
||||
"uuid": "^13.0.0",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@applitools/eyes-storybook": "^3.63.9",
|
||||
"@applitools/eyes-storybook": "^3.63.10",
|
||||
"@babel/cli": "^7.28.6",
|
||||
"@babel/compat-data": "^7.28.4",
|
||||
"@babel/core": "^7.28.6",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/eslint-parser": "^7.28.6",
|
||||
"@babel/node": "^7.28.6",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.28.6",
|
||||
"@babel/plugin-transform-runtime": "^7.28.5",
|
||||
"@babel/preset-env": "^7.28.6",
|
||||
"@babel/preset-env": "^7.29.0",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@babel/register": "^7.23.7",
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"@babel/runtime-corejs3": "^7.28.6",
|
||||
"@babel/runtime-corejs3": "^7.29.0",
|
||||
"@babel/types": "^7.28.6",
|
||||
"@cypress/react": "^8.0.2",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -244,18 +246,18 @@
|
||||
"@hot-loader/react-dom": "^17.0.2",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@mihkeleidast/storybook-addon-source": "^1.0.1",
|
||||
"@playwright/test": "^1.58.0",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-actions": "8.6.14",
|
||||
"@storybook/addon-controls": "8.6.14",
|
||||
"@storybook/addon-essentials": "8.6.14",
|
||||
"@storybook/addon-links": "8.6.14",
|
||||
"@storybook/addon-mdx-gfm": "8.6.14",
|
||||
"@storybook/components": "8.6.14",
|
||||
"@storybook/preview-api": "8.6.14",
|
||||
"@storybook/react": "8.6.14",
|
||||
"@storybook/react-webpack5": "8.6.14",
|
||||
"@storybook/test": "^8.6.14",
|
||||
"@storybook/addon-actions": "^8.6.15",
|
||||
"@storybook/addon-controls": "^8.6.15",
|
||||
"@storybook/addon-essentials": "^8.6.15",
|
||||
"@storybook/addon-links": "^8.6.15",
|
||||
"@storybook/addon-mdx-gfm": "^8.6.15",
|
||||
"@storybook/components": "^8.6.15",
|
||||
"@storybook/preview-api": "^8.6.15",
|
||||
"@storybook/react": "^8.6.15",
|
||||
"@storybook/react-webpack5": "^8.6.15",
|
||||
"@storybook/test": "^8.6.15",
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.14.0",
|
||||
@@ -272,7 +274,7 @@
|
||||
"@types/js-levenshtein": "^1.1.3",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/node": "^25.1.0",
|
||||
"@types/react": "^17.0.83",
|
||||
"@types/react-dom": "^17.0.26",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
@@ -286,6 +288,7 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/sinon": "^17.0.3",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"babel-jest": "^30.0.2",
|
||||
@@ -294,12 +297,12 @@
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"babel-plugin-typescript-to-proptypes": "^2.0.0",
|
||||
"baseline-browser-mapping": "^2.9.18",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"copy-webpack-plugin": "^13.0.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.2",
|
||||
"css-loader": "^7.1.3",
|
||||
"css-minimizer-webpack-plugin": "^7.0.4",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^7.2.0",
|
||||
@@ -322,7 +325,7 @@
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"eslint-plugin-testing-library": "^7.15.4",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^11.1.5",
|
||||
"fetch-mock": "^12.6.0",
|
||||
"fork-ts-checker-webpack-plugin": "^9.1.0",
|
||||
"history": "^5.3.0",
|
||||
"html-webpack-plugin": "^5.6.6",
|
||||
@@ -359,9 +362,10 @@
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"wait-on": "^9.0.3",
|
||||
"webpack": "^5.104.1",
|
||||
"webpack": "^5.105.0",
|
||||
"webpack-bundle-analyzer": "^5.2.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.3",
|
||||
@@ -386,7 +390,7 @@
|
||||
"puppeteer": "^22.4.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"underscore": "^1.13.7",
|
||||
"jspdf": "^3.0.2",
|
||||
"jspdf": "^4.0.0",
|
||||
"nwsapi": "^2.2.13",
|
||||
"@deck.gl/aggregation-layers": "~9.2.2",
|
||||
"@deck.gl/core": "~9.2.2",
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.28.6",
|
||||
"@babel/core": "^7.28.6",
|
||||
"@babel/preset-env": "^7.28.6",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/preset-env": "^7.29.0",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"install": "^0.13.0",
|
||||
|
||||
@@ -18,12 +18,19 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Editors API for Superset extension editor contributions.
|
||||
* @fileoverview Editors API for Superset text editor integration.
|
||||
*
|
||||
* This module defines the interfaces and types for editor contributions to the
|
||||
* Superset platform. Extensions can register custom text editor implementations
|
||||
* (e.g., Monaco, CodeMirror) through the extension manifest, replacing the
|
||||
* default Ace editor for specific languages.
|
||||
* This module defines the interfaces and types for working with text editors
|
||||
* in Superset. It provides:
|
||||
*
|
||||
* - `EditorHandle`: Imperative API for programmatically controlling editors
|
||||
* (get/set content, cursor position, selections, annotations, completions)
|
||||
* - `EditorProps`: Props contract for editor React components
|
||||
* - `CompletionProvider`: Interface for registering custom autocomplete providers
|
||||
* - Registration functions for custom editor implementations
|
||||
*
|
||||
* The API is editor-agnostic, supporting Ace, Monaco, CodeMirror, or any
|
||||
* compliant implementation.
|
||||
*/
|
||||
|
||||
import { ForwardRefExoticComponent, RefAttributes } from 'react';
|
||||
@@ -36,69 +43,111 @@ export type { EditorContribution, EditorLanguage };
|
||||
|
||||
/**
|
||||
* Represents a position in the editor (line and column).
|
||||
* Both line and column are zero-based indices.
|
||||
*
|
||||
* @example
|
||||
* // Position at the start of line 5, column 10
|
||||
* const pos: Position = { line: 4, column: 9 };
|
||||
*/
|
||||
export interface Position {
|
||||
/** Zero-based line number */
|
||||
/** Zero-based line number (first line is 0) */
|
||||
line: number;
|
||||
/** Zero-based column number */
|
||||
/** Zero-based column number (first column is 0) */
|
||||
column: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a range in the editor with start and end positions.
|
||||
* Represents a contiguous range in the editor defined by start and end positions.
|
||||
* The range is inclusive of the start position and exclusive of the end position.
|
||||
*/
|
||||
export interface Range {
|
||||
/** Start position of the range */
|
||||
/** Start position of the range (inclusive) */
|
||||
start: Position;
|
||||
/** End position of the range */
|
||||
/** End position of the range (exclusive) */
|
||||
end: Position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a selection in the editor.
|
||||
* Represents a selection in the editor, extending Range with direction information.
|
||||
* A selection is a highlighted range of text that can be manipulated.
|
||||
*/
|
||||
export interface Selection extends Range {
|
||||
/** Direction of the selection */
|
||||
/**
|
||||
* Direction of the selection.
|
||||
* - 'ltr': Selection was made left-to-right (anchor at start, cursor at end)
|
||||
* - 'rtl': Selection was made right-to-left (anchor at end, cursor at start)
|
||||
*/
|
||||
direction?: 'ltr' | 'rtl';
|
||||
}
|
||||
|
||||
/**
|
||||
* Annotation severity levels for editor markers.
|
||||
* Severity levels for editor annotations.
|
||||
* Determines the visual style and icon used to display the annotation.
|
||||
*/
|
||||
export type AnnotationSeverity = 'error' | 'warning' | 'info';
|
||||
|
||||
/**
|
||||
* Represents an annotation (marker/diagnostic) in the editor.
|
||||
* Represents a diagnostic annotation displayed in the editor.
|
||||
* Annotations are used to highlight issues like syntax errors, linting warnings,
|
||||
* or informational messages at specific locations in the code.
|
||||
*
|
||||
* @example
|
||||
* const annotation: EditorAnnotation = {
|
||||
* line: 5,
|
||||
* column: 10,
|
||||
* message: 'Unknown column "user_id"',
|
||||
* severity: 'error',
|
||||
* source: 'sql-validator',
|
||||
* };
|
||||
*/
|
||||
export interface EditorAnnotation {
|
||||
/** Zero-based line number */
|
||||
/** Zero-based line number where the annotation appears */
|
||||
line: number;
|
||||
/** Zero-based column number (optional) */
|
||||
/** Zero-based column number for precise positioning (optional) */
|
||||
column?: number;
|
||||
/** Annotation message to display */
|
||||
/** Human-readable message describing the issue or information */
|
||||
message: string;
|
||||
/** Severity level of the annotation */
|
||||
/** Severity determines visual styling (red for error, yellow for warning, blue for info) */
|
||||
severity: AnnotationSeverity;
|
||||
/** Optional source of the annotation (e.g., "linter", "typescript") */
|
||||
/** Identifies what produced this annotation (e.g., "linter", "sql-validator") */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a keyboard shortcut binding.
|
||||
* Defines a keyboard shortcut that triggers a custom action in the editor.
|
||||
* Hotkeys allow binding key combinations to functions that manipulate
|
||||
* the editor or perform other actions.
|
||||
*
|
||||
* @example
|
||||
* const runQueryHotkey: EditorHotkey = {
|
||||
* name: 'runQuery',
|
||||
* key: 'Ctrl+Enter',
|
||||
* description: 'Execute the current query',
|
||||
* exec: (handle) => {
|
||||
* const sql = handle.getValue();
|
||||
* executeQuery(sql);
|
||||
* },
|
||||
* };
|
||||
*/
|
||||
export interface EditorHotkey {
|
||||
/** Unique name for the hotkey command */
|
||||
/** Unique identifier for this hotkey command */
|
||||
name: string;
|
||||
/** Key binding string (e.g., "Ctrl+Enter", "Alt+Enter") */
|
||||
/**
|
||||
* Key combination string. Format varies by editor but typically uses:
|
||||
* - Modifiers: Ctrl, Alt, Shift, Meta (Cmd on Mac)
|
||||
* - Separator: + (e.g., "Ctrl+Enter", "Ctrl+Shift+F")
|
||||
*/
|
||||
key: string;
|
||||
/** Description of what the hotkey does */
|
||||
/** Human-readable description shown in keyboard shortcut help */
|
||||
description?: string;
|
||||
/** Function to execute when the hotkey is triggered */
|
||||
/** Callback invoked when the hotkey is pressed, receives the editor handle */
|
||||
exec: (handle: EditorHandle) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Completion item kinds for autocompletion.
|
||||
* Categories for completion items, determining the icon displayed.
|
||||
* Includes standard programming concepts plus SQL-specific types
|
||||
* (table, column, schema, catalog, database).
|
||||
*/
|
||||
export type CompletionItemKind =
|
||||
| 'text'
|
||||
@@ -132,53 +181,87 @@ export type CompletionItemKind =
|
||||
| 'database';
|
||||
|
||||
/**
|
||||
* Represents a completion item for autocompletion.
|
||||
* Represents a single item in the autocompletion dropdown.
|
||||
* Completion items are suggestions shown to users as they type,
|
||||
* allowing quick insertion of code snippets, keywords, or identifiers.
|
||||
*
|
||||
* @example
|
||||
* const tableCompletion: CompletionItem = {
|
||||
* label: 'users',
|
||||
* insertText: 'users',
|
||||
* kind: 'table',
|
||||
* detail: 'public schema',
|
||||
* documentation: 'User accounts table with profile information',
|
||||
* };
|
||||
*/
|
||||
export interface CompletionItem {
|
||||
/** Display label for the completion item */
|
||||
/** Text displayed in the completion dropdown */
|
||||
label: string;
|
||||
/** Text to insert when the item is selected */
|
||||
/** Text inserted into the editor when this item is selected */
|
||||
insertText: string;
|
||||
/** Kind of completion item for icon display */
|
||||
/** Category of completion, determines the icon shown (e.g., table, column, function) */
|
||||
kind: CompletionItemKind;
|
||||
/** Optional documentation to show in the completion popup */
|
||||
/** Extended description shown in a details pane or tooltip */
|
||||
documentation?: string;
|
||||
/** Optional detail text to show alongside the label */
|
||||
/** Short additional info displayed next to the label (e.g., type, schema) */
|
||||
detail?: string;
|
||||
/** Sorting priority (higher numbers appear first) */
|
||||
/** String used for sorting; items are sorted lexicographically by this value */
|
||||
sortText?: string;
|
||||
/** Text used for filtering completions */
|
||||
/** String used for filtering; if omitted, label is used for matching user input */
|
||||
filterText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context provided to completion providers.
|
||||
* Context information passed to completion providers when requesting suggestions.
|
||||
* Contains details about how completion was triggered and the current environment.
|
||||
*/
|
||||
export interface CompletionContext {
|
||||
/** Character that triggered the completion (if any) */
|
||||
/** The character that triggered automatic completion (e.g., '.', ' '), if applicable */
|
||||
triggerCharacter?: string;
|
||||
/** How the completion was triggered */
|
||||
/**
|
||||
* How the completion was triggered:
|
||||
* - 'invoke': User explicitly requested completion (e.g., Ctrl+Space)
|
||||
* - 'automatic': Triggered automatically by typing a trigger character
|
||||
*/
|
||||
triggerKind: 'invoke' | 'automatic';
|
||||
/** Language of the editor */
|
||||
/** The language mode of the editor (e.g., 'sql', 'json') */
|
||||
language: EditorLanguage;
|
||||
/** Generic metadata passed from the host (e.g., SQL Lab can pass database context) */
|
||||
/** Host-provided context (e.g., database ID, schema name for SQL completions) */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider interface for dynamic completions.
|
||||
* Interface for providing dynamic autocompletion suggestions.
|
||||
* Providers are invoked when the user triggers completion, allowing
|
||||
* context-aware suggestions based on cursor position and editor content.
|
||||
*
|
||||
* @example
|
||||
* const tableCompletionProvider: CompletionProvider = {
|
||||
* id: 'sql-tables',
|
||||
* triggerCharacters: [' ', '.'],
|
||||
* provideCompletions: async (content, position, context) => {
|
||||
* const dbId = context.metadata?.databaseId;
|
||||
* const tables = await fetchTables(dbId);
|
||||
* return tables.map(t => ({
|
||||
* label: t.name,
|
||||
* insertText: t.name,
|
||||
* kind: 'table',
|
||||
* }));
|
||||
* },
|
||||
* };
|
||||
*/
|
||||
export interface CompletionProvider {
|
||||
/** Unique identifier for this provider */
|
||||
/** Unique identifier for this provider, used for debugging and deduplication */
|
||||
id: string;
|
||||
/** Trigger characters that invoke this provider (e.g., '.', ' ') */
|
||||
/** Characters that trigger this provider automatically when typed (e.g., '.', ' ') */
|
||||
triggerCharacters?: string[];
|
||||
/**
|
||||
* Provide completions at the given position.
|
||||
* @param content The editor content
|
||||
* @param position The cursor position
|
||||
* @param context Completion context with trigger info and metadata
|
||||
* @returns Array of completion items or a promise that resolves to them
|
||||
* Generate completion suggestions for the current cursor position.
|
||||
*
|
||||
* @param content Full text content of the editor
|
||||
* @param position Current cursor position where completion was triggered
|
||||
* @param context Additional context about the trigger and environment
|
||||
* @returns Array of completion items, or a Promise resolving to them for async providers
|
||||
*/
|
||||
provideCompletions(
|
||||
content: string,
|
||||
@@ -188,98 +271,186 @@ export interface CompletionProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* A keyword for editor autocomplete.
|
||||
* This is a generic format that editor implementations convert to their native format.
|
||||
* Represents a static keyword for basic autocomplete.
|
||||
* Keywords are simpler than CompletionItems and are used for static lists
|
||||
* of suggestions (e.g., SQL keywords, table names) that don't require
|
||||
* dynamic computation.
|
||||
*
|
||||
* Editor implementations convert these to their native completion format.
|
||||
*
|
||||
* @example
|
||||
* const sqlKeywords: EditorKeyword[] = [
|
||||
* { name: 'SELECT', meta: 'keyword', score: 100 },
|
||||
* { name: 'FROM', meta: 'keyword', score: 100 },
|
||||
* { name: 'users', value: 'users', meta: 'table', score: 50 },
|
||||
* ];
|
||||
*/
|
||||
export interface EditorKeyword {
|
||||
/** Display name of the keyword */
|
||||
/** Display name shown in the completion dropdown */
|
||||
name: string;
|
||||
/** Value to insert when selected (defaults to name if not provided) */
|
||||
/** Text to insert when selected; defaults to name if not provided */
|
||||
value?: string;
|
||||
/** Category/type of the keyword (e.g., "column", "table", "function") */
|
||||
/** Category label shown alongside the name (e.g., "column", "table", "function") */
|
||||
meta?: string;
|
||||
/** Optional score for sorting (higher = more relevant) */
|
||||
/** Sorting priority; higher scores appear first in the completion list */
|
||||
score?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props that all editor implementations must accept.
|
||||
* Props accepted by all editor component implementations.
|
||||
* This interface defines the contract between Superset and editor components,
|
||||
* ensuring consistent behavior regardless of the underlying editor library.
|
||||
*/
|
||||
export interface EditorProps {
|
||||
/** Instance identifier */
|
||||
/** Unique identifier for this editor instance */
|
||||
id: string;
|
||||
/** Controlled value */
|
||||
/** Current editor content (controlled component pattern) */
|
||||
value: string;
|
||||
/** Content change handler */
|
||||
/** Called when the editor content changes */
|
||||
onChange: (value: string) => void;
|
||||
/** Blur handler */
|
||||
/** Called when the editor loses focus, with the current value */
|
||||
onBlur?: (value: string) => void;
|
||||
/** Cursor position change handler */
|
||||
/** Called when the cursor position changes */
|
||||
onCursorPositionChange?: (pos: Position) => void;
|
||||
/** Selection change handler */
|
||||
/** Called when the selection(s) change */
|
||||
onSelectionChange?: (sel: Selection[]) => void;
|
||||
/** Language mode for syntax highlighting */
|
||||
/** Language mode for syntax highlighting and language features */
|
||||
language: EditorLanguage;
|
||||
/** Whether the editor is read-only */
|
||||
/** When true, prevents editing (view-only mode) */
|
||||
readOnly?: boolean;
|
||||
/** Tab size in spaces */
|
||||
/** Number of spaces per tab character */
|
||||
tabSize?: number;
|
||||
/** Whether to show line numbers */
|
||||
/** Whether to display line numbers in the gutter */
|
||||
lineNumbers?: boolean;
|
||||
/** Whether to enable word wrap */
|
||||
/** Whether long lines should wrap to the next visual line */
|
||||
wordWrap?: boolean;
|
||||
/** Linting/error annotations */
|
||||
/** Diagnostic annotations to display (errors, warnings, info) */
|
||||
annotations?: EditorAnnotation[];
|
||||
/** Keyboard shortcuts */
|
||||
/** Custom keyboard shortcuts */
|
||||
hotkeys?: EditorHotkey[];
|
||||
/** Static keywords for autocomplete */
|
||||
/** Static keywords for basic autocomplete */
|
||||
keywords?: EditorKeyword[];
|
||||
/** CSS height (e.g., "100%", "500px") */
|
||||
/** CSS height value (e.g., "100%", "500px", "calc(100vh - 200px)") */
|
||||
height?: string;
|
||||
/** CSS width (e.g., "100%", "800px") */
|
||||
/** CSS width value (e.g., "100%", "800px") */
|
||||
width?: string;
|
||||
/** Callback when editor is ready with imperative handle */
|
||||
/** Called when the editor is fully initialized, providing the imperative handle */
|
||||
onReady?: (handle: EditorHandle) => void;
|
||||
/** Host-specific context (e.g., database info from SQL Lab) */
|
||||
/** Contextual data passed to completion providers (e.g., database ID, schema) */
|
||||
metadata?: Record<string, unknown>;
|
||||
/** Theme object for styling the editor */
|
||||
/** Theme object for styling the editor to match Superset's appearance */
|
||||
theme?: SupersetTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imperative API for controlling the editor programmatically.
|
||||
*
|
||||
* This handle provides a unified interface for interacting with text editors
|
||||
* regardless of the underlying implementation (Ace, Monaco, CodeMirror, etc.).
|
||||
* It can be used by any part of Superset that needs to manipulate editor content,
|
||||
* read selections, or register custom behaviors.
|
||||
*/
|
||||
export interface EditorHandle {
|
||||
/** Focus the editor */
|
||||
focus(): void;
|
||||
/** Get the current editor content */
|
||||
getValue(): string;
|
||||
/** Set the editor content */
|
||||
setValue(value: string): void;
|
||||
/** Get the current cursor position */
|
||||
getCursorPosition(): Position;
|
||||
/** Move the cursor to a specific position */
|
||||
moveCursorToPosition(position: Position): void;
|
||||
/** Get all selections in the editor */
|
||||
getSelections(): Selection[];
|
||||
/** Set the selection range */
|
||||
setSelection(selection: Range): void;
|
||||
/** Get the selected text */
|
||||
getSelectedText(): string;
|
||||
/** Insert text at the current cursor position */
|
||||
insertText(text: string): void;
|
||||
/** Execute a named editor command */
|
||||
executeCommand(commandName: string): void;
|
||||
/** Scroll to a specific line */
|
||||
scrollToLine(line: number): void;
|
||||
/** Set annotations (replaces existing) */
|
||||
setAnnotations(annotations: EditorAnnotation[]): void;
|
||||
/** Clear all annotations */
|
||||
clearAnnotations(): void;
|
||||
/**
|
||||
* Register a completion provider for dynamic suggestions.
|
||||
* Moves keyboard focus to the editor.
|
||||
* Useful after programmatic operations to return user focus to the editing area.
|
||||
*/
|
||||
focus(): void;
|
||||
|
||||
/**
|
||||
* Returns the complete text content of the editor.
|
||||
* @returns The full editor content as a string
|
||||
*/
|
||||
getValue(): string;
|
||||
|
||||
/**
|
||||
* Replaces the entire editor content with the provided value.
|
||||
* This will clear any existing content and reset the undo history in most editors.
|
||||
* @param value The new content to set
|
||||
*/
|
||||
setValue(value: string): void;
|
||||
|
||||
/**
|
||||
* Returns the current cursor position in the editor.
|
||||
* @returns Position object with zero-based line and column numbers
|
||||
*/
|
||||
getCursorPosition(): Position;
|
||||
|
||||
/**
|
||||
* Moves the cursor to the specified position.
|
||||
* @param position Target position with zero-based line and column numbers
|
||||
*/
|
||||
moveCursorToPosition(position: Position): void;
|
||||
|
||||
/**
|
||||
* Returns all active selections in the editor.
|
||||
* Most editors support multiple selections (e.g., via Ctrl+click).
|
||||
* Each selection includes start/end positions and optional direction.
|
||||
* @returns Array of Selection objects, empty array if no selections
|
||||
*/
|
||||
getSelections(): Selection[];
|
||||
|
||||
/**
|
||||
* Sets the selection to the specified range.
|
||||
* This replaces any existing selections with a single new selection.
|
||||
* @param selection Range to select, with start and end positions
|
||||
*/
|
||||
setSelection(selection: Range): void;
|
||||
|
||||
/**
|
||||
* Returns the text within the current selection.
|
||||
* If multiple selections exist, behavior depends on the editor implementation
|
||||
* (typically returns the primary/first selection's text).
|
||||
* @returns The selected text, or empty string if no selection
|
||||
*/
|
||||
getSelectedText(): string;
|
||||
|
||||
/**
|
||||
* Inserts text at the current cursor position.
|
||||
* If text is selected, the selection is replaced with the inserted text.
|
||||
* @param text The text to insert
|
||||
*/
|
||||
insertText(text: string): void;
|
||||
/**
|
||||
* Execute a named editor command.
|
||||
*
|
||||
* Note: Command names are editor-specific. For example:
|
||||
* - Ace: 'centerselection', 'gotoline', 'fold', 'unfold'
|
||||
* - Monaco: 'editor.action.formatDocument', 'editor.action.commentLine'
|
||||
*
|
||||
* Callers using this method should be aware of which editor is active
|
||||
* or handle cases where the command may not exist.
|
||||
*
|
||||
* @param commandName The editor-specific command name to execute
|
||||
*/
|
||||
executeCommand(commandName: string): void;
|
||||
/**
|
||||
* Scrolls the editor viewport to bring the specified line into view.
|
||||
* The exact positioning (top, center, bottom) depends on the editor implementation.
|
||||
* @param line Zero-based line number to scroll to
|
||||
*/
|
||||
scrollToLine(line: number): void;
|
||||
|
||||
/**
|
||||
* Sets diagnostic annotations (errors, warnings, info markers) in the editor.
|
||||
* This replaces any previously set annotations.
|
||||
* Annotations appear as markers in the gutter and/or inline decorations.
|
||||
* @param annotations Array of annotations to display
|
||||
*/
|
||||
setAnnotations(annotations: EditorAnnotation[]): void;
|
||||
|
||||
/**
|
||||
* Removes all annotations from the editor.
|
||||
* Equivalent to calling setAnnotations([]).
|
||||
*/
|
||||
clearAnnotations(): void;
|
||||
|
||||
/**
|
||||
* Registers a provider for dynamic autocompletion suggestions.
|
||||
* The provider will be invoked when completion is triggered (manually or automatically).
|
||||
* Multiple providers can be registered; their results are merged.
|
||||
* @param provider The completion provider to register
|
||||
* @returns A Disposable to unregister the provider
|
||||
* @returns A Disposable that removes the provider when disposed
|
||||
*/
|
||||
registerCompletionProvider(provider: CompletionProvider): Disposable;
|
||||
}
|
||||
|
||||
@@ -30,44 +30,14 @@
|
||||
*/
|
||||
|
||||
import { Event, Database, SupersetError, Column } from './core';
|
||||
import { EditorHandle } from './editors';
|
||||
|
||||
/**
|
||||
* Represents an SQL editor instance within a SQL Lab tab.
|
||||
* Contains the editor content and associated database connection information.
|
||||
* Provides imperative control over the code editor component.
|
||||
* Allows extensions to manipulate text content, cursor position,
|
||||
* selections, annotations, and register completion providers.
|
||||
*/
|
||||
export interface Editor {
|
||||
/**
|
||||
* The SQL content of the editor.
|
||||
* This represents the current text in the SQL editor.
|
||||
*/
|
||||
content: string;
|
||||
|
||||
/**
|
||||
* The database identifier associated with the editor.
|
||||
* This determines which database the queries will be executed against.
|
||||
*/
|
||||
databaseId: number;
|
||||
|
||||
/**
|
||||
* The catalog name associated with the editor.
|
||||
* Can be null if no specific catalog is selected.
|
||||
*/
|
||||
catalog: string | null;
|
||||
|
||||
/**
|
||||
* The schema name associated with the editor.
|
||||
* Defines the database schema context for the editor.
|
||||
*/
|
||||
schema: string;
|
||||
|
||||
/**
|
||||
* The table name associated with the editor.
|
||||
* Can be null if no specific table is selected.
|
||||
*
|
||||
* @todo Revisit if we actually need the table property
|
||||
*/
|
||||
table: string | null;
|
||||
}
|
||||
export interface Editor extends EditorHandle {}
|
||||
|
||||
/**
|
||||
* Represents a panel within a SQL Lab tab.
|
||||
@@ -99,10 +69,40 @@ export interface Tab {
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* The SQL editor instance associated with this tab.
|
||||
* Contains the editor content and database connection settings.
|
||||
* The database identifier for this tab's query context.
|
||||
* This determines which database the queries will be executed against.
|
||||
*/
|
||||
editor: Editor;
|
||||
databaseId: number;
|
||||
|
||||
/**
|
||||
* The catalog name for this tab's query context.
|
||||
* Can be null if no specific catalog is selected (for multi-catalog databases like Trino).
|
||||
*/
|
||||
catalog: string | null;
|
||||
|
||||
/**
|
||||
* The schema name for this tab's query context.
|
||||
* Can be null if no schema is selected.
|
||||
*/
|
||||
schema: string | null;
|
||||
|
||||
/**
|
||||
* Gets the code editor instance for this tab.
|
||||
* Returns a Promise that resolves when the editor is ready.
|
||||
* The returned editor is a proxy that always delegates to the current
|
||||
* editor implementation, even if the editor is swapped (e.g., Ace to Monaco).
|
||||
*
|
||||
* @returns Promise that resolves to the Editor instance
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const tab = sqlLab.getCurrentTab();
|
||||
* const editor = await tab.getEditor();
|
||||
* editor.setValue("SELECT * FROM users");
|
||||
* editor.focus();
|
||||
* ```
|
||||
*/
|
||||
getEditor(): Promise<Editor>;
|
||||
|
||||
/**
|
||||
* The panels associated with the tab.
|
||||
@@ -262,7 +262,12 @@ export declare const getActivePanel: () => Panel;
|
||||
* const tab = getCurrentTab();
|
||||
* if (tab) {
|
||||
* console.log(`Active tab: ${tab.title}`);
|
||||
* console.log(`Database ID: ${tab.editor.databaseId}`);
|
||||
* console.log(`Database ID: ${tab.databaseId}, Schema: ${tab.schema}`);
|
||||
*
|
||||
* // Editor manipulation via async getEditor()
|
||||
* const editor = await tab.getEditor();
|
||||
* editor.setValue("SELECT * FROM users");
|
||||
* editor.focus();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@@ -326,9 +331,10 @@ export declare const onDidChangeTabTitle: Event<string>;
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* onDidQueryRun.event((query) => {
|
||||
* console.log('Query started on database:', query.tab.editor.databaseId);
|
||||
* console.log('Query content:', query.tab.editor.content);
|
||||
* onDidQueryRun.event(async (query) => {
|
||||
* console.log('Query started on database:', query.tab.databaseId);
|
||||
* const editor = await query.tab.getEditor();
|
||||
* console.log('Query SQL:', editor.getValue());
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
@@ -341,7 +347,7 @@ export declare const onDidQueryRun: Event<QueryContext>;
|
||||
* @example
|
||||
* ```typescript
|
||||
* onDidQueryStop.event((query) => {
|
||||
* console.log('Query stopped for database:', query.tab.editor.databaseId);
|
||||
* console.log('Query stopped for database:', query.tab.databaseId);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
@@ -444,3 +450,253 @@ export declare const onDidCloseTab: Event<Tab>;
|
||||
* ```
|
||||
*/
|
||||
export declare const onDidChangeActiveTab: Event<Tab>;
|
||||
|
||||
/**
|
||||
* Event fired when a new tab is created in SQL Lab.
|
||||
* Provides the newly created tab object as the event payload.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* onDidCreateTab.event((tab) => {
|
||||
* console.log('New tab created:', tab.title);
|
||||
* // Initialize extension state for new tab
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export declare const onDidCreateTab: Event<Tab>;
|
||||
|
||||
/**
|
||||
* Tab/Editor Management APIs
|
||||
*
|
||||
* These APIs allow extensions to create, close, and manage SQL Lab tabs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Options for creating a new SQL Lab tab.
|
||||
*/
|
||||
export interface CreateTabOptions {
|
||||
/**
|
||||
* Initial SQL content for the editor.
|
||||
*/
|
||||
sql?: string;
|
||||
|
||||
/**
|
||||
* Display title for the tab.
|
||||
* If not provided, defaults to "Untitled Query N".
|
||||
*/
|
||||
title?: string;
|
||||
|
||||
/**
|
||||
* Database ID to connect to.
|
||||
* If not provided, inherits from the active tab or uses default.
|
||||
*/
|
||||
databaseId?: number;
|
||||
|
||||
/**
|
||||
* Catalog name (for multi-catalog databases like Trino).
|
||||
*/
|
||||
catalog?: string | null;
|
||||
|
||||
/**
|
||||
* Schema name for the query context.
|
||||
*/
|
||||
schema?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new query editor tab in SQL Lab.
|
||||
*
|
||||
* @param options Optional configuration for the new tab
|
||||
* @returns The newly created tab object
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Create a tab with default settings
|
||||
* const tab = await createTab();
|
||||
*
|
||||
* // Create a tab with specific SQL and database
|
||||
* const tab = await createTab({
|
||||
* sql: "SELECT * FROM users LIMIT 10",
|
||||
* title: "User Query",
|
||||
* databaseId: 1,
|
||||
* schema: "public"
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export declare function createTab(options?: CreateTabOptions): Promise<Tab>;
|
||||
|
||||
/**
|
||||
* Closes a specific tab in SQL Lab.
|
||||
*
|
||||
* @param tabId The ID of the tab to close
|
||||
* @returns Promise that resolves when the tab is closed
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const tabs = getTabs();
|
||||
* if (tabs.length > 1) {
|
||||
* await closeTab(tabs[0].id);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function closeTab(tabId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Switches to a specific tab in SQL Lab.
|
||||
*
|
||||
* @param tabId The ID of the tab to activate
|
||||
* @returns Promise that resolves when the tab is activated
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const tabs = getTabs();
|
||||
* const targetTab = tabs.find(t => t.title === "My Query");
|
||||
* if (targetTab) {
|
||||
* await setActiveTab(targetTab.id);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function setActiveTab(tabId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Query Execution APIs
|
||||
*
|
||||
* These APIs allow extensions to execute and control SQL queries.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Options for executing a SQL query.
|
||||
*/
|
||||
export interface QueryOptions {
|
||||
/**
|
||||
* SQL to execute without modifying editor content.
|
||||
* If not provided, uses the current editor content.
|
||||
*/
|
||||
sql?: string;
|
||||
|
||||
/**
|
||||
* Run only the selected text in the editor.
|
||||
* Ignored if `sql` option is provided.
|
||||
*/
|
||||
selectedOnly?: boolean;
|
||||
|
||||
/**
|
||||
* Override the query row limit.
|
||||
* If not provided, uses the tab's configured limit.
|
||||
*/
|
||||
limit?: number;
|
||||
|
||||
/**
|
||||
* Template parameters for Jinja templating.
|
||||
* Merged with existing template parameters from the editor.
|
||||
*/
|
||||
templateParameters?: Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Create Table/View As Select options.
|
||||
* When provided, query results are stored in a new table instead of returned directly.
|
||||
*/
|
||||
ctas?: {
|
||||
/**
|
||||
* Whether to create a TABLE or VIEW.
|
||||
*/
|
||||
method: 'TABLE' | 'VIEW';
|
||||
|
||||
/**
|
||||
* Name of the table or view to create.
|
||||
*/
|
||||
tableName: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a SQL query in the current tab.
|
||||
*
|
||||
* @param options Optional query execution options
|
||||
* @returns Promise that resolves with the query ID
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Execute the current editor content
|
||||
* const queryId = await executeQuery();
|
||||
*
|
||||
* // Execute custom SQL without modifying the editor
|
||||
* const queryId = await executeQuery({
|
||||
* sql: "SELECT * FROM users LIMIT 10"
|
||||
* });
|
||||
*
|
||||
* // Execute only selected text
|
||||
* const queryId = await executeQuery({ selectedOnly: true });
|
||||
*
|
||||
* // Create a table from query results
|
||||
* const queryId = await executeQuery({
|
||||
* ctas: { method: 'TABLE', tableName: 'my_results' }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export declare function executeQuery(options?: QueryOptions): Promise<string>;
|
||||
|
||||
/**
|
||||
* Cancels a running query.
|
||||
*
|
||||
* @param queryId The client ID of the query to cancel
|
||||
* @returns Promise that resolves when the cancellation request is sent
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const queryId = await executeQuery();
|
||||
* // Later, if needed:
|
||||
* await cancelQuery(queryId);
|
||||
* ```
|
||||
*/
|
||||
export declare function cancelQuery(queryId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Tab Context APIs
|
||||
*
|
||||
* These APIs manage tab-level query context and settings.
|
||||
* Text manipulation is handled directly via Editor (e.g., tab.editor.setValue(sql)).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sets the database for the current tab.
|
||||
*
|
||||
* @param databaseId The ID of the database to set
|
||||
* @returns Promise that resolves when the database is updated
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const databases = getDatabases();
|
||||
* const prodDb = databases.find(d => d.database_name === "production");
|
||||
* if (prodDb) {
|
||||
* await setDatabase(prodDb.id);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function setDatabase(databaseId: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sets the catalog for the current tab.
|
||||
*
|
||||
* @param catalog The catalog name to set, or null to clear
|
||||
* @returns Promise that resolves when the catalog is updated
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await setCatalog("hive_metastore");
|
||||
* ```
|
||||
*/
|
||||
export declare function setCatalog(catalog: string | null): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sets the schema for the current tab.
|
||||
*
|
||||
* @param schema The schema name to set, or null to clear
|
||||
* @returns Promise that resolves when the schema is updated
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await setSchema("public");
|
||||
* ```
|
||||
*/
|
||||
export declare function setSchema(schema: string | null): Promise<void>;
|
||||
|
||||
@@ -441,6 +441,10 @@ export interface ThemeContextType {
|
||||
canSetTheme: () => boolean;
|
||||
canDetectOSPreference: () => boolean;
|
||||
createDashboardThemeProvider: (themeId: string) => Promise<Theme | null>;
|
||||
createTheme: (
|
||||
themeId: string,
|
||||
parentThemeConfig?: AnyThemeConfig,
|
||||
) => Promise<Theme | null>;
|
||||
getAppliedThemeId: () => number | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,8 +29,9 @@ import {
|
||||
FieldStringOutlined,
|
||||
NumberOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Icons } from '@superset-ui/core/components';
|
||||
|
||||
export type ColumnLabelExtendedType = 'expression' | '';
|
||||
export type ColumnLabelExtendedType = 'expression' | 'metric' | '';
|
||||
|
||||
export type ColumnTypeLabelProps = {
|
||||
type?: ColumnLabelExtendedType | GenericDataType;
|
||||
@@ -59,7 +60,9 @@ export function ColumnTypeLabel({ type }: ColumnTypeLabelProps) {
|
||||
<QuestionOutlined aria-label={t('unknown type icon')} />
|
||||
);
|
||||
|
||||
if (type === '' || type === 'expression') {
|
||||
if (type === 'metric') {
|
||||
typeIcon = <Icons.Sigma aria-label={t('metric type icon')} />;
|
||||
} else if (type === '' || type === 'expression') {
|
||||
typeIcon = <FunctionOutlined aria-label={t('function type icon')} />;
|
||||
} else if (type === GenericDataType.String) {
|
||||
typeIcon = <FieldStringOutlined aria-label={t('string type icon')} />;
|
||||
|
||||
@@ -95,7 +95,7 @@ export function MetricOption({
|
||||
|
||||
return (
|
||||
<FlexRowContainer className="metric-option">
|
||||
{showType && <ColumnTypeLabel type="expression" />}
|
||||
{showType && <ColumnTypeLabel type="metric" />}
|
||||
{shouldShowTooltip ? (
|
||||
<Tooltip id="metric-name-tooltip" title={tooltipText}>
|
||||
{label}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { ControlPanelSectionConfig } from '../types';
|
||||
import { formatSelectOptions } from '../utils';
|
||||
|
||||
export const TITLE_MARGIN_OPTIONS: number[] = [
|
||||
15, 30, 50, 75, 100, 125, 150, 200,
|
||||
0, 15, 30, 50, 75, 100, 125, 150, 200,
|
||||
];
|
||||
export const TITLE_POSITION_OPTIONS: [string, string][] = [
|
||||
['Left', t('Left')],
|
||||
@@ -82,7 +82,7 @@ export const titleControls: ControlPanelSectionConfig = {
|
||||
clearable: true,
|
||||
label: t('Y Axis Title Margin'),
|
||||
renderTrigger: true,
|
||||
default: TITLE_MARGIN_OPTIONS[1],
|
||||
default: TITLE_MARGIN_OPTIONS[0],
|
||||
choices: formatSelectOptions(TITLE_MARGIN_OPTIONS),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -52,6 +52,10 @@ describe('ColumnOption', () => {
|
||||
renderColumnTypeLabel({ type: 'expression' });
|
||||
expect(screen.getByLabelText('function type icon')).toBeVisible();
|
||||
});
|
||||
it('metric type shows sigma icon', () => {
|
||||
renderColumnTypeLabel({ type: 'metric' });
|
||||
expect(screen.getByLabelText('metric type icon')).toBeVisible();
|
||||
});
|
||||
it('unknown type shows question mark', () => {
|
||||
renderColumnTypeLabel({ type: undefined });
|
||||
expect(screen.getByLabelText('unknown type icon')).toBeVisible();
|
||||
|
||||
@@ -78,11 +78,11 @@
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/jquery": "^3.5.33",
|
||||
"@types/lodash": "^4.17.23",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/node": "^25.1.0",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/seedrandom": "^3.0.8",
|
||||
"fetch-mock": "^11.1.4",
|
||||
"fetch-mock": "^12.6.0",
|
||||
"jest-mock-console": "^2.0.0",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"timezone-mock": "1.3.6"
|
||||
|
||||
@@ -126,7 +126,7 @@ export function Button(props: ButtonProps) {
|
||||
minWidth: cta ? theme.sizeUnit * 36 : undefined,
|
||||
minHeight: cta ? theme.sizeUnit * 8 : undefined,
|
||||
marginLeft: 0,
|
||||
'& + .superset-button': {
|
||||
'& + .superset-button:not(.ant-btn-compact-item)': {
|
||||
marginLeft: theme.sizeUnit * 2,
|
||||
},
|
||||
'& > span > :first-of-type': {
|
||||
|
||||
@@ -76,6 +76,10 @@ import {
|
||||
FileOutlined,
|
||||
FileTextOutlined,
|
||||
FireOutlined,
|
||||
FolderAddOutlined,
|
||||
FolderOpenOutlined,
|
||||
FolderOutlined,
|
||||
FolderViewOutlined,
|
||||
FormOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
@@ -94,15 +98,18 @@ import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
MinusCircleOutlined,
|
||||
MinusSquareOutlined,
|
||||
MoonOutlined,
|
||||
LoadingOutlined,
|
||||
LoginOutlined,
|
||||
MonitorOutlined,
|
||||
MoreOutlined,
|
||||
OrderedListOutlined,
|
||||
PartitionOutlined,
|
||||
PieChartOutlined,
|
||||
PicCenterOutlined,
|
||||
PlusCircleOutlined,
|
||||
PlusSquareOutlined,
|
||||
PlusOutlined,
|
||||
ProfileOutlined,
|
||||
QuestionCircleOutlined,
|
||||
@@ -217,6 +224,10 @@ const AntdIcons = {
|
||||
FileOutlined,
|
||||
FileTextOutlined,
|
||||
FireOutlined,
|
||||
FolderAddOutlined,
|
||||
FolderOpenOutlined,
|
||||
FolderOutlined,
|
||||
FolderViewOutlined,
|
||||
FormOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
@@ -240,13 +251,16 @@ const AntdIcons = {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
MinusCircleOutlined,
|
||||
MinusSquareOutlined,
|
||||
MonitorOutlined,
|
||||
MoonOutlined,
|
||||
MoreOutlined,
|
||||
OrderedListOutlined,
|
||||
PartitionOutlined,
|
||||
PieChartOutlined,
|
||||
PicCenterOutlined,
|
||||
PlusCircleOutlined,
|
||||
PlusSquareOutlined,
|
||||
PlusOutlined,
|
||||
ProfileOutlined,
|
||||
ReloadOutlined,
|
||||
|
||||
@@ -42,10 +42,12 @@ const customIcons = [
|
||||
'Error',
|
||||
'Full',
|
||||
'Layers',
|
||||
'Move',
|
||||
'Multiple',
|
||||
'Queued',
|
||||
'Redo',
|
||||
'Running',
|
||||
'Sigma',
|
||||
'Slack',
|
||||
'Square',
|
||||
'SortAsc',
|
||||
|
||||
@@ -24,12 +24,18 @@ import { ImageLoader, type BackgroundPosition } from './ImageLoader';
|
||||
global.URL.createObjectURL = jest.fn(() => '/local_url');
|
||||
const blob = new Blob([], { type: 'image/png' });
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.mockGlobal();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fetchMock.hardReset();
|
||||
});
|
||||
|
||||
fetchMock.get(
|
||||
'/thumbnail',
|
||||
'glob:*/thumbnail',
|
||||
{ body: blob, headers: { 'Content-Type': 'image/png' } },
|
||||
{
|
||||
sendAsJson: false,
|
||||
},
|
||||
{ name: 'thumbnail' },
|
||||
);
|
||||
|
||||
describe('ImageLoader', () => {
|
||||
@@ -44,7 +50,7 @@ describe('ImageLoader', () => {
|
||||
return render(<ImageLoader {...props} />);
|
||||
};
|
||||
|
||||
afterEach(() => fetchMock.resetHistory());
|
||||
afterEach(() => fetchMock.clearHistory());
|
||||
|
||||
it('is a valid element', async () => {
|
||||
setup();
|
||||
@@ -57,7 +63,7 @@ describe('ImageLoader', () => {
|
||||
'src',
|
||||
'/fallback',
|
||||
);
|
||||
expect(fetchMock.calls(/thumbnail/)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(/thumbnail/)).toHaveLength(1);
|
||||
expect(global.URL.createObjectURL).toHaveBeenCalled();
|
||||
expect(await screen.findByTestId('image-loader')).toHaveAttribute(
|
||||
'src',
|
||||
@@ -66,13 +72,14 @@ describe('ImageLoader', () => {
|
||||
});
|
||||
|
||||
it('displays fallback image when response is not an image', async () => {
|
||||
fetchMock.once('/thumbnail2', {});
|
||||
setup({ src: '/thumbnail2' });
|
||||
fetchMock.once('glob:*/thumbnail2', {}, { name: 'thumbnail2' });
|
||||
|
||||
setup({ src: 'glob:*/thumbnail2' });
|
||||
expect(screen.getByTestId('image-loader')).toHaveAttribute(
|
||||
'src',
|
||||
'/fallback',
|
||||
);
|
||||
expect(fetchMock.calls(/thumbnail2/)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(/thumbnail2/)).toHaveLength(1);
|
||||
expect(await screen.findByTestId('image-loader')).toHaveAttribute(
|
||||
'src',
|
||||
'/fallback',
|
||||
|
||||
@@ -34,8 +34,10 @@ export enum FeatureFlag {
|
||||
ConfirmDashboardDiff = 'CONFIRM_DASHBOARD_DIFF',
|
||||
CssTemplates = 'CSS_TEMPLATES',
|
||||
DashboardVirtualization = 'DASHBOARD_VIRTUALIZATION',
|
||||
DashboardVirtualizationDeferData = 'DASHBOARD_VIRTUALIZATION_DEFER_DATA',
|
||||
DashboardRbac = 'DASHBOARD_RBAC',
|
||||
DatapanelClosedByDefault = 'DATAPANEL_CLOSED_BY_DEFAULT',
|
||||
DatasetFolders = 'DATASET_FOLDERS',
|
||||
DateRangeTimeshiftsEnabled = 'DATE_RANGE_TIMESHIFTS_ENABLED',
|
||||
/** @deprecated */
|
||||
DrillToDetail = 'DRILL_TO_DETAIL',
|
||||
|
||||
@@ -25,6 +25,7 @@ export { default as isEqualArray } from './isEqualArray';
|
||||
export { default as makeSingleton } from './makeSingleton';
|
||||
export { default as promiseTimeout } from './promiseTimeout';
|
||||
export { default as removeDuplicates } from './removeDuplicates';
|
||||
export { default as withLabel } from './withLabel';
|
||||
export { lruCache } from './lruCache';
|
||||
export { getSelectedText } from './getSelectedText';
|
||||
export * from './featureFlags';
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import type { ValidatorFunction } from '../validator';
|
||||
|
||||
/**
|
||||
* Wraps a validator function to prepend a label to its error message.
|
||||
*
|
||||
* @param validator - The validator function to wrap
|
||||
* @param label - The label to prepend to error messages
|
||||
* @returns A new validator function that includes the label in error messages
|
||||
*
|
||||
* @example
|
||||
* validators: [
|
||||
* withLabel(validateInteger, t('Row limit')),
|
||||
* ]
|
||||
* // Returns: "Row limit is expected to be an integer"
|
||||
*/
|
||||
export default function withLabel<V = unknown, S = unknown>(
|
||||
validator: ValidatorFunction<V, S>,
|
||||
label: string,
|
||||
): ValidatorFunction<V, S> {
|
||||
return (value: V, state?: S): string | false => {
|
||||
const error = validator(value, state);
|
||||
return error ? `${label} ${error}` : false;
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export { default as legacyValidateInteger } from './legacyValidateInteger';
|
||||
export { default as legacyValidateNumber } from './legacyValidateNumber';
|
||||
export { default as validateInteger } from './validateInteger';
|
||||
|
||||
@@ -23,7 +23,7 @@ import { t } from '@apache-superset/core';
|
||||
* formerly called integer()
|
||||
* @param v
|
||||
*/
|
||||
export default function legacyValidateInteger(v: unknown) {
|
||||
export default function legacyValidateInteger(v: unknown): string | false {
|
||||
if (
|
||||
v &&
|
||||
(Number.isNaN(Number(v)) || parseInt(v as string, 10) !== Number(v))
|
||||
|
||||
@@ -23,7 +23,7 @@ import { t } from '@apache-superset/core';
|
||||
* formerly called numeric()
|
||||
* @param v
|
||||
*/
|
||||
export default function numeric(v: unknown) {
|
||||
export default function numeric(v: unknown): string | false {
|
||||
if (v && Number.isNaN(Number(v))) {
|
||||
return t('is expected to be a number');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Type definition for a validator function.
|
||||
* Returns an error message string if validation fails, or false if validation passes.
|
||||
*/
|
||||
export type ValidatorFunction<V = unknown, S = unknown> = (
|
||||
value: V,
|
||||
state?: S,
|
||||
) => string | false;
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { t } from '@apache-superset/core';
|
||||
|
||||
export default function validateInteger(v: unknown) {
|
||||
export default function validateInteger(v: unknown): string | false {
|
||||
if (
|
||||
(typeof v === 'string' &&
|
||||
v.trim().length > 0 &&
|
||||
|
||||
@@ -25,7 +25,7 @@ const VALIDE_OSM_URLS = ['https://tile.osm', 'https://tile.openstreetmap'];
|
||||
* Validate a [Mapbox styles URL](https://docs.mapbox.com/help/glossary/style-url/)
|
||||
* @param v
|
||||
*/
|
||||
export default function validateMapboxStylesUrl(v: unknown) {
|
||||
export default function validateMapboxStylesUrl(v: unknown): string | false {
|
||||
if (typeof v === 'string') {
|
||||
const trimmed_v = v.trim();
|
||||
if (
|
||||
|
||||
@@ -18,7 +18,10 @@
|
||||
*/
|
||||
import { t } from '@apache-superset/core';
|
||||
|
||||
export default function validateMaxValue(v: unknown, max: number) {
|
||||
export default function validateMaxValue(
|
||||
v: unknown,
|
||||
max: number,
|
||||
): string | false {
|
||||
if (Number(v) > +max) {
|
||||
return t('Value cannot exceed %s', max);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { t } from '@apache-superset/core';
|
||||
|
||||
export default function validateNonEmpty(v: unknown) {
|
||||
export default function validateNonEmpty(v: unknown): string | false {
|
||||
if (
|
||||
v === null ||
|
||||
typeof v === 'undefined' ||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { t } from '@apache-superset/core';
|
||||
|
||||
export default function validateInteger(v: any) {
|
||||
export default function validateNumber(v: unknown): string | false {
|
||||
if (
|
||||
(typeof v === 'string' &&
|
||||
v.trim().length > 0 &&
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function validateServerPagination(
|
||||
serverPagination: boolean,
|
||||
maxValueWithoutServerPagination: number,
|
||||
maxServer: number,
|
||||
) {
|
||||
): string | false {
|
||||
if (
|
||||
Number(v) > +maxValueWithoutServerPagination &&
|
||||
Number(v) <= maxServer &&
|
||||
|
||||
@@ -22,13 +22,13 @@ import { t } from '@apache-superset/core';
|
||||
import { ensureIsArray } from '../utils';
|
||||
|
||||
export const validateTimeComparisonRangeValues = (
|
||||
timeRangeValue?: any,
|
||||
controlValue?: any,
|
||||
) => {
|
||||
timeRangeValue?: unknown,
|
||||
controlValue?: unknown,
|
||||
): string[] => {
|
||||
const isCustomTimeRange = timeRangeValue === ComparisonTimeRangeType.Custom;
|
||||
const isCustomControlEmpty = controlValue?.every(
|
||||
(val: any) => ensureIsArray(val).length === 0,
|
||||
);
|
||||
const isCustomControlEmpty =
|
||||
Array.isArray(controlValue) &&
|
||||
controlValue.every((val: unknown) => ensureIsArray(val).length === 0);
|
||||
return isCustomTimeRange && isCustomControlEmpty
|
||||
? [t('Filters for comparison must have a value')]
|
||||
: [];
|
||||
|
||||
@@ -37,6 +37,9 @@ import { SliceIdAndOrFormData } from '../../../src/chart/clients/ChartClient';
|
||||
|
||||
configureTranslation();
|
||||
|
||||
beforeAll(() => fetchMock.mockGlobal());
|
||||
afterAll(() => fetchMock.hardReset());
|
||||
|
||||
describe('ChartClient', () => {
|
||||
let chartClient: ChartClient;
|
||||
|
||||
@@ -50,7 +53,7 @@ describe('ChartClient', () => {
|
||||
chartClient = new ChartClient();
|
||||
});
|
||||
|
||||
afterEach(() => fetchMock.restore());
|
||||
afterEach(() => fetchMock.removeRoutes().clearHistory());
|
||||
|
||||
describe('new ChartClient(config)', () => {
|
||||
it('creates a client without argument', () => {
|
||||
|
||||
@@ -21,10 +21,13 @@ import fetchMock from 'fetch-mock';
|
||||
import { SupersetClient, SupersetClientClass } from '@superset-ui/core';
|
||||
import { LOGIN_GLOB } from './fixtures/constants';
|
||||
|
||||
describe('SupersetClient', () => {
|
||||
beforeAll(() => fetchMock.get(LOGIN_GLOB, { result: '' }));
|
||||
beforeAll(() => fetchMock.mockGlobal());
|
||||
afterAll(() => fetchMock.hardReset());
|
||||
|
||||
afterAll(() => fetchMock.restore());
|
||||
describe('SupersetClient', () => {
|
||||
beforeAll(() => fetchMock.get(LOGIN_GLOB, { result: '1234' }));
|
||||
|
||||
afterAll(() => fetchMock.removeRoutes().clearHistory());
|
||||
|
||||
afterEach(() => SupersetClient.reset());
|
||||
|
||||
@@ -108,9 +111,11 @@ describe('SupersetClient', () => {
|
||||
mockDeleteUrl,
|
||||
];
|
||||
networkCalls.map((url: string) =>
|
||||
expect(fetchMock.calls(url)[0][1]?.headers).toStrictEqual({
|
||||
Accept: 'application/json',
|
||||
'X-CSRFToken': '1234',
|
||||
expect(
|
||||
fetchMock.callHistory.calls(url)[0].options?.headers,
|
||||
).toStrictEqual({
|
||||
accept: 'application/json',
|
||||
'x-csrftoken': '1234',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -137,6 +142,6 @@ describe('SupersetClient', () => {
|
||||
authenticatedSpy.mockRestore();
|
||||
csrfSpy.mockRestore();
|
||||
|
||||
fetchMock.reset();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,14 +20,15 @@ import fetchMock from 'fetch-mock';
|
||||
import { SupersetClientClass, ClientConfig, CallApi } from '@superset-ui/core';
|
||||
import { LOGIN_GLOB } from './fixtures/constants';
|
||||
|
||||
beforeAll(() => fetchMock.mockGlobal());
|
||||
afterAll(() => fetchMock.hardReset());
|
||||
|
||||
describe('SupersetClientClass', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.reset();
|
||||
fetchMock.get(LOGIN_GLOB, { result: '' });
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
fetchMock.get(LOGIN_GLOB, { result: '' }, { name: LOGIN_GLOB });
|
||||
});
|
||||
|
||||
afterAll(() => fetchMock.restore());
|
||||
|
||||
describe('new SupersetClientClass()', () => {
|
||||
it('fallback protocol to https when setting only host', () => {
|
||||
const client = new SupersetClientClass({ host: 'TEST-HOST' });
|
||||
@@ -89,21 +90,22 @@ describe('SupersetClientClass', () => {
|
||||
});
|
||||
|
||||
describe('.init()', () => {
|
||||
beforeEach(() =>
|
||||
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { overwriteRoutes: true }),
|
||||
);
|
||||
afterEach(() => fetchMock.reset());
|
||||
beforeEach(() => {
|
||||
fetchMock.removeRoute(LOGIN_GLOB);
|
||||
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { name: LOGIN_GLOB });
|
||||
});
|
||||
afterEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
|
||||
it('calls api/v1/security/csrf_token/ when init() is called if no CSRF token is passed', async () => {
|
||||
expect.assertions(1);
|
||||
// expect.assertions(1);
|
||||
await new SupersetClientClass().init();
|
||||
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(LOGIN_GLOB)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does NOT call api/v1/security/csrf_token/ when init() is called if a CSRF token is passed', async () => {
|
||||
expect.assertions(1);
|
||||
await new SupersetClientClass({ csrfToken: 'abc' }).init();
|
||||
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0);
|
||||
expect(fetchMock.callHistory.calls(LOGIN_GLOB)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('calls api/v1/security/csrf_token/ when init(force=true) is called even if a CSRF token is passed', async () => {
|
||||
@@ -112,20 +114,19 @@ describe('SupersetClientClass', () => {
|
||||
const client = new SupersetClientClass({ csrfToken: initialToken });
|
||||
|
||||
await client.init();
|
||||
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0);
|
||||
expect(fetchMock.callHistory.calls(LOGIN_GLOB)).toHaveLength(0);
|
||||
expect(client.csrfToken).toBe(initialToken);
|
||||
|
||||
await client.init(true);
|
||||
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(LOGIN_GLOB)).toHaveLength(1);
|
||||
expect(client.csrfToken).not.toBe(initialToken);
|
||||
});
|
||||
|
||||
it('throws if api/v1/security/csrf_token/ returns an error', async () => {
|
||||
expect.assertions(1);
|
||||
const rejectError = { status: 403 };
|
||||
fetchMock.get(LOGIN_GLOB, () => Promise.reject(rejectError), {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
fetchMock.removeRoute(LOGIN_GLOB);
|
||||
fetchMock.get(LOGIN_GLOB, { throws: rejectError }, { name: LOGIN_GLOB });
|
||||
|
||||
let error;
|
||||
try {
|
||||
@@ -141,7 +142,7 @@ describe('SupersetClientClass', () => {
|
||||
|
||||
it('throws if api/v1/security/csrf_token/ does not return a token', async () => {
|
||||
expect.assertions(1);
|
||||
fetchMock.get(LOGIN_GLOB, {}, { overwriteRoutes: true });
|
||||
fetchMock.modifyRoute(LOGIN_GLOB, { response: {} });
|
||||
|
||||
let error;
|
||||
try {
|
||||
@@ -157,9 +158,8 @@ describe('SupersetClientClass', () => {
|
||||
|
||||
it('does not set csrfToken if response is not json', async () => {
|
||||
expect.assertions(1);
|
||||
fetchMock.get(LOGIN_GLOB, '123', {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
fetchMock.removeRoute(LOGIN_GLOB);
|
||||
fetchMock.get(LOGIN_GLOB, { response: '123' }, { name: LOGIN_GLOB });
|
||||
|
||||
let error;
|
||||
try {
|
||||
@@ -175,7 +175,7 @@ describe('SupersetClientClass', () => {
|
||||
});
|
||||
|
||||
describe('.isAuthenticated()', () => {
|
||||
afterEach(() => fetchMock.reset());
|
||||
afterEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
|
||||
it('returns true if there is a token and false if not', async () => {
|
||||
expect.assertions(2);
|
||||
@@ -227,9 +227,8 @@ describe('SupersetClientClass', () => {
|
||||
expect.assertions(4);
|
||||
|
||||
const rejectValue = { status: 403 };
|
||||
fetchMock.get(LOGIN_GLOB, () => Promise.reject(rejectValue), {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
fetchMock.removeRoutes();
|
||||
fetchMock.get(LOGIN_GLOB, { throws: rejectValue }, { name: LOGIN_GLOB });
|
||||
|
||||
const client = new SupersetClientClass({});
|
||||
let error;
|
||||
@@ -253,18 +252,19 @@ describe('SupersetClientClass', () => {
|
||||
}
|
||||
|
||||
// reset
|
||||
fetchMock.removeRoutes();
|
||||
fetchMock.get(
|
||||
LOGIN_GLOB,
|
||||
{ result: 1234 },
|
||||
{
|
||||
overwriteRoutes: true,
|
||||
name: LOGIN_GLOB,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requests', () => {
|
||||
afterEach(() => fetchMock.restore());
|
||||
afterEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
|
||||
const protocol = 'https:';
|
||||
const host = 'host';
|
||||
@@ -306,11 +306,11 @@ describe('SupersetClientClass', () => {
|
||||
await client.delete({ url: mockDeleteUrl });
|
||||
await client.request({ url: mockRequestUrl, method: 'DELETE' });
|
||||
|
||||
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
|
||||
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
|
||||
expect(fetchMock.calls(mockDeleteUrl)).toHaveLength(1);
|
||||
expect(fetchMock.calls(mockPutUrl)).toHaveLength(1);
|
||||
expect(fetchMock.calls(mockRequestUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockDeleteUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockPutUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockRequestUrl)).toHaveLength(1);
|
||||
|
||||
expect(authSpy).toHaveBeenCalledTimes(5);
|
||||
authSpy.mockRestore();
|
||||
@@ -331,7 +331,8 @@ describe('SupersetClientClass', () => {
|
||||
await client.init();
|
||||
await client.get({ url: mockGetUrl });
|
||||
|
||||
const fetchRequest = fetchMock.calls(mockGetUrl)[0][1] as CallApi;
|
||||
const fetchRequest = fetchMock.callHistory.calls(mockGetUrl)[0]
|
||||
.options as CallApi;
|
||||
expect(fetchRequest.mode).toBe(clientConfig.mode);
|
||||
expect(fetchRequest.credentials).toBe(clientConfig.credentials);
|
||||
expect(fetchRequest.headers).toEqual(
|
||||
@@ -354,10 +355,11 @@ describe('SupersetClientClass', () => {
|
||||
|
||||
await client.init();
|
||||
await client.get({ url: mockGetUrl });
|
||||
const fetchRequest = fetchMock.calls(mockGetUrl)[0][1] as CallApi;
|
||||
const fetchRequest = fetchMock.callHistory.calls(mockGetUrl)[0]
|
||||
.options as CallApi;
|
||||
expect(fetchRequest.headers).toEqual(
|
||||
expect.objectContaining({
|
||||
guestTokenHeader: 'abc123',
|
||||
guesttokenheader: 'abc123',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -370,10 +372,10 @@ describe('SupersetClientClass', () => {
|
||||
await client.init();
|
||||
|
||||
await client.get({ url: mockGetUrl });
|
||||
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(1);
|
||||
|
||||
await client.get({ endpoint: mockGetEndpoint });
|
||||
expect(fetchMock.calls(mockGetUrl)).toHaveLength(2);
|
||||
expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('supports parsing a response as text', async () => {
|
||||
@@ -384,7 +386,7 @@ describe('SupersetClientClass', () => {
|
||||
url: mockTextUrl,
|
||||
parseMethod: 'text',
|
||||
});
|
||||
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockTextUrl)).toHaveLength(1);
|
||||
expect(text).toBe(mockTextJsonResponse);
|
||||
});
|
||||
|
||||
@@ -409,7 +411,8 @@ describe('SupersetClientClass', () => {
|
||||
await client.init();
|
||||
await client.get({ url: mockGetUrl, ...overrideConfig });
|
||||
|
||||
const fetchRequest = fetchMock.calls(mockGetUrl)[0][1] as CallApi;
|
||||
const fetchRequest = fetchMock.callHistory.calls(mockGetUrl)[0]
|
||||
.options as CallApi;
|
||||
expect(fetchRequest.mode).toBe(overrideConfig.mode);
|
||||
expect(fetchRequest.credentials).toBe(overrideConfig.credentials);
|
||||
expect(fetchRequest.headers).toEqual(
|
||||
@@ -428,10 +431,10 @@ describe('SupersetClientClass', () => {
|
||||
await client.init();
|
||||
|
||||
await client.post({ url: mockPostUrl });
|
||||
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1);
|
||||
|
||||
await client.post({ endpoint: mockPostEndpoint });
|
||||
expect(fetchMock.calls(mockPostUrl)).toHaveLength(2);
|
||||
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('allows overriding host, headers, mode, and credentials per-request', async () => {
|
||||
@@ -454,7 +457,8 @@ describe('SupersetClientClass', () => {
|
||||
await client.init();
|
||||
await client.post({ url: mockPostUrl, ...overrideConfig });
|
||||
|
||||
const fetchRequest = fetchMock.calls(mockPostUrl)[0][1] as CallApi;
|
||||
const fetchRequest = fetchMock.callHistory.calls(mockPostUrl)[0]
|
||||
.options as CallApi;
|
||||
|
||||
expect(fetchRequest.mode).toBe(overrideConfig.mode);
|
||||
expect(fetchRequest.credentials).toBe(overrideConfig.credentials);
|
||||
@@ -473,7 +477,7 @@ describe('SupersetClientClass', () => {
|
||||
url: mockTextUrl,
|
||||
parseMethod: 'text',
|
||||
});
|
||||
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockTextUrl)).toHaveLength(1);
|
||||
expect(text).toBe(mockTextJsonResponse);
|
||||
});
|
||||
|
||||
@@ -485,10 +489,11 @@ describe('SupersetClientClass', () => {
|
||||
await client.init();
|
||||
await client.post({ url: mockPostUrl, postPayload });
|
||||
|
||||
const fetchRequest = fetchMock.calls(mockPostUrl)[0][1] as CallApi;
|
||||
const fetchRequest = fetchMock.callHistory.calls(mockPostUrl)[0]
|
||||
.options as CallApi;
|
||||
const formData = fetchRequest.body as FormData;
|
||||
|
||||
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1);
|
||||
Object.entries(postPayload).forEach(([key, value]) => {
|
||||
expect(formData.get(key)).toBe(JSON.stringify(value));
|
||||
});
|
||||
@@ -502,10 +507,11 @@ describe('SupersetClientClass', () => {
|
||||
await client.init();
|
||||
await client.post({ url: mockPostUrl, postPayload, stringify: false });
|
||||
|
||||
const fetchRequest = fetchMock.calls(mockPostUrl)[0][1] as CallApi;
|
||||
const fetchRequest = fetchMock.callHistory.calls(mockPostUrl)[0]
|
||||
.options as CallApi;
|
||||
const formData = fetchRequest.body as FormData;
|
||||
|
||||
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1);
|
||||
Object.entries(postPayload).forEach(([key, value]) => {
|
||||
expect(formData.get(key)).toBe(String(value));
|
||||
});
|
||||
@@ -528,6 +534,7 @@ describe('SupersetClientClass', () => {
|
||||
// @ts-ignore
|
||||
window.location = {
|
||||
pathname: mockRequestPath,
|
||||
// @ts-ignore
|
||||
search: mockRequestSearch,
|
||||
href: mockHref,
|
||||
};
|
||||
@@ -535,9 +542,7 @@ describe('SupersetClientClass', () => {
|
||||
.spyOn(SupersetClientClass.prototype, 'ensureAuth')
|
||||
.mockImplementation();
|
||||
const rejectValue = { status: 401 };
|
||||
fetchMock.get(mockRequestUrl, () => Promise.reject(rejectValue), {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
fetchMock.get(mockRequestUrl, () => Promise.reject(rejectValue));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -563,10 +568,11 @@ describe('SupersetClientClass', () => {
|
||||
it('should not redirect again if already on login page', async () => {
|
||||
const client = new SupersetClientClass({});
|
||||
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
window.location = {
|
||||
href: '/login?next=something',
|
||||
pathname: '/login',
|
||||
// @ts-ignore
|
||||
search: '?next=something',
|
||||
};
|
||||
|
||||
@@ -636,7 +642,8 @@ describe('SupersetClientClass', () => {
|
||||
let createElement: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { overwriteRoutes: true });
|
||||
fetchMock.removeRoute(LOGIN_GLOB);
|
||||
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { name: LOGIN_GLOB });
|
||||
|
||||
client = new SupersetClientClass({ protocol, host });
|
||||
authSpy = jest.spyOn(SupersetClientClass.prototype, 'ensureAuth');
|
||||
|
||||
@@ -29,14 +29,17 @@ const corruptObject = new BadObject();
|
||||
/* @ts-expect-error */
|
||||
BadObject.prototype.toString = undefined;
|
||||
|
||||
const mockGetUrl = '/mock/get/url';
|
||||
const mockPostUrl = '/mock/post/url';
|
||||
const mockPutUrl = '/mock/put/url';
|
||||
const mockPatchUrl = '/mock/patch/url';
|
||||
const mockCacheUrl = '/mock/cache/url';
|
||||
const mockNotFound = '/mock/notfound';
|
||||
const mockErrorUrl = '/mock/error/url';
|
||||
const mock503 = '/mock/503';
|
||||
beforeAll(() => fetchMock.mockGlobal());
|
||||
afterAll(() => fetchMock.hardReset());
|
||||
|
||||
const mockGetUrl = 'glob:*/mock/get/url';
|
||||
const mockPostUrl = 'glob:*/mock/post/url';
|
||||
const mockPutUrl = 'glob:*/mock/put/url';
|
||||
const mockPatchUrl = 'glob:*/mock/patch/url';
|
||||
const mockCacheUrl = 'glob:*/mock/cache/url';
|
||||
const mockNotFound = 'glob:*/mock/notfound';
|
||||
const mockErrorUrl = 'glob:*/mock/error/url';
|
||||
const mock503 = 'glob:*/mock/503';
|
||||
|
||||
const mockGetPayload = { get: 'payload' };
|
||||
const mockPostPayload = { post: 'payload' };
|
||||
@@ -50,20 +53,23 @@ const mockCachePayload = {
|
||||
const mockErrorPayload = { status: 500, statusText: 'Internal error' };
|
||||
|
||||
describe('callApi()', () => {
|
||||
beforeAll(() => fetchMock.get(LOGIN_GLOB, { result: '1234' }));
|
||||
beforeAll(() => {
|
||||
fetchMock.mockGlobal();
|
||||
fetchMock.get(LOGIN_GLOB, { result: '1234' });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.get(mockGetUrl, mockGetPayload);
|
||||
fetchMock.post(mockPostUrl, mockPostPayload);
|
||||
fetchMock.put(mockPutUrl, mockPutPayload);
|
||||
fetchMock.patch(mockPatchUrl, mockPatchPayload);
|
||||
fetchMock.get(mockCacheUrl, mockCachePayload);
|
||||
fetchMock.get(mockCacheUrl, mockCachePayload, { name: mockCacheUrl });
|
||||
fetchMock.get(mockNotFound, { status: 404 });
|
||||
fetchMock.get(mock503, { status: 503 });
|
||||
fetchMock.get(mockErrorUrl, () => Promise.reject(mockErrorPayload));
|
||||
});
|
||||
|
||||
afterEach(() => fetchMock.reset());
|
||||
afterEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
|
||||
describe('request config', () => {
|
||||
it('calls the right url with the specified method', async () => {
|
||||
@@ -74,10 +80,10 @@ describe('callApi()', () => {
|
||||
callApi({ url: mockPutUrl, method: 'PUT' }),
|
||||
callApi({ url: mockPatchUrl, method: 'PATCH' }),
|
||||
]);
|
||||
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
|
||||
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
|
||||
expect(fetchMock.calls(mockPutUrl)).toHaveLength(1);
|
||||
expect(fetchMock.calls(mockPatchUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockPutUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockPatchUrl)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('passes along mode, cache, credentials, headers, body, signal, and redirect parameters in the request', async () => {
|
||||
@@ -92,12 +98,11 @@ describe('callApi()', () => {
|
||||
},
|
||||
redirect: 'follow',
|
||||
signal: undefined,
|
||||
body: 'BODY',
|
||||
};
|
||||
|
||||
await callApi(mockRequest);
|
||||
const calls = fetchMock.calls(mockGetUrl);
|
||||
const fetchParams = calls[0][1] as RequestInit;
|
||||
const calls = fetchMock.callHistory.calls(mockGetUrl);
|
||||
const fetchParams = calls[0].options as RequestInit;
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(fetchParams.mode).toBe(mockRequest.mode);
|
||||
expect(fetchParams.cache).toBe(mockRequest.cache);
|
||||
@@ -119,10 +124,10 @@ describe('callApi()', () => {
|
||||
const postPayload = { key: 'value', anotherKey: 1237 };
|
||||
|
||||
await callApi({ url: mockPostUrl, method: 'POST', postPayload });
|
||||
const calls = fetchMock.calls(mockPostUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockPostUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const fetchParams = calls[0][1] as RequestInit;
|
||||
const fetchParams = calls[0].options as RequestInit;
|
||||
const body = fetchParams.body as FormData;
|
||||
|
||||
Object.entries(postPayload).forEach(([key, value]) => {
|
||||
@@ -136,10 +141,10 @@ describe('callApi()', () => {
|
||||
const postPayload = { key: 'value', noValue: undefined };
|
||||
|
||||
await callApi({ url: mockPostUrl, method: 'POST', postPayload });
|
||||
const calls = fetchMock.calls(mockPostUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockPostUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const fetchParams = calls[0][1] as RequestInit;
|
||||
const fetchParams = calls[0].options as RequestInit;
|
||||
const body = fetchParams.body as FormData;
|
||||
expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
|
||||
expect(body.get('noValue')).toBeNull();
|
||||
@@ -167,13 +172,13 @@ describe('callApi()', () => {
|
||||
}),
|
||||
callApi({ url: mockPostUrl, method: 'POST', jsonPayload: postPayload }),
|
||||
]);
|
||||
const calls = fetchMock.calls(mockPostUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockPostUrl);
|
||||
expect(calls).toHaveLength(3);
|
||||
|
||||
const stringified = (calls[0][1] as RequestInit).body as FormData;
|
||||
const unstringified = (calls[1][1] as RequestInit).body as FormData;
|
||||
const stringified = (calls[0].options as RequestInit).body as FormData;
|
||||
const unstringified = (calls[1].options as RequestInit).body as FormData;
|
||||
const jsonRequestBody = JSON.parse(
|
||||
(calls[2][1] as RequestInit).body as string,
|
||||
(calls[2].options as RequestInit).body as string,
|
||||
) as JsonObject;
|
||||
|
||||
Object.entries(postPayload).forEach(([key, value]) => {
|
||||
@@ -211,9 +216,9 @@ describe('callApi()', () => {
|
||||
stringify: false,
|
||||
});
|
||||
|
||||
const calls = fetchMock.calls(mockPostUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockPostUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
const unstringified = (calls[0][1] as RequestInit).body as FormData;
|
||||
const unstringified = (calls[0].options as RequestInit).body as FormData;
|
||||
const hasCorruptKey = unstringified.has('corrupt');
|
||||
expect(hasCorruptKey).toBeFalsy();
|
||||
// When a corrupt attribute is encountered, a console.error call is made with info about the corrupt attribute
|
||||
@@ -228,10 +233,10 @@ describe('callApi()', () => {
|
||||
const postPayload = { key: 'value', anotherKey: 1237 };
|
||||
|
||||
await callApi({ url: mockPutUrl, method: 'PUT', postPayload });
|
||||
const calls = fetchMock.calls(mockPutUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockPutUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const fetchParams = calls[0][1] as RequestInit;
|
||||
const fetchParams = calls[0].options as RequestInit;
|
||||
const body = fetchParams.body as FormData;
|
||||
|
||||
Object.entries(postPayload).forEach(([key, value]) => {
|
||||
@@ -245,10 +250,10 @@ describe('callApi()', () => {
|
||||
const postPayload = { key: 'value', noValue: undefined };
|
||||
|
||||
await callApi({ url: mockPutUrl, method: 'PUT', postPayload });
|
||||
const calls = fetchMock.calls(mockPutUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockPutUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const fetchParams = calls[0][1] as RequestInit;
|
||||
const fetchParams = calls[0].options as RequestInit;
|
||||
const body = fetchParams.body as FormData;
|
||||
expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
|
||||
expect(body.get('noValue')).toBeNull();
|
||||
@@ -275,11 +280,11 @@ describe('callApi()', () => {
|
||||
stringify: false,
|
||||
}),
|
||||
]);
|
||||
const calls = fetchMock.calls(mockPutUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockPutUrl);
|
||||
expect(calls).toHaveLength(2);
|
||||
|
||||
const stringified = (calls[0][1] as RequestInit).body as FormData;
|
||||
const unstringified = (calls[1][1] as RequestInit).body as FormData;
|
||||
const stringified = (calls[0].options as RequestInit).body as FormData;
|
||||
const unstringified = (calls[1].options as RequestInit).body as FormData;
|
||||
|
||||
Object.entries(postPayload).forEach(([key, value]) => {
|
||||
expect(stringified.get(key)).toBe(JSON.stringify(value));
|
||||
@@ -294,10 +299,10 @@ describe('callApi()', () => {
|
||||
const postPayload = { key: 'value', anotherKey: 1237 };
|
||||
|
||||
await callApi({ url: mockPatchUrl, method: 'PATCH', postPayload });
|
||||
const calls = fetchMock.calls(mockPatchUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockPatchUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const fetchParams = calls[0][1] as RequestInit;
|
||||
const fetchParams = calls[0].options as RequestInit;
|
||||
const body = fetchParams.body as FormData;
|
||||
|
||||
Object.entries(postPayload).forEach(([key, value]) => {
|
||||
@@ -311,10 +316,10 @@ describe('callApi()', () => {
|
||||
const postPayload = { key: 'value', noValue: undefined };
|
||||
|
||||
await callApi({ url: mockPatchUrl, method: 'PATCH', postPayload });
|
||||
const calls = fetchMock.calls(mockPatchUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockPatchUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const fetchParams = calls[0][1] as RequestInit;
|
||||
const fetchParams = calls[0].options as RequestInit;
|
||||
const body = fetchParams.body as FormData;
|
||||
expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
|
||||
expect(body.get('noValue')).toBeNull();
|
||||
@@ -341,11 +346,11 @@ describe('callApi()', () => {
|
||||
stringify: false,
|
||||
}),
|
||||
]);
|
||||
const calls = fetchMock.calls(mockPatchUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockPatchUrl);
|
||||
expect(calls).toHaveLength(2);
|
||||
|
||||
const stringified = (calls[0][1] as RequestInit).body as FormData;
|
||||
const unstringified = (calls[1][1] as RequestInit).body as FormData;
|
||||
const stringified = (calls[0].options as RequestInit).body as FormData;
|
||||
const unstringified = (calls[1].options as RequestInit).body as FormData;
|
||||
|
||||
Object.entries(postPayload).forEach(([key, value]) => {
|
||||
expect(stringified.get(key)).toBe(JSON.stringify(value));
|
||||
@@ -373,7 +378,7 @@ describe('callApi()', () => {
|
||||
it('caches requests with ETags', async () => {
|
||||
expect.assertions(2);
|
||||
await callApi({ url: mockCacheUrl, method: 'GET' });
|
||||
const calls = fetchMock.calls(mockCacheUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockCacheUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
const supersetCache = await caches.open(constants.CACHE_KEY);
|
||||
const cachedResponse = await supersetCache.match(mockCacheUrl);
|
||||
@@ -385,7 +390,7 @@ describe('callApi()', () => {
|
||||
window.location.protocol = 'http:';
|
||||
|
||||
await callApi({ url: mockCacheUrl, method: 'GET' });
|
||||
const calls = fetchMock.calls(mockCacheUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockCacheUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const supersetCache = await caches.open(constants.CACHE_KEY);
|
||||
@@ -399,7 +404,7 @@ describe('callApi()', () => {
|
||||
Object.defineProperty(constants, 'CACHE_AVAILABLE', { value: false });
|
||||
|
||||
const firstResponse = await callApi({ url: mockCacheUrl, method: 'GET' });
|
||||
let calls = fetchMock.calls(mockCacheUrl);
|
||||
let calls = fetchMock.callHistory.calls(mockCacheUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
const firstBody = await firstResponse.text();
|
||||
expect(firstBody).toEqual('BODY');
|
||||
@@ -408,8 +413,8 @@ describe('callApi()', () => {
|
||||
url: mockCacheUrl,
|
||||
method: 'GET',
|
||||
});
|
||||
calls = fetchMock.calls(mockCacheUrl);
|
||||
const fetchParams = calls[1][1] as RequestInit;
|
||||
calls = fetchMock.callHistory.calls(mockCacheUrl);
|
||||
const fetchParams = calls[1].options as RequestInit;
|
||||
expect(calls).toHaveLength(2);
|
||||
// second call should not have If-None-Match header
|
||||
expect(fetchParams.headers).toBeUndefined();
|
||||
@@ -424,14 +429,14 @@ describe('callApi()', () => {
|
||||
expect.assertions(3);
|
||||
// first call sets the cache
|
||||
await callApi({ url: mockCacheUrl, method: 'GET' });
|
||||
let calls = fetchMock.calls(mockCacheUrl);
|
||||
let calls = fetchMock.callHistory.calls(mockCacheUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
// second call sends the Etag in the If-None-Match header
|
||||
await callApi({ url: mockCacheUrl, method: 'GET' });
|
||||
calls = fetchMock.calls(mockCacheUrl);
|
||||
const fetchParams = calls[1][1] as RequestInit;
|
||||
const headers = { 'If-None-Match': 'etag' };
|
||||
calls = fetchMock.callHistory.calls(mockCacheUrl);
|
||||
const fetchParams = calls[1].options as RequestInit;
|
||||
const headers = { 'if-none-match': 'etag' };
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(fetchParams.headers).toEqual(
|
||||
expect.objectContaining(headers) as typeof fetchParams.headers,
|
||||
@@ -442,16 +447,16 @@ describe('callApi()', () => {
|
||||
expect.assertions(3);
|
||||
// first call sets the cache
|
||||
await callApi({ url: mockCacheUrl, method: 'GET' });
|
||||
expect(fetchMock.calls(mockCacheUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockCacheUrl)).toHaveLength(1);
|
||||
// second call reuses the cached payload on a 304
|
||||
const mockCachedPayload = { status: 304 };
|
||||
fetchMock.get(mockCacheUrl, mockCachedPayload, { overwriteRoutes: true });
|
||||
fetchMock.modifyRoute(mockCacheUrl, { response: mockCachedPayload });
|
||||
|
||||
const secondResponse = await callApi({
|
||||
url: mockCacheUrl,
|
||||
method: 'GET',
|
||||
});
|
||||
expect(fetchMock.calls(mockCacheUrl)).toHaveLength(2);
|
||||
expect(fetchMock.callHistory.calls(mockCacheUrl)).toHaveLength(2);
|
||||
const secondBody = await secondResponse.text();
|
||||
expect(secondBody).toEqual('BODY');
|
||||
});
|
||||
@@ -461,7 +466,7 @@ describe('callApi()', () => {
|
||||
|
||||
// this should never happen, since a 304 is only returned if we have
|
||||
// the cached response and sent the If-None-Match header
|
||||
const mockUncachedUrl = '/mock/uncached/url';
|
||||
const mockUncachedUrl = 'glob:*/mock/uncached/url';
|
||||
const mockCachedPayload = { status: 304 };
|
||||
let error;
|
||||
fetchMock.get(mockUncachedUrl, mockCachedPayload);
|
||||
@@ -471,7 +476,7 @@ describe('callApi()', () => {
|
||||
} catch (err) {
|
||||
error = err;
|
||||
} finally {
|
||||
const calls = fetchMock.calls(mockUncachedUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockUncachedUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect((error as { message: string }).message).toEqual(
|
||||
'Received 304 but no content is cached!',
|
||||
@@ -483,7 +488,7 @@ describe('callApi()', () => {
|
||||
expect.assertions(3);
|
||||
const url = mockGetUrl;
|
||||
const response = await callApi({ url, method: 'GET' });
|
||||
const calls = fetchMock.calls(url);
|
||||
const calls = fetchMock.callHistory.calls(url);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(response.status).toEqual(200);
|
||||
const body = await response.json();
|
||||
@@ -494,7 +499,7 @@ describe('callApi()', () => {
|
||||
expect.assertions(2);
|
||||
const url = mockNotFound;
|
||||
const response = await callApi({ url, method: 'GET' });
|
||||
const calls = fetchMock.calls(url);
|
||||
const calls = fetchMock.callHistory.calls(url);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(response.status).toEqual(404);
|
||||
});
|
||||
@@ -513,7 +518,7 @@ describe('callApi()', () => {
|
||||
error = err;
|
||||
} finally {
|
||||
const err = error as { status: number; statusText: string };
|
||||
expect(fetchMock.calls(mockErrorUrl)).toHaveLength(4);
|
||||
expect(fetchMock.callHistory.calls(mockErrorUrl)).toHaveLength(4);
|
||||
expect(err.status).toBe(mockErrorPayload.status);
|
||||
expect(err.statusText).toBe(mockErrorPayload.statusText);
|
||||
}
|
||||
@@ -531,7 +536,7 @@ describe('callApi()', () => {
|
||||
} catch (err) {
|
||||
error = err as { status: number; statusText: string };
|
||||
} finally {
|
||||
expect(fetchMock.calls(mockErrorUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockErrorUrl)).toHaveLength(1);
|
||||
expect(error?.status).toBe(mockErrorPayload.status);
|
||||
expect(error?.statusText).toBe(mockErrorPayload.statusText);
|
||||
}
|
||||
@@ -545,7 +550,7 @@ describe('callApi()', () => {
|
||||
url,
|
||||
method: 'GET',
|
||||
});
|
||||
const calls = fetchMock.calls(url);
|
||||
const calls = fetchMock.callHistory.calls(url);
|
||||
expect(calls).toHaveLength(4);
|
||||
expect(response.status).toEqual(503);
|
||||
});
|
||||
@@ -581,7 +586,9 @@ describe('callApi()', () => {
|
||||
const result = await response.json();
|
||||
expect(response.status).toEqual(200);
|
||||
expect(result).toEqual({ yes: 'ok' });
|
||||
expect(fetchMock.lastUrl()).toEqual(`http://localhost/get-search?abc=1`);
|
||||
expect(fetchMock.callHistory.lastCall()?.url).toEqual(
|
||||
`http://localhost/get-search?abc=1`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept URLSearchParams', async () => {
|
||||
@@ -596,8 +603,10 @@ describe('callApi()', () => {
|
||||
method: 'POST',
|
||||
jsonPayload: { request: 'ok' },
|
||||
});
|
||||
expect(fetchMock.lastUrl()).toEqual(`http://localhost/post-search?abc=1`);
|
||||
expect(fetchMock.lastOptions()).toEqual(
|
||||
expect(fetchMock.callHistory.lastCall()?.url).toEqual(
|
||||
`http://localhost/post-search?abc=1`,
|
||||
);
|
||||
expect(fetchMock.callHistory.lastCall()?.options).toEqual(
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({ request: 'ok' }),
|
||||
}),
|
||||
@@ -634,7 +643,7 @@ describe('callApi()', () => {
|
||||
method: 'POST',
|
||||
postPayload: payload,
|
||||
});
|
||||
expect(fetchMock.lastOptions()?.body).toBe(payload);
|
||||
expect(fetchMock.callHistory.lastCall()?.options.body).toBe(payload);
|
||||
});
|
||||
|
||||
it('should ignore "null" postPayload string', async () => {
|
||||
@@ -646,6 +655,6 @@ describe('callApi()', () => {
|
||||
method: 'POST',
|
||||
postPayload: 'null',
|
||||
});
|
||||
expect(fetchMock.lastOptions()?.body).toBeUndefined();
|
||||
expect(fetchMock.callHistory.lastCall()?.options.body).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,15 +30,16 @@ import { LOGIN_GLOB } from '../fixtures/constants';
|
||||
const mockGetUrl = '/mock/get/url';
|
||||
const mockGetPayload = { get: 'payload' };
|
||||
|
||||
beforeAll(() => fetchMock.mockGlobal());
|
||||
afterAll(() => fetchMock.hardReset());
|
||||
|
||||
describe('callApiAndParseWithTimeout()', () => {
|
||||
beforeAll(() => fetchMock.get(LOGIN_GLOB, { result: '1234' }));
|
||||
|
||||
beforeEach(() => fetchMock.get(mockGetUrl, mockGetPayload));
|
||||
|
||||
afterAll(() => fetchMock.restore());
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
fetchMock.removeRoutes().clearHistory();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -108,7 +109,7 @@ describe('callApiAndParseWithTimeout()', () => {
|
||||
} catch (err) {
|
||||
error = err;
|
||||
} finally {
|
||||
expect(fetchMock.calls(mockTimeoutUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockTimeoutUrl)).toHaveLength(1);
|
||||
expect(error).toEqual({
|
||||
error: 'Request timed out',
|
||||
statusText: 'timeout',
|
||||
|
||||
@@ -22,12 +22,15 @@ import parseResponse from '../../../src/connection/callApi/parseResponse';
|
||||
|
||||
import { LOGIN_GLOB } from '../fixtures/constants';
|
||||
|
||||
beforeAll(() => fetchMock.mockGlobal());
|
||||
afterAll(() => fetchMock.hardReset());
|
||||
|
||||
describe('parseResponse()', () => {
|
||||
beforeAll(() => {
|
||||
fetchMock.get(LOGIN_GLOB, { result: '1234' });
|
||||
});
|
||||
|
||||
afterAll(() => fetchMock.restore());
|
||||
afterAll(() => fetchMock.removeRoutes().clearHistory());
|
||||
|
||||
const mockGetUrl = '/mock/get/url';
|
||||
const mockPostUrl = '/mock/post/url';
|
||||
@@ -45,7 +48,7 @@ describe('parseResponse()', () => {
|
||||
fetchMock.get(mockNoParseUrl, new Response('test response'));
|
||||
});
|
||||
|
||||
afterEach(() => fetchMock.reset());
|
||||
afterEach(() => fetchMock.removeRoutes().clearHistory());
|
||||
|
||||
it('returns a Promise', () => {
|
||||
const apiPromise = callApi({ url: mockGetUrl, method: 'GET' });
|
||||
@@ -58,7 +61,7 @@ describe('parseResponse()', () => {
|
||||
const args = await parseResponse(
|
||||
callApi({ url: mockGetUrl, method: 'GET' }),
|
||||
);
|
||||
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(1);
|
||||
const keys = Object.keys(args);
|
||||
expect(keys).toContain('response');
|
||||
expect(keys).toContain('json');
|
||||
@@ -81,7 +84,7 @@ describe('parseResponse()', () => {
|
||||
} catch (err) {
|
||||
error = err as Error;
|
||||
} finally {
|
||||
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockTextUrl)).toHaveLength(1);
|
||||
expect(error?.stack).toBeDefined();
|
||||
expect(error?.message).toContain('Unexpected token');
|
||||
}
|
||||
@@ -99,7 +102,7 @@ describe('parseResponse()', () => {
|
||||
callApi({ url: mockTextParseUrl, method: 'GET' }),
|
||||
'text',
|
||||
);
|
||||
expect(fetchMock.calls(mockTextParseUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockTextParseUrl)).toHaveLength(1);
|
||||
const keys = Object.keys(args);
|
||||
expect(keys).toContain('response');
|
||||
expect(keys).toContain('text');
|
||||
@@ -134,7 +137,7 @@ describe('parseResponse()', () => {
|
||||
callApi({ url: mockNoParseUrl, method: 'GET' }),
|
||||
'raw',
|
||||
);
|
||||
expect(fetchMock.calls(mockNoParseUrl)).toHaveLength(2);
|
||||
expect(fetchMock.callHistory.calls(mockNoParseUrl)).toHaveLength(2);
|
||||
expect(responseNull.bodyUsed).toBe(false);
|
||||
expect(responseRaw.bodyUsed).toBe(false);
|
||||
});
|
||||
@@ -193,7 +196,7 @@ describe('parseResponse()', () => {
|
||||
} catch (err) {
|
||||
error = err as { ok: boolean; status: number };
|
||||
} finally {
|
||||
expect(fetchMock.calls(mockNotOkayUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockNotOkayUrl)).toHaveLength(1);
|
||||
expect(error?.ok).toBe(false);
|
||||
expect(error?.status).toBe(404);
|
||||
}
|
||||
|
||||
@@ -21,10 +21,13 @@ import { getDatasourceMetadata } from '../../../../src/query/api/legacy';
|
||||
|
||||
import setupClientForTest from '../setupClientForTest';
|
||||
|
||||
beforeAll(() => fetchMock.mockGlobal());
|
||||
afterAll(() => fetchMock.hardReset());
|
||||
|
||||
describe('getFormData()', () => {
|
||||
beforeAll(() => setupClientForTest());
|
||||
|
||||
afterEach(() => fetchMock.restore());
|
||||
afterEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
|
||||
it('returns datasource metadata for given datasource key', () => {
|
||||
const mockData = {
|
||||
|
||||
@@ -22,10 +22,13 @@ import { getFormData } from '../../../../src/query/api/legacy';
|
||||
|
||||
import setupClientForTest from '../setupClientForTest';
|
||||
|
||||
beforeAll(() => fetchMock.mockGlobal());
|
||||
afterAll(() => fetchMock.hardReset());
|
||||
|
||||
describe('getFormData()', () => {
|
||||
beforeAll(() => setupClientForTest());
|
||||
|
||||
afterEach(() => fetchMock.restore());
|
||||
afterEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
|
||||
const mockData = {
|
||||
datasource: '1__table',
|
||||
|
||||
@@ -20,9 +20,13 @@ import fetchMock from 'fetch-mock';
|
||||
import { buildQueryContext, ApiV1, VizType } from '@superset-ui/core';
|
||||
import setupClientForTest from '../setupClientForTest';
|
||||
|
||||
beforeAll(() => fetchMock.mockGlobal());
|
||||
afterAll(() => fetchMock.hardReset());
|
||||
|
||||
describe('API v1 > getChartData()', () => {
|
||||
beforeAll(() => setupClientForTest());
|
||||
afterEach(() => fetchMock.restore());
|
||||
|
||||
afterEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
|
||||
it('returns a promise of ChartDataResponse', async () => {
|
||||
const response = {
|
||||
|
||||
@@ -21,9 +21,13 @@ import { JsonValue, SupersetClientClass } from '@superset-ui/core';
|
||||
import { makeApi, SupersetApiError } from '../../../../src/query';
|
||||
import setupClientForTest from '../setupClientForTest';
|
||||
|
||||
beforeAll(() => fetchMock.mockGlobal());
|
||||
afterAll(() => fetchMock.hardReset());
|
||||
|
||||
describe('makeApi()', () => {
|
||||
beforeAll(() => setupClientForTest());
|
||||
afterEach(() => fetchMock.restore());
|
||||
|
||||
afterEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
|
||||
it('should expose method and endpoint', () => {
|
||||
const api = makeApi({
|
||||
@@ -95,7 +99,7 @@ describe('makeApi()', () => {
|
||||
|
||||
const expected = new FormData();
|
||||
expected.append('request', JSON.stringify('test'));
|
||||
const received = fetchMock.lastOptions()?.body as FormData;
|
||||
const received = fetchMock.callHistory.lastCall()?.options.body as FormData;
|
||||
|
||||
expect(received).toBeInstanceOf(FormData);
|
||||
expect(received.get('request')).toEqual(expected.get('request'));
|
||||
@@ -109,7 +113,7 @@ describe('makeApi()', () => {
|
||||
});
|
||||
fetchMock.get('glob:*/test-get-search*', { search: 'get' });
|
||||
await api({ p1: 1, p2: 2, p3: [1, 2] });
|
||||
expect(fetchMock.lastUrl()).toContain(
|
||||
expect(fetchMock.callHistory.lastCall()?.url).toContain(
|
||||
'/test-get-search?p1=1&p2=2&p3=1%2C2',
|
||||
);
|
||||
});
|
||||
@@ -123,7 +127,7 @@ describe('makeApi()', () => {
|
||||
});
|
||||
fetchMock.get('glob:*/test-post-search*', { rison: 'get' });
|
||||
await api({ p1: 1, p3: [1, 2] });
|
||||
expect(fetchMock.lastUrl()).toContain(
|
||||
expect(fetchMock.callHistory.lastCall()?.url).toContain(
|
||||
'/test-post-search?q=(p1:1,p3:!(1,2))',
|
||||
);
|
||||
});
|
||||
@@ -137,7 +141,9 @@ describe('makeApi()', () => {
|
||||
});
|
||||
fetchMock.post('glob:*/test-post-search*', { search: 'post' });
|
||||
await api({ p1: 1, p3: [1, 2] });
|
||||
expect(fetchMock.lastUrl()).toContain('/test-post-search?p1=1&p3=1%2C2');
|
||||
expect(fetchMock.callHistory.lastCall()?.url).toContain(
|
||||
'/test-post-search?p1=1&p3=1%2C2',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when requestType is invalid', () => {
|
||||
@@ -215,6 +221,8 @@ describe('makeApi()', () => {
|
||||
fetchMock.delete('glob:*/test-raw-response?*', 'ok');
|
||||
const result = await api({ field1: 11 }, {});
|
||||
expect(result).toEqual(200);
|
||||
expect(fetchMock.lastUrl()).toContain('/test-raw-response?field1=11');
|
||||
expect(fetchMock.callHistory.lastCall()?.url).toContain(
|
||||
'/test-raw-response?field1=11',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,10 @@ import {
|
||||
formatTimeRangeComparison,
|
||||
} from '../../src/time-comparison/fetchTimeRange';
|
||||
|
||||
afterEach(() => fetchMock.restore());
|
||||
beforeAll(() => fetchMock.mockGlobal());
|
||||
afterAll(() => fetchMock.hardReset());
|
||||
|
||||
afterEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
|
||||
test('generates proper time range string', () => {
|
||||
expect(
|
||||
@@ -84,34 +87,41 @@ test('returns a formatted time range from empty response', async () => {
|
||||
});
|
||||
|
||||
test('returns a formatted error message from response', async () => {
|
||||
fetchMock.get('glob:*/api/v1/time_range/?q=%27Last+day%27', {
|
||||
throws: new Response(JSON.stringify({ message: 'Network error' })),
|
||||
});
|
||||
const getTimeRangeUrl = 'glob:*/api/v1/time_range/?q=%27Last+day%27';
|
||||
fetchMock.get(
|
||||
getTimeRangeUrl,
|
||||
{
|
||||
throws: new Response(JSON.stringify({ message: 'Network error' })),
|
||||
},
|
||||
{ name: getTimeRangeUrl },
|
||||
);
|
||||
let timeRange = await fetchTimeRange('Last day');
|
||||
expect(timeRange).toEqual({
|
||||
error: 'Network error',
|
||||
});
|
||||
|
||||
fetchMock.removeRoute(getTimeRangeUrl);
|
||||
fetchMock.get(
|
||||
'glob:*/api/v1/time_range/?q=%27Last+day%27',
|
||||
getTimeRangeUrl,
|
||||
{
|
||||
throws: new Error('Internal Server Error'),
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
{ name: getTimeRangeUrl },
|
||||
);
|
||||
timeRange = await fetchTimeRange('Last day');
|
||||
expect(timeRange).toEqual({
|
||||
error: 'Internal Server Error',
|
||||
});
|
||||
|
||||
fetchMock.removeRoute(getTimeRangeUrl);
|
||||
fetchMock.get(
|
||||
'glob:*/api/v1/time_range/?q=%27Last+day%27',
|
||||
getTimeRangeUrl,
|
||||
{
|
||||
throws: new Response(JSON.stringify({ statusText: 'Network error' }), {
|
||||
statusText: 'Network error',
|
||||
}),
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
{ name: getTimeRangeUrl },
|
||||
);
|
||||
timeRange = await fetchTimeRange('Last day');
|
||||
expect(timeRange).toEqual({
|
||||
|
||||
@@ -20,13 +20,13 @@
|
||||
import { validateMaxValue } from '@superset-ui/core';
|
||||
import './setup';
|
||||
|
||||
test('validateInteger returns the warning message if invalid', () => {
|
||||
test('validateMaxValue returns the warning message if invalid', () => {
|
||||
expect(validateMaxValue(10.1, 10)).toBeTruthy();
|
||||
expect(validateMaxValue(1, 0)).toBeTruthy();
|
||||
expect(validateMaxValue('2', 1)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('validateInteger returns false if the input is valid', () => {
|
||||
test('validateMaxValue returns false if the input is valid', () => {
|
||||
expect(validateMaxValue(0, 1)).toBeFalsy();
|
||||
expect(validateMaxValue(10, 10)).toBeFalsy();
|
||||
expect(validateMaxValue(undefined, 1)).toBeFalsy();
|
||||
|
||||
@@ -36,11 +36,11 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mihkeleidast/storybook-addon-source": "^1.0.1",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@storybook/addon-actions": "8.6.14",
|
||||
"@storybook/addon-controls": "8.6.14",
|
||||
"@storybook/addon-links": "8.6.14",
|
||||
"@storybook/react": "8.6.14",
|
||||
"@storybook/types": "8.6.14",
|
||||
"@storybook/addon-actions": "^8.6.15",
|
||||
"@storybook/addon-controls": "^8.6.15",
|
||||
"@storybook/addon-links": "^8.6.15",
|
||||
"@storybook/react": "^8.6.15",
|
||||
"@storybook/types": "^8.6.15",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
"core-js": "3.48.0",
|
||||
"gh-pages": "^6.3.0",
|
||||
@@ -52,19 +52,19 @@
|
||||
"react-resizable": "^3.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.6",
|
||||
"@babel/preset-env": "^7.28.6",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/preset-env": "^7.29.0",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@storybook/react-webpack5": "8.6.14",
|
||||
"@storybook/react-webpack5": "^8.6.15",
|
||||
"babel-loader": "^10.0.0",
|
||||
"fork-ts-checker-webpack-plugin": "^9.1.0",
|
||||
"ts-loader": "^9.5.4",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@superset-ui/core": "*",
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@superset-ui/legacy-plugin-chart-calendar": "*",
|
||||
"@superset-ui/legacy-plugin-chart-chord": "*",
|
||||
"@superset-ui/legacy-plugin-chart-country-map": "*",
|
||||
|
||||
@@ -74,6 +74,9 @@ export default defineConfig({
|
||||
|
||||
viewport: { width: 1280, height: 1024 },
|
||||
|
||||
// Accept downloads without prompts (needed for export tests)
|
||||
acceptDownloads: true,
|
||||
|
||||
// Screenshots and videos on failure
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
@@ -117,10 +120,19 @@ export default defineConfig({
|
||||
// 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,
|
||||
},
|
||||
: (() => {
|
||||
// Support custom base URL (e.g., http://localhost:9012/app/prefix/)
|
||||
const baseUrl =
|
||||
process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
|
||||
// Extract origin (scheme + host + port) for health check
|
||||
// Health endpoint is always at /health regardless of app prefix
|
||||
const healthUrl = new URL('/health', new URL(baseUrl).origin).href;
|
||||
return {
|
||||
// Quote URL to prevent shell injection via PLAYWRIGHT_BASE_URL
|
||||
command: `curl -f '${healthUrl}'`,
|
||||
url: healthUrl,
|
||||
reuseExistingServer: true,
|
||||
timeout: 5000,
|
||||
};
|
||||
})(),
|
||||
});
|
||||
|
||||
116
superset-frontend/playwright/components/ListView/BulkSelect.ts
Normal file
116
superset-frontend/playwright/components/ListView/BulkSelect.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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 { Button, Checkbox, Table } from '../core';
|
||||
|
||||
const BULK_SELECT_SELECTORS = {
|
||||
CONTROLS: '[data-test="bulk-select-controls"]',
|
||||
ACTION: '[data-test="bulk-select-action"]',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* BulkSelect component for Superset ListView bulk operations.
|
||||
* Provides a reusable interface for bulk selection and actions across list pages.
|
||||
*
|
||||
* @example
|
||||
* const bulkSelect = new BulkSelect(page, table);
|
||||
* await bulkSelect.enable();
|
||||
* await bulkSelect.selectRow('my-dataset');
|
||||
* await bulkSelect.selectRow('another-dataset');
|
||||
* await bulkSelect.clickAction('Delete');
|
||||
*/
|
||||
export class BulkSelect {
|
||||
private readonly page: Page;
|
||||
private readonly table: Table;
|
||||
|
||||
constructor(page: Page, table: Table) {
|
||||
this.page = page;
|
||||
this.table = table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the "Bulk select" toggle button
|
||||
*/
|
||||
getToggleButton(): Button {
|
||||
return new Button(
|
||||
this.page,
|
||||
this.page.getByRole('button', { name: 'Bulk select' }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables bulk selection mode by clicking the toggle button
|
||||
*/
|
||||
async enable(): Promise<void> {
|
||||
await this.getToggleButton().click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the checkbox for a row by name
|
||||
* @param rowName - The name/text identifying the row
|
||||
*/
|
||||
getRowCheckbox(rowName: string): Checkbox {
|
||||
const row = this.table.getRow(rowName);
|
||||
return new Checkbox(this.page, row.getByRole('checkbox'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a row's checkbox in bulk select mode
|
||||
* @param rowName - The name/text identifying the row to select
|
||||
*/
|
||||
async selectRow(rowName: string): Promise<void> {
|
||||
await this.getRowCheckbox(rowName).check();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deselects a row's checkbox in bulk select mode
|
||||
* @param rowName - The name/text identifying the row to deselect
|
||||
*/
|
||||
async deselectRow(rowName: string): Promise<void> {
|
||||
await this.getRowCheckbox(rowName).uncheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the bulk select controls container locator (for assertions)
|
||||
*/
|
||||
getControls(): Locator {
|
||||
return this.page.locator(BULK_SELECT_SELECTORS.CONTROLS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a bulk action button by name
|
||||
* @param actionName - The name of the bulk action (e.g., "Export", "Delete")
|
||||
*/
|
||||
getActionButton(actionName: string): Button {
|
||||
const controls = this.getControls();
|
||||
return new Button(
|
||||
this.page,
|
||||
controls.locator(BULK_SELECT_SELECTORS.ACTION, { hasText: actionName }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a bulk action button by name (e.g., "Export", "Delete")
|
||||
* @param actionName - The name of the bulk action to click
|
||||
*/
|
||||
async clickAction(actionName: string): Promise<void> {
|
||||
await this.getActionButton(actionName).click();
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,6 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
@primary-color: #20a7c9;
|
||||
@info-color: #66bcfe;
|
||||
@success-color: #59c189;
|
||||
@processing-color: #66bcfe;
|
||||
@error-color: #e04355;
|
||||
@highlight-color: #e04355;
|
||||
@normal-color: #d9d9d9;
|
||||
@white: #FFF;
|
||||
@black: #000;
|
||||
|
||||
// ListView-specific Playwright Components for Superset
|
||||
export { BulkSelect } from './BulkSelect';
|
||||
207
superset-frontend/playwright/components/core/AceEditor.ts
Normal file
207
superset-frontend/playwright/components/core/AceEditor.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
const ACE_EDITOR_SELECTORS = {
|
||||
TEXT_INPUT: '.ace_text-input',
|
||||
TEXT_LAYER: '.ace_text-layer',
|
||||
CONTENT: '.ace_content',
|
||||
SCROLLER: '.ace_scroller',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* AceEditor component for interacting with Ace Editor instances in Playwright.
|
||||
* Uses the ace editor API directly for reliable text manipulation.
|
||||
*/
|
||||
export class AceEditor {
|
||||
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 editor element locator
|
||||
*/
|
||||
get element(): Locator {
|
||||
return this.locator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the ace editor to be fully loaded and ready for interaction.
|
||||
*/
|
||||
async waitForReady(): Promise<void> {
|
||||
// Wait for editor to be attached (outer .ace_editor div may be CSS-hidden)
|
||||
await this.locator.waitFor({ state: 'attached' });
|
||||
await this.locator
|
||||
.locator(ACE_EDITOR_SELECTORS.CONTENT)
|
||||
.waitFor({ state: 'attached' });
|
||||
// Wait for window.ace library to be fully loaded (may load async)
|
||||
await this.page.waitForFunction(
|
||||
() =>
|
||||
typeof (window as unknown as { ace?: { edit?: unknown } }).ace?.edit ===
|
||||
'function',
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets text in the ace editor using the ace API.
|
||||
* Uses element handle to target the specific editor instance (not global ID lookup).
|
||||
* @param text - The text to set
|
||||
*/
|
||||
async setText(text: string): Promise<void> {
|
||||
await this.waitForReady();
|
||||
const elementHandle = await this.locator.elementHandle();
|
||||
if (!elementHandle) {
|
||||
throw new Error('Could not get element handle for ace editor');
|
||||
}
|
||||
await this.page.evaluate(
|
||||
({ element, value }) => {
|
||||
const windowWithAce = window as unknown as {
|
||||
ace?: {
|
||||
edit(el: Element): {
|
||||
setValue(v: string, c: number): void;
|
||||
session: { getUndoManager(): { reset(): void } };
|
||||
};
|
||||
};
|
||||
};
|
||||
if (!windowWithAce.ace) {
|
||||
throw new Error(
|
||||
'Ace editor library not loaded. Ensure the page has finished loading.',
|
||||
);
|
||||
}
|
||||
// ace.edit() accepts either an element ID string or the DOM element itself
|
||||
const editor = windowWithAce.ace.edit(element);
|
||||
editor.setValue(value, 1);
|
||||
editor.session.getUndoManager().reset();
|
||||
},
|
||||
{ element: elementHandle, value: text },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the text content from the ace editor.
|
||||
* Uses element handle to target the specific editor instance.
|
||||
* @returns The text content
|
||||
*/
|
||||
async getText(): Promise<string> {
|
||||
await this.waitForReady();
|
||||
const elementHandle = await this.locator.elementHandle();
|
||||
if (!elementHandle) {
|
||||
throw new Error('Could not get element handle for ace editor');
|
||||
}
|
||||
return this.page.evaluate(element => {
|
||||
const windowWithAce = window as unknown as {
|
||||
ace?: { edit(el: Element): { getValue(): string } };
|
||||
};
|
||||
if (!windowWithAce.ace) {
|
||||
throw new Error(
|
||||
'Ace editor library not loaded. Ensure the page has finished loading.',
|
||||
);
|
||||
}
|
||||
return windowWithAce.ace.edit(element).getValue();
|
||||
}, elementHandle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the text in the ace editor.
|
||||
*/
|
||||
async clear(): Promise<void> {
|
||||
await this.setText('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends text to the existing content in the ace editor.
|
||||
* Uses element handle to target the specific editor instance.
|
||||
* @param text - The text to append
|
||||
*/
|
||||
async appendText(text: string): Promise<void> {
|
||||
await this.waitForReady();
|
||||
const elementHandle = await this.locator.elementHandle();
|
||||
if (!elementHandle) {
|
||||
throw new Error('Could not get element handle for ace editor');
|
||||
}
|
||||
await this.page.evaluate(
|
||||
({ element, value }) => {
|
||||
const windowWithAce = window as unknown as {
|
||||
ace?: {
|
||||
edit(el: Element): {
|
||||
getValue(): string;
|
||||
setValue(v: string, c: number): void;
|
||||
};
|
||||
};
|
||||
};
|
||||
if (!windowWithAce.ace) {
|
||||
throw new Error(
|
||||
'Ace editor library not loaded. Ensure the page has finished loading.',
|
||||
);
|
||||
}
|
||||
const editor = windowWithAce.ace.edit(element);
|
||||
const currentText = editor.getValue();
|
||||
// Only add newline if there's existing text that doesn't already end with one
|
||||
const needsNewline = currentText && !currentText.endsWith('\n');
|
||||
const newText = currentText + (needsNewline ? '\n' : '') + value;
|
||||
editor.setValue(newText, 1);
|
||||
},
|
||||
{ element: elementHandle, value: text },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses the ace editor.
|
||||
* Uses element handle to target the specific editor instance.
|
||||
*/
|
||||
async focus(): Promise<void> {
|
||||
await this.waitForReady();
|
||||
const elementHandle = await this.locator.elementHandle();
|
||||
if (!elementHandle) {
|
||||
throw new Error('Could not get element handle for ace editor');
|
||||
}
|
||||
await this.page.evaluate(element => {
|
||||
const windowWithAce = window as unknown as {
|
||||
ace?: { edit(el: Element): { focus(): void } };
|
||||
};
|
||||
if (!windowWithAce.ace) {
|
||||
throw new Error(
|
||||
'Ace editor library not loaded. Ensure the page has finished loading.',
|
||||
);
|
||||
}
|
||||
windowWithAce.ace.edit(element).focus();
|
||||
}, elementHandle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the editor is visible.
|
||||
*/
|
||||
async isVisible(): Promise<boolean> {
|
||||
return this.locator.isVisible();
|
||||
}
|
||||
}
|
||||
95
superset-frontend/playwright/components/core/Checkbox.ts
Normal file
95
superset-frontend/playwright/components/core/Checkbox.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* Core Checkbox component used in Playwright tests to interact with checkbox
|
||||
* elements in the Superset UI.
|
||||
*
|
||||
* This class wraps a Playwright {@link Locator} pointing to a checkbox input
|
||||
* and provides convenience methods for common interactions such as checking,
|
||||
* unchecking, toggling, and asserting checkbox state and visibility.
|
||||
*
|
||||
* @example
|
||||
* const checkbox = new Checkbox(page, page.locator('input[type="checkbox"]'));
|
||||
* await checkbox.check();
|
||||
* await expect(await checkbox.isChecked()).toBe(true);
|
||||
*
|
||||
* @param page - The Playwright {@link Page} instance associated with the test.
|
||||
* @param locator - The Playwright {@link Locator} targeting the checkbox element.
|
||||
*/
|
||||
export class Checkbox {
|
||||
readonly page: Page;
|
||||
private readonly locator: Locator;
|
||||
|
||||
constructor(page: Page, locator: Locator) {
|
||||
this.page = page;
|
||||
this.locator = locator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the checkbox element locator
|
||||
*/
|
||||
get element(): Locator {
|
||||
return this.locator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the checkbox (ensures it's checked)
|
||||
*/
|
||||
async check(): Promise<void> {
|
||||
await this.locator.check();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unchecks the checkbox (ensures it's unchecked)
|
||||
*/
|
||||
async uncheck(): Promise<void> {
|
||||
await this.locator.uncheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the checkbox state
|
||||
*/
|
||||
async toggle(): Promise<void> {
|
||||
await this.locator.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the checkbox is checked
|
||||
*/
|
||||
async isChecked(): Promise<boolean> {
|
||||
return this.locator.isChecked();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the checkbox is visible
|
||||
*/
|
||||
async isVisible(): Promise<boolean> {
|
||||
return this.locator.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the checkbox is enabled
|
||||
*/
|
||||
async isEnabled(): Promise<boolean> {
|
||||
return this.locator.isEnabled();
|
||||
}
|
||||
}
|
||||
217
superset-frontend/playwright/components/core/Menu.ts
Normal file
217
superset-frontend/playwright/components/core/Menu.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 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 { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
/**
|
||||
* Menu component for Ant Design dropdown menus.
|
||||
* Uses hover as primary approach (most natural user interaction).
|
||||
* Falls back to keyboard navigation, then dispatchEvent if hover fails.
|
||||
*
|
||||
* This component handles menu content only - not the trigger that opens the menu.
|
||||
* The calling page object should open the menu first, then use this component.
|
||||
*
|
||||
* @example
|
||||
* // In a page object
|
||||
* async selectDownloadOption(optionText: string): Promise<void> {
|
||||
* await this.openHeaderActionsMenu();
|
||||
* const menu = new Menu(this.page, '[data-test="header-actions-menu"]');
|
||||
* await menu.selectSubmenuItem('Download', optionText);
|
||||
* }
|
||||
*/
|
||||
export class Menu {
|
||||
private readonly page: Page;
|
||||
private readonly locator: Locator;
|
||||
|
||||
private static readonly SELECTORS = {
|
||||
SUBMENU: '.ant-dropdown-menu-submenu',
|
||||
SUBMENU_POPUP: '.ant-dropdown-menu-submenu-popup',
|
||||
SUBMENU_TITLE: '.ant-dropdown-menu-submenu-title',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Ant Design animation delay - allows slide-in animation to complete.
|
||||
* Without this, elements may be "not stable" and clicks can fail.
|
||||
*/
|
||||
private static readonly ANIMATION_DELAY = 150;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a submenu and selects an item within it.
|
||||
* Uses hover as primary approach, falls back to keyboard then dispatchEvent.
|
||||
*
|
||||
* @param submenuText - The text of the submenu to open (e.g., "Download")
|
||||
* @param itemText - The text of the item to select (e.g., "Export YAML")
|
||||
* @param options - Optional timeout settings
|
||||
*/
|
||||
async selectSubmenuItem(
|
||||
submenuText: string,
|
||||
itemText: string,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
const timeout = options?.timeout ?? TIMEOUT.FORM_LOAD;
|
||||
|
||||
// Try hover first (most natural user interaction)
|
||||
let popup = await this.openSubmenuWithHover(submenuText, itemText, timeout);
|
||||
|
||||
// Fallback to keyboard navigation
|
||||
if (!popup) {
|
||||
popup = await this.openSubmenuWithKeyboard(
|
||||
submenuText,
|
||||
itemText,
|
||||
timeout,
|
||||
);
|
||||
}
|
||||
|
||||
// Last resort: dispatchEvent
|
||||
if (!popup) {
|
||||
popup = await this.openSubmenuWithDispatchEvent(
|
||||
submenuText,
|
||||
itemText,
|
||||
timeout,
|
||||
);
|
||||
}
|
||||
|
||||
if (!popup) {
|
||||
throw new Error(
|
||||
`Failed to open submenu "${submenuText}". Tried hover, keyboard, and dispatchEvent.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Use dispatchEvent instead of click to bypass viewport and pointer interception
|
||||
// issues. Ant Design renders submenu popups in a portal that can be positioned
|
||||
// outside the viewport or behind chart content (e.g., large tables with z-index).
|
||||
await popup.getByText(itemText, { exact: true }).dispatchEvent('click');
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a submenu using native Playwright hover.
|
||||
* Returns the popup locator if successful, null otherwise.
|
||||
*/
|
||||
private async openSubmenuWithHover(
|
||||
submenuText: string,
|
||||
itemText: string,
|
||||
timeout: number,
|
||||
): Promise<Locator | null> {
|
||||
try {
|
||||
const submenuTitle = this.getSubmenuTitle(submenuText);
|
||||
await submenuTitle.hover();
|
||||
|
||||
// Find the popup that contains the expected item (scopes to correct popup)
|
||||
const popup = this.page
|
||||
.locator(Menu.SELECTORS.SUBMENU_POPUP)
|
||||
.filter({ hasText: itemText });
|
||||
await popup.waitFor({ state: 'visible', timeout });
|
||||
|
||||
// Allow Ant Design's slide-in animation to complete before clicking.
|
||||
// Without this, the element may be "not stable" and clicks can fail.
|
||||
await this.page.waitForTimeout(Menu.ANIMATION_DELAY);
|
||||
|
||||
return popup;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a submenu using keyboard navigation.
|
||||
* Returns the popup locator if successful, null otherwise.
|
||||
*/
|
||||
private async openSubmenuWithKeyboard(
|
||||
submenuText: string,
|
||||
itemText: string,
|
||||
timeout: number,
|
||||
): Promise<Locator | null> {
|
||||
try {
|
||||
const submenuTitle = this.getSubmenuTitle(submenuText);
|
||||
await submenuTitle.focus();
|
||||
await this.page.keyboard.press('ArrowRight');
|
||||
|
||||
const popup = this.page
|
||||
.locator(Menu.SELECTORS.SUBMENU_POPUP)
|
||||
.filter({ hasText: itemText });
|
||||
await popup.waitFor({ state: 'visible', timeout });
|
||||
|
||||
return popup;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a submenu using dispatchEvent to trigger mouseover/mouseenter.
|
||||
* Returns the popup locator if successful, null otherwise.
|
||||
*/
|
||||
private async openSubmenuWithDispatchEvent(
|
||||
submenuText: string,
|
||||
itemText: string,
|
||||
timeout: number,
|
||||
): Promise<Locator | null> {
|
||||
try {
|
||||
const submenuTitle = this.getSubmenuTitle(submenuText);
|
||||
|
||||
await submenuTitle.evaluate(el => {
|
||||
el.dispatchEvent(
|
||||
new MouseEvent('mouseover', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
}),
|
||||
);
|
||||
el.dispatchEvent(
|
||||
new MouseEvent('mouseenter', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const popup = this.page
|
||||
.locator(Menu.SELECTORS.SUBMENU_POPUP)
|
||||
.filter({ hasText: itemText });
|
||||
await popup.waitFor({ state: 'visible', timeout });
|
||||
|
||||
return popup;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the submenu title element for a submenu containing the given text.
|
||||
*/
|
||||
private getSubmenuTitle(submenuText: string): Locator {
|
||||
return this.locator
|
||||
.locator(Menu.SELECTORS.SUBMENU)
|
||||
.filter({ hasText: submenuText })
|
||||
.locator(Menu.SELECTORS.SUBMENU_TITLE);
|
||||
}
|
||||
}
|
||||
187
superset-frontend/playwright/components/core/Select.ts
Normal file
187
superset-frontend/playwright/components/core/Select.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* Ant Design Select component selectors
|
||||
*/
|
||||
const SELECT_SELECTORS = {
|
||||
DROPDOWN: '.ant-select-dropdown',
|
||||
OPTION: '.ant-select-item-option',
|
||||
SEARCH_INPUT: '.ant-select-selection-search-input',
|
||||
CLEAR: '.ant-select-clear',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Select component for Ant Design Select/Combobox interactions.
|
||||
*/
|
||||
export class Select {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Select from a combobox role with the given accessible name
|
||||
* @param page - The Playwright page
|
||||
* @param name - The accessible name (aria-label or placeholder text)
|
||||
*/
|
||||
static fromRole(page: Page, name: string): Select {
|
||||
const locator = page.getByRole('combobox', { name });
|
||||
return new Select(page, locator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the select element locator
|
||||
*/
|
||||
get element(): Locator {
|
||||
return this.locator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the dropdown, types to filter, and selects an option.
|
||||
* Handles cases where the option may not be initially visible in the dropdown.
|
||||
* Waits for dropdown to close after selection to avoid stale dropdowns.
|
||||
* @param optionText - The text of the option to select
|
||||
*/
|
||||
async selectOption(optionText: string): Promise<void> {
|
||||
await this.open();
|
||||
await this.type(optionText);
|
||||
await this.clickOption(optionText);
|
||||
// Wait for dropdown to close to avoid multiple visible dropdowns
|
||||
await this.waitForDropdownClose();
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for dropdown to close after selection
|
||||
* This prevents strict mode violations when multiple selects are used sequentially
|
||||
*/
|
||||
private async waitForDropdownClose(): Promise<void> {
|
||||
// Wait for dropdown to actually close (become hidden)
|
||||
await this.page
|
||||
.locator(`${SELECT_SELECTORS.DROPDOWN}:not(.ant-select-dropdown-hidden)`)
|
||||
.last()
|
||||
.waitFor({ state: 'hidden', timeout: 5000 })
|
||||
.catch(error => {
|
||||
// Only ignore TimeoutError (dropdown may already be closed); re-throw others
|
||||
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the dropdown
|
||||
*/
|
||||
async open(): Promise<void> {
|
||||
await this.locator.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks an option in an already-open dropdown by its text content.
|
||||
* Uses selector-based approach matching Cypress patterns.
|
||||
* Handles multiple dropdowns by targeting only visible, non-hidden ones.
|
||||
* @param optionText - The text of the option to click (partial match for filtered results)
|
||||
*/
|
||||
async clickOption(optionText: string): Promise<void> {
|
||||
// Target visible dropdown (excludes hidden ones via :not(.ant-select-dropdown-hidden))
|
||||
// Use .last() in case multiple dropdowns exist - the most recent one is what we want
|
||||
const dropdown = this.page
|
||||
.locator(`${SELECT_SELECTORS.DROPDOWN}:not(.ant-select-dropdown-hidden)`)
|
||||
.last();
|
||||
await dropdown.waitFor({ state: 'visible' });
|
||||
|
||||
// Find option by text content - use partial match since filtered results may have prefixes
|
||||
// (e.g., searching for 'main' shows 'examples.main', 'system.main')
|
||||
// First try exact match, fall back to partial match
|
||||
const exactOption = dropdown
|
||||
.locator(SELECT_SELECTORS.OPTION)
|
||||
.getByText(optionText, { exact: true });
|
||||
|
||||
if ((await exactOption.count()) > 0) {
|
||||
await exactOption.click();
|
||||
} else {
|
||||
// Fall back to first option containing the text
|
||||
const partialOption = dropdown
|
||||
.locator(SELECT_SELECTORS.OPTION)
|
||||
.filter({ hasText: optionText })
|
||||
.first();
|
||||
await partialOption.click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the dropdown by pressing Escape
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
await this.page.keyboard.press('Escape');
|
||||
}
|
||||
|
||||
/**
|
||||
* Types into the select to filter options (assumes dropdown is open)
|
||||
* @param text - The text to type
|
||||
*/
|
||||
async type(text: string): Promise<void> {
|
||||
// Find the actual search input inside the select component
|
||||
const searchInput = this.locator.locator(SELECT_SELECTORS.SEARCH_INPUT);
|
||||
try {
|
||||
// Wait for search input in case dropdown is still rendering
|
||||
await searchInput.first().waitFor({ state: 'attached', timeout: 1000 });
|
||||
await searchInput.first().fill(text);
|
||||
} catch (error) {
|
||||
// Only handle TimeoutError (search input not found); re-throw other errors
|
||||
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
|
||||
throw error;
|
||||
}
|
||||
// Fallback: locator might be the input itself (e.g., from getByRole('combobox'))
|
||||
await this.locator.fill(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the current selection
|
||||
*/
|
||||
async clear(): Promise<void> {
|
||||
await this.locator.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the select is visible
|
||||
*/
|
||||
async isVisible(): Promise<boolean> {
|
||||
return this.locator.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the select is enabled
|
||||
*/
|
||||
async isEnabled(): Promise<boolean> {
|
||||
return this.locator.isEnabled();
|
||||
}
|
||||
}
|
||||
75
superset-frontend/playwright/components/core/Tabs.ts
Normal file
75
superset-frontend/playwright/components/core/Tabs.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* Tabs component for Ant Design tab navigation.
|
||||
*/
|
||||
export class Tabs {
|
||||
readonly page: Page;
|
||||
private readonly locator: Locator;
|
||||
|
||||
constructor(page: Page, locator?: Locator) {
|
||||
this.page = page;
|
||||
// Default to the tablist role if no specific locator provided
|
||||
this.locator = locator ?? page.getByRole('tablist');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the tablist element locator
|
||||
*/
|
||||
get element(): Locator {
|
||||
return this.locator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a tab by name, scoped to this tablist's container
|
||||
* @param tabName - The name/label of the tab
|
||||
*/
|
||||
getTab(tabName: string): Locator {
|
||||
return this.locator.getByRole('tab', { name: tabName });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a tab by name
|
||||
* @param tabName - The name/label of the tab to click
|
||||
*/
|
||||
async clickTab(tabName: string): Promise<void> {
|
||||
await this.getTab(tabName).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the tab panel content for a given tab
|
||||
* @param tabName - The name/label of the tab
|
||||
*/
|
||||
getTabPanel(tabName: string): Locator {
|
||||
return this.page.getByRole('tabpanel', { name: tabName });
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tab is selected
|
||||
* @param tabName - The name/label of the tab
|
||||
*/
|
||||
async isSelected(tabName: string): Promise<boolean> {
|
||||
const tab = this.getTab(tabName);
|
||||
const ariaSelected = await tab.getAttribute('aria-selected');
|
||||
return ariaSelected === 'true';
|
||||
}
|
||||
}
|
||||
109
superset-frontend/playwright/components/core/Textarea.ts
Normal file
109
superset-frontend/playwright/components/core/Textarea.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* Playwright helper for interacting with HTML {@link HTMLTextAreaElement | `<textarea>`} elements.
|
||||
*
|
||||
* This component wraps a Playwright {@link Locator} and provides convenience methods for
|
||||
* filling, clearing, and reading the value of a textarea without having to work with
|
||||
* locators directly.
|
||||
*
|
||||
* Typical usage:
|
||||
* ```ts
|
||||
* const textarea = new Textarea(page, 'textarea[name="description"]');
|
||||
* await textarea.fill('Some multi-line text');
|
||||
* const value = await textarea.getValue();
|
||||
* ```
|
||||
*
|
||||
* You can also construct an instance from the `name` attribute:
|
||||
* ```ts
|
||||
* const textarea = Textarea.fromName(page, 'description');
|
||||
* await textarea.clear();
|
||||
* ```
|
||||
*/
|
||||
export class Textarea {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Textarea from a name attribute
|
||||
* @param page - The Playwright page
|
||||
* @param name - The name attribute value
|
||||
*/
|
||||
static fromName(page: Page, name: string): Textarea {
|
||||
const locator = page.locator(`textarea[name="${name}"]`);
|
||||
return new Textarea(page, locator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the textarea element locator
|
||||
*/
|
||||
get element(): Locator {
|
||||
return this.locator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the textarea with text (clears existing content)
|
||||
* @param text - The text to fill
|
||||
*/
|
||||
async fill(text: string): Promise<void> {
|
||||
await this.locator.fill(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the textarea content
|
||||
*/
|
||||
async clear(): Promise<void> {
|
||||
await this.locator.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current value of the textarea
|
||||
*/
|
||||
async getValue(): Promise<string> {
|
||||
return this.locator.inputValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the textarea is visible
|
||||
*/
|
||||
async isVisible(): Promise<boolean> {
|
||||
return this.locator.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the textarea is enabled
|
||||
*/
|
||||
async isEnabled(): Promise<boolean> {
|
||||
return this.locator.isEnabled();
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,15 @@
|
||||
*/
|
||||
|
||||
// Core Playwright Components for Superset
|
||||
export { AceEditor } from './AceEditor';
|
||||
export { Button } from './Button';
|
||||
export { Checkbox } from './Checkbox';
|
||||
export { Form } from './Form';
|
||||
export { Input } from './Input';
|
||||
export { Menu } from './Menu';
|
||||
export { Modal } from './Modal';
|
||||
export { Select } from './Select';
|
||||
export { Table } from './Table';
|
||||
export { Tabs } from './Tabs';
|
||||
export { Textarea } from './Textarea';
|
||||
export { Toast } from './Toast';
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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, Locator } from '@playwright/test';
|
||||
import { Modal } from '../core/Modal';
|
||||
|
||||
/**
|
||||
* Confirm Dialog component for Ant Design Modal.confirm dialogs.
|
||||
* These are the "OK" / "Cancel" confirmation dialogs used throughout Superset.
|
||||
* Uses getByRole with name to target specific confirm dialogs when multiple are open.
|
||||
*/
|
||||
export class ConfirmDialog extends Modal {
|
||||
private readonly specificLocator: Locator;
|
||||
|
||||
constructor(page: Page, dialogName = 'Confirm save') {
|
||||
super(page);
|
||||
// Use getByRole with specific name to avoid strict mode violations
|
||||
// when multiple dialogs are open (e.g., Edit Dataset modal + Confirm save dialog)
|
||||
this.specificLocator = page.getByRole('dialog', { name: dialogName });
|
||||
}
|
||||
|
||||
/**
|
||||
* Override element getter to use specific locator
|
||||
*/
|
||||
override get element(): Locator {
|
||||
return this.specificLocator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the OK button to confirm.
|
||||
* @param options.timeout - If provided, silently returns if dialog doesn't appear
|
||||
* within timeout. If not provided, waits indefinitely (strict mode).
|
||||
*/
|
||||
async clickOk(options?: { timeout?: number }): Promise<void> {
|
||||
try {
|
||||
await this.element.waitFor({
|
||||
state: 'visible',
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
await this.clickFooterButton('OK');
|
||||
await this.waitForHidden();
|
||||
} catch (error) {
|
||||
// Only swallow TimeoutError when timeout was explicitly provided
|
||||
if (options?.timeout !== undefined) {
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the Cancel button to dismiss
|
||||
*/
|
||||
async clickCancel(): Promise<void> {
|
||||
await this.clickFooterButton('Cancel');
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,10 @@ export class DuplicateDatasetModal extends Modal {
|
||||
datasetName: string,
|
||||
options?: { timeout?: number; force?: boolean },
|
||||
): Promise<void> {
|
||||
await this.nameInput.fill(datasetName, options);
|
||||
const input = this.nameInput.element;
|
||||
// Clear existing text then fill (fill() clears first, but explicit clear is more reliable)
|
||||
await input.clear();
|
||||
await input.fill(datasetName, options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 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, Modal, Tabs, AceEditor } from '../core';
|
||||
|
||||
/**
|
||||
* Edit Dataset Modal component (DatasourceModal).
|
||||
* Used for editing dataset properties like description, metrics, columns, etc.
|
||||
* Uses specific dialog name to avoid strict mode violations when multiple dialogs are open.
|
||||
*/
|
||||
export class EditDatasetModal extends Modal {
|
||||
private static readonly SELECTORS = {
|
||||
NAME_INPUT: '[data-test="inline-name"]',
|
||||
LOCK_ICON: '[data-test="lock"]',
|
||||
UNLOCK_ICON: '[data-test="unlock"]',
|
||||
};
|
||||
|
||||
private readonly tabs: Tabs;
|
||||
private readonly specificLocator: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
// Use getByRole with specific name to target Edit Dataset dialog
|
||||
// The dialog has aria-labelledby that resolves to "edit Edit Dataset"
|
||||
this.specificLocator = page.getByRole('dialog', { name: /edit.*dataset/i });
|
||||
// Scope tabs to modal's tablist to avoid matching tablists elsewhere on page
|
||||
this.tabs = new Tabs(page, this.specificLocator.getByRole('tablist'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Override element getter to use specific locator
|
||||
*/
|
||||
override get element(): Locator {
|
||||
return this.specificLocator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the Save button to save changes
|
||||
*/
|
||||
async clickSave(): Promise<void> {
|
||||
await this.clickFooterButton('Save');
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the Cancel button to discard changes
|
||||
*/
|
||||
async clickCancel(): Promise<void> {
|
||||
await this.clickFooterButton('Cancel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the lock icon to enable edit mode
|
||||
* The modal starts in read-only mode and requires clicking the lock to edit
|
||||
*/
|
||||
async enableEditMode(): Promise<void> {
|
||||
const lockButton = this.body.locator(EditDatasetModal.SELECTORS.LOCK_ICON);
|
||||
await lockButton.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the dataset name input component
|
||||
*/
|
||||
private get nameInput(): Input {
|
||||
return new Input(
|
||||
this.page,
|
||||
this.body.locator(EditDatasetModal.SELECTORS.NAME_INPUT),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in the dataset name field
|
||||
* Note: Call enableEditMode() first if the modal is in read-only mode
|
||||
* @param name - The new dataset name
|
||||
*/
|
||||
async fillName(name: string): Promise<void> {
|
||||
await this.nameInput.fill(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a specific tab in the modal
|
||||
* @param tabName - The name of the tab (e.g., 'Source', 'Metrics', 'Columns')
|
||||
*/
|
||||
async clickTab(tabName: string): Promise<void> {
|
||||
await this.tabs.clickTab(tabName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the Settings tab
|
||||
*/
|
||||
async clickSettingsTab(): Promise<void> {
|
||||
await this.tabs.clickTab('Settings');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the Columns tab.
|
||||
* Uses regex to avoid matching "Calculated columns" tab, scoped to modal.
|
||||
*/
|
||||
async clickColumnsTab(): Promise<void> {
|
||||
// Use regex starting with "Columns" to avoid matching "Calculated columns"
|
||||
// Scope to modal element to avoid matching tabs elsewhere on page
|
||||
await this.element.getByRole('tab', { name: /^Columns/ }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the description Ace Editor component (Settings tab).
|
||||
* The Description button and ace-editor are in the same form item.
|
||||
*/
|
||||
private get descriptionEditor(): AceEditor {
|
||||
// Use tabpanel role with name "Settings" for more reliable lookup
|
||||
const settingsPanel = this.element.getByRole('tabpanel', {
|
||||
name: 'Settings',
|
||||
});
|
||||
// Find the form item that contains the Description button
|
||||
const descriptionFormItem = settingsPanel
|
||||
.locator('.ant-form-item')
|
||||
.filter({
|
||||
has: this.page.getByRole('button', {
|
||||
name: 'Description',
|
||||
exact: true,
|
||||
}),
|
||||
})
|
||||
.first();
|
||||
// The ace-editor has class .ace_editor within the form item
|
||||
const editorElement = descriptionFormItem.locator('.ace_editor');
|
||||
return new AceEditor(this.page, editorElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the dataset description field (Settings tab).
|
||||
* @param description - The description text to set
|
||||
*/
|
||||
async fillDescription(description: string): Promise<void> {
|
||||
await this.descriptionEditor.setText(description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a column row by column name.
|
||||
* Uses exact cell match to avoid false positives with short names like "ds".
|
||||
* @param columnName - The name of the column to expand
|
||||
* @returns The row locator for scoped selector access
|
||||
*/
|
||||
async expandColumn(columnName: string): Promise<Locator> {
|
||||
// Find cell with exact column name text, then derive row from that cell
|
||||
const cell = this.body.getByRole('cell', { name: columnName, exact: true });
|
||||
const row = cell.locator('xpath=ancestor::tr[1]');
|
||||
await row.getByRole('button', { name: /expand row/i }).click();
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill column datetime format for a given column.
|
||||
* Expands the column row and fills the date format input.
|
||||
* Note: Expanded content appears in a sibling row, so we scope to modal body.
|
||||
* @param columnName - The name of the column to edit
|
||||
* @param format - The python date format string (e.g., '%Y-%m-%d')
|
||||
*/
|
||||
async fillColumnDateFormat(
|
||||
columnName: string,
|
||||
format: string,
|
||||
): Promise<void> {
|
||||
await this.expandColumn(columnName);
|
||||
// Expanded content appears in a sibling row, not nested inside the original row.
|
||||
// Use modal body scope with placeholder selector to find the datetime format input.
|
||||
const dateFormatInput = new Input(
|
||||
this.page,
|
||||
this.body.getByPlaceholder('%Y-%m-%d'),
|
||||
);
|
||||
await dateFormatInput.element.waitFor({ state: 'visible' });
|
||||
await dateFormatInput.clear();
|
||||
await dateFormatInput.fill(format);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 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 { Modal, Input } from '../core';
|
||||
|
||||
/**
|
||||
* Import dataset modal for uploading dataset export files.
|
||||
* Handles file upload, overwrite confirmation, and import submission.
|
||||
*/
|
||||
export class ImportDatasetModal extends Modal {
|
||||
private static readonly SELECTORS = {
|
||||
FILE_INPUT: '[data-test="model-file-input"]',
|
||||
OVERWRITE_INPUT: '[data-test="overwrite-modal-input"]',
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload a file to the import modal
|
||||
* @param filePath - Absolute path to the file to upload
|
||||
*/
|
||||
async uploadFile(filePath: string): Promise<void> {
|
||||
await this.page
|
||||
.locator(ImportDatasetModal.SELECTORS.FILE_INPUT)
|
||||
.setInputFiles(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the overwrite confirmation input (only needed if dataset exists)
|
||||
*/
|
||||
async fillOverwriteConfirmation(): Promise<void> {
|
||||
const input = new Input(
|
||||
this.page,
|
||||
this.body.locator(ImportDatasetModal.SELECTORS.OVERWRITE_INPUT),
|
||||
);
|
||||
await input.fill('OVERWRITE');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the overwrite confirmation input locator
|
||||
*/
|
||||
getOverwriteInput() {
|
||||
return this.body.locator(ImportDatasetModal.SELECTORS.OVERWRITE_INPUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if overwrite confirmation is visible
|
||||
*/
|
||||
async isOverwriteVisible(): Promise<boolean> {
|
||||
return this.getOverwriteInput().isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the Import button in the footer
|
||||
*/
|
||||
async clickImport(): Promise<void> {
|
||||
await this.clickFooterButton('Import');
|
||||
}
|
||||
}
|
||||
@@ -20,3 +20,4 @@
|
||||
// Specific modal implementations
|
||||
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
|
||||
export { DuplicateDatasetModal } from './DuplicateDatasetModal';
|
||||
export { ImportDatasetModal } from './ImportDatasetModal';
|
||||
|
||||
BIN
superset-frontend/playwright/fixtures/dataset_export.zip
Normal file
BIN
superset-frontend/playwright/fixtures/dataset_export.zip
Normal file
Binary file not shown.
61
superset-frontend/playwright/helpers/api/assertions.ts
Normal file
61
superset-frontend/playwright/helpers/api/assertions.ts
Normal 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 type { Response, APIResponse } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Common interface for response types with status() method.
|
||||
* Supports both Response (network interception) and APIResponse (page.request API).
|
||||
*/
|
||||
type ResponseLike = Response | APIResponse;
|
||||
|
||||
/**
|
||||
* Verify response has exact status code
|
||||
* @param response - Playwright Response or APIResponse object
|
||||
* @param expected - Expected status code
|
||||
* @returns The response for chaining
|
||||
*/
|
||||
export function expectStatus<T extends ResponseLike>(
|
||||
response: T,
|
||||
expected: number,
|
||||
): T {
|
||||
expect(
|
||||
response.status(),
|
||||
`Expected status ${expected}, got ${response.status()}`,
|
||||
).toBe(expected);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify response status code is one of the expected values
|
||||
* @param response - Playwright Response or APIResponse object
|
||||
* @param expected - Array of acceptable status codes
|
||||
* @returns The response for chaining
|
||||
*/
|
||||
export function expectStatusOneOf<T extends ResponseLike>(
|
||||
response: T,
|
||||
expected: number[],
|
||||
): T {
|
||||
expect(
|
||||
expected,
|
||||
`Expected status to be one of ${expected.join(', ')}, got ${response.status()}`,
|
||||
).toContain(response.status());
|
||||
return response;
|
||||
}
|
||||
@@ -18,12 +18,33 @@
|
||||
*/
|
||||
|
||||
import { Page, APIResponse } from '@playwright/test';
|
||||
import { apiPost, apiDelete, ApiRequestOptions } from './requests';
|
||||
import rison from 'rison';
|
||||
import { apiGet, apiPost, apiDelete, ApiRequestOptions } from './requests';
|
||||
|
||||
const ENDPOINTS = {
|
||||
DATABASE: 'api/v1/database/',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* TypeScript interface for database API response
|
||||
*/
|
||||
export interface DatabaseResult {
|
||||
id: number;
|
||||
database_name: string;
|
||||
/** Optional - list API masks this for security, only detail API returns it */
|
||||
sqlalchemy_uri?: string;
|
||||
backend?: string;
|
||||
engine_information?: {
|
||||
disable_ssh_tunneling?: boolean;
|
||||
supports_dynamic_catalog?: boolean;
|
||||
supports_file_upload?: boolean;
|
||||
supports_oauth2?: boolean;
|
||||
};
|
||||
extra?: string;
|
||||
expose_in_sqllab?: boolean;
|
||||
impersonate_user?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* TypeScript interface for database creation API payload
|
||||
* Provides compile-time safety for required fields
|
||||
@@ -31,6 +52,7 @@ const ENDPOINTS = {
|
||||
export interface DatabaseCreatePayload {
|
||||
database_name: string;
|
||||
engine: string;
|
||||
sqlalchemy_uri?: string;
|
||||
configuration_method?: string;
|
||||
engine_information?: {
|
||||
disable_ssh_tunneling?: boolean;
|
||||
@@ -77,3 +99,53 @@ export async function apiDeleteDatabase(
|
||||
): Promise<APIResponse> {
|
||||
return apiDelete(page, `${ENDPOINTS.DATABASE}${databaseId}`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request to fetch a database's details
|
||||
* @param page - Playwright page instance (provides authentication context)
|
||||
* @param databaseId - ID of the database to fetch
|
||||
* @returns API response with database details
|
||||
*/
|
||||
export async function apiGetDatabase(
|
||||
page: Page,
|
||||
databaseId: number,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<APIResponse> {
|
||||
return apiGet(page, `${ENDPOINTS.DATABASE}${databaseId}`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a database by its name
|
||||
* @param page - Playwright page instance (provides authentication context)
|
||||
* @param databaseName - The database_name to search for
|
||||
* @returns Database object if found, null if not found
|
||||
*/
|
||||
export async function getDatabaseByName(
|
||||
page: Page,
|
||||
databaseName: string,
|
||||
): Promise<DatabaseResult | null> {
|
||||
const filter = {
|
||||
filters: [
|
||||
{
|
||||
col: 'database_name',
|
||||
opr: 'eq',
|
||||
value: databaseName,
|
||||
},
|
||||
],
|
||||
};
|
||||
const queryParam = rison.encode(filter);
|
||||
const response = await apiGet(page, `${ENDPOINTS.DATABASE}?q=${queryParam}`, {
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
if (body.result && body.result.length > 0) {
|
||||
return body.result[0] as DatabaseResult;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -20,9 +20,13 @@
|
||||
import { Page, APIResponse } from '@playwright/test';
|
||||
import rison from 'rison';
|
||||
import { apiGet, apiPost, apiDelete, ApiRequestOptions } from './requests';
|
||||
import { getDatabaseByName } from './database';
|
||||
|
||||
export const ENDPOINTS = {
|
||||
DATASET: 'api/v1/dataset/',
|
||||
DATASET_EXPORT: 'api/v1/dataset/export/',
|
||||
DATASET_DUPLICATE: 'api/v1/dataset/duplicate',
|
||||
DATASET_IMPORT: 'api/v1/dataset/import/',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -37,12 +41,12 @@ export interface DatasetCreatePayload {
|
||||
}
|
||||
|
||||
/**
|
||||
* TypeScript interface for virtual dataset creation API payload
|
||||
* Virtual datasets are SQL-based and support the Duplicate action in UI
|
||||
* TypeScript interface for virtual dataset creation API payload.
|
||||
* Virtual datasets are defined by SQL queries rather than physical tables.
|
||||
*/
|
||||
export interface VirtualDatasetCreatePayload {
|
||||
database: number;
|
||||
schema: string;
|
||||
schema: string | null;
|
||||
table_name: string;
|
||||
sql: string;
|
||||
owners?: number[];
|
||||
@@ -55,8 +59,8 @@ export interface VirtualDatasetCreatePayload {
|
||||
export interface DatasetResult {
|
||||
id: number;
|
||||
table_name: string;
|
||||
sql?: string;
|
||||
schema?: string;
|
||||
sql?: string | null;
|
||||
schema?: string | null;
|
||||
database: {
|
||||
id: number;
|
||||
database_name: string;
|
||||
@@ -79,11 +83,11 @@ export async function apiPostDataset(
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request to create a virtual (SQL-based) dataset
|
||||
* Virtual datasets support the Duplicate action in the UI
|
||||
* POST request to create a virtual dataset with SQL.
|
||||
* Use expectStatusOneOf() on the response and handle both result.id and id shapes.
|
||||
* @param page - Playwright page instance (provides authentication context)
|
||||
* @param requestBody - Virtual dataset config (database, schema, table_name, sql)
|
||||
* @returns API response from dataset creation
|
||||
* @param requestBody - Virtual dataset configuration (database, schema, table_name, sql)
|
||||
* @returns API response from virtual dataset creation
|
||||
*/
|
||||
export async function apiPostVirtualDataset(
|
||||
page: Page,
|
||||
@@ -96,16 +100,27 @@ export async function apiPostVirtualDataset(
|
||||
* Creates a simple virtual dataset for testing purposes
|
||||
* @param page - Playwright page instance
|
||||
* @param name - Name for the virtual dataset
|
||||
* @param databaseId - ID of the database to use (defaults to 1 for examples db)
|
||||
* @param databaseId - ID of the database to use (looks up 'examples' DB if not provided)
|
||||
* @returns The created dataset ID, or null on failure
|
||||
*/
|
||||
export async function createTestVirtualDataset(
|
||||
page: Page,
|
||||
name: string,
|
||||
databaseId = 1,
|
||||
databaseId?: number,
|
||||
): Promise<number | null> {
|
||||
// Look up examples database if no ID provided
|
||||
let dbId = databaseId;
|
||||
if (dbId === undefined) {
|
||||
const examplesDb = await getDatabaseByName(page, 'examples');
|
||||
if (!examplesDb?.id) {
|
||||
console.warn('Failed to find examples database');
|
||||
return null;
|
||||
}
|
||||
dbId = examplesDb.id;
|
||||
}
|
||||
|
||||
const response = await apiPostVirtualDataset(page, {
|
||||
database: databaseId,
|
||||
database: dbId,
|
||||
schema: '',
|
||||
table_name: name,
|
||||
sql: "SELECT 1 as id, 'test' as name",
|
||||
@@ -118,7 +133,8 @@ export async function createTestVirtualDataset(
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
return body.id ?? null;
|
||||
// Handle both response shapes: { id } or { result: { id } }
|
||||
return body.result?.id ?? body.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,3 +202,30 @@ export async function apiDeleteDataset(
|
||||
): Promise<APIResponse> {
|
||||
return apiDelete(page, `${ENDPOINTS.DATASET}${datasetId}`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a dataset via the API
|
||||
* @param page - Playwright page instance (provides authentication context)
|
||||
* @param datasetId - ID of the dataset to duplicate
|
||||
* @param newName - Name for the duplicated dataset
|
||||
* @returns Object containing the new dataset's ID (use apiGetDataset for full details)
|
||||
*/
|
||||
export async function duplicateDataset(
|
||||
page: Page,
|
||||
datasetId: number,
|
||||
newName: string,
|
||||
): Promise<{ id: number }> {
|
||||
const response = await apiPost(page, `${ENDPOINTS.DATASET}duplicate`, {
|
||||
base_model_id: datasetId,
|
||||
table_name: newName,
|
||||
});
|
||||
const body = await response.json();
|
||||
// Normalize: API may return id at top level or inside result
|
||||
const resolvedId = body.result?.id ?? body.id;
|
||||
if (!resolvedId) {
|
||||
throw new Error(
|
||||
`Duplicate dataset API returned no id. Response: ${JSON.stringify(body)}`,
|
||||
);
|
||||
}
|
||||
return { id: resolvedId };
|
||||
}
|
||||
|
||||
145
superset-frontend/playwright/helpers/api/intercepts.ts
Normal file
145
superset-frontend/playwright/helpers/api/intercepts.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import type { Page, Response } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* HTTP methods enum for consistency
|
||||
*/
|
||||
export const HTTP_METHODS = {
|
||||
GET: 'GET',
|
||||
POST: 'POST',
|
||||
PUT: 'PUT',
|
||||
DELETE: 'DELETE',
|
||||
PATCH: 'PATCH',
|
||||
} as const;
|
||||
|
||||
type HttpMethod = (typeof HTTP_METHODS)[keyof typeof HTTP_METHODS];
|
||||
|
||||
/**
|
||||
* Options for waitFor* functions
|
||||
*/
|
||||
interface WaitForResponseOptions {
|
||||
/** Optional timeout in milliseconds */
|
||||
timeout?: number;
|
||||
/** Match against URL pathname suffix instead of full URL includes (default: false) */
|
||||
pathMatch?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a path by removing trailing slashes
|
||||
*/
|
||||
function normalizePath(path: string): string {
|
||||
return path.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL matches a pattern
|
||||
* - String + pathMatch: pathname.endsWith(pattern) with trailing slash normalization
|
||||
* - String: url.includes(pattern)
|
||||
* - RegExp: pattern.test(url)
|
||||
*/
|
||||
function matchUrl(
|
||||
url: string,
|
||||
pattern: string | RegExp,
|
||||
pathMatch?: boolean,
|
||||
): boolean {
|
||||
if (typeof pattern === 'string') {
|
||||
if (pathMatch) {
|
||||
const pathname = normalizePath(new URL(url).pathname);
|
||||
const normalizedPattern = normalizePath(pattern);
|
||||
return pathname.endsWith(normalizedPattern);
|
||||
}
|
||||
return url.includes(pattern);
|
||||
}
|
||||
return pattern.test(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic helper to wait for a response matching URL pattern and HTTP method
|
||||
*/
|
||||
function waitForResponse(
|
||||
page: Page,
|
||||
urlPattern: string | RegExp,
|
||||
method: HttpMethod,
|
||||
options?: WaitForResponseOptions,
|
||||
): Promise<Response> {
|
||||
const { pathMatch, ...waitOptions } = options ?? {};
|
||||
return page.waitForResponse(
|
||||
response =>
|
||||
matchUrl(response.url(), urlPattern, pathMatch) &&
|
||||
response.request().method() === method,
|
||||
waitOptions,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a GET response matching the URL pattern
|
||||
*/
|
||||
export function waitForGet(
|
||||
page: Page,
|
||||
urlPattern: string | RegExp,
|
||||
options?: WaitForResponseOptions,
|
||||
): Promise<Response> {
|
||||
return waitForResponse(page, urlPattern, HTTP_METHODS.GET, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a POST response matching the URL pattern
|
||||
*/
|
||||
export function waitForPost(
|
||||
page: Page,
|
||||
urlPattern: string | RegExp,
|
||||
options?: WaitForResponseOptions,
|
||||
): Promise<Response> {
|
||||
return waitForResponse(page, urlPattern, HTTP_METHODS.POST, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a PUT response matching the URL pattern
|
||||
*/
|
||||
export function waitForPut(
|
||||
page: Page,
|
||||
urlPattern: string | RegExp,
|
||||
options?: WaitForResponseOptions,
|
||||
): Promise<Response> {
|
||||
return waitForResponse(page, urlPattern, HTTP_METHODS.PUT, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a DELETE response matching the URL pattern
|
||||
*/
|
||||
export function waitForDelete(
|
||||
page: Page,
|
||||
urlPattern: string | RegExp,
|
||||
options?: WaitForResponseOptions,
|
||||
): Promise<Response> {
|
||||
return waitForResponse(page, urlPattern, HTTP_METHODS.DELETE, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a PATCH response matching the URL pattern
|
||||
*/
|
||||
export function waitForPatch(
|
||||
page: Page,
|
||||
urlPattern: string | RegExp,
|
||||
options?: WaitForResponseOptions,
|
||||
): Promise<Response> {
|
||||
return waitForResponse(page, urlPattern, HTTP_METHODS.PATCH, options);
|
||||
}
|
||||
21
superset-frontend/playwright/helpers/fixtures/index.ts
Normal file
21
superset-frontend/playwright/helpers/fixtures/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Base fixture with test asset cleanup
|
||||
export { test as testWithAssets, expect, type TestAssets } from './testAssets';
|
||||
68
superset-frontend/playwright/helpers/fixtures/testAssets.ts
Normal file
68
superset-frontend/playwright/helpers/fixtures/testAssets.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 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 as base } from '@playwright/test';
|
||||
import { apiDeleteDataset } from '../api/dataset';
|
||||
import { apiDeleteDatabase } from '../api/database';
|
||||
|
||||
/**
|
||||
* Test asset tracker for automatic cleanup after each test.
|
||||
* Inspired by Cypress's cleanDashboards/cleanCharts pattern.
|
||||
*/
|
||||
export interface TestAssets {
|
||||
trackDataset(id: number): void;
|
||||
trackDatabase(id: number): void;
|
||||
}
|
||||
|
||||
export const test = base.extend<{ testAssets: TestAssets }>({
|
||||
testAssets: async ({ page }, use) => {
|
||||
// Use Set to de-dupe IDs (same resource may be tracked multiple times)
|
||||
const datasetIds = new Set<number>();
|
||||
const databaseIds = new Set<number>();
|
||||
|
||||
await use({
|
||||
trackDataset: id => datasetIds.add(id),
|
||||
trackDatabase: id => databaseIds.add(id),
|
||||
});
|
||||
|
||||
// Cleanup: Delete datasets FIRST (they reference databases)
|
||||
// Then delete databases. Use failOnStatusCode: false for tolerance.
|
||||
await Promise.all(
|
||||
[...datasetIds].map(id =>
|
||||
apiDeleteDataset(page, id, { failOnStatusCode: false }).catch(error => {
|
||||
console.warn(`[testAssets] Failed to cleanup dataset ${id}:`, error);
|
||||
}),
|
||||
),
|
||||
);
|
||||
await Promise.all(
|
||||
[...databaseIds].map(id =>
|
||||
apiDeleteDatabase(page, id, { failOnStatusCode: false }).catch(
|
||||
error => {
|
||||
console.warn(
|
||||
`[testAssets] Failed to cleanup database ${id}:`,
|
||||
error,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
138
superset-frontend/playwright/pages/ChartCreationPage.ts
Normal file
138
superset-frontend/playwright/pages/ChartCreationPage.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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 { expect, Locator, Page } from '@playwright/test';
|
||||
import { Button, Select } from '../components/core';
|
||||
|
||||
/**
|
||||
* Chart Creation Page object for the "Create a new chart" wizard.
|
||||
* This page appears after creating a dataset via the wizard.
|
||||
*/
|
||||
export class ChartCreationPage {
|
||||
readonly page: Page;
|
||||
|
||||
private static readonly SELECTORS = {
|
||||
VIZ_GALLERY: '.viz-gallery',
|
||||
VIZ_TYPE_ITEM: '[data-test="viz-type-gallery__item"]',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the dataset selector container (includes the displayed selection value)
|
||||
*/
|
||||
getDatasetSelectContainer(): Locator {
|
||||
return this.page.getByLabel('Dataset', { exact: false }).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the dataset selector for interactions
|
||||
*/
|
||||
getDatasetSelect(): Select {
|
||||
return new Select(
|
||||
this.page,
|
||||
this.page.getByRole('combobox', { name: /dataset/i }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the visualization gallery container
|
||||
*/
|
||||
getVizGallery(): Locator {
|
||||
return this.page.locator(ChartCreationPage.SELECTORS.VIZ_GALLERY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the "Create new chart" button
|
||||
*/
|
||||
getCreateChartButton(): Button {
|
||||
return new Button(
|
||||
this.page,
|
||||
this.page.getByRole('button', { name: /create new chart/i }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the chart creation page
|
||||
*/
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto('chart/add');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the page to load (dataset selector visible)
|
||||
*/
|
||||
async waitForPageLoad(): Promise<void> {
|
||||
await expect(this.getDatasetSelect().element).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a dataset from the dropdown
|
||||
* @param datasetName - The name of the dataset to select
|
||||
*/
|
||||
async selectDataset(datasetName: string): Promise<void> {
|
||||
await this.getDatasetSelect().selectOption(datasetName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a visualization type from the gallery
|
||||
* @param vizType - The visualization type to select (e.g., 'Table', 'Bar Chart')
|
||||
*/
|
||||
async selectVizType(vizType: string): Promise<void> {
|
||||
const vizGallery = this.getVizGallery();
|
||||
await expect(vizGallery).toBeVisible();
|
||||
|
||||
// Button names in the gallery are duplicated (e.g., "Table Table", "Bar Chart Bar Chart")
|
||||
// because they include both the image alt text and the label text.
|
||||
// Use exact match with the duplicated pattern to avoid matching similar names.
|
||||
const vizTypeItem = vizGallery.getByRole('button', {
|
||||
name: `${vizType} ${vizType}`,
|
||||
exact: true,
|
||||
});
|
||||
await vizTypeItem.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Create new chart" button to navigate to Explore
|
||||
*/
|
||||
async clickCreateNewChart(): Promise<void> {
|
||||
await this.getCreateChartButton().click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the dataset is pre-selected (shown in the selector)
|
||||
* @param datasetName - The expected dataset name
|
||||
*/
|
||||
async expectDatasetSelected(datasetName: string): Promise<void> {
|
||||
// For Ant Design selects, the selected value is displayed in a sibling element,
|
||||
// not in the combobox input. Check the container for the displayed text.
|
||||
await expect(this.getDatasetSelectContainer()).toContainText(datasetName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the "Create new chart" button is enabled
|
||||
*/
|
||||
async isCreateButtonEnabled(): Promise<boolean> {
|
||||
return this.getCreateChartButton().isEnabled();
|
||||
}
|
||||
}
|
||||
138
superset-frontend/playwright/pages/CreateDatasetPage.ts
Normal file
138
superset-frontend/playwright/pages/CreateDatasetPage.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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 } from '@playwright/test';
|
||||
import { Button, Select } from '../components/core';
|
||||
|
||||
/**
|
||||
* Create Dataset Page object for the dataset creation wizard.
|
||||
*/
|
||||
export class CreateDatasetPage {
|
||||
readonly page: Page;
|
||||
|
||||
/**
|
||||
* Data-test selectors for the create dataset form elements.
|
||||
* Using data-test attributes avoids strict mode violations with multiple selects.
|
||||
*/
|
||||
private static readonly SELECTORS = {
|
||||
DATABASE: '[data-test="select-database"]',
|
||||
SCHEMA: '[data-test="Select schema or type to search schemas"]',
|
||||
TABLE: '[data-test="Select table or type to search tables"]',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the database selector using data-test attribute
|
||||
*/
|
||||
getDatabaseSelect(): Select {
|
||||
return new Select(this.page, CreateDatasetPage.SELECTORS.DATABASE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the schema selector using data-test attribute
|
||||
*/
|
||||
getSchemaSelect(): Select {
|
||||
return new Select(this.page, CreateDatasetPage.SELECTORS.SCHEMA);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the table selector using data-test attribute
|
||||
*/
|
||||
getTableSelect(): Select {
|
||||
return new Select(this.page, CreateDatasetPage.SELECTORS.TABLE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the create and explore button
|
||||
*/
|
||||
getCreateAndExploreButton(): Button {
|
||||
return new Button(
|
||||
this.page,
|
||||
this.page.getByRole('button', { name: /Create and explore dataset/i }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the create dataset page
|
||||
*/
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto('dataset/add/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a database from the dropdown
|
||||
* @param databaseName - The name of the database to select
|
||||
*/
|
||||
async selectDatabase(databaseName: string): Promise<void> {
|
||||
await this.getDatabaseSelect().selectOption(databaseName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a schema from the dropdown
|
||||
* @param schemaName - The name of the schema to select
|
||||
*/
|
||||
async selectSchema(schemaName: string): Promise<void> {
|
||||
await this.getSchemaSelect().selectOption(schemaName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a table from the dropdown
|
||||
* @param tableName - The name of the table to select
|
||||
*/
|
||||
async selectTable(tableName: string): Promise<void> {
|
||||
await this.getTableSelect().selectOption(tableName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Create dataset" button (without exploring)
|
||||
* Uses the dropdown menu to select "Create dataset" option
|
||||
*/
|
||||
async clickCreateDataset(): Promise<void> {
|
||||
// Find the "Create and explore dataset" button, then its sibling dropdown trigger
|
||||
// This avoids ambiguity if other "down" buttons exist on the page
|
||||
const mainButton = this.page.getByRole('button', {
|
||||
name: /Create and explore dataset/i,
|
||||
});
|
||||
// The dropdown trigger is in the same button group, find it relative to main button
|
||||
const dropdownTrigger = mainButton
|
||||
.locator('xpath=following-sibling::button')
|
||||
.first();
|
||||
await dropdownTrigger.click();
|
||||
|
||||
// Click "Create dataset" option from the dropdown menu
|
||||
await this.page.getByText('Create dataset', { exact: true }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Create and explore dataset" button
|
||||
*/
|
||||
async clickCreateAndExploreDataset(): Promise<void> {
|
||||
await this.getCreateAndExploreButton().click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the page to load
|
||||
*/
|
||||
async waitForPageLoad(): Promise<void> {
|
||||
await this.getDatabaseSelect().element.waitFor({ state: 'visible' });
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { Page, Download } from '@playwright/test';
|
||||
import { Menu } from '../components/core';
|
||||
import { TIMEOUT } from '../utils/constants';
|
||||
|
||||
/**
|
||||
@@ -54,7 +55,7 @@ export class DashboardPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the dashboard to load
|
||||
* Wait for the dashboard header to be visible.
|
||||
*/
|
||||
async waitForLoad(options?: { timeout?: number }): Promise<void> {
|
||||
const timeout = options?.timeout ?? TIMEOUT.PAGE_LOAD;
|
||||
@@ -63,6 +64,35 @@ export class DashboardPage {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all charts on the dashboard to finish loading.
|
||||
* Waits until no loading indicators are visible on the page.
|
||||
*/
|
||||
async waitForChartsToLoad(options?: { timeout?: number }): Promise<void> {
|
||||
const timeout = options?.timeout ?? TIMEOUT.API_RESPONSE;
|
||||
|
||||
// Use browser-context evaluation to check visibility directly.
|
||||
// Loading indicators ([aria-label="Loading"]) may persist in the DOM as hidden
|
||||
// elements after charts finish loading. This checks that none are currently visible,
|
||||
// returning immediately when charts are already loaded (no timeout penalty).
|
||||
await this.page.waitForFunction(
|
||||
() => {
|
||||
const loaders = document.querySelectorAll('[aria-label="Loading"]');
|
||||
if (loaders.length === 0) return true;
|
||||
return Array.from(loaders).every(el => {
|
||||
const style = getComputedStyle(el);
|
||||
return (
|
||||
style.display === 'none' ||
|
||||
style.visibility === 'hidden' ||
|
||||
style.opacity === '0'
|
||||
);
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
{ timeout },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the dashboard header actions menu (three-dot menu)
|
||||
*/
|
||||
@@ -78,33 +108,21 @@ export class DashboardPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover over the Download submenu to open it (Ant Design submenus open on hover)
|
||||
* Selects an option from the Download submenu.
|
||||
* Opens the header actions menu, navigates to Download submenu,
|
||||
* and clicks the specified option.
|
||||
*
|
||||
* @param optionText - The download option to select (e.g., "Export YAML")
|
||||
*/
|
||||
async openDownloadMenu(): Promise<void> {
|
||||
// Find the Download menu item within the header actions menu and hover
|
||||
const menu = this.page.locator(DashboardPage.SELECTORS.HEADER_ACTIONS_MENU);
|
||||
await menu.getByText('Download', { exact: true }).hover();
|
||||
// Wait for Export YAML to become visible (indicates submenu opened)
|
||||
await this.page.getByText('Export YAML').waitFor({ state: 'visible' });
|
||||
}
|
||||
async selectDownloadOption(optionText: string): Promise<Download> {
|
||||
await this.openHeaderActionsMenu();
|
||||
|
||||
/**
|
||||
* Click "Export YAML" in the download menu
|
||||
* Returns a Promise that resolves when download starts
|
||||
*/
|
||||
async clickExportYaml(): Promise<Download> {
|
||||
const menu = new Menu(
|
||||
this.page,
|
||||
DashboardPage.SELECTORS.HEADER_ACTIONS_MENU,
|
||||
);
|
||||
const downloadPromise = this.page.waitForEvent('download');
|
||||
await this.page.getByText('Export YAML').click();
|
||||
return downloadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click "Export as Example" in the download menu
|
||||
* Returns a Promise that resolves when download starts
|
||||
*/
|
||||
async clickExportAsExample(): Promise<Download> {
|
||||
const downloadPromise = this.page.waitForEvent('download');
|
||||
await this.page.getByText('Export as Example').click();
|
||||
await menu.selectSubmenuItem('Download', optionText);
|
||||
return downloadPromise;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user