Compare commits

..

6 Commits

Author SHA1 Message Date
Evan Rusackas
b8123f87ed address review: only skip first docstring in static_return_bool
A later bare string literal in a function body is not a docstring and
should count as non-trivial logic. Track whether the docstring has been
skipped so subsequent string-constant expression statements fall through
to the conservative 'other_logic' branch.
2026-04-27 08:43:18 -07:00
Evan Rusackas
5dc9ca153e address review: check has_implicit_cancel return value
diagnose() in lib.py calls spec.has_implicit_cancel() and uses the
return value; the extractor was treating any override as cancel
support, regardless of what it returns. An override that explicitly
returns False (e.g. ImpalaEngineSpec) should NOT enable
query_cancelation — only a cancel_query override should, per
has_custom_method(spec, "cancel_query") or spec.has_implicit_cancel().

Added static_return_bool() to statically resolve simple "return
<bool>" methods. has_implicit_cancel overrides that explicitly
return False are now excluded from direct_methods; True /
unresolvable / complex bodies still count (conservative). For current
specs the outcome is unchanged (Impala/Hive have cancel_query
overrides; Presto/Hive return True), but the heuristic now matches
diagnose() exactly and won't misclassify future specs that override
has_implicit_cancel to False without a cancel_query override.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 08:43:18 -07:00
Evan Rusackas
88a28b180a address review: align query_cancelation with diagnose()
diagnose() in lib.py only counts cancel_query being overridden OR
has_implicit_cancel() returning True (equivalent, statically, to
overriding has_implicit_cancel since the base returns False). The
extractor additionally counted get_cancel_query_id, which could mark
cancellation as supported even when cancel_query wasn't overridden.
Removed get_cancel_query_id from CAP_METHODS and the query_cancelation
check so the generated docs match diagnose().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 08:43:18 -07:00
Evan Rusackas
5f0fe4a2b7 address review: preserve existing JSON for unresolvable cap flags
Cap attrs assigned via expressions (e.g. DruidEngineSpec.allows_joins =
is_feature_enabled("DRUID_JOINS")) can't be statically evaluated, so
they previously silently fell back to the BaseEngineSpec default —
causing generated docs to disagree with runtime behavior.

Now the Python extractor tracks unresolvable assignments per class,
propagates them through the MRO walk, and emits an
_unresolved_cap_fields marker on the database entry. The JS layer
uses that marker to prefer the value from the previously-generated
databases.json (which was produced with Flask context and reflects
real runtime values) and strips the marker before writing output.

Verified against druid.py: extraction correctly emits
"_unresolved_cap_fields": ["joins"], and the existing JSON's
"joins": false is preserved rather than overwritten with the base
default of true.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 08:43:18 -07:00
Evan Rusackas
97af6bd9f7 address review: iterate bases right-to-left so leftmost wins (MRO)
Python attribute lookup picks the first base that defines the attr; the
previous left-to-right update() order caused later bases to override
earlier ones, contradicting MRO. Reversing the iteration makes the
leftmost base the final writer, which matches Python semantics for the
common single-chain hierarchies in db_engine_specs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 08:43:18 -07:00
Superset Dev
3c19df8706 fix(docs): read capability flags from engine specs in database docs generator
Update the database docs generator to extract capability flag values
directly from Python engine spec class attributes (supports_catalog,
supports_dynamic_schema, etc.) and method overrides (cancel_query,
estimate_statement_cost, impersonate_user, has_implicit_cancel, etc.)
using AST parsing in the fallback path.

Previously the generator hardcoded False for all capability flags and
relied on mergeWithExistingDiagnostics to preserve manual edits from
the existing databases.json. This made it impossible to keep flags
in sync with the Python source.

Changes:
- Fallback AST path now extracts cap flags via AST with proper
  inheritance resolution (mirrors lib.py has_custom_method logic)
- mergeWithExistingDiagnostics now only preserves score/max_score/
  time_grains (Flask-context-only fields), not capability flags
- lib.py generate_yaml_docs now includes supports_dynamic_catalog
  in its output, and skips base specs that would overwrite a real
  product spec's flags for the same engine_name
- Regenerated databases.json with corrected flag values

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:43:18 -07:00
412 changed files with 24124 additions and 60024 deletions

4
.github/CODEOWNERS vendored
View File

@@ -36,10 +36,6 @@
**/*.geojson @villebro @rusackas
/superset-frontend/plugins/legacy-plugin-chart-country-map/ @villebro @rusackas
# Notify translation maintainers of changes to translations
/superset/translations/ @sfirke
# Notify PMC members of changes to extension-related files
/docs/developer_portal/extensions/ @michael-s-molina @villebro @rusackas

View File

@@ -37,10 +37,6 @@ updates:
# `just-handlerbars-helpers` library in plugin-chart-handlebars requires `currencyformatter`` to be < 2
- dependency-name: "currencyformatter.js"
update-types: ["version-update:semver-major"]
# TODO: remove below clause once https://github.com/pmmmwh/react-refresh-webpack-plugin/pull/940 lands onto a future release
# and confirm the issue https://github.com/apache/superset/issues/39600 is fixed
- dependency-name: "react-checkbox-tree"
update-types: ["version-update:semver-major"]
groups:
storybook:
applies-to: version-updates
@@ -59,13 +55,15 @@ updates:
versioning-strategy: increase
- package-ecosystem: "pip"
directory: "/"
# NOTE: `uv` support is in beta, more details here:
# https://github.com/dependabot/dependabot-core/pull/10040#issuecomment-2696978430
- package-ecosystem: "uv"
directory: "requirements/"
open-pull-requests-limit: 10
schedule:
interval: "weekly"
labels:
- pip
- uv
- dependabot
- package-ecosystem: "npm"

10
.github/labeler.yml vendored
View File

@@ -77,11 +77,6 @@
- any-glob-to-any-file:
- 'superset/translations/zh/**'
"i18n:czech":
- changed-files:
- any-glob-to-any-file:
- 'superset/translations/cs/**'
"i18n:traditional-chinese":
- changed-files:
- any-glob-to-any-file:
@@ -127,11 +122,6 @@
- any-glob-to-any-file:
- 'superset/translations/sk/**'
"i18n:latvian":
- changed-files:
- any-glob-to-any-file:
- 'superset/translations/lv/**'
"i18n:ukrainian":
- changed-files:
- any-glob-to-any-file:

View File

@@ -127,20 +127,6 @@ playwright_testdata() {
superset load_test_users
superset load_examples
superset init
# Enable DML on the examples database so Playwright tests can create/drop
# temporary tables via SQL Lab without depending on external data sources.
superset shell <<'PYEOF'
import sys
from superset.extensions import db
from superset.models.core import Database
examples_db = db.session.query(Database).filter_by(database_name='examples').first()
if not examples_db:
sys.exit('ERROR: examples database not found. load_examples may have failed.')
examples_db.allow_dml = True
db.session.commit()
print('Enabled allow_dml on examples database')
PYEOF
say "::endgroup::"
}

View File

@@ -265,7 +265,7 @@ jobs:
- name: Fill in the new image ID in the Amazon ECS task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@6853cfae8c3a7d978fbf68b5a55453395541dfbb # v1
uses: aws-actions/amazon-ecs-render-task-definition@77954e213ba1f9f9cb016b86a1d4f6fcdea0d57e # v1
with:
task-definition: .github/workflows/ecs-task-definition.json
container-name: superset-ci
@@ -300,7 +300,7 @@ jobs:
--tags key=pr,value=$PR_NUMBER key=github_user,value=${{ github.actor }}
- name: Deploy Amazon ECS task definition
id: deploy-task
uses: aws-actions/amazon-ecs-deploy-task-definition@a310a830f5c14e583e35d84e4e1ec7dd177c3c9c # v2
uses: aws-actions/amazon-ecs-deploy-task-definition@fc8fc60f3a60ffd500fcb13b209c59d221ac8c8c # v2
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: pr-${{ github.event.inputs.issue_number || github.event.pull_request.number }}-service

View File

@@ -70,7 +70,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@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
continue-on-error: true
with:
workflow: superset-python-integrationtest.yml
@@ -79,7 +79,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@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
continue-on-error: true
with:
workflow: superset-python-integrationtest.yml

View File

@@ -111,7 +111,7 @@ jobs:
run: |
yarn install --check-cache
- name: Download database diagnostics from integration tests
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
workflow: superset-python-integrationtest.yml
run_id: ${{ github.event.workflow_run.id }}

View File

@@ -29,7 +29,7 @@ ARG BUILD_TRANSLATIONS="false"
######################################################################
# superset-node-ci used as a base for building frontend assets and CI
######################################################################
FROM --platform=${BUILDPLATFORM} node:22-trixie-slim AS superset-node-ci
FROM --platform=${BUILDPLATFORM} node:20-trixie-slim AS superset-node-ci
ARG BUILD_TRANSLATIONS
ENV BUILD_TRANSLATIONS=${BUILD_TRANSLATIONS}
ARG DEV_MODE="false" # Skip frontend build in dev mode

View File

@@ -58,10 +58,6 @@ categories:
url: https://www.ontruck.com/
Financial Services:
- name: Aadhar Housing Finance Limited
url: https://www.aadharhousing.com
contributors: ["@thakerhardiks"]
- name: Aktia Bank plc
url: https://www.aktia.com

View File

@@ -138,33 +138,14 @@ THUMBNAIL_CACHE_CONFIG = init_thumbnail_cache
```
Using the above example cache keys for dashboards will be `superset_thumb__dashboard__{ID}`. You can
override the base URL for Selenium using:
override the base URL for selenium using:
```
WEBDRIVER_BASEURL = "https://superset.company.com"
```
To control which user account is used for rendering thumbnails and warming up caches, configure
`THUMBNAIL_EXECUTORS` and `CACHE_WARMUP_EXECUTORS`. Each accepts a list of executor types (which
resolve to an owner, creator, modifier, or the currently-logged-in user) and/or a `FixedExecutor`
pinned to a specific username. By default, thumbnails render as the current user
(`ExecutorType.CURRENT_USER`) and cache warmup runs as the chart/dashboard owner
(`ExecutorType.OWNER`).
To force both to run as a dedicated service account (`admin` in this example):
```python
from superset.tasks.types import ExecutorType, FixedExecutor
THUMBNAIL_EXECUTORS = [FixedExecutor("admin")]
CACHE_WARMUP_EXECUTORS = [FixedExecutor("admin")]
```
Use a dedicated read-only service account here rather than a personal admin account, so that
thumbnail rendering and cache warmup tasks don't fail if a specific user's credentials change.
Additional Selenium WebDriver configuration can be set using `WEBDRIVER_CONFIGURATION`. You can
implement a custom function to authenticate Selenium. The default function uses the `flask-login`
Additional selenium web drive configuration can be set using `WEBDRIVER_CONFIGURATION`. You can
implement a custom function to authenticate selenium. The default function uses the `flask-login`
session cookie. Here's an example of a custom function signature:
```python
@@ -178,20 +159,6 @@ Then on configuration:
WEBDRIVER_AUTH_FUNC = auth_driver
```
## ETag Support for Thumbnails
Thumbnail and screenshot endpoints return `ETag` response headers based on the cached content digest. Clients can use conditional requests to avoid downloading unchanged images:
```
GET /api/v1/chart/42/thumbnail/
If-None-Match: "abc123..."
→ 304 Not Modified (if unchanged)
→ 200 OK (with new image if changed)
```
This is particularly useful for embedded dashboards and external integrations that periodically poll for updated screenshots — unchanged thumbnails return immediately with no payload.
## Distributed Coordination Backend
Superset supports an optional distributed coordination (`DISTRIBUTED_COORDINATION_CONFIG`) for

View File

@@ -372,26 +372,6 @@ CUSTOM_SECURITY_MANAGER = CustomSsoSecurityManager
]
```
### PKCE Support
For public OAuth2 clients that cannot securely store a client secret, enable Proof Key for Code Exchange (PKCE) by adding `code_challenge_method` to the `remote_app` configuration:
```python
OAUTH_PROVIDERS = [
{
'name': 'myProvider',
'remote_app': {
'client_id': 'myClientId',
'client_secret': 'mySecret', # may be empty for pure public clients
'code_challenge_method': 'S256', # enables PKCE
'server_metadata_url': 'https://myAuthorizationServer/.well-known/openid-configuration'
}
}
]
```
PKCE (`S256`) is recommended for all OAuth2 flows, even when a client secret is present, as it protects against authorization code interception attacks.
## LDAP Authentication
FAB supports authenticating user credentials against an LDAP server.

View File

@@ -30,10 +30,6 @@ Superset's ZIP-based import/export also covers **dashboards**, **charts**, and *
| └── ... (more databases)
```
:::note
When you export a database connection, the `masked_encrypted_extra` field (used for sensitive connection parameters such as service account JSON, OAuth tokens, and other encrypted credentials) is included in the export. When importing on another instance, these values are decrypted and re-encrypted using the destination instance's `SECRET_KEY`. Ensure the receiving instance has a valid `SECRET_KEY` configured before importing.
:::
## Exporting Datasources to YAML
You can print your current datasources to stdout by running:

View File

@@ -501,7 +501,7 @@ All MCP settings go in `superset_config.py`. Defaults are defined in `superset/m
| `MCP_SERVICE_URL` | `None` | Public base URL for MCP-generated links (set this when behind a reverse proxy) |
| `MCP_DEBUG` | `False` | Enable debug logging |
| `MCP_DEV_USERNAME` | -- | Superset username for development mode (no auth) |
| `MCP_RBAC_ENABLED` | `True` | Enforce Superset's role-based access control on MCP tool calls. When `True`, each tool checks that the authenticated user has the required FAB permission before executing. Disable only for testing or trusted-network deployments. |
| `MCP_PARSE_REQUEST_ENABLED` | `True` | Pre-parse MCP tool inputs from JSON strings into objects. Set to `False` for clients (Claude Desktop, LangChain) that do not double-serialize arguments — this produces cleaner tool schemas for those clients |
### Authentication
@@ -517,7 +517,6 @@ All MCP settings go in `superset_config.py`. Defaults are defined in `superset/m
| `MCP_REQUIRED_SCOPES` | `[]` | Required JWT scopes |
| `MCP_JWT_DEBUG_ERRORS` | `False` | Log detailed JWT errors server-side (never exposed in HTTP responses per RFC 6750) |
| `MCP_AUTH_FACTORY` | `None` | Custom auth provider factory `(flask_app) -> auth_provider`. Takes precedence over built-in JWT |
| `MCP_USER_RESOLVER` | `None` | Custom function `(app, access_token) -> username` to extract a Superset username from a validated JWT token. When `None`, the default resolver checks `preferred_username`, `username`, `email`, and `sub` claims in that order. |
### Response Size Guard
@@ -601,43 +600,6 @@ MCP_STORE_CONFIG = {
| `event_store_max_events` | `100` | Maximum events retained per session |
| `event_store_ttl` | `3600` | Event TTL in seconds |
### Tool Search
By default the MCP server exposes a lightweight tool-search interface instead of advertising every tool at once. This reduces the initial context sent to the LLM by ~70%, which lowers cost and latency. The AI client discovers tools on demand by calling `search_tools` and then invokes them via `call_tool`.
```python
MCP_TOOL_SEARCH_CONFIG = {
"enabled": True,
"strategy": "bm25", # "bm25" (natural language) or "regex"
"max_results": 5,
"always_visible": [ # Tools always listed (pinned)
"health_check",
"get_instance_info",
],
"search_tool_name": "search_tools",
"call_tool_name": "call_tool",
"include_schemas": False, # False=summary mode (name + parameters_hint)
"compact_schemas": True, # Strip $defs (only applies when include_schemas=True)
"max_description_length": 300,
}
```
| Key | Default | Description |
|-----|---------|-------------|
| `enabled` | `True` | Enable tool search. When `False`, all tools are listed upfront |
| `strategy` | `"bm25"` | Search ranking algorithm. `"bm25"` supports natural language; `"regex"` supports pattern matching |
| `max_results` | `5` | Maximum tools returned per search query |
| `always_visible` | See above | Tools that always appear in `list_tools`, regardless of search |
| `include_schemas` | `False` | When `False` (default, "summary mode"), search results omit `inputSchema` entirely and include a lightweight `parameters_hint` listing top-level parameter names. Set to `True` to include the full `inputSchema` in search results. Full schemas are always used when a tool is actually invoked via `call_tool`. |
| `compact_schemas` | `True` | Strip `$defs` / `$ref` and replace with `{"type": "object"}` in search results to reduce token cost. Only takes effect when `include_schemas=True` — ignored in summary mode. |
| `max_description_length` | `300` | Truncate tool descriptions in search results (0 = no truncation). Applies in both summary and full-schema modes. |
:::tip
Set `enabled: False` to revert to the traditional "show all tools at once" behavior, which some clients or workflows may prefer.
:::
Tool search reduces the initial token cost from ~1520K tokens (full catalog) down to ~45K tokens (pinned tools + search interface) — roughly 85% savings at the start of each conversation.
### Session & CSRF
These values are flat-merged into the Flask app config used by the MCP server process:
@@ -659,102 +621,6 @@ MCP_CSRF_CONFIG = {
---
## Access Control
### RBAC Enforcement
The MCP server respects Superset's full role-based access control (RBAC). Every authenticated user can only access the data and operations their Superset roles permit — the same rules that apply in the Superset UI apply through MCP.
Each tool declares one or more required FAB permissions. The table below maps tool groups to their permission requirements:
| Tool group | Required FAB permission |
|------------|------------------------|
| `list_charts`, `get_chart_info`, `get_chart_data`, `get_chart_preview`, `generate_chart`, `update_chart` | `can_read` on `Chart` (read), `can_write` on `Chart` (mutate) |
| `list_dashboards`, `get_dashboard_info`, `generate_dashboard`, `add_chart_to_existing_dashboard` | `can_read` on `Dashboard` (read), `can_write` on `Dashboard` (mutate) |
| `list_datasets`, `get_dataset_info`, `create_virtual_dataset` | `can_read` on `Dataset` (read), `can_write` on `Dataset` (mutate) |
| `list_databases`, `get_database_info` | `can_read` on `Database` |
| `execute_sql` | `can_execute_sql_query` on `SQLLab` |
| `open_sql_lab_with_context` | `can_read` on `SQLLab` |
| `save_sql_query` | `can_write` on `SavedQuery` |
| `health_check` | None (public) |
To disable RBAC checking globally (for trusted-network deployments or testing), set:
```python
# superset_config.py
MCP_RBAC_ENABLED = False
```
:::warning
Disabling RBAC removes all permission checks from MCP tool calls. Only do this on isolated, internal deployments where all MCP users are trusted admins.
:::
### Audit Log
All MCP tool calls are recorded in Superset's action log. You can view them at **Settings → Action Log** (admin only). Each log entry records:
- The tool name (e.g., `mcp.generate_chart.db_write`)
- The authenticated user
- A timestamp
This makes MCP activity fully auditable alongside regular Superset activity. The action log uses the same event logger as the rest of Superset, so existing log ingestion pipelines (e.g., sending logs to Elasticsearch or a SIEM) capture MCP events automatically.
### Middleware Pipeline
Every MCP request passes through a middleware stack before reaching the tool function. The default stack (assembled in `build_middleware_list()` in `server.py`) is:
| Middleware | Purpose | Default |
|------------|---------|---------|
| `StructuredContentStripperMiddleware` | Strips `structuredContent` from responses for Claude.ai bridge compatibility | Enabled |
| `LoggingMiddleware` | Logs each tool call with user, parameters, and duration | Enabled |
| `GlobalErrorHandlerMiddleware` | Catches unhandled exceptions and sanitizes sensitive data before it reaches the client | Enabled |
| `ResponseSizeGuardMiddleware` | Estimates token count, warns at 80% of limit, blocks at limit | Enabled (configurable via `MCP_RESPONSE_SIZE_CONFIG`) |
| `ResponseCachingMiddleware` | Caches read-heavy tool responses (in-memory or Redis) | Disabled (enable via `MCP_CACHE_CONFIG`) |
Additional middleware classes (`RateLimitMiddleware`, `FieldPermissionsMiddleware`, `PrivateToolMiddleware`) are implemented in `superset/mcp_service/middleware.py` but are not added to the default pipeline. They are available for operators who want to layer them in via a custom startup path.
### Error Sanitization
The `GlobalErrorHandlerMiddleware` automatically redacts sensitive information from all error messages before they reach the LLM client. The following are replaced with generic messages:
- **Database connection strings** — replaced with a generic connection error message
- **API keys and tokens** — redacted from error traces
- **File system paths** — stripped to prevent information disclosure
- **IP addresses** — removed from error context
This ensures that a misconfigured database connection or an unexpected exception never leaks credentials or internal topology to the LLM or its users. All regex patterns used for redaction are bounded to prevent ReDoS attacks.
---
## Performance
### Connection Pooling
Each MCP server process maintains its own SQLAlchemy connection pool to the database. For multi-worker deployments, total open connections = **workers × pool size**.
```python
# superset_config.py
SQLALCHEMY_POOL_SIZE = 5
SQLALCHEMY_MAX_OVERFLOW = 10
SQLALCHEMY_POOL_TIMEOUT = 30
SQLALCHEMY_POOL_RECYCLE = 3600 # Recycle connections after 1 hour
```
For a 3-pod Kubernetes deployment with the defaults above, expect up to 3 × (5 + 10) = 45 connections. Size your database's `max_connections` accordingly.
### Response Caching
Enable response caching for read-heavy workloads (dashboards/datasets that don't change frequently). With the in-memory backend (default when `MCP_STORE_CONFIG` is disabled), caching is per-process. Use Redis-backed caching for consistent cache hits across multiple pods:
```python
MCP_CACHE_CONFIG = {"enabled": True, "call_tool_ttl": 3600}
MCP_STORE_CONFIG = {"enabled": True, "CACHE_REDIS_URL": "redis://redis:6379/0"}
```
Mutating tools (`generate_chart`, `update_chart`, `execute_sql`, `generate_dashboard`) are always excluded from caching regardless of this setting.
---
## Troubleshooting
### Server won't start

View File

@@ -84,35 +84,6 @@ THEME_DARK = {
# - OS preference detection is automatically enabled
```
### App Branding
The application name shown in the browser title bar and navigation can be
set via the `brandAppName` theme token:
```python
THEME_DEFAULT = {
"token": {
"brandAppName": "Acme Analytics",
# ... other tokens
}
}
```
Or in the theme CRUD UI JSON editor:
```json
{
"token": {
"brandAppName": "Acme Analytics"
}
}
```
The existing `APP_NAME` Python config key continues to work for backward compatibility.
`brandAppName` takes precedence when both are set, and allows different themes to carry different brand names.
Email and alert/report notification subjects are driven by backend settings such as
`EMAIL_REPORTS_SUBJECT_PREFIX` and `APP_NAME`, not by this theme token.
### Migration from Configuration to UI
When `ENABLE_UI_THEME_ADMINISTRATION = True`:
@@ -341,25 +312,11 @@ Available chart types for `echartsOptionsOverridesByChartType`:
- `echarts_heatmap` - Heatmaps
- `echarts_mixed_timeseries` - Mixed time series
### Array Property Overrides
Array properties (such as color palettes) are fully supported in overrides. Arrays are **replaced entirely** rather than merged, so specify the complete array:
```python
THEME_DEFAULT = {
"token": { ... },
"echartsOptionsOverrides": {
# Replace the default color palette for all ECharts visualizations
"color": ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b"]
}
}
```
### Best Practices
1. **Start with global overrides** for consistent styling across all charts
2. **Use chart-specific overrides** for unique requirements per visualization type
3. **Test thoroughly** as overrides use deep merge for objects, but arrays are completely replaced — always specify the full array value
3. **Test thoroughly** as overrides use deep merge - nested objects are combined, but arrays are completely replaced
4. **Document your overrides** to help team members understand custom styling
5. **Consider performance** - complex overrides may impact chart rendering speed

View File

@@ -52,15 +52,6 @@ only see the objects that they have access to.
The **sql_lab** role grants access to SQL Lab. Note that while **Admin** users have access
to all databases by default, both **Alpha** and **Gamma** users need to be given access on a per database basis.
Beyond the base `sql_lab` role, two additional SQL Lab permissions must be explicitly granted for users who need these capabilities:
| Permission | Feature |
|------------|---------|
| `can_estimate_query_cost` on `SQLLab` | Estimate query cost before running |
| `can_format_sql` on `SQLLab` | Format SQL using the database's dialect |
Grant these in **Security → List Roles** by adding the permissions to the relevant role.
### Public
The **Public** role is the most restrictive built-in role, designed specifically for anonymous/unauthenticated
@@ -191,8 +182,6 @@ However, it is crucial to understand the following:
By combining Superset's configurable safeguards with strong database-level security practices, you can achieve a more robust and layered security posture.
**Dataset Sample Access**: The `get_samples()` endpoint now enforces datasource-level access control. Users can only fetch sample rows from datasets they have been explicitly granted access to — the same permission check applied when running chart queries. This closes a prior gap where unauthenticated or under-privileged access could retrieve sample data.
### REST API for user & role management
Flask-AppBuilder supports a REST API for user CRUD,
@@ -250,143 +239,26 @@ based on the roles and permissions that were attributed.
### Row Level Security
Using Row Level Security filters (under the **Security** menu) you can create filters
that are assigned to a particular dataset, as well as a set of roles.
that are assigned to a particular table, as well as a set of roles.
If you want members of the Finance team to only have access to
rows where `department = "finance"`, you could:
- Create a Row Level Security filter with that clause (`department = "finance"`)
- Then assign the clause to the **Finance** role and the dataset it applies to
- Then assign the clause to the **Finance** role and the table it applies to
The **clause** field, which can contain arbitrary text, is then added to the generated
SQL statement's WHERE clause. So you could even do something like create a filter
SQL statements WHERE clause. So you could even do something like create a filter
for the last 30 days and apply it to a specific role, with a clause
like `date_field > DATE_SUB(NOW(), INTERVAL 30 DAY)`. It can also support
multiple conditions: `client_id = 6` AND `advertiser="foo"`, etc.
RLS clauses also support **Jinja templating** when `ENABLE_TEMPLATE_PROCESSING` is enabled, so you can write dynamic filters such as
`user_id = '{{ current_username() }}'` to restrict rows based on the logged-in user.
All relevant Row level security filters will be combined together (under the hood,
the different SQL clauses are combined using AND statements). This means it's
possible to create a situation where two roles conflict in such a way as to limit a table subset to empty.
#### Filter Types
There are two types of RLS filters:
- **Regular** — The filter clause is applied when the querying user belongs to one of the
roles assigned to the filter. Use this to restrict what specific roles can see.
- **Base** — The filter clause is applied to **all** users _except_ those in the assigned
roles. Use this to define a default restriction that privileged roles (e.g. Admin) are
exempt from. For example, a Base filter with clause `1 = 0` and the Admin role would
hide all rows from everyone except Admin — useful as a deny-by-default baseline.
#### Group Keys and Filter Combination
All applicable RLS filters are combined before being added to the query. The combination
rules are:
- Filters that share the **same group key** are combined with **OR** (any match within
the group is sufficient).
- Different filter groups (different group keys, or no group key) are combined with
**AND** (all groups must match).
- Filters with **no group key** are each treated as their own group and are always AND'd.
For example, if a dataset has three filters:
| Filter | Clause | Group Key |
|--------|--------|-----------|
| F1 | `department = 'Finance'` | `department` |
| F2 | `department = 'Marketing'` | `department` |
| F3 | `region = 'Europe'` | `region` |
The resulting WHERE clause would be:
```sql
(department = 'Finance' OR department = 'Marketing') AND (region = 'Europe')
```
:::caution Conflicting filters
It is possible to create filters that conflict and produce an empty result set. For
example, the filters `client_id = 4` and `client_id = 5` **without a shared group key**
will be AND'd together, producing `client_id = 4 AND client_id = 5`, which can never
be true.
If you intend for these to be alternatives, assign them the **same group key** so they
are OR'd instead.
:::
#### RLS and Virtual (SQL-Based) Datasets
RLS filters are assigned to **datasets**, not to underlying database tables directly. This
has important implications when working with virtual (SQL-based) datasets:
- **Physical datasets** (backed directly by a table or view) — RLS filters assigned to
the dataset are added as WHERE clauses to the query.
- **Virtual datasets** (defined by a custom SQL query) — RLS filters assigned directly to
the virtual dataset are applied to the _outer_ query that wraps the dataset's SQL.
Additionally, RLS filters on the **underlying physical datasets** referenced by the
virtual dataset's SQL are injected into the inner subquery for each referenced table.
For example, if you have:
1. A physical dataset `orders` with RLS filter `region = 'US'`
2. A virtual dataset defined as `SELECT * FROM orders WHERE status = 'active'`
A user affected by the RLS filter will effectively see:
```sql
SELECT * FROM (
SELECT * FROM orders WHERE (region = 'US') AND status = 'active'
) ...
```
**Key considerations for virtual datasets:**
- You generally do **not** need to duplicate RLS filters on both the physical and virtual
dataset — filters on the physical dataset are applied automatically at query time.
- If you assign an RLS filter directly to a virtual dataset, the clause must reference
columns available in the virtual dataset's _output_, not necessarily the underlying
table's columns.
- In **SQL Lab**, RLS is enforced only when the `RLS_IN_SQLLAB` feature flag is enabled:
queries run against tables that have associated datasets with RLS filters will then have
the appropriate predicates injected automatically.
#### Checking RLS Filters via the API
You can use the RLS REST API to audit which filters are configured and which datasets
they affect. This requires the `can_read` permission on the `Row Level Security` resource.
**List all RLS rules:**
```
GET /api/v1/rowlevelsecurity/
```
**Filter RLS rules for a specific dataset** (using [Rison](https://github.com/Nanonid/rison) query syntax):
```
GET /api/v1/rowlevelsecurity/?q=(filters:!((col:tables,opr:rel_m_m,value:<dataset_id>)))
```
**Filter RLS rules by role:**
```
GET /api/v1/rowlevelsecurity/?q=(filters:!((col:roles,opr:rel_m_m,value:<role_id>)))
```
**View details of a specific rule** (including clause, assigned datasets, and roles):
```
GET /api/v1/rowlevelsecurity/<id>
```
The response includes the filter's `name`, `filter_type` (Regular or Base), `clause`,
`group_key`, assigned `tables` (with id, schema, and table\_name), and assigned `roles`
(with id and name).
:::tip Auditing RLS for virtual datasets
To find all RLS rules that could affect a particular virtual dataset, query the list
endpoint filtered by that dataset's ID for any directly-assigned rules. Then also check
the physical datasets referenced in the virtual dataset's SQL, since their RLS filters
are applied at query time too.
:::
For example, the filters `client_id=4` and `client_id=5`, applied to a role,
will result in users of that role having `client_id=4` AND `client_id=5`
added to their query, which can never be true.
### User Sessions

View File

@@ -485,7 +485,7 @@ Frontend assets (TypeScript, JavaScript, CSS, and images) must be compiled in or
First, be sure you are using the following versions of Node.js and npm:
- `Node.js`: Version 22 (LTS)
- `Node.js`: Version 20
- `npm`: Version 10
We recommend using [nvm](https://github.com/nvm-sh/nvm) to manage your node environment:

View File

@@ -256,34 +256,6 @@ For example, when running the local development build, the following will disabl
Top Nav and remove the Filter Bar:
`http://localhost:8088/superset/dashboard/my-dashboard/?standalone=1&show_filters=0`
### Table Chart Features
The **Table** chart type has several advanced capabilities worth knowing:
#### Conditional Formatting
Conditional formatting rules highlight cells based on their values. Rules can be applied to:
- **Numeric columns** — color cells above/below a threshold, or use a gradient across a range
- **String columns** — highlight cells matching specific text values or patterns
- **Boolean columns** — color cells that are `true` or `false`, or `null`/`not null`
Each rule has a **"Use gradient"** toggle: enabled applies a varying opacity (lighter = further from threshold), disabled applies a solid fill at full opacity regardless of value.
#### HTML Rendering in Table Cells
Table chart cells can render raw HTML, enabling rich formatting such as hyperlinks, colored badges, and icons directly in the data. Enable this per-column in the chart's **Column Configuration** panel by toggling **Render HTML**.
:::caution
Only enable HTML rendering for columns sourced from data you control. Rendering untrusted HTML can expose users to cross-site scripting (XSS) risks.
:::
#### Column Header Tooltips
Column headers display a tooltip with the column's **Description** from the dataset editor when the user hovers over them. Keep dataset column descriptions up to date to improve chart discoverability.
#### Display Controls
In dashboard view mode (without entering Edit mode), charts with configurable display options expose a **Display Controls** panel accessible from the chart's context menu. This surfaces controls such as Time Grain, Time Column, and layer visibility for applicable chart types — making it easy to adjust a chart's view without going to Explore.
### AG Grid Interactive Table
The **AG Grid Interactive Table** chart type is Superset's fully-featured data grid, suitable for large paginated datasets where the standard Table chart is not enough.
@@ -342,26 +314,6 @@ ECharts option overrides bypass Superset's validation layer. Invalid option keys
When the **Search Box** is visible in a Table chart, the **Download** action exports only the rows currently visible after the search filter is applied — not the full underlying dataset. This matches the visual output and is intentional. To export the full dataset regardless of search state, use the **Download as CSV** option from the chart's three-dot menu in the dashboard or from the Explore chart toolbar before applying a search filter.
### Sharing a Specific Tab
When a dashboard has tabs, each tab gets its own shareable URL. Navigate to the tab you want to share and copy the URL from your browser's address bar — the tab anchor is encoded in the URL so that anyone opening the link lands directly on that tab.
### Auto-Refresh
Dashboards can be configured to refresh automatically at a fixed interval without user interaction. Open a dashboard, click the **⋮** (more options) menu in the top-right, and select **Set auto-refresh interval**. Choose an interval (e.g., every 10 seconds, 1 minute, or 10 minutes). The setting is per-session and resets when you close the tab.
:::note
Auto-refresh triggers a full data reload for all charts on the dashboard. For dashboards with expensive queries, choose longer intervals to avoid overloading your database.
:::
### Last Queried Timestamp
Charts can display a "Last queried at" timestamp showing when the chart data was last fetched. This is useful on auto-refreshing dashboards to confirm data freshness. Enable it in **Dashboard Properties → Styling → Show last queried time**.
### Saving a Chart to a Specific Tab
When saving or adding a chart to a dashboard from Explore, you can select which tab it should land on using the tab tree-select dropdown in the "Add to dashboard" modal.
:::resources
- [Dashboard Customization](https://docs.preset.io/docs/dashboard-customization) - Advanced dashboard styling and layout options
- [Blog: BI Dashboard Best Practices](https://preset.io/blog/bi-dashboard-best-practices/)

View File

@@ -1,8 +1,3 @@
---
title: Embedding Superset
sidebar_position: 6
---
{/*
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
@@ -22,6 +17,10 @@ specific language governing permissions and limitations
under the License.
*/}
---
title: Embedding Superset
sidebar_position: 6
---
# Embedding Superset

View File

@@ -1,143 +0,0 @@
---
title: Handlebars Chart
hide_title: true
sidebar_position: 10
version: 1
---
## Handlebars Chart
The Handlebars chart lets you render query results using a custom [Handlebars](https://handlebarsjs.com/) template. This gives you full control over how your data is displayed — from simple tables to rich HTML layouts.
### Basic Usage
In the chart editor, write a Handlebars template in the **Template** field. Your query results are available as `data`, an array of row objects.
```handlebars
{{#each data}}
<p>{{this.name}}: {{this.value}}</p>
{{/each}}
```
### Built-in Helpers
Superset registers several custom helpers on top of the standard Handlebars built-ins.
#### `dateFormat`
Formats a date value using [Day.js](https://day.js.org/) format strings.
```handlebars
{{dateFormat my_date format="MMMM YYYY"}}
```
| Option | Default | Description |
|--------|---------|-------------|
| `format` | `YYYY-MM-DD` | A Day.js-compatible format string |
---
#### `stringify`
Converts an object to a JSON string, or any other value to its string representation.
```handlebars
{{stringify myObj}}
```
---
#### `formatNumber`
Formats a number using locale-aware formatting.
```handlebars
{{formatNumber myNumber "en-US"}}
```
| Option | Default | Description |
|--------|---------|-------------|
| `locale` | `en-US` | A BCP 47 language tag |
---
#### `parseJson`
Parses a JSON string into an object that can be used in your template.
```handlebars
{{parseJson myJsonString}}
```
---
#### `groupBy`
Groups an array of objects by a key, powered by [handlebars-group-by](https://github.com/nicktindall/handlebars-group-by).
```handlebars
{{#groupBy data "department"}}
<h3>{{value}}</h3>
{{#each items}}
<p>{{this.name}}</p>
{{/each}}
{{/groupBy}}
```
---
### Helpers from just-handlebars-helpers
Superset also registers all helpers from the [just-handlebars-helpers](https://github.com/leapfrogtechnology/just-handlebars-helpers) library. These include a wide range of comparison, math, string, and conditional helpers. Commonly used ones include:
#### Comparison
| Helper | Description | Example |
|--------|-------------|---------|
| `eq` | Strict equality | `{{#if (eq status "active")}}` |
| `eqw` | Weak equality | `{{#if (eqw count "5")}}` |
| `neq` | Strict inequality | `{{#if (neq role "admin")}}` |
| `lt` | Less than | `{{#if (lt score 50)}}` |
| `lte` | Less than or equal | `{{#if (lte score 100)}}` |
| `gt` | Greater than | `{{#if (gt price 0)}}` |
| `gte` | Greater than or equal | `{{#if (gte age 18)}}` |
#### Logical
| Helper | Description | Example |
|--------|-------------|---------|
| `and` | Logical AND | `{{#if (and isActive isVerified)}}` |
| `or` | Logical OR | `{{#if (or isAdmin isMod)}}` |
| `not` | Logical NOT | `{{#if (not isDisabled)}}` |
| `ifx` | Inline conditional | `{{ifx isActive "Yes" "No"}}` |
| `coalesce` | Returns first non-falsy value | `{{coalesce nickname name "Anonymous"}}` |
#### String
| Helper | Description | Example |
|--------|-------------|---------|
| `capitalize` | Capitalizes first letter | `{{capitalize name}}` |
| `uppercase` | Converts to uppercase | `{{uppercase status}}` |
| `lowercase` | Converts to lowercase | `{{lowercase email}}` |
| `truncate` | Truncates a string | `{{truncate description 100}}` |
| `contains` | Checks if string contains substring | `{{#if (contains tag "urgent")}}` |
#### Math
| Helper | Description | Example |
|--------|-------------|---------|
| `add` | Addition | `{{add a b}}` |
| `subtract` | Subtraction | `{{subtract total discount}}` |
| `multiply` | Multiplication | `{{multiply price quantity}}` |
| `divide` | Division | `{{divide total count}}` |
| `ceil` | Ceiling | `{{ceil value}}` |
| `floor` | Floor | `{{floor value}}` |
| `round` | Round | `{{round value}}` |
For the full list of available helpers, see the [just-handlebars-helpers documentation](https://github.com/leapfrogtechnology/just-handlebars-helpers).
### Tips
- Use raw blocks to escape Handlebars syntax if you need to display double curly braces literally.
- Comparison helpers like `eq` must be wrapped in a subexpression when used with `#if`: `{{#if (eq myVal "foo")}}`.
- HTML output is sanitized by default based on your Superset configuration (`HTML_SANITIZATION`).

View File

@@ -33,29 +33,6 @@ SQL templating must be enabled by your administrator via the `ENABLE_TEMPLATE_PR
For advanced configuration options, see the [SQL Templating Configuration Guide](/admin-docs/configuration/sql-templating).
:::
## Using Jinja in Calculated Columns
Jinja template macros are available in calculated column expressions in the dataset editor — not just in SQL Lab queries and virtual datasets. This allows column expressions to reference the current user or dynamic context.
**Example: User-scoped calculated column**
```sql
CASE WHEN sales_rep = '{{ current_username() }}' THEN amount ELSE 0 END
```
**Example: Conditional display based on role**
Because `current_user_roles()` returns a Python list, test role membership with a Jinja
conditional at template time rather than matching against the list's string representation:
```sql
{% if 'Finance' in current_user_roles() %}revenue{% else %}NULL{% endif %} AS finance_revenue
```
:::note
The `ENABLE_TEMPLATE_PROCESSING` feature flag must be enabled by your administrator for Jinja in calculated columns to work.
:::
## Basic Usage
Jinja templates use double curly braces `{{ }}` for expressions and `{% %}` for logic blocks.
@@ -266,7 +243,6 @@ Using `remove_filter=True` applies the filter in the inner query for better perf
- Use `|tojson` to serialize arrays as JSON strings
- Test queries with explicit parameter values before relying on filter context
- For complex templating needs, ask your administrator about custom Jinja macros
- **Format SQL is Jinja-aware**: The "Format SQL" button in SQL Lab correctly preserves `{{ }}` and `{% %}` template syntax and applies your selected database's SQL dialect when formatting.
:::resources
- [Admin Guide: SQL Templating Configuration](/admin-docs/configuration/sql-templating)

View File

@@ -55,10 +55,9 @@ Ask your AI assistant to browse what's available in your Superset instance:
Describe the visualization you want and AI creates it for you:
- **Preview-first workflow** -- by default AI generates an Explore link so you can review the chart before it is saved. Say "save it" to commit permanently
- **Create charts from natural language** -- describe what you want to see and AI picks the right chart type, metrics, and dimensions
- **Preview before saving** -- `generate_chart` defaults to `save_chart=False`, showing the chart in Explore before it's committed. Ask AI to save once you're satisfied.
- **Modify existing charts** -- `update_chart` also supports preview mode so you can review changes before saving (update filters, change chart types, add metrics)
- **Modify existing charts** -- `update_chart` also supports preview mode so you can review changes before saving
- **Get Explore links** -- open any chart in Superset's Explore view for further refinement
**Example prompts:**
@@ -66,16 +65,6 @@ Describe the visualization you want and AI creates it for you:
> "Update chart 42 to use a line chart instead"
> "Give me a link to explore this chart further"
:::tip Preview-first workflow
Charts are **not saved by default**. The workflow is intentionally iterative:
1. **Explore** — AI generates an Explore link so you can see the chart before it exists in Superset
2. **Iterate** — ask the AI to adjust the chart; changes are previewed without touching the database
3. **Save** — when you're happy, say "save it" and the chart is permanently stored
To skip the preview and save immediately, include "and save it" in your prompt.
:::
### Create Dashboards
Build dashboards from a collection of charts:
@@ -87,40 +76,16 @@ Build dashboards from a collection of charts:
> "Create a dashboard called 'Q4 Sales Overview' with charts 10, 15, and 22"
> "Add the revenue trend chart to the executive dashboard"
### Browse Databases
Discover what database connections are configured in your Superset instance:
- **List databases** -- see all database connections you have access to
- **Get database details** -- name, backend type (PostgreSQL, Snowflake, etc.), and connection status
**Example prompts:**
> "What databases are connected to Superset?"
> "Show me details about the data warehouse connection"
### Create Virtual Datasets
Build ad-hoc SQL datasets that can be used as the basis for charts:
- **Create virtual datasets** -- write a SQL query and save it as a reusable dataset
- **Use immediately in charts** -- the returned dataset ID can be passed directly to chart creation
**Example prompts:**
> "Create a dataset from: SELECT region, SUM(revenue) as total_revenue FROM orders GROUP BY region"
> "Make a virtual dataset called 'monthly_signups' from the users table filtered to last 12 months"
### Run SQL Queries
Execute SQL directly through your AI assistant:
- **Run queries** -- execute SQL with full Superset RBAC enforcement (you can only query data your roles allow)
- **Open SQL Lab** -- get a link to SQL Lab pre-populated with a query, ready to run and explore
- **Save queries** -- save a SQL query to SQL Lab's Saved Queries for later reuse
**Example prompts:**
> "Run this query: SELECT region, SUM(revenue) FROM sales GROUP BY region"
> "Open SQL Lab with a query to show the top 10 customers by order count"
> "Save this query as 'Weekly Revenue Report'"
### Analyze Chart Data

View File

@@ -885,8 +885,10 @@ const config: Config = {
],
},
{
type: 'custom-getStartedSplit',
href: '/user-docs/',
position: 'right',
className: 'default-button-theme get-started-button',
label: 'Get Started',
},
{
href: 'https://github.com/apache/superset',

View File

@@ -40,13 +40,13 @@
"version:remove:components": "node scripts/manage-versions.mjs remove components"
},
"dependencies": {
"@ant-design/icons": "^6.2.2",
"@docusaurus/core": "^3.10.1",
"@docusaurus/faster": "^3.10.1",
"@docusaurus/plugin-client-redirects": "^3.10.1",
"@docusaurus/preset-classic": "3.10.1",
"@docusaurus/theme-live-codeblock": "^3.10.1",
"@docusaurus/theme-mermaid": "^3.10.1",
"@ant-design/icons": "^6.2.0",
"@docusaurus/core": "^3.10.0",
"@docusaurus/faster": "^3.10.0",
"@docusaurus/plugin-client-redirects": "^3.10.0",
"@docusaurus/preset-classic": "3.10.0",
"@docusaurus/theme-live-codeblock": "^3.10.0",
"@docusaurus/theme-mermaid": "^3.10.0",
"@emotion/core": "^11.0.0",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.14.1",
@@ -67,9 +67,9 @@
"@storybook/preview-api": "^8.6.18",
"@storybook/theming": "^8.6.15",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.32",
"@swc/core": "^1.15.30",
"antd": "^6.3.7",
"baseline-browser-mapping": "^2.10.24",
"baseline-browser-mapping": "^2.10.21",
"caniuse-lite": "^1.0.30001791",
"docusaurus-plugin-openapi-docs": "^5.0.1",
"docusaurus-theme-openapi-docs": "^5.0.1",
@@ -86,14 +86,14 @@
"remark-import-partial": "^0.0.2",
"reselect": "^5.1.1",
"storybook": "^8.6.18",
"swagger-ui-react": "^5.32.5",
"swagger-ui-react": "^5.32.4",
"swc-loader": "^0.2.7",
"tinycolor2": "^1.4.2",
"unist-util-visit": "^5.1.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.10.1",
"@docusaurus/tsconfig": "^3.10.1",
"@docusaurus/module-type-aliases": "^3.10.0",
"@docusaurus/tsconfig": "^3.10.0",
"@eslint/js": "^9.39.2",
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.1.8",
@@ -106,7 +106,7 @@
"globals": "^17.5.0",
"prettier": "^3.8.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.59.1",
"typescript-eslint": "^8.59.0",
"webpack": "^5.106.2"
},
"browserslist": {
@@ -124,7 +124,8 @@
"resolutions": {
"react-redux": "^9.2.0",
"@reduxjs/toolkit": "^2.5.0",
"baseline-browser-mapping": "^2.9.19"
"baseline-browser-mapping": "^2.9.19",
"webpackbar": "^7.0.0"
},
"packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
}

View File

@@ -141,6 +141,47 @@ def eval_node(node):
return "<f-string>"
return None
def static_return_bool(func_node):
"""
Statically resolve a method's return value to a bool when possible.
Returns True/False for functions whose body is (effectively) a single
\`return True\` / \`return False\` — allowing a leading docstring and
ignoring pure-comment/pass statements. Returns None for anything more
complex (conditional returns, computed values, no return, etc.).
Used by \`has_implicit_cancel\` handling: \`diagnose()\` in lib.py calls
the method and checks the return value, so an override that explicitly
returns False must NOT be treated as enabling query cancelation.
"""
returns = []
other_logic = False
docstring_skipped = False
for stmt in func_node.body:
# Skip docstring (only the FIRST expression statement that is a
# string constant — later bare string literals are not docstrings
# and should count as non-trivial logic).
if (not docstring_skipped
and isinstance(stmt, ast.Expr)
and isinstance(stmt.value, ast.Constant)
and isinstance(stmt.value.value, str)):
docstring_skipped = True
continue
if isinstance(stmt, ast.Pass):
continue
if isinstance(stmt, ast.Return):
returns.append(stmt)
continue
# Any other statement (if/for/assign/etc.) means control flow is
# non-trivial; bail out to be conservative.
other_logic = True
break
if other_logic or len(returns) != 1:
return None
val = eval_node(returns[0].value)
return val if isinstance(val, bool) else None
def deep_merge(base, override):
"""Deep merge two dictionaries. Override values take precedence."""
if base is None:
@@ -186,8 +227,55 @@ if not os.path.isdir(specs_dir):
print(json.dumps({"error": f"Directory not found: {specs_dir}", "cwd": os.getcwd()}))
sys.exit(1)
# First pass: collect all class info (name, bases, metadata)
class_info = {} # class_name -> {bases: [], metadata: {}, engine_name: str, filename: str}
# Capability flag attributes with their defaults from BaseEngineSpec
CAP_ATTR_DEFAULTS = {
'supports_dynamic_schema': False,
'supports_catalog': False,
'supports_dynamic_catalog': False,
'disable_ssh_tunneling': False,
'supports_file_upload': True,
'allows_joins': True,
'allows_subqueries': True,
}
# Maps source capability attribute -> output field name used in databases.json.
# When a cap attr is assigned an unevaluable expression (e.g.
# allows_joins = is_feature_enabled("DRUID_JOINS")), the JS layer uses this
# mapping to preserve the corresponding field from the previously-generated
# JSON rather than silently inheriting an incorrect parent default.
CAP_ATTR_TO_OUTPUT_FIELD = {
'allows_joins': 'joins',
'allows_subqueries': 'subqueries',
'supports_dynamic_schema': 'supports_dynamic_schema',
'supports_catalog': 'supports_catalog',
'supports_dynamic_catalog': 'supports_dynamic_catalog',
'disable_ssh_tunneling': 'ssh_tunneling',
'supports_file_upload': 'supports_file_upload',
}
# Methods that indicate a capability when overridden by a non-BaseEngineSpec class.
# Mirrors the has_custom_method checks in superset/db_engine_specs/lib.py.
# cancel_query / has_implicit_cancel -> query_cancelation
# (diagnose() checks cancel_query override OR has_implicit_cancel() == True;
# base has_implicit_cancel returns False, so overriding it is the static
# equivalent of that method returning True. get_cancel_query_id is NOT
# part of the diagnose() heuristic and is intentionally excluded.)
# estimate_statement_cost / estimate_query_cost -> query_cost_estimation
# impersonate_user / update_impersonation_config / get_url_for_impersonation -> user_impersonation
# validate_sql -> sql_validation (not used yet; validation is engine-based)
CAP_METHODS = {
'cancel_query', 'has_implicit_cancel',
'estimate_statement_cost', 'estimate_query_cost',
'impersonate_user', 'update_impersonation_config', 'get_url_for_impersonation',
'validate_sql',
}
# Only the literal BaseEngineSpec is excluded from method-override tracking.
# Intermediate base classes (e.g. PrestoBaseEngineSpec) do count as overrides.
TRUE_BASE_CLASS = 'BaseEngineSpec'
# First pass: collect all class info (name, bases, metadata, cap_attrs, direct_methods)
class_info = {} # class_name -> {bases: [], metadata: {}, engine_name: str, filename: str, ...}
for filename in sorted(os.listdir(specs_dir)):
if not filename.endswith('.py') or filename in ('__init__.py', 'lib.py', 'lint_metadata.py'):
@@ -218,30 +306,54 @@ for filename in sorted(os.listdir(specs_dir)):
# Extract class attributes
engine_name = None
engine_attr = None
metadata = None
cap_attrs = {} # capability flag attributes defined directly in this class
# Cap attrs assigned via expressions we can't statically resolve
# (e.g. is_feature_enabled("FLAG")). Tracked so the JS layer can
# fall back to the previously-generated databases.json value
# rather than inherit a parent default that would be wrong.
unresolved_cap_attrs = set()
direct_methods = set() # capability methods defined directly in this class
for item in node.body:
if isinstance(item, ast.Assign):
for target in item.targets:
if isinstance(target, ast.Name):
if target.id == 'engine_name':
val = eval_node(item.value)
if isinstance(val, str):
engine_name = val
elif target.id == 'metadata':
metadata = eval_node(item.value)
if not isinstance(target, ast.Name):
continue
if target.id == 'engine_name':
val = eval_node(item.value)
if isinstance(val, str):
engine_name = val
elif target.id == 'engine':
val = eval_node(item.value)
if isinstance(val, str):
engine_attr = val
elif target.id == 'metadata':
metadata = eval_node(item.value)
elif target.id in CAP_ATTR_DEFAULTS:
val = eval_node(item.value)
if isinstance(val, bool):
cap_attrs[target.id] = val
else:
# Unevaluable expression — defer to JS fallback.
unresolved_cap_attrs.add(target.id)
elif isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
if item.name in CAP_METHODS:
# has_implicit_cancel is special: diagnose() uses the
# method's RETURN VALUE, not just its presence. If the
# override statically returns False, treat it as if
# the method weren't overridden so query_cancelation
# matches diagnose(). Unresolvable / True / anything
# else falls through as an override (conservative).
if item.name == 'has_implicit_cancel':
if static_return_bool(item) is False:
continue
direct_methods.add(item.name)
# Check for engine attribute with non-empty value to distinguish
# true base classes from product classes like OceanBaseEngineSpec
has_non_empty_engine = False
for item in node.body:
if isinstance(item, ast.Assign):
for target in item.targets:
if isinstance(target, ast.Name) and target.id == 'engine':
# Check if engine value is non-empty string
if isinstance(item.value, ast.Constant):
has_non_empty_engine = bool(item.value.value)
break
has_non_empty_engine = engine_attr is not None and bool(engine_attr)
# True base classes: end with BaseEngineSpec AND don't define engine
# or have empty engine (like PostgresBaseEngineSpec with engine = "")
@@ -254,13 +366,18 @@ for filename in sorted(os.listdir(specs_dir)):
'bases': base_names,
'metadata': metadata,
'engine_name': engine_name,
'engine': engine_attr,
'filename': filename,
'is_base_or_mixin': is_true_base,
'cap_attrs': cap_attrs,
'unresolved_cap_attrs': unresolved_cap_attrs,
'direct_methods': direct_methods,
}
except Exception as e:
errors.append(f"{filename}: {str(e)}")
# Second pass: resolve inheritance and build final metadata
# Second pass: resolve inheritance and build final metadata + capability flags
def get_inherited_metadata(class_name, visited=None):
"""Recursively get metadata from parent classes."""
if visited is None:
@@ -286,6 +403,64 @@ def get_inherited_metadata(class_name, visited=None):
return inherited
def get_resolved_caps(class_name, visited=None):
"""
Resolve capability flags and method overrides with inheritance.
Returns (attr_values, unresolved, methods):
- attr_values: {attr: bool} for attrs where the nearest MRO assignment
was a literal bool. Defaults are applied at the call site.
- unresolved: attrs where the nearest MRO assignment was an unevaluable
expression (e.g. is_feature_enabled("FLAG")). The JS layer falls
back to the previously-generated JSON value for these.
- methods: capability methods defined directly in some non-base ancestor,
matching the has_custom_method() logic in db_engine_specs/lib.py.
attr_values and unresolved are disjoint — an attr is in at most one.
"""
if visited is None:
visited = set()
if class_name in visited:
return {}, set(), set()
visited.add(class_name)
info = class_info.get(class_name)
if not info:
return {}, set(), set()
attr_values = {}
unresolved = set()
resolved_methods = set()
# Collect from parents, iterating right-to-left so leftmost bases win
# (matches Python MRO: for class C(A, B), A's attributes take precedence).
for base_name in reversed(info['bases']):
p_vals, p_unres, p_meth = get_resolved_caps(base_name, visited.copy())
# A parent's literal assignments overwrite whatever we inherited so far.
for attr, val in p_vals.items():
attr_values[attr] = val
unresolved.discard(attr)
# A parent's unresolved assignments likewise take precedence.
for attr in p_unres:
unresolved.add(attr)
attr_values.pop(attr, None)
resolved_methods.update(p_meth)
# Apply this class's own assignments (override parents).
for attr, val in info['cap_attrs'].items():
attr_values[attr] = val
unresolved.discard(attr)
for attr in info['unresolved_cap_attrs']:
unresolved.add(attr)
attr_values.pop(attr, None)
# Accumulate method overrides, but skip the literal BaseEngineSpec
# (its implementations are stubs; only non-base overrides count).
if class_name != TRUE_BASE_CLASS:
resolved_methods.update(info['direct_methods'])
return attr_values, unresolved, resolved_methods
for class_name, info in class_info.items():
# Skip base classes and mixins
if info['is_base_or_mixin']:
@@ -310,7 +485,14 @@ for class_name, info in class_info.items():
if final_metadata and isinstance(final_metadata, dict) and display_name:
debug_info["classes_with_metadata"] += 1
databases[display_name] = {
# Resolve capability flags from Python source
attr_values, unresolved_caps, cap_methods = get_resolved_caps(class_name)
cap_attrs = dict(CAP_ATTR_DEFAULTS)
cap_attrs.update(attr_values)
engine_attr = info.get('engine') or ''
entry = {
'engine': display_name.lower().replace(' ', '_'),
'engine_name': display_name,
'module': info['filename'][:-3], # Remove .py extension
@@ -318,19 +500,40 @@ for class_name, info in class_info.items():
'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,
# Capability flags read from engine spec class attributes/methods
'joins': cap_attrs['allows_joins'],
'subqueries': cap_attrs['allows_subqueries'],
'supports_dynamic_schema': cap_attrs['supports_dynamic_schema'],
'supports_catalog': cap_attrs['supports_catalog'],
'supports_dynamic_catalog': cap_attrs['supports_dynamic_catalog'],
'ssh_tunneling': not cap_attrs['disable_ssh_tunneling'],
'supports_file_upload': cap_attrs['supports_file_upload'],
# Method-based flags: True only when a non-base class overrides them.
# Matches diagnose() in lib.py: cancel_query override OR
# has_implicit_cancel() returning True (which, given the base
# returns False, is equivalent to overriding has_implicit_cancel).
'query_cancelation': bool({'cancel_query', 'has_implicit_cancel'} & cap_methods),
'query_cost_estimation': bool({'estimate_statement_cost', 'estimate_query_cost'} & cap_methods),
# SQL validation is implemented in external validator classes keyed by engine name
'sql_validation': engine_attr in {'presto', 'postgresql'},
'user_impersonation': bool(
{'impersonate_user', 'update_impersonation_config', 'get_url_for_impersonation'} & cap_methods
),
}
# Tell the JS layer which output fields were populated from the
# BaseEngineSpec default because the source assignment was an
# unevaluable expression; those get overridden from existing JSON.
unresolved_fields = sorted(
CAP_ATTR_TO_OUTPUT_FIELD[attr]
for attr in unresolved_caps
if attr in CAP_ATTR_TO_OUTPUT_FIELD
)
if unresolved_fields:
entry['_unresolved_cap_fields'] = unresolved_fields
databases[display_name] = entry
if errors and not databases:
print(json.dumps({"error": "Parse errors", "details": errors, "debug": debug_info}), file=sys.stderr)
@@ -851,24 +1054,52 @@ function loadExistingData() {
}
}
/**
* Fall back to the previously-generated databases.json for capability flags
* whose source assignment couldn't be statically resolved (e.g.
* `allows_joins = is_feature_enabled("DRUID_JOINS")`). The Python extractor
* flags these via the internal `_unresolved_cap_fields` marker; without this
* fallback those fields would silently inherit the BaseEngineSpec default
* and disagree with runtime behavior. The marker is stripped before output.
*/
function fallbackUnresolvedCaps(newDatabases, existingData) {
for (const [name, db] of Object.entries(newDatabases)) {
const unresolved = db._unresolved_cap_fields;
if (!unresolved || unresolved.length === 0) {
delete db._unresolved_cap_fields;
continue;
}
const existingDb = existingData?.databases?.[name];
if (existingDb) {
for (const field of unresolved) {
if (existingDb[field] !== undefined) {
db[field] = existingDb[field];
}
}
}
delete db._unresolved_cap_fields;
}
return newDatabases;
}
/**
* Merge new documentation with existing diagnostics
* Preserves score, time_grains, and feature flags from existing data
* Preserves score, max_score, and time_grains from existing data (these require
* Flask context to generate and cannot be derived from static source analysis).
* Capability flags (joins, supports_catalog, etc.) are NOT preserved here — they
* are read fresh from the Python engine spec source by extractEngineSpecMetadata(),
* with a separate fallback for expression-based assignments (see fallbackUnresolvedCaps).
*/
function mergeWithExistingDiagnostics(newDatabases, existingData) {
if (!existingData?.databases) return newDatabases;
const diagnosticFields = [
'score', 'max_score', 'time_grains', 'joins', 'subqueries',
'supports_dynamic_schema', 'supports_catalog', 'supports_dynamic_catalog',
'ssh_tunneling', 'query_cancelation', 'supports_file_upload',
'user_impersonation', 'query_cost_estimation', 'sql_validation'
];
// Only preserve fields that require Flask/runtime context to generate
const diagnosticFields = ['score', 'max_score', 'time_grains'];
for (const [name, db] of Object.entries(newDatabases)) {
const existingDb = existingData.databases[name];
if (existingDb && existingDb.score > 0) {
// Preserve diagnostics from existing data
// Preserve score/time_grain diagnostics from existing data
for (const field of diagnosticFields) {
if (existingDb[field] !== undefined) {
db[field] = existingDb[field];
@@ -879,7 +1110,7 @@ function mergeWithExistingDiagnostics(newDatabases, existingData) {
const preserved = Object.values(newDatabases).filter(d => d.score > 0).length;
if (preserved > 0) {
console.log(`Preserved diagnostics for ${preserved} databases from existing data`);
console.log(`Preserved score/time_grains for ${preserved} databases from existing data`);
}
return newDatabases;
@@ -927,6 +1158,12 @@ async function main() {
databases = mergeWithExistingDiagnostics(databases, existingData);
}
// For cap flags assigned via unevaluable expressions (e.g.
// `is_feature_enabled(...)`), prefer the value from a previously-generated
// JSON. Runs regardless of scores since it addresses static-analysis gaps,
// not missing Flask diagnostics. Always strips the internal marker.
databases = fallbackUnresolvedCaps(databases, existingData);
// Extract and merge custom_errors for troubleshooting documentation
const customErrors = extractCustomErrors();
mergeCustomErrors(databases, customErrors);

View File

@@ -1,155 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { DownOutlined } from '@ant-design/icons';
import Link from '@docusaurus/Link';
import { Dropdown } from 'antd';
import type { MenuProps } from 'antd';
import styled from '@emotion/styled';
import { mq } from '../utils.js';
const getStartedMenuItems: MenuProps['items'] = [
{ key: 'users', label: <Link to="/user-docs/">Users</Link> },
{ key: 'admins', label: <Link to="/admin-docs/">Admins</Link> },
{ key: 'developers', label: <Link to="/developer-docs/">Developers</Link> },
];
const Root = styled.div<{ $variant: 'hero' | 'navbar' }>`
display: flex;
align-items: stretch;
border-radius: 10px;
overflow: hidden;
position: relative;
z-index: 2;
font-weight: bold;
${({ $variant }) =>
$variant === 'hero'
? `
width: 208px;
margin: 15px auto 0;
font-size: 20px;
${mq[1]} {
font-size: 19px;
width: 214px;
}
`
: `
width: 176px;
margin-right: 20px;
font-size: 18px;
`}
.split-main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
text-decoration: none;
min-width: 0;
${({ $variant }) =>
$variant === 'hero'
? `padding: 10px 10px;`
: `padding: 7px 8px;`}
}
.split-main:hover {
color: #ffffff;
}
.split-divider {
width: 1px;
flex-shrink: 0;
align-self: stretch;
background: rgba(255, 255, 255, 0.38);
${({ $variant }) =>
$variant === 'hero'
? `margin: 8px 0;`
: `margin: 6px 0;`}
}
.split-dropdown-trigger {
flex-shrink: 0;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
color: #ffffff;
${({ $variant }) =>
$variant === 'hero'
? `
width: 44px;
font-size: 11px;
${mq[1]} {
width: 46px;
}
`
: `
width: 38px;
font-size: 10px;
`}
}
.split-dropdown-trigger:hover {
color: #ffffff;
}
`;
export type GetStartedSplitButtonProps = {
variant: 'hero' | 'navbar';
/** Classes for the outer control (include default-button-theme get-started-split) */
rootClassName: string;
};
export default function GetStartedSplitButton({
variant,
rootClassName,
}: GetStartedSplitButtonProps) {
const menuClassName = `get-started-split-dropdown-menu get-started-split-dropdown-menu--${variant}`;
return (
<Root $variant={variant} className={rootClassName}>
<Link to="/user-docs/" className="split-main">
Get Started
</Link>
<span className="split-divider" aria-hidden />
<Dropdown
menu={{
items: getStartedMenuItems,
className: menuClassName,
}}
trigger={['click']}
placement="bottomRight"
>
<button
type="button"
className="split-dropdown-trigger"
aria-haspopup="menu"
aria-label="Choose documentation: Users, Admins, or Developers"
>
<DownOutlined />
</button>
</Dropdown>
</Root>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,6 @@ 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 GetStartedSplitButton from '../components/GetStartedSplitButton';
import '../styles/main.css';
// Build database list from databases.json (databases with logos)
@@ -192,6 +191,20 @@ const StyledTitleContainer = styled('div')`
}
`;
const StyledButton = styled(Link)`
border-radius: 10px;
font-size: 20px;
font-weight: bold;
width: 170px;
padding: 10px 0;
margin: 15px auto 0;
${mq[1]} {
font-size: 19px;
width: 175px;
padding: 10px 0;
}
`;
const StyledScreenshotContainer = styled('div')`
position: relative;
display: inline-block;
@@ -704,10 +717,9 @@ export default function Home(): JSX.Element {
</span>
</div>
<img src="/img/community/line.png" alt="line" />
<GetStartedSplitButton
variant="hero"
rootClassName="default-button-theme get-started-split"
/>
<StyledButton className="default-button-theme" href="/user-docs/intro">
Get Started
</StyledButton>
</div>
<StyledScreenshotContainer>
<img

View File

@@ -105,45 +105,6 @@ a > span > svg {
opacity: 1;
}
/* Homepage split "Get started": gradient button + chevron column */
.default-button-theme.get-started-split {
display: flex;
padding: 0;
}
.get-started-split-dropdown-menu.ant-dropdown-menu {
background: linear-gradient(180deg, #20a7c9 0%, #0c8fae 100%) !important;
border: 1px solid rgba(255, 255, 255, 0.22);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2) !important;
}
.get-started-split-dropdown-menu--hero.ant-dropdown-menu {
min-width: 208px;
}
@media (max-width: 768px) {
.get-started-split-dropdown-menu--hero.ant-dropdown-menu {
min-width: 214px;
}
}
.get-started-split-dropdown-menu--navbar.ant-dropdown-menu {
min-width: 176px;
}
.get-started-split-dropdown-menu .ant-dropdown-menu-item {
color: #ffffff !important;
}
.get-started-split-dropdown-menu .ant-dropdown-menu-item:hover,
.get-started-split-dropdown-menu .ant-dropdown-menu-item-active {
background: rgba(255, 255, 255, 0.15) !important;
}
.get-started-split-dropdown-menu .ant-dropdown-menu-item a {
color: inherit !important;
}
/* Navbar */
.navbar {
@@ -156,14 +117,11 @@ a > span > svg {
border-radius: 10px;
font-size: 18px;
font-weight: bold;
width: 142px;
padding: 7px 0;
margin-right: 20px;
}
.navbar .get-started-button.get-started-split {
width: 176px;
padding: 0;
}
.navbar .github-button {
background-image: url('/img/github.png');
background-size: contain;

View File

@@ -1,41 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import DocNavbarItem from '@theme-original/NavbarItem/DocNavbarItem';
import DocSidebarNavbarItem from '@theme-original/NavbarItem/DocSidebarNavbarItem';
import DocsVersionDropdownNavbarItem from '@theme-original/NavbarItem/DocsVersionDropdownNavbarItem';
import DocsVersionNavbarItem from '@theme-original/NavbarItem/DocsVersionNavbarItem';
import DropdownNavbarItem from '@theme-original/NavbarItem/DropdownNavbarItem';
import DefaultNavbarItem from '@theme-original/NavbarItem/DefaultNavbarItem';
import HtmlNavbarItem from '@theme-original/NavbarItem/HtmlNavbarItem';
import LocaleDropdownNavbarItem from '@theme-original/NavbarItem/LocaleDropdownNavbarItem';
import SearchNavbarItem from '@theme-original/NavbarItem/SearchNavbarItem';
import GetStartedSplitNavbarItem from './GetStartedSplitNavbarItem';
export default {
default: DefaultNavbarItem,
localeDropdown: LocaleDropdownNavbarItem,
search: SearchNavbarItem,
dropdown: DropdownNavbarItem,
html: HtmlNavbarItem,
doc: DocNavbarItem,
docSidebar: DocSidebarNavbarItem,
docsVersion: DocsVersionNavbarItem,
docsVersionDropdown: DocsVersionDropdownNavbarItem,
'custom-getStartedSplit': GetStartedSplitNavbarItem,
};

View File

@@ -1,29 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import GetStartedSplitButton from '../../components/GetStartedSplitButton';
import '../../styles/main.css';
export default function GetStartedSplitNavbarItem() {
return (
<GetStartedSplitButton
variant="navbar"
rootClassName="default-button-theme get-started-split get-started-button"
/>
);
}

View File

@@ -147,9 +147,7 @@ export default function Root({ children }) {
const button = event.target.closest('.get-started-button, .default-button-theme');
if (button) {
const buttonText = button.textContent?.trim() || 'Unknown';
const clickedLink = event.target.closest?.('a');
const href =
clickedLink?.getAttribute('href') || button.getAttribute('href') || '';
const href = button.getAttribute('href') || '';
trackEvent('CTA', 'Click', `${buttonText} - ${href}`);
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -70,13 +70,13 @@ dependencies = [
# marshmallow>=4 has issues: https://github.com/apache/superset/issues/33162
"marshmallow>=3.0, <4",
"marshmallow-union>=0.1",
"msgpack>=1.0.0, <1.2",
"msgpack>=1.0.0, <1.1",
"nh3>=0.2.11, <0.3",
"numpy>1.23.5, <2.3",
"packaging",
# --------------------------
# pandas and related (wanting pandas[performance] without numba as it's 100+MB and not needed)
"pandas[excel]>=2.1.4, <2.4",
"pandas[excel]>=2.1.4, <2.2",
"bottleneck", # recommended performance dependency for pandas, see https://pandas.pydata.org/docs/getting_started/install.html#performance-dependencies-recommended
# --------------------------
"parsedatetime",
@@ -109,7 +109,7 @@ dependencies = [
"watchdog>=6.0.0",
"wtforms>=2.3.3, <4",
"wtforms-json",
"xlsxwriter>=3.0.7, <3.3",
"xlsxwriter>=3.0.7, <3.1",
]
[project.optional-dependencies]
@@ -145,7 +145,7 @@ solr = ["sqlalchemy-solr >= 0.2.0"]
elasticsearch = ["elasticsearch-dbapi>=0.2.12, <0.3.0"]
exasol = ["sqlalchemy-exasol >= 2.4.0, <3.0"]
excel = ["xlrd>=1.2.0, <1.3"]
fastmcp = ["fastmcp>=3.2.4,<4.0"]
fastmcp = ["fastmcp>=3.1.0,<4.0"]
firebird = ["sqlalchemy-firebird>=0.7.0, <0.8"]
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
gevent = ["gevent>=23.9.1"]
@@ -175,7 +175,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.12"]
postgres = ["psycopg2-binary==2.9.9"]
presto = ["pyhive[presto]>=0.6.5"]
trino = ["trino>=0.328.0"]
prophet = ["prophet>=1.1.6, <2"]

View File

@@ -339,7 +339,7 @@ python-dateutil==2.9.0.post0
# holidays
# pandas
# shillelagh
python-dotenv==1.2.2
python-dotenv==1.1.0
# via apache-superset (pyproject.toml)
pytz==2025.2
# via

View File

@@ -236,7 +236,7 @@ et-xmlfile==2.0.0
# openpyxl
exceptiongroup==1.3.0
# via fastmcp
fastmcp==3.2.4
fastmcp==3.1.0
# via apache-superset
filelock==3.20.3
# via
@@ -379,8 +379,6 @@ greenlet==3.1.1
# gevent
# shillelagh
# sqlalchemy
griffelib==2.0.2
# via fastmcp
grpcio==1.71.0
# via
# apache-superset
@@ -707,7 +705,7 @@ protobuf==4.25.8
# proto-plus
psutil==6.1.0
# via apache-superset
psycopg2-binary==2.9.12
psycopg2-binary==2.9.9
# via apache-superset
py-key-value-aio==0.4.4
# via fastmcp
@@ -827,7 +825,7 @@ python-dateutil==2.9.0.post0
# pyhive
# shillelagh
# trino
python-dotenv==1.2.2
python-dotenv==1.1.0
# via
# -c requirements/base-constraint.txt
# apache-superset

View File

@@ -45,17 +45,7 @@ if [ ${#js_ts_files[@]} -gt 0 ]; then
# Skip custom OXC build in pre-commit for speed
export SKIP_CUSTOM_OXC=true
# Use quiet mode in pre-commit to reduce noise (only show errors)
# Capture output so we can treat "No files found" (all files ignored by
# ignorePatterns) as success rather than a false-positive failure.
output=$(npx oxlint --config oxlint.json --fix --quiet "${js_ts_files[@]}" 2>&1) || {
if echo "$output" | grep -q "No files found"; then
echo "No files to lint after applying ignore patterns"
exit 0
fi
echo "$output" >&2
exit 1
}
[ -n "$output" ] && echo "$output"
npx oxlint --config oxlint.json --fix --quiet "${js_ts_files[@]}"
else
echo "No JavaScript/TypeScript files to lint"
fi

View File

@@ -18,7 +18,7 @@
[project]
name = "apache-superset-core"
version = "0.1.0rc3"
version = "0.1.0rc2"
description = "Core Python package for building Apache Superset backend extensions and integrations"
readme = "README.md"
authors = [

View File

@@ -17,7 +17,7 @@
[project]
name = "apache-superset-extensions-cli"
version = "0.1.0rc3"
version = "0.1.0rc2"
description = "Official command-line interface for building, bundling, and managing Apache Superset extensions"
readme = "README.md"
authors = [

File diff suppressed because it is too large Load Diff

View File

@@ -183,7 +183,7 @@
"json-bigint": "^1.0.0",
"json-stringify-pretty-compact": "^2.0.0",
"lodash": "^4.18.1",
"mapbox-gl": "^3.23.0",
"mapbox-gl": "^3.22.0",
"markdown-to-jsx": "^9.7.16",
"match-sorter": "^8.3.0",
"memoize-one": "^5.2.1",
@@ -194,13 +194,13 @@
"pretty-ms": "^9.3.0",
"query-string": "9.3.1",
"re-resizable": "^6.11.2",
"react": "^18.2.0",
"react": "^17.0.2",
"react-arborist": "^3.5.0",
"react-checkbox-tree": "^1.8.0",
"react-checkbox-tree": "^2.0.1",
"react-diff-viewer-continued": "^4.2.2",
"react-dnd": "^11.1.3",
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^18.2.0",
"react-dom": "^17.0.2",
"react-google-recaptcha": "^3.1.0",
"react-intersection-observer": "^10.0.3",
"react-json-tree": "^0.20.0",
@@ -211,6 +211,7 @@
"react-reverse-portal": "^2.3.0",
"react-router-dom": "^5.3.4",
"react-search-input": "^0.11.3",
"react-sortable-hoc": "^2.0.0",
"react-split": "^2.0.9",
"react-table": "^7.8.0",
"react-transition-group": "^4.4.5",
@@ -243,13 +244,14 @@
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@babel/plugin-transform-modules-commonjs": "^7.28.6",
"@babel/plugin-transform-runtime": "^7.29.0",
"@babel/preset-env": "^7.29.3",
"@babel/preset-env": "^7.29.2",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@babel/register": "^7.23.7",
"@babel/runtime": "^7.29.2",
"@babel/runtime-corejs3": "^7.29.2",
"@babel/types": "^7.28.6",
"@cypress/react": "^8.0.2",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/jest": "^11.14.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
@@ -268,12 +270,13 @@
"@storybook/test": "^8.6.18",
"@storybook/test-runner": "^0.17.0",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.32",
"@swc/plugin-emotion": "^14.9.0",
"@swc/core": "^1.15.30",
"@swc/plugin-emotion": "^14.8.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^9.3.4",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^14.0.0",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^12.8.3",
"@types/content-disposition": "^0.5.9",
"@types/dom-to-image": "^2.6.7",
@@ -283,8 +286,8 @@
"@types/json-bigint": "^1.0.4",
"@types/mousetrap": "^1.6.15",
"@types/node": "^25.6.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/react": "^17.0.83",
"@types/react-dom": "^17.0.26",
"@types/react-loadable": "^5.5.11",
"@types/react-redux": "^7.1.10",
"@types/react-resizable": "^3.0.8",
@@ -296,14 +299,14 @@
"@types/rison": "0.1.0",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.58.2",
"babel-jest": "^30.0.2",
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.10.24",
"baseline-browser-mapping": "^2.10.21",
"cheerio": "1.2.0",
"concurrently": "^9.2.1",
"copy-webpack-plugin": "^14.0.0",
@@ -320,7 +323,7 @@
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest-dom": "^5.5.0",
"eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-no-only-tests": "^3.3.0",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.9.3",
@@ -343,7 +346,7 @@
"lightningcss": "^1.32.0",
"mini-css-extract-plugin": "^2.10.2",
"open-cli": "^9.0.0",
"oxlint": "^1.62.0",
"oxlint": "^1.61.0",
"po2json": "^0.4.5",
"prettier": "3.8.3",
"prettier-plugin-packagejson": "^3.0.2",
@@ -358,6 +361,7 @@
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.5.0",
"thread-loader": "^4.0.4",
"ts-jest": "^29.4.9",
"tscw-config": "^1.1.2",
"tsx": "^4.21.0",
@@ -370,7 +374,7 @@
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.3",
"webpack-manifest-plugin": "^5.0.1",
"webpack-sources": "^3.4.1",
"webpack-sources": "^3.4.0",
"webpack-visualizer-plugin2": "^2.0.0"
},
"peerDependencies": {

View File

@@ -30,14 +30,14 @@
"dependencies": {
"chalk": "^5.6.2",
"lodash-es": "^4.18.1",
"yeoman-generator": "^8.2.2",
"yeoman-generator": "^8.1.2",
"yosay": "^3.0.0"
},
"devDependencies": {
"cross-env": "^10.1.0",
"fs-extra": "^11.3.4",
"jest": "^30.3.0",
"yeoman-test": "^11.4.2"
"yeoman-test": "^11.3.1"
},
"engines": {
"npm": ">= 4.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@apache-superset/core",
"version": "0.1.0-rc3",
"version": "0.1.0-rc2",
"description": "This package contains UI elements, APIs, and utility functions used by Superset.",
"sideEffects": false,
"main": "lib/index.js",
@@ -75,15 +75,16 @@
"devDependencies": {
"@babel/cli": "^7.28.6",
"@babel/core": "^7.29.0",
"@babel/preset-env": "^7.29.3",
"@babel/preset-env": "^7.29.2",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"typescript": "^5.0.0",
"@emotion/styled": "^11.14.1",
"@types/lodash": "^4.17.24",
"@testing-library/dom": "^9.3.4",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^14.0.0",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "*",
"@testing-library/user-event": "*",
"@types/react": "*",
"@types/react-loadable": "*",
@@ -97,8 +98,8 @@
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/inter": "^5.2.6",
"nanoid": "^5.0.9",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-loadable": "^5.5.0",
"tinycolor2": "*",
"lodash": "^4.18.1",

View File

@@ -18,7 +18,7 @@
*/
import userEvent from '@testing-library/user-event';
import { ReactElement } from 'react';
import { render, RenderOptions, RenderResult } from '@testing-library/react';
import { render, RenderOptions } from '@testing-library/react';
import '@testing-library/jest-dom';
import { themeObject } from './theme';
@@ -33,7 +33,7 @@ const Providers = ({ children }: { children: React.ReactNode }) => (
const customRender = (
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>,
): RenderResult => render(ui, { wrapper: Providers, ...options });
) => render(ui, { wrapper: Providers, ...options });
export {
createEvent,

View File

@@ -29,20 +29,14 @@ import '@fontsource/ibm-plex-mono/600.css';
/* eslint-enable import/extensions */
import { css, useTheme, Global } from '@emotion/react';
import { useThemeMode } from './utils/themeUtils';
export const GlobalStyles = () => {
const theme = useTheme();
const isDark = useThemeMode();
return (
<Global
key={`global-${theme.colorLink}`}
styles={css`
// SPA
html {
color-scheme: ${isDark ? 'dark' : 'light'};
}
html,
body,
#app {

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import React from 'react';
import { renderHook } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { ThemeProvider } from '@emotion/react';
import { theme as antdTheme } from 'antd';
import {

View File

@@ -33,16 +33,17 @@
"@ant-design/icons": "^5.6.1",
"@emotion/react": "^11.4.1",
"@superset-ui/core": "*",
"@testing-library/dom": "^9.3.4",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^14.0.0",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "*",
"@testing-library/user-event": "*",
"ace-builds": "^1.4.14",
"brace": "^0.11.1",
"memoize-one": "^5.1.1",
"react": "^18.2.0",
"react": "^17.0.2",
"react-ace": "^10.1.0",
"react-dom": "^18.2.0"
"react-dom": "^17.0.2"
},
"publishConfig": {
"access": "public"

View File

@@ -24,7 +24,7 @@
"lib"
],
"dependencies": {
"@ant-design/icons": "^6.2.2",
"@ant-design/icons": "^6.1.1",
"@apache-superset/core": "*",
"@babel/runtime": "^7.29.2",
"@types/json-bigint": "^1.0.4",
@@ -91,9 +91,10 @@
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.14.1",
"@testing-library/dom": "^9.3.4",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^14.0.0",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "*",
"@testing-library/user-event": "*",
"@types/react": "*",
"@types/react-loadable": "*",
@@ -101,8 +102,8 @@
"@types/tinycolor2": "*",
"antd": "^5.26.0",
"nanoid": "^5.0.9",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-loadable": "^5.5.0",
"tinycolor2": "*"
},

View File

@@ -17,15 +17,19 @@
* under the License.
*/
import type { ReactElement, ReactNode } from 'react';
import { Tooltip, type TooltipPlacement } from '@superset-ui/core/components';
import type { ReactElement } from 'react';
import {
Tooltip,
type TooltipPlacement,
type IconType,
} from '@superset-ui/core/components';
import { css, useTheme } from '@apache-superset/core/theme';
export interface ActionProps {
label: string;
tooltip?: string | ReactElement;
placement?: TooltipPlacement;
icon: ReactNode;
icon: IconType;
onClick: () => void;
}

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { renderHook } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { useJsonValidation } from './useJsonValidation';
describe('useJsonValidation', () => {
@@ -60,7 +60,7 @@ describe('useJsonValidation', () => {
expect(result.current[0]).toMatchObject({
type: 'error',
row: 0,
column: 1,
column: 0,
text: expect.stringContaining('Invalid JSON'),
});
});

View File

@@ -16,7 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useEffect, useState, forwardRef, ComponentType } from 'react';
import {
useEffect,
useState,
RefObject,
forwardRef,
ComponentType,
ForwardRefExoticComponent,
PropsWithoutRef,
RefAttributes,
} from 'react';
import { Loading } from '../Loading';
import type { PlaceholderProps } from './types';
@@ -84,16 +93,15 @@ export function AsyncEsmComponent<
return promise;
}
type AsyncComponent = React.ForwardRefExoticComponent<
React.PropsWithoutRef<FullProps> & React.RefAttributes<unknown>
type AsyncComponent = ForwardRefExoticComponent<
PropsWithoutRef<FullProps> & RefAttributes<ComponentType<FullProps>>
> & {
preload?: typeof waitForPromise;
};
// @ts-expect-error -- generic forwardRef has PropsWithoutRef incompatibility with FullProps
const AsyncComponent: AsyncComponent = forwardRef(function AsyncComponent(
props: FullProps,
ref,
ref: RefObject<ComponentType<FullProps>>,
) {
const [loaded, setLoaded] = useState(component !== undefined);
useEffect(() => {

View File

@@ -24,6 +24,7 @@ import type {
ButtonVariantType,
ButtonColorType,
} from 'antd/es/button';
import { IconType } from '@superset-ui/core/components/Icons/types';
import type { TooltipPlacement } from '../Tooltip/types';
export type { AntdButtonProps, ButtonType, ButtonVariantType, ButtonColorType };
@@ -48,5 +49,5 @@ export type ButtonProps = Omit<AntdButtonProps, 'css'> & {
buttonStyle?: ButtonStyle;
cta?: boolean;
showMarginRight?: boolean;
icon?: ReactNode;
icon?: IconType;
};

View File

@@ -73,7 +73,7 @@ export const Component = (props: DropdownContainerProps) => {
const [overflowingState, setOverflowingState] = useState<OverflowingState>();
const containerRef = useRef<DropdownRef>(null);
const onOverflowingStateChange = useCallback(
(value: OverflowingState) => {
value => {
if (!isEqual(overflowingState, value)) {
setItems(generateItems(value));
setOverflowingState(value);

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import type { CSSProperties, ReactElement, RefObject, ReactNode } from 'react';
import { IconType } from '../Icons';
/**
* Container item.
@@ -69,7 +70,7 @@ export interface DropdownContainerProps {
/**
* Icon of the dropdown trigger.
*/
dropdownTriggerIcon?: ReactNode;
dropdownTriggerIcon?: IconType;
/**
* Text of the dropdown trigger.
*/

View File

@@ -1,80 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { fireEvent, render, screen, userEvent } from '@superset-ui/core/spec';
import { useState } from 'react';
import { DynamicEditableTitle } from '.';
const Harness = ({ initialTitle = 'Original' }: { initialTitle?: string }) => {
const [title, setTitle] = useState(initialTitle);
return (
<DynamicEditableTitle
title={title}
placeholder="placeholder"
canEdit
label="Title"
onSave={setTitle}
/>
);
};
test('rapid typing then backspacing keeps every keystroke', async () => {
render(<Harness />);
const input = screen.getByRole('textbox') as HTMLInputElement;
userEvent.click(input);
await userEvent.type(input, 'abc', { delay: 1 });
expect(input.value).toBe('Originalabc');
await userEvent.type(input, '{backspace}{backspace}{backspace}', {
delay: 1,
});
expect(input.value).toBe('Original');
});
test('a change event that arrives before isEditing flips is not dropped', () => {
// Reproduces the regression: the input is focused but `isEditing` is still
// false because no click has been registered yet (e.g. focus arrived via
// tab, autofocus, or programmatic focus). The pre-fix `handleChange`
// bailed out with `!isEditing`, dropping the keystroke. Because the
// input is controlled, antd's internal `useMergedState` then resyncs the
// DOM value back to the (stale) `props.value`, so the user sees their
// typed character disappear. This test fires a raw change event so it
// doesn't go through userEvent's implicit click.
const onSave = jest.fn();
render(
<DynamicEditableTitle
title="Foo"
placeholder="placeholder"
canEdit
label="Title"
onSave={onSave}
/>,
);
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'FooX' } });
expect(input.value).toBe('FooX');
});
test('prop changes mid-edit do not clobber unsaved typing', async () => {
const { rerender } = render(<Harness initialTitle="Foo" />);
const input = screen.getByRole('textbox') as HTMLInputElement;
userEvent.click(input);
await userEvent.type(input, 'X', { delay: 1 });
expect(input.value).toBe('FooX');
rerender(<Harness initialTitle="Foo" />);
expect(input.value).toBe('FooX');
});

View File

@@ -23,7 +23,6 @@ import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { t } from '@apache-superset/core/translation';
@@ -31,7 +30,6 @@ import { css, SupersetTheme, useTheme } from '@apache-superset/core/theme';
import { useResizeDetector } from 'react-resize-detector';
import { Tooltip } from '../Tooltip';
import { Input } from '../Input';
import type { InputRef } from '../Input';
import type { DynamicEditableTitleProps } from './types';
const titleStyles = (theme: SupersetTheme) => css`
@@ -77,10 +75,8 @@ export const DynamicEditableTitle = memo(
const [isEditing, setIsEditing] = useState(false);
const [showTooltip, setShowTooltip] = useState(false);
const [currentTitle, setCurrentTitle] = useState(title || '');
const [inputWidth, setInputWidth] = useState<number>(0);
const sizerRef = useRef<HTMLSpanElement>(null);
const inputRef = useRef<InputRef>(null);
const { width: inputWidth, ref: sizerRef } = useResizeDetector();
const { width: containerWidth, ref: containerRef } = useResizeDetector({
refreshMode: 'debounce',
});
@@ -89,33 +85,27 @@ export const DynamicEditableTitle = memo(
setCurrentTitle(title);
}, [title]);
useEffect(() => {
if (isEditing) {
if (isEditing && sizerRef?.current) {
// move cursor and scroll to the end
const inputElement = inputRef.current?.input;
if (inputElement) {
const { length } = inputElement.value;
inputElement.setSelectionRange(length, length);
inputElement.scrollLeft = inputElement.scrollWidth;
if (sizerRef.current.setSelectionRange) {
const { length } = sizerRef.current.value;
sizerRef.current.setSelectionRange(length, length);
sizerRef.current.scrollLeft = sizerRef.current.scrollWidth;
}
}
}, [isEditing]);
// a trick to make the input grow when user types text
// we make an additional span component, place it somewhere out of view and
// mirror the input value, then measure the span synchronously (pre-paint)
// to resize the input element. Reading offsetWidth in a useLayoutEffect
// forces a sync layout, so the input width updates in the same commit as
// the value change — preventing a flicker frame where the input is shown
// with new value but stale width.
// we make additional span component, place it somewhere out of view and copy input
// then we can measure the width of that span to resize the input element
useLayoutEffect(() => {
if (sizerRef.current) {
if (sizerRef?.current) {
sizerRef.current.textContent = currentTitle || placeholder;
setInputWidth(sizerRef.current.offsetWidth);
}
}, [currentTitle, placeholder]);
}, [currentTitle, placeholder, sizerRef]);
useEffect(() => {
const inputElement = inputRef.current?.input;
const inputElement = sizerRef.current?.input;
if (inputElement) {
if (inputElement.scrollWidth > inputElement.clientWidth) {
@@ -147,17 +137,9 @@ export const DynamicEditableTitle = memo(
const handleChange = useCallback(
(ev: ChangeEvent<HTMLInputElement>) => {
if (!canEdit) {
if (!canEdit || !isEditing) {
return;
}
// Any change implies the user is editing. Ensure isEditing is true
// even if the change event arrives before the click handler has
// committed (e.g. focus via tab, autofocus, or batched click+type
// events). Otherwise the keystroke would be dropped and the
// controlled input would revert to the previous value.
if (!isEditing) {
setIsEditing(true);
}
setCurrentTitle(ev.target.value);
},
[canEdit, isEditing],
@@ -186,7 +168,6 @@ export const DynamicEditableTitle = memo(
}
>
<Input
ref={inputRef}
data-test="editable-title-input"
variant="borderless"
aria-label={label ?? t('Title')}

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import type { ReactNode, SyntheticEvent } from 'react';
import type { IconType } from '@superset-ui/core/components';
export type EmptyStateSize = 'small' | 'medium' | 'large';
@@ -25,7 +26,7 @@ export type EmptyStateProps = {
description?: ReactNode;
image?: ReactNode | string;
buttonText?: ReactNode;
buttonIcon?: ReactNode;
buttonIcon?: IconType;
buttonAction?: (event: SyntheticEvent) => void;
/** Controls image size. Defaults to 'medium'. */
size?: EmptyStateSize;

View File

@@ -20,7 +20,7 @@ import { Form as AntdForm } from 'antd';
import { FormProps } from './types';
function CustomForm(props: FormProps) {
return <AntdForm {...(props as any)} />;
return <AntdForm {...props} />;
}
export const Form = Object.assign(CustomForm, {

View File

@@ -41,6 +41,7 @@ test('renders with monospace prop', () => {
// test stories from the storybook!
test('renders all the storybook gallery variants', () => {
// @ts-expect-error: Suppress TypeScript error for LabelGallery usage
const { container } = render(<LabelGallery />);
const nonInteractiveLabelCount = 4;
const renderedLabelCount = options.length * 2 + nonInteractiveLabelCount;

View File

@@ -21,7 +21,6 @@ import type { BackgroundPosition } from './ImageLoader';
export interface LinkProps {
to: string;
children?: ReactNode;
}
export interface ListViewCardProps {

View File

@@ -194,7 +194,7 @@ const MetadataBar = ({ items, tooltipPlacement = 'top' }: MetadataBarProps) => {
}
const onResize = useCallback(
(width: number | undefined) => {
width => {
// Calculates the breakpoint width to collapse the bar.
// The last item does not have a space, so we subtract SPACE_BETWEEN_ITEMS from the total.
const breakpoint =

View File

@@ -54,7 +54,7 @@ export function FormModal({
}, [onSave, resetForm]);
const handleFormSubmit = useCallback(
async (values: object) => {
async values => {
try {
setIsSaving(true);
await formSubmitHandler(values);

View File

@@ -55,141 +55,132 @@ export const StyledModal = styled(BaseModal)<StyledModalProps>`
height,
draggable,
hideFooter,
}) => {
const closeButtonWidth = theme.sizeUnit * 14;
}) => css`
${responsive &&
css`
max-width: ${maxWidth ?? '900px'};
padding-left: ${theme.sizeUnit * 3}px;
padding-right: ${theme.sizeUnit * 3}px;
padding-bottom: 0;
top: 0;
`}
return css`
${responsive &&
css`
max-width: ${maxWidth ?? '900px'};
padding-left: ${theme.sizeUnit * 3}px;
padding-right: ${theme.sizeUnit * 3}px;
padding-bottom: 0;
top: 0;
`}
.ant-modal-content {
background-color: ${theme.colorBgContainer};
display: flex;
flex-direction: column;
max-height: calc(100vh - ${theme.sizeUnit * 8}px);
margin-bottom: ${theme.sizeUnit * 4}px;
margin-top: ${theme.sizeUnit * 4}px;
padding: 0;
}
.ant-modal-content {
background-color: ${theme.colorBgContainer};
display: flex;
flex-direction: column;
max-height: calc(100vh - ${theme.sizeUnit * 8}px);
margin-bottom: ${theme.sizeUnit * 4}px;
margin-top: ${theme.sizeUnit * 4}px;
padding: 0;
}
.ant-modal-header {
flex: 0 0 auto;
border-radius: ${theme.borderRadius}px ${theme.borderRadius}px 0 0;
padding: ${theme.sizeUnit * 4}px ${closeButtonWidth}px
${theme.sizeUnit * 4}px ${theme.sizeUnit * 4}px;
.ant-modal-title {
font-weight: ${theme.fontWeightStrong};
}
.ant-modal-title h4 {
display: flex;
margin: 0;
align-items: center;
}
}
.ant-modal-close {
width: ${closeButtonWidth}px;
height: ${theme.sizeUnit * 14}px;
padding: ${theme.sizeUnit * 6}px ${theme.sizeUnit * 4}px
${theme.sizeUnit * 4}px;
top: 0;
right: 0;
display: flex;
justify-content: center;
// Keep the close button clickable when modal body content uses
// position: sticky with elevated z-index (e.g. DatabaseModal header).
z-index: ${theme.zIndexPopupBase + 1};
}
.ant-modal-close:hover {
background: transparent;
}
.ant-modal-close-x {
.ant-modal-header {
flex: 0 0 auto;
border-radius: ${theme.borderRadius}px ${theme.borderRadius}px 0 0;
padding: ${theme.sizeUnit * 4}px ${theme.sizeUnit * 4}px;
.ant-modal-title {
font-weight: ${theme.fontWeightStrong};
}
.ant-modal-title h4 {
display: flex;
margin: 0;
align-items: center;
[data-test='close-modal-btn'] {
justify-content: center;
}
.close {
flex: 1 1 auto;
margin-bottom: ${theme.sizeUnit}px;
color: ${theme.colorPrimaryText};
font-weight: ${theme.fontWeightLight};
}
}
}
.ant-modal-close {
width: ${theme.sizeUnit * 14}px;
height: ${theme.sizeUnit * 14}px;
padding: ${theme.sizeUnit * 6}px ${theme.sizeUnit * 4}px
${theme.sizeUnit * 4}px;
top: 0;
right: 0;
display: flex;
justify-content: center;
}
.ant-modal-close:hover {
background: transparent;
}
.ant-modal-close-x {
display: flex;
align-items: center;
[data-test='close-modal-btn'] {
justify-content: center;
}
.close {
flex: 1 1 auto;
margin-bottom: ${theme.sizeUnit}px;
color: ${theme.colorPrimaryText};
font-weight: ${theme.fontWeightLight};
}
}
.ant-modal-body {
flex: 0 1 auto;
padding: ${theme.sizeUnit * 4}px ${theme.sizeUnit * 6}px;
overflow: auto;
${!resizable && height && `height: ${height};`}
}
.ant-modal-footer {
flex: 0 0 1;
border-top: ${theme.sizeUnit / 4}px solid ${theme.colorSplit};
padding: ${theme.sizeUnit * 4}px;
margin-top: 0;
.btn {
font-size: 12px;
}
.ant-modal-body {
flex: 0 1 auto;
padding: ${theme.sizeUnit * 4}px ${theme.sizeUnit * 6}px;
overflow: auto;
${!resizable && height && `height: ${height};`}
.btn + .btn {
margin-left: ${theme.sizeUnit * 2}px;
}
}
.ant-modal-footer {
flex: 0 0 1;
border-top: ${theme.sizeUnit / 4}px solid ${theme.colorSplit};
padding: ${theme.sizeUnit * 4}px;
margin-top: 0;
&.no-content-padding .ant-modal-body {
padding: 0;
}
.btn {
font-size: 12px;
}
.btn + .btn {
margin-left: ${theme.sizeUnit * 2}px;
}
}
&.no-content-padding .ant-modal-body {
${draggable &&
css`
.ant-modal-header {
padding: 0;
.draggable-trigger {
cursor: move;
padding: ${theme.sizeUnit * 4}px;
width: 100%;
}
}
`}
${draggable &&
css`
.ant-modal-header {
padding: 0;
${resizable &&
css`
.resizable {
pointer-events: all;
.draggable-trigger {
cursor: move;
padding: ${theme.sizeUnit * 4}px ${closeButtonWidth}px
${theme.sizeUnit * 4}px ${theme.sizeUnit * 4}px;
width: 100%;
.resizable-wrapper {
height: 100%;
}
.ant-modal-content {
height: 100%;
.ant-modal-body {
height: ${hideFooter
? `calc(100% - ${MODAL_HEADER_HEIGHT}px)`
: `calc(100% - ${MODAL_HEADER_HEIGHT}px - ${MODAL_FOOTER_HEIGHT}px)`};
}
}
`}
${resizable &&
css`
.resizable {
pointer-events: all;
.resizable-wrapper {
height: 100%;
}
.ant-modal-content {
height: 100%;
.ant-modal-body {
height: ${hideFooter
? `calc(100% - ${MODAL_HEADER_HEIGHT}px)`
: `calc(100% - ${MODAL_HEADER_HEIGHT}px - ${MODAL_FOOTER_HEIGHT}px)`};
}
}
}
`}
`;
}}
}
`}
`}
`;
const defaultResizableConfig = (hideFooter: boolean | undefined) => ({

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import type { CSSProperties, ReactNode } from 'react';
import type { FormInstance, ModalFuncProps } from 'antd';
import type { ModalFuncProps } from 'antd';
import type { ResizableProps } from 're-resizable';
import type { DraggableProps } from 'react-draggable';
import { ButtonStyle } from '../Button/types';
@@ -68,8 +68,7 @@ export interface StyledModalProps {
export type { ModalFuncProps };
export interface FormModalProps extends Omit<ModalProps, 'children'> {
children: ReactNode | ((form: FormInstance) => ReactNode);
export interface FormModalProps extends ModalProps {
initialValues?: object;
formSubmitHandler: (values: object) => Promise<void>;
onSave: () => void;

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode, ReactElement, memo } from 'react';
import { ReactNode, ReactElement } from 'react';
import { t } from '@apache-superset/core/translation';
import { css, SupersetTheme, useTheme } from '@apache-superset/core/theme';
import { Icons } from '@superset-ui/core/components/Icons';
@@ -118,64 +118,62 @@ export type PageHeaderWithActionsProps = {
};
};
export const PageHeaderWithActions = memo(
({
editableTitleProps,
showTitlePanelItems,
certificatiedBadgeProps,
showFaveStar,
faveStarProps,
titlePanelAdditionalItems,
rightPanelAdditionalItems,
additionalActionsMenu,
menuDropdownProps,
showMenuDropdown = true,
tooltipProps,
}: PageHeaderWithActionsProps) => {
const theme = useTheme();
return (
<div css={headerStyles} className="header-with-actions">
<div className="title-panel">
<DynamicEditableTitle {...editableTitleProps} />
{showTitlePanelItems && (
<div css={buttonsStyles}>
{certificatiedBadgeProps?.certifiedBy && (
<CertifiedBadge {...certificatiedBadgeProps} />
)}
{showFaveStar && <FaveStar {...faveStarProps} />}
{titlePanelAdditionalItems}
</div>
export const PageHeaderWithActions = ({
editableTitleProps,
showTitlePanelItems,
certificatiedBadgeProps,
showFaveStar,
faveStarProps,
titlePanelAdditionalItems,
rightPanelAdditionalItems,
additionalActionsMenu,
menuDropdownProps,
showMenuDropdown = true,
tooltipProps,
}: PageHeaderWithActionsProps) => {
const theme = useTheme();
return (
<div css={headerStyles} className="header-with-actions">
<div className="title-panel">
<DynamicEditableTitle {...editableTitleProps} />
{showTitlePanelItems && (
<div css={buttonsStyles}>
{certificatiedBadgeProps?.certifiedBy && (
<CertifiedBadge {...certificatiedBadgeProps} />
)}
{showFaveStar && <FaveStar {...faveStarProps} />}
{titlePanelAdditionalItems}
</div>
)}
</div>
<div className="right-button-panel">
{rightPanelAdditionalItems}
<div css={additionalActionsContainerStyles}>
{showMenuDropdown && (
<Dropdown
trigger={['click']}
popupRender={() => additionalActionsMenu}
{...menuDropdownProps}
>
<span>
<Button
css={menuTriggerStyles}
buttonStyle="tertiary"
aria-label={t('Menu actions trigger')}
tooltip={tooltipProps?.text}
placement={tooltipProps?.placement}
data-test="actions-trigger"
>
<Icons.EllipsisOutlined
iconColor={theme.colorPrimary}
iconSize="l"
/>
</Button>
</span>
</Dropdown>
)}
</div>
<div className="right-button-panel">
{rightPanelAdditionalItems}
<div css={additionalActionsContainerStyles}>
{showMenuDropdown && (
<Dropdown
trigger={['click']}
popupRender={() => additionalActionsMenu}
{...menuDropdownProps}
>
<span>
<Button
css={menuTriggerStyles}
buttonStyle="tertiary"
aria-label={t('Menu actions trigger')}
tooltip={tooltipProps?.text}
placement={tooltipProps?.placement}
data-test="actions-trigger"
>
<Icons.EllipsisOutlined
iconColor={theme.colorPrimary}
iconSize="l"
/>
</Button>
</span>
</Dropdown>
)}
</div>
</div>
</div>
);
},
);
</div>
);
};

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { render, screen, fireEvent } from '@superset-ui/core/spec';
import { renderHook } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { TableInstance, useTable } from 'react-table';
import TableCollection from '.';

View File

@@ -91,7 +91,7 @@ export function mapColumns<T extends object>(
return columns.map(column => {
const { isSorted, isSortedDesc } = getSortingInfo(headerGroups, column.id);
return {
title: column.Header as ReactNode,
title: column.Header,
dataIndex: column.id?.includes('.') ? column.id.split('.') : column.id,
hidden: column.hidden,
key: column.id,
@@ -121,7 +121,7 @@ export function mapColumns<T extends object>(
column,
});
}
return val as ReactNode;
return val;
},
className: column.className,
};

View File

@@ -19,14 +19,6 @@
import { render, screen, userEvent, waitFor } from '@superset-ui/core/spec';
import { TableView, TableViewProps } from '.';
// Mock window.scrollTo to prevent jsdom "Not implemented" errors
beforeAll(() => {
window.scrollTo = jest.fn();
});
afterAll(() => {
jest.restoreAllMocks();
});
const mockedProps: TableViewProps = {
columns: [
{
@@ -133,25 +125,27 @@ test('should change page when pagination is clicked', async () => {
expect(screen.getByText('Emily')).toBeInTheDocument();
expect(screen.queryByText('Kate')).not.toBeInTheDocument();
await userEvent.click(screen.getByTitle('Next Page'));
const page2 = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2);
await waitFor(() => {
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.getByText('321')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('Kate')).toBeInTheDocument();
expect(screen.queryByText('Emily')).not.toBeInTheDocument();
});
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.getByText('321')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.queryByText('Emily')).not.toBeInTheDocument();
await userEvent.click(screen.getByTitle('Previous Page'));
const page1 = screen.getByRole('listitem', { name: '1' });
await userEvent.click(page1);
await waitFor(() => {
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.getByText('123')).toBeInTheDocument();
expect(screen.getByText('27')).toBeInTheDocument();
expect(screen.getByText('Emily')).toBeInTheDocument();
expect(screen.queryByText('Kate')).not.toBeInTheDocument();
});
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.getByText('123')).toBeInTheDocument();
expect(screen.getByText('27')).toBeInTheDocument();
expect(screen.queryByText('Kate')).not.toBeInTheDocument();
});
test('should sort by age', async () => {
@@ -246,7 +240,8 @@ test('should handle server-side pagination', async () => {
render(<TableView {...serverPaginationProps} />);
// Click next page
await userEvent.click(screen.getByTitle('Next Page'));
const page2 = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2);
await waitFor(() => {
expect(onServerPagination).toHaveBeenCalledWith({
@@ -306,7 +301,9 @@ test('should scroll to top when scrollTopOnPagination is true', async () => {
};
render(<TableView {...scrollProps} />);
await userEvent.click(screen.getByTitle('Next Page'));
// Click next page
const page2 = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2);
await waitFor(() => {
expect(scrollToSpy).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
@@ -327,7 +324,9 @@ test('should NOT scroll to top when scrollTopOnPagination is false', async () =>
};
render(<TableView {...scrollProps} />);
await userEvent.click(screen.getByTitle('Next Page'));
// Click next page
const page2 = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2);
await waitFor(() => {
expect(screen.getByText('321')).toBeInTheDocument();

View File

@@ -16,10 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import { memo, useEffect, useRef, useMemo, useCallback, useState } from 'react';
import { memo, useEffect, useRef, useMemo, useCallback } from 'react';
import { isEqual } from 'lodash';
import { styled } from '@apache-superset/core/theme';
import { useFilters, useSortBy, useTable } from 'react-table';
import { useFilters, usePagination, useSortBy, useTable } from 'react-table';
import { Empty } from '@superset-ui/core/components';
import TableCollection from '@superset-ui/core/components/TableCollection';
import { TableSize } from '@superset-ui/core/components/Table';
@@ -117,45 +117,43 @@ const RawTableView = ({
...props
}: TableViewProps) => {
const tableRef = useRef<HTMLTableElement>(null);
const effectivePageSize = initialPageSize ?? DEFAULT_PAGE_SIZE;
const [pageIndex, setPageIndex] = useState(initialPageIndex ?? 0);
const initialState = useMemo(
() => ({
pageSize: effectivePageSize,
pageIndex: 0,
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
pageIndex: initialPageIndex ?? 0,
sortBy: initialSortBy,
}),
[effectivePageSize, initialSortBy],
[initialPageSize, initialPageIndex, initialSortBy],
);
const {
getTableProps,
getTableBodyProps,
headerGroups,
page,
rows,
prepareRow,
gotoPage,
setSortBy,
state: { sortBy },
state: { pageIndex, sortBy },
} = useTable(
{
columns,
data,
initialState,
manualPagination: true,
manualPagination: serverPagination,
manualSortBy: serverPagination,
pageCount: serverPagination
? Math.ceil(totalCount / initialState.pageSize)
: undefined,
autoResetSortBy: false,
},
useFilters,
useSortBy,
...(withPagination ? [usePagination] : []),
);
const content = useMemo(() => {
if (!withPagination || serverPagination) return rows;
const start = pageIndex * effectivePageSize;
return rows.slice(start, start + effectivePageSize);
}, [withPagination, serverPagination, rows, pageIndex, effectivePageSize]);
const EmptyWrapperComponent = useMemo(() => {
switch (emptyWrapperType) {
case EmptyWrapperType.Small:
@@ -166,6 +164,11 @@ const RawTableView = ({
}
}, [emptyWrapperType]);
const content = useMemo(
() => (withPagination ? page : rows),
[withPagination, page, rows],
);
const isEmpty = useMemo(
() => !loading && content.length === 0,
[loading, content.length],
@@ -189,9 +192,10 @@ const RawTableView = ({
const handlePageChange = useCallback(
(p: number) => {
if (scrollTopOnPagination) handleScrollToTop();
setPageIndex(p);
gotoPage(p);
},
[scrollTopOnPagination, handleScrollToTop],
[scrollTopOnPagination, handleScrollToTop, gotoPage],
);
const paginationProps = useMemo(() => {
@@ -207,7 +211,7 @@ const RawTableView = ({
if (serverPagination) {
return {
pageIndex,
pageSize: effectivePageSize,
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
totalCount,
onPageChange: handlePageChange,
};
@@ -215,7 +219,7 @@ const RawTableView = ({
return {
pageIndex,
pageSize: effectivePageSize,
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
totalCount: data.length,
onPageChange: handlePageChange,
};
@@ -223,28 +227,28 @@ const RawTableView = ({
withPagination,
serverPagination,
pageIndex,
effectivePageSize,
initialPageSize,
totalCount,
data.length,
handlePageChange,
]);
useEffect(() => {
if (serverPagination && pageIndex !== (initialPageIndex ?? 0)) {
if (serverPagination && pageIndex !== initialState.pageIndex) {
onServerPagination({
pageIndex,
});
}
}, [initialPageIndex, onServerPagination, pageIndex, serverPagination]);
}, [initialState.pageIndex, onServerPagination, pageIndex, serverPagination]);
useEffect(() => {
if (serverPagination && !isEqual(sortBy, initialSortBy)) {
if (serverPagination && !isEqual(sortBy, initialState.sortBy)) {
onServerPagination({
pageIndex: 0,
sortBy,
});
}
}, [initialSortBy, onServerPagination, serverPagination, sortBy]);
}, [initialState.sortBy, onServerPagination, serverPagination, sortBy]);
return (
<TableViewStyles {...props} ref={tableRef}>

View File

@@ -97,8 +97,8 @@ const StyledPlus = styled.span`
export default function TruncatedList<ListItemType>({
items,
renderVisibleItem = item => item as ReactNode,
renderTooltipItem = item => item as ReactNode,
renderVisibleItem = item => item,
renderTooltipItem = item => item,
getKey = item => item as unknown as Key,
maxLinks = 20,
}: TruncatedListProps<ListItemType>) {

View File

@@ -20,10 +20,6 @@ import { t } from '@apache-superset/core/translation';
import { Icons, Modal, Typography, Button } from '@superset-ui/core/components';
import type { FC, ReactElement } from 'react';
// Ant Design's default modal zIndex is 1000. Using a higher value ensures
// this dialog always renders above other open modals (e.g. a draggable View SQL modal).
const UNSAVED_CHANGES_MODAL_Z_INDEX = 1100;
export type UnsavedChangesModalProps = {
showModal: boolean;
onHide: () => void;
@@ -31,7 +27,6 @@ export type UnsavedChangesModalProps = {
onConfirmNavigation: () => void;
title?: string;
body?: string;
zIndex?: number;
};
export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({
@@ -41,7 +36,6 @@ export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({
onConfirmNavigation,
title = 'Unsaved Changes',
body = "If you don't save, changes will be lost.",
zIndex = UNSAVED_CHANGES_MODAL_Z_INDEX,
}: UnsavedChangesModalProps): ReactElement => (
<Modal
centered
@@ -49,7 +43,6 @@ export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({
onHide={onHide}
show={showModal}
width="444px"
zIndex={zIndex}
title={
<>
<Icons.WarningOutlined iconSize="m" style={{ marginRight: 8 }} />

View File

@@ -51,7 +51,6 @@ const SupersetClient: SupersetClientInterface = {
reAuthenticate: () => getInstance().reAuthenticate(),
request: request => getInstance().request(request),
getCSRFToken: () => getInstance().getCSRFToken(),
getUrl: (...args) => getInstance().getUrl(...args),
};
export default SupersetClient;

View File

@@ -158,7 +158,6 @@ export interface SupersetClientInterface extends Pick<
| 'isAuthenticated'
| 'reAuthenticate'
| 'getGuestToken'
| 'getUrl'
> {
configure: (config?: ClientConfig) => SupersetClientInterface;
reset: () => void;

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { renderHook } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { useChangeEffect } from './useChangeEffect';
test('call callback the first time with undefined and value', () => {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { renderHook } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { useComponentDidMount } from './useComponentDidMount';
test('the effect should only be executed on the first render', () => {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { renderHook } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { useComponentDidUpdate } from './useComponentDidUpdate';
test('the effect should not be executed on the first render', () => {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { renderHook, act } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { useElementOnScreen } from './useElementOnScreen';
const observeMock = jest.fn();
@@ -46,9 +46,10 @@ test('should return isSticky as true when intersectionRatio < 1', async () => {
useElementOnScreen({ rootMargin: '-50px 0px 0px 0px' }),
);
const callback = IntersectionObserverMock.mock.calls[0][0];
act(() => {
callback([{ isIntersecting: true, intersectionRatio: 0.5 }]);
});
const callBack = callback([{ isIntersecting: true, intersectionRatio: 0.5 }]);
const observer = new IntersectionObserverMock(callBack, {});
const newDiv = document.createElement('div');
observer.observe(newDiv);
expect(hook.result.current[1]).toEqual(true);
});
@@ -57,9 +58,10 @@ test('should return isSticky as false when intersectionRatio >= 1', async () =>
useElementOnScreen({ rootMargin: '-50px 0px 0px 0px' }),
);
const callback = IntersectionObserverMock.mock.calls[0][0];
act(() => {
callback([{ isIntersecting: true, intersectionRatio: 1 }]);
});
const callBack = callback([{ isIntersecting: true, intersectionRatio: 1 }]);
const observer = new IntersectionObserverMock(callBack, {});
const newDiv = document.createElement('div');
observer.observe(newDiv);
expect(hook.result.current[1]).toEqual(false);
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { renderHook } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { usePrevious } from './usePrevious';
test('get undefined on the first render when initialValue is not defined', () => {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { renderHook } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import useCSSTextTruncation from './useCSSTextTruncation';
afterEach(() => {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { renderHook } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { RefObject } from 'react';
import useChildElementTruncation from './useChildElementTruncation';

View File

@@ -28,7 +28,7 @@ export const PREVIEW_TIME = new Date(Date.UTC(2017, 1, 14, 11, 22, 33));
// Use type augmentation to indicate that
// an instance of TimeFormatter is also a function
interface TimeFormatter {
(value: Date | number | string | null | undefined): string;
(value: Date | number | null | undefined): string;
}
class TimeFormatter extends ExtensibleFunction {
@@ -49,9 +49,7 @@ class TimeFormatter extends ExtensibleFunction {
formatFunc: TimeFormatFunction;
useLocalTime?: boolean;
}) {
super((value: Date | number | string | null | undefined) =>
this.format(value),
);
super((value: Date | number | null | undefined) => this.format(value));
const {
id = isRequired('config.id'),
@@ -68,7 +66,7 @@ class TimeFormatter extends ExtensibleFunction {
this.useLocalTime = useLocalTime;
}
format(value: Date | number | string | null | undefined) {
format(value: Date | number | null | undefined) {
return stringifyTimeInput(value, time => this.formatFunc(time));
}

View File

@@ -18,18 +18,12 @@
*/
export default function stringifyTimeInput(
value: Date | number | string | undefined | null,
value: Date | number | undefined | null,
fn: (time: Date) => string,
) {
if (value === null || value === undefined) {
return `${value}`;
}
if (typeof value === 'string') {
const trimmed = value.trim();
const isIntegerString = /^-?\d+$/.test(trimmed);
return fn(new Date(isIntegerString ? Number(trimmed) : value));
}
return fn(value instanceof Date ? value : new Date(value));
}

View File

@@ -249,8 +249,7 @@ export type Extensions = Partial<{
'navbar.right-menu.item.icon': ComponentType<RightMenuItemIconProps>;
'navbar.right': ComponentType;
'report-modal.dropdown.item.icon': ComponentType;
'root.context.provider': ComponentType<{ children?: ReactNode }>;
'root.context.provider': ComponentType;
'welcome.message': ComponentType;
'welcome.banner': ComponentType;
'welcome.main.replacement': ComponentType;

View File

@@ -143,7 +143,7 @@ describe('SuperChart', () => {
);
expect(await screen.findByText('Custom Fallback!')).toBeInTheDocument();
expect(CustomFallbackComponent).toHaveBeenCalled();
expect(CustomFallbackComponent).toHaveBeenCalledTimes(1);
});
test('call onErrorBoundary', async () => {
expectedErrors = 1;

View File

@@ -31,42 +31,33 @@ describe('SupersetClient', () => {
afterEach(() => SupersetClient.reset());
test('exposes configure, init, get, post, postForm, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => {
test('exposes reset, configure, init, get, post, postForm, isAuthenticated, and reAuthenticate methods', () => {
expect(typeof SupersetClient.configure).toBe('function');
expect(typeof SupersetClient.init).toBe('function');
expect(typeof SupersetClient.get).toBe('function');
expect(typeof SupersetClient.post).toBe('function');
expect(typeof SupersetClient.postForm).toBe('function');
expect(typeof SupersetClient.delete).toBe('function');
expect(typeof SupersetClient.put).toBe('function');
expect(typeof SupersetClient.request).toBe('function');
expect(typeof SupersetClient.reset).toBe('function');
expect(typeof SupersetClient.getGuestToken).toBe('function');
expect(typeof SupersetClient.getCSRFToken).toBe('function');
expect(typeof SupersetClient.getUrl).toBe('function');
expect(typeof SupersetClient.isAuthenticated).toBe('function');
expect(typeof SupersetClient.reAuthenticate).toBe('function');
expect(typeof SupersetClient.getGuestToken).toBe('function');
expect(typeof SupersetClient.request).toBe('function');
expect(typeof SupersetClient.reset).toBe('function');
});
test('throws if you call init, get, post, postForm, delete, put, request, getGuestToken, getCSRFToken, getUrl, isAuthenticated, or reAuthenticate before configure', () => {
test('throws if you call init, get, post, postForm, isAuthenticated, or reAuthenticate before configure', () => {
expect(SupersetClient.init).toThrow();
expect(SupersetClient.get).toThrow();
expect(SupersetClient.post).toThrow();
expect(SupersetClient.postForm).toThrow();
expect(SupersetClient.delete).toThrow();
expect(SupersetClient.put).toThrow();
expect(SupersetClient.request).toThrow();
expect(SupersetClient.getGuestToken).toThrow();
expect(SupersetClient.getCSRFToken).toThrow();
expect(SupersetClient.getUrl).toThrow();
expect(SupersetClient.isAuthenticated).toThrow();
expect(SupersetClient.reAuthenticate).toThrow();
expect(SupersetClient.request).toThrow();
expect(SupersetClient.configure).not.toThrow();
});
// this also tests that the ^above doesn't throw if configure is called appropriately
test('calls appropriate SupersetClient methods when configured', async () => {
expect.assertions(18);
expect.assertions(16);
const mockGetUrl = '/mock/get/url';
const mockPostUrl = '/mock/post/url';
const mockRequestUrl = '/mock/request/url';
@@ -97,13 +88,6 @@ describe('SupersetClient', () => {
SupersetClientClass.prototype,
'getGuestToken',
);
const getUrlSpy = jest.spyOn(SupersetClientClass.prototype, 'getUrl');
SupersetClient.configure({ appRoot: '/app' });
expect(SupersetClient.getUrl({ endpoint: '/some/path' })).toContain(
'/app/some/path',
);
expect(getUrlSpy).toHaveBeenCalledTimes(1);
SupersetClient.configure({});
await SupersetClient.init();
@@ -157,7 +141,6 @@ describe('SupersetClient', () => {
postSpy.mockRestore();
authenticatedSpy.mockRestore();
csrfSpy.mockRestore();
getUrlSpy.mockRestore();
fetchMock.clearHistory().removeRoutes();
});

View File

@@ -67,15 +67,6 @@ describe('TimeFormatter', () => {
test('handles number, treating it as a timestamp', () => {
expect(formatter.format(PREVIEW_TIME.getTime())).toEqual('2017');
});
test('handles numeric string, treating it as a timestamp', () => {
// PivotData.processRecord coerces values with String(), turning numeric
// timestamps into strings.
const timestamp = PREVIEW_TIME.getTime().toString();
expect(formatter.format(timestamp)).toEqual('2017');
});
test('handles ISO-8601 string without misinterpreting it as a number', () => {
expect(formatter.format('2017-02-14T11:22:33.000Z')).toEqual('2017');
});
test('otherwise returns formatted value', () => {
expect(formatter.format(PREVIEW_TIME)).toEqual('2017');
});

View File

@@ -39,24 +39,6 @@ export class ImportDatasetModal extends Modal {
.setInputFiles(filePath);
}
/**
* Upload a file buffer to the import modal (no temp file needed)
* @param buffer - File contents as a Buffer
* @param fileName - Name to use for the uploaded file
*/
async uploadFileBuffer(
buffer: Buffer,
fileName: string = 'dataset_export.zip',
): Promise<void> {
await this.page
.locator(ImportDatasetModal.SELECTORS.FILE_INPUT)
.setInputFiles({
name: fileName,
mimeType: 'application/zip',
buffer,
});
}
/**
* Fill the overwrite confirmation input (only needed if dataset exists)
*/

View File

@@ -17,10 +17,9 @@
* under the License.
*/
import type { Page, Response, APIResponse } from '@playwright/test';
import type { Response, APIResponse } from '@playwright/test';
import { expect } from '@playwright/test';
import * as unzipper from 'unzipper';
import { apiGet } from './requests';
/**
* Common interface for response types with status() method.
@@ -62,35 +61,6 @@ export function expectStatusOneOf<T extends ResponseLike>(
return response;
}
/**
* Poll an API endpoint until it returns 404, confirming a resource was deleted.
* Shared across chart, dashboard, and dataset list tests.
* @param page - Playwright page instance (provides authentication context)
* @param endpoint - API endpoint path (e.g. 'api/v1/dataset/')
* @param id - Resource ID to check
* @param options - Optional timeout (default 10000ms) and label for error messages
*/
export async function expectDeleted(
page: Page,
endpoint: string,
id: number,
options?: { timeout?: number; label?: string },
): Promise<void> {
const timeout = options?.timeout ?? 10000;
const label = options?.label ?? `Resource ${id}`;
await expect
.poll(
async () => {
const response = await apiGet(page, `${endpoint}${id}`, {
failOnStatusCode: false,
});
return response.status();
},
{ timeout, message: `${label} should return 404 after delete` },
)
.toBe(404);
}
/**
* Extract the resource ID from a JSON response body.
* Handles both `{ result: { id } }` and `{ id }` shapes.

View File

@@ -203,21 +203,6 @@ export async function apiDeleteDataset(
return apiDelete(page, `${ENDPOINTS.DATASET}${datasetId}`, options);
}
/**
* Export datasets as a zip file via the API.
* Uses Rison encoding for the query parameter (required by the endpoint).
* @param page - Playwright page instance (provides authentication context)
* @param datasetIds - Array of dataset IDs to export
* @returns API response containing the export zip
*/
export async function apiExportDatasets(
page: Page,
datasetIds: number[],
): Promise<APIResponse> {
const query = rison.encode(datasetIds);
return apiGet(page, `${ENDPOINTS.DATASET_EXPORT}?q=${query}`);
}
/**
* Duplicate a dataset via the API
* @param page - Playwright page instance (provides authentication context)

View File

@@ -1,53 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Page, APIResponse } from '@playwright/test';
import { apiPost, ApiRequestOptions } from './requests';
const ENDPOINTS = {
SQLLAB_EXECUTE: 'api/v1/sqllab/execute/',
} as const;
/**
* Execute a SQL query via SQL Lab API.
* Requires `allow_dml=True` on the target database for DDL/DML statements.
* @param page - Playwright page instance (provides authentication context)
* @param databaseId - ID of the database to execute against
* @param sql - SQL statement to execute
* @param schema - Optional schema context for the query
* @returns API response from SQL Lab execution
*/
export async function apiExecuteSql(
page: Page,
databaseId: number,
sql: string,
schema?: string,
options?: ApiRequestOptions,
): Promise<APIResponse> {
return apiPost(
page,
ENDPOINTS.SQLLAB_EXECUTE,
{
database_id: databaseId,
sql,
schema: schema ?? null,
},
options,
);
}

View File

@@ -32,7 +32,7 @@ export class CreateDatasetPage {
*/
private static readonly SELECTORS = {
DATABASE: '[data-test="select-database"]',
SCHEMA: '[data-test="Select schema"]',
SCHEMA: '[data-test="Select schema or type to search schemas"]',
TABLE: '[data-test="Select table or type to search tables"]',
} as const;

View File

@@ -28,7 +28,6 @@ import { apiGetChart, ENDPOINTS } from '../../helpers/api/chart';
import { createTestChart } from './chart-test-helpers';
import { waitForGet, waitForPut } from '../../helpers/api/intercepts';
import {
expectDeleted,
expectStatusOneOf,
expectValidExportZip,
} from '../../helpers/api/assertions';
@@ -89,9 +88,17 @@ test('should delete a chart with confirmation', async ({
await expect(chartListPage.getChartRow(chartName)).not.toBeVisible();
// Backend verification: API returns 404
await expectDeleted(page, ENDPOINTS.CHART, chartId, {
label: `Chart ${chartId}`,
});
await expect
.poll(
async () => {
const response = await apiGetChart(page, chartId, {
failOnStatusCode: false,
});
return response.status();
},
{ timeout: 10000, message: `Chart ${chartId} should return 404` },
)
.toBe(404);
});
test('should edit chart name via properties modal', async ({
@@ -239,9 +246,17 @@ test('should bulk delete multiple charts', async ({
// Backend verification: Both return 404
for (const chart of [chart1, chart2]) {
await expectDeleted(page, ENDPOINTS.CHART, chart.id, {
label: `Chart ${chart.id}`,
});
await expect
.poll(
async () => {
const response = await apiGetChart(page, chart.id, {
failOnStatusCode: false,
});
return response.status();
},
{ timeout: 10000, message: `Chart ${chart.id} should return 404` },
)
.toBe(404);
}
});

View File

@@ -25,6 +25,7 @@ import {
} from '../../components/modals';
import { Toast } from '../../components/core';
import {
apiGetDashboard,
apiDeleteDashboard,
apiExportDashboards,
getDashboardByName,
@@ -33,7 +34,6 @@ import {
import { createTestDashboard } from './dashboard-test-helpers';
import { waitForGet, waitForPost } from '../../helpers/api/intercepts';
import {
expectDeleted,
expectStatusOneOf,
expectValidExportZip,
} from '../../helpers/api/assertions';
@@ -97,9 +97,17 @@ test('should delete a dashboard with confirmation', async ({
).not.toBeVisible();
// Backend verification: API returns 404
await expectDeleted(page, ENDPOINTS.DASHBOARD, dashboardId, {
label: `Dashboard ${dashboardId}`,
});
await expect
.poll(
async () => {
const response = await apiGetDashboard(page, dashboardId, {
failOnStatusCode: false,
});
return response.status();
},
{ timeout: 10000, message: `Dashboard ${dashboardId} should return 404` },
)
.toBe(404);
});
test('should export a dashboard as a zip file', async ({
@@ -202,9 +210,20 @@ test('should bulk delete multiple dashboards', async ({
// Backend verification: Both return 404
for (const dashboard of [dashboard1, dashboard2]) {
await expectDeleted(page, ENDPOINTS.DASHBOARD, dashboard.id, {
label: `Dashboard ${dashboard.id}`,
});
await expect
.poll(
async () => {
const response = await apiGetDashboard(page, dashboard.id, {
failOnStatusCode: false,
});
return response.status();
},
{
timeout: 10000,
message: `Dashboard ${dashboard.id} should return 404`,
},
)
.toBe(404);
}
});
@@ -289,9 +308,20 @@ test.describe('import dashboard', () => {
await apiDeleteDashboard(page, dashboardId);
// Verify it's gone
await expectDeleted(page, ENDPOINTS.DASHBOARD, dashboardId, {
label: `Dashboard ${dashboardId}`,
});
await expect
.poll(
async () => {
const response = await apiGetDashboard(page, dashboardId, {
failOnStatusCode: false,
});
return response.status();
},
{
timeout: 10000,
message: `Dashboard ${dashboardId} should return 404 after delete`,
},
)
.toBe(404);
// Refresh to confirm dashboard is no longer in the list
await dashboardListPage.goto();

View File

@@ -27,190 +27,193 @@ import { ChartCreationPage } from '../../pages/ChartCreationPage';
import { ENDPOINTS } from '../../helpers/api/dataset';
import { waitForPost } from '../../helpers/api/intercepts';
import { expectStatusOneOf } from '../../helpers/api/assertions';
import { getDatabaseByName } from '../../helpers/api/database';
import { apiExecuteSql } from '../../helpers/api/sqllab';
import { apiPostDatabase } from '../../helpers/api/database';
interface ExamplesSetupResult {
tableName: string;
dbId: number;
interface GsheetsSetupResult {
sheetName: string;
dbName: string;
createDatasetPage: CreateDatasetPage;
}
/**
* Creates a temporary table in the examples database via SQL Lab,
* then navigates to the create dataset wizard with it pre-selected.
*
* Requires `allow_dml=True` on the examples database (configured in CI setup).
*
* @param page - Playwright page instance
* @param testAssets - Test assets tracker for cleanup
* @param testInfo - Test info for parallelIndex to avoid name collisions
* @returns Setup result with table name, database ID, and page object
* Sets up gsheets database and navigates to create dataset page.
* Skips test if gsheets connector unavailable (test.skip() throws, so no return).
* @param testInfo - Test info for parallelIndex to avoid name collisions in parallel runs
* @returns Setup result with names and page object
*/
async function setupExamplesDataset(
async function setupGsheetsDataset(
page: Page,
_testAssets: TestAssets,
testAssets: TestAssets,
testInfo: TestInfo,
): Promise<ExamplesSetupResult> {
// Look up the examples database (always available in CI via load_examples)
const examplesDb = await getDatabaseByName(page, 'examples');
if (!examplesDb) {
throw new Error(
'Examples database not found. Ensure "superset load_examples" has run.',
);
}
const dbId = examplesDb.id;
// Create a uniquely-named temporary table via SQL Lab
): Promise<GsheetsSetupResult> {
// Public Google Sheet for testing (published to web, no auth required).
// This is a Netflix dataset that is publicly accessible via the Google Visualization API.
// NOTE: This sheet is hosted on an external Google account and is not created by the test itself.
// If this sheet is deleted, its ID changes, or its sharing settings are restricted,
// these tests will start failing when they attempt to create a database pointing at it.
// In that case, create or select a new publicly readable test sheet, update `sheetUrl`
// to use its URL, and update this comment to describe who owns/maintains that sheet
// and the expected access controls (e.g., "anyone with the link can view").
const sheetUrl =
'https://docs.google.com/spreadsheets/d/19XNqckHGKGGPh83JGFdFGP4Bw9gdXeujq5EoIGwttdM/edit#gid=347941303';
// Include parallelIndex to avoid collisions when tests run in parallel
const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`;
const tableName = `test_pw_${uniqueSuffix}`;
const sheetName = `test_netflix_${uniqueSuffix}`;
const dbName = `test_gsheets_db_${uniqueSuffix}`;
// CI examples DB is always PostgreSQL, so 'public' is the correct schema.
const createTableRes = await apiExecuteSql(
page,
dbId,
`CREATE TABLE ${tableName} AS SELECT 1 AS id, 'test' AS name`,
'public',
);
if (!createTableRes.ok()) {
const errorBody = await createTableRes.json().catch(() => ({}));
throw new Error(
`Failed to create temp table "${tableName}": ${JSON.stringify(errorBody)}`,
);
// Create a Google Sheets database via API
// The catalog must be in `extra` as JSON with engine_params.catalog format
const catalogDict = { [sheetName]: sheetUrl };
const createDbRes = await apiPostDatabase(page, {
database_name: dbName,
engine: 'gsheets',
sqlalchemy_uri: 'gsheets://',
configuration_method: 'dynamic_form',
expose_in_sqllab: true,
extra: JSON.stringify({
engine_params: {
catalog: catalogDict,
},
}),
});
// Check if gsheets connector is available
if (!createDbRes.ok()) {
const errorBody = await createDbRes.json();
const errorText = JSON.stringify(errorBody);
// Skip test if gsheets connector not installed
if (
errorText.includes('gsheets') ||
errorText.includes('No such DB engine')
) {
await test.info().attach('skip-reason', {
body: `Google Sheets connector unavailable: ${errorText}`,
contentType: 'text/plain',
});
test.skip(); // throws, no return needed
}
throw new Error(`Failed to create gsheets database: ${errorText}`);
}
const createDbBody = await createDbRes.json();
const dbId = createDbBody.result?.id ?? createDbBody.id;
if (!dbId) {
throw new Error('Database creation did not return an ID');
}
testAssets.trackDatabase(dbId);
// Navigate to create dataset page
const createDatasetPage = new CreateDatasetPage(page);
await createDatasetPage.goto();
await createDatasetPage.waitForPageLoad();
// Select the examples database, public schema, and temp table.
// Schema is 'public' because the CI examples DB is always PostgreSQL.
await createDatasetPage.selectDatabase('examples');
await createDatasetPage.selectSchema('public');
await createDatasetPage.selectTable(tableName);
// Select the Google Sheets database
await createDatasetPage.selectDatabase(dbName);
return { tableName, dbId, createDatasetPage };
}
/**
* Drop a temporary table created during test setup.
* Uses failOnStatusCode: false so cleanup doesn't throw if the table was already removed.
*/
async function dropTempTable(
page: Page,
dbId: number,
tableName: string,
): Promise<void> {
// Schema matches 'public' used in setupExamplesDataset (CI examples DB is PostgreSQL).
await apiExecuteSql(
page,
dbId,
`DROP TABLE IF EXISTS ${tableName}`,
'public',
{ failOnStatusCode: false },
);
}
// Both tests create a temp table and use the dataset wizard, so they must run serially.
// Uses test.describe only because Playwright's serial mode API requires it -
// (Deviation from "avoid describe" guideline is necessary for functional reasons)
test.describe('create dataset wizard', () => {
test.describe.configure({ mode: 'serial' });
test('should create a dataset via wizard', async ({ page, testAssets }) => {
const { tableName, dbId, createDatasetPage } = await setupExamplesDataset(
page,
testAssets,
test.info(),
);
// Set up response intercept to capture new dataset ID
const createResponsePromise = waitForPost(page, ENDPOINTS.DATASET, {
pathMatch: true,
});
// Click "Create and explore dataset" button
await createDatasetPage.clickCreateAndExploreDataset();
// Wait for dataset creation and capture ID for cleanup
const createResponse = expectStatusOneOf(
await createResponsePromise,
[200, 201],
);
const createBody = await createResponse.json();
const newDatasetId = createBody.result?.id ?? createBody.id;
if (newDatasetId) {
testAssets.trackDataset(newDatasetId);
// Try to select the sheet - if not found due to timeout, skip
try {
await createDatasetPage.selectTable(sheetName);
} catch (error) {
// Only skip on TimeoutError (sheet not loaded); re-throw everything else
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
throw error;
}
await test.info().attach('skip-reason', {
body: `Table "${sheetName}" not found in dropdown after timeout.`,
contentType: 'text/plain',
});
test.skip(); // throws, no return needed
}
// Verify we navigated to Chart Creation page with dataset pre-selected
await page.waitForURL(/.*\/chart\/add.*/);
const chartCreationPage = new ChartCreationPage(page);
await chartCreationPage.waitForPageLoad();
return { sheetName, dbName, createDatasetPage };
}
// Verify the dataset is pre-selected
await chartCreationPage.expectDatasetSelected(tableName);
// Select a visualization type and create chart
await chartCreationPage.selectVizType('Table');
// Click "Create new chart" to go to Explore
await chartCreationPage.clickCreateNewChart();
// Verify we navigated to Explore page
await page.waitForURL(/.*\/explore\/.*/);
const explorePage = new ExplorePage(page);
await explorePage.waitForPageLoad();
// Verify the dataset name is shown in Explore
const loadedDatasetName = await explorePage.getDatasetName();
expect(loadedDatasetName).toContain(tableName);
// Clean up temp table (dataset cleanup handled by testAssets)
await dropTempTable(page, dbId, tableName);
});
test('should create a dataset without exploring', async ({
test('should create a dataset via wizard', async ({ page, testAssets }) => {
const { sheetName, createDatasetPage } = await setupGsheetsDataset(
page,
testAssets,
}) => {
const { tableName, dbId, createDatasetPage } = await setupExamplesDataset(
page,
testAssets,
test.info(),
);
test.info(),
);
// Set up response intercept to capture dataset ID
const createResponsePromise = waitForPost(page, ENDPOINTS.DATASET, {
pathMatch: true,
});
// Click "Create dataset" (not explore)
await createDatasetPage.clickCreateDataset();
// Capture dataset ID from response for cleanup
const createResponse = expectStatusOneOf(
await createResponsePromise,
[200, 201],
);
const createBody = await createResponse.json();
const datasetId = createBody.result?.id ?? createBody.id;
if (datasetId) {
testAssets.trackDataset(datasetId);
}
// Verify redirect to dataset list (not chart creation)
// Note: "Create dataset" action does not show a toast
await page.waitForURL(/.*tablemodelview\/list.*/);
// Wait for table load, verify row visible
const datasetListPage = new DatasetListPage(page);
await datasetListPage.waitForTableLoad();
await expect(datasetListPage.getDatasetRow(tableName)).toBeVisible();
// Clean up temp table (dataset cleanup handled by testAssets)
await dropTempTable(page, dbId, tableName);
// Set up response intercept to capture new dataset ID
const createResponsePromise = waitForPost(page, ENDPOINTS.DATASET, {
pathMatch: true,
});
// Click "Create and explore dataset" button
await createDatasetPage.clickCreateAndExploreDataset();
// Wait for dataset creation and capture ID for cleanup
const createResponse = expectStatusOneOf(
await createResponsePromise,
[200, 201],
);
const createBody = await createResponse.json();
const newDatasetId = createBody.result?.id ?? createBody.id;
if (newDatasetId) {
testAssets.trackDataset(newDatasetId);
}
// Verify we navigated to Chart Creation page with dataset pre-selected
await page.waitForURL(/.*\/chart\/add.*/);
const chartCreationPage = new ChartCreationPage(page);
await chartCreationPage.waitForPageLoad();
// Verify the dataset is pre-selected
await chartCreationPage.expectDatasetSelected(sheetName);
// Select a visualization type and create chart
await chartCreationPage.selectVizType('Table');
// Click "Create new chart" to go to Explore
await chartCreationPage.clickCreateNewChart();
// Verify we navigated to Explore page
await page.waitForURL(/.*\/explore\/.*/);
const explorePage = new ExplorePage(page);
await explorePage.waitForPageLoad();
// Verify the dataset name is shown in Explore
const loadedDatasetName = await explorePage.getDatasetName();
expect(loadedDatasetName).toContain(sheetName);
});
test('should create a dataset without exploring', async ({
page,
testAssets,
}) => {
const { sheetName, createDatasetPage } = await setupGsheetsDataset(
page,
testAssets,
test.info(),
);
// Set up response intercept to capture dataset ID
const createResponsePromise = waitForPost(page, ENDPOINTS.DATASET, {
pathMatch: true,
});
// Click "Create dataset" (not explore)
await createDatasetPage.clickCreateDataset();
// Capture dataset ID from response for cleanup
const createResponse = expectStatusOneOf(
await createResponsePromise,
[200, 201],
);
const createBody = await createResponse.json();
const datasetId = createBody.result?.id ?? createBody.id;
if (datasetId) {
testAssets.trackDataset(datasetId);
}
// Verify redirect to dataset list (not chart creation)
// Note: "Create dataset" action does not show a toast
await page.waitForURL(/.*tablemodelview\/list.*/);
// Wait for table load, verify row visible
const datasetListPage = new DatasetListPage(page);
await datasetListPage.waitForTableLoad();
await expect(datasetListPage.getDatasetRow(sheetName)).toBeVisible();
});

View File

@@ -18,6 +18,7 @@
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import path from 'path';
import { DatasetListPage } from '../../pages/DatasetListPage';
import { ExplorePage } from '../../pages/ExplorePage';
import {
@@ -30,7 +31,6 @@ import {
import { Toast } from '../../components/core';
import {
apiDeleteDataset,
apiExportDatasets,
apiGetDataset,
apiPostVirtualDataset,
getDatasetByName,
@@ -43,7 +43,6 @@ import {
waitForPut,
} from '../../helpers/api/intercepts';
import {
expectDeleted,
expectStatusOneOf,
expectValidExportZip,
} from '../../helpers/api/assertions';
@@ -136,9 +135,17 @@ test('should delete a dataset with confirmation', async ({
await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
// Verify via API that dataset no longer exists (404)
await expectDeleted(page, ENDPOINTS.DATASET, datasetId, {
label: `Dataset ${datasetId}`,
});
await expect
.poll(
async () => {
const response = await apiGetDataset(page, datasetId, {
failOnStatusCode: false,
});
return response.status();
},
{ timeout: 10000, message: `Dataset ${datasetId} should return 404` },
)
.toBe(404);
});
test('should duplicate a dataset with new name', async ({
@@ -413,17 +420,34 @@ test('should bulk delete multiple datasets', async ({
await expect(datasetListPage.getDatasetRow(dataset2.name)).not.toBeVisible();
// Verify via API that datasets no longer exist (404)
await expectDeleted(page, ENDPOINTS.DATASET, dataset1.id, {
label: `Dataset ${dataset1.id}`,
});
await expectDeleted(page, ENDPOINTS.DATASET, dataset2.id, {
label: `Dataset ${dataset2.id}`,
});
// Use polling with explicit timeout since deletes may be async
await expect
.poll(
async () => {
const response = await apiGetDataset(page, dataset1.id, {
failOnStatusCode: false,
});
return response.status();
},
{ timeout: 10000, message: `Dataset ${dataset1.id} should return 404` },
)
.toBe(404);
await expect
.poll(
async () => {
const response = await apiGetDataset(page, dataset2.id, {
failOnStatusCode: false,
});
return response.status();
},
{ timeout: 10000, message: `Dataset ${dataset2.id} should return 404` },
)
.toBe(404);
});
// Import test uses export-then-reimport approach (no static fixture needed).
// Import test uses a fixed dataset name from the zip fixture.
// Uses test.describe only because Playwright's serial mode API requires it -
// this prevents race conditions when parallel workers import the same dataset.
// this prevents race conditions when parallel workers import the same fixture.
// (Deviation from "avoid describe" guideline is necessary for functional reasons)
test.describe('import dataset', () => {
test.describe.configure({ mode: 'serial' });
@@ -432,33 +456,22 @@ test.describe('import dataset', () => {
datasetListPage,
testAssets,
}) => {
test.setTimeout(60_000);
// Create a dataset, export it via API, then delete it, then reimport via UI
const { id: datasetId, name: datasetName } = await createTestDataset(
page,
testAssets,
test.info(),
{ prefix: 'test_import' },
// Dataset name from fixture (test_netflix_1768502050965)
// Note: Fixture contains a Google Sheets dataset backed by shillelagh[gsheetsapi],
// which is a base dependency — import failure fails the test hard (no skip).
const importedDatasetName = 'test_netflix_1768502050965';
const fixturePath = path.resolve(
__dirname,
'../../fixtures/dataset_export.zip',
);
// Export the dataset via API to get a zip buffer
const exportResponse = await apiExportDatasets(page, [datasetId]);
expect(exportResponse.ok()).toBe(true);
const exportBuffer = await exportResponse.body();
// Delete the dataset so reimport creates it fresh
await apiDeleteDataset(page, datasetId);
// Verify it's gone
await expectDeleted(page, ENDPOINTS.DATASET, datasetId, {
label: `Dataset ${datasetId}`,
});
// Refresh to confirm dataset is no longer in the list
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
// Cleanup: Delete any existing dataset with the same name from previous runs
const existingDataset = await getDatasetByName(page, importedDatasetName);
if (existingDataset) {
await apiDeleteDataset(page, existingDataset.id, {
failOnStatusCode: false,
});
}
// Click the import button
await datasetListPage.clickImportButton();
@@ -467,10 +480,11 @@ test.describe('import dataset', () => {
const importModal = new ImportDatasetModal(page);
await importModal.waitForReady();
// Upload the exported zip via buffer (no temp file needed)
await importModal.uploadFileBuffer(exportBuffer);
// Upload the fixture zip file
await importModal.uploadFile(fixturePath);
// Set up response intercept to catch the import POST
// Use pathMatch to avoid false matches if URL lacks trailing slash
let importResponsePromise = waitForPost(page, ENDPOINTS.DATASET_IMPORT, {
pathMatch: true,
});
@@ -482,27 +496,35 @@ test.describe('import dataset', () => {
let importResponse = await importResponsePromise;
// Handle overwrite confirmation if dataset already exists
// First response may be 409/422 indicating overwrite is required
// First response may be 409/422 indicating overwrite is required - this is expected
const overwriteInput = importModal.getOverwriteInput();
await overwriteInput
.waitFor({ state: 'visible', timeout: 3000 })
.catch(error => {
// Only ignore TimeoutError (input not visible); re-throw other errors
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
throw error;
}
});
if (await overwriteInput.isVisible()) {
// Set up new intercept for the actual import after overwrite confirmation
importResponsePromise = waitForPost(page, ENDPOINTS.DATASET_IMPORT, {
pathMatch: true,
});
await importModal.fillOverwriteConfirmation();
await importModal.clickImport();
// Wait for the second (final) import response
importResponse = await importResponsePromise;
}
// Verify import succeeded
expectStatusOneOf(importResponse, [200]);
// Fail hard if dataset import fails.
// The fixture contains a gsheets dataset; shillelagh[gsheetsapi] is a base
// dependency (pyproject.toml), so the engine is always available in CI.
if (!importResponse.ok()) {
const errorBody = await importResponse.json().catch(() => ({}));
throw new Error(`Import failed: ${JSON.stringify(errorBody)}`);
}
// Modal should close on success
await importModal.waitForHidden({ timeout: TIMEOUT.FILE_IMPORT });
@@ -511,19 +533,19 @@ test.describe('import dataset', () => {
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
// Refresh to see the imported dataset
// Refresh the page to see the imported dataset
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify dataset appears in list
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
await expect(
datasetListPage.getDatasetRow(importedDatasetName),
).toBeVisible();
// Track for cleanup: the dataset import API returns {"message": "OK"}
// with no ID, so look up the reimported dataset by name.
const reimported = await getDatasetByName(page, datasetName);
if (reimported) {
testAssets.trackDataset(reimported.id);
}
// Get dataset ID for cleanup
const importedDataset = await getDatasetByName(page, importedDatasetName);
expect(importedDataset).not.toBeNull();
testAssets.trackDataset(importedDataset!.id);
});
});

View File

@@ -33,7 +33,7 @@
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"react": "^18.2.0"
"react": "^17.0.2"
},
"publishConfig": {
"access": "public"

View File

@@ -34,6 +34,6 @@
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.2.0"
"react": "^17.0.2"
}
}

View File

@@ -29,27 +29,27 @@
{ "type": "Feature", "properties": { "ISO": "DZ-46", "NAME_1": "Aïn Témouchent" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -1.361975709870848, 35.319898656042554 ], [ -1.267730272999927, 35.390326239000046 ], [ -1.183338995999918, 35.57648346600007 ], [ -1.106353318999936, 35.623236395000049 ], [ -1.006728888227599, 35.524912014078154 ], [ -1.06920569458714, 35.454451199263417 ], [ -1.059025438273352, 35.411533922552451 ], [ -1.017787644584132, 35.455872300766032 ], [ -0.954122279819387, 35.437320462369428 ], [ -0.715428840368702, 35.482485662882993 ], [ -0.629490933259945, 35.409621893735391 ], [ -0.668868374076226, 35.332003892765442 ], [ -0.880845098622387, 35.254308377429652 ], [ -0.890456915953905, 35.159895535450573 ], [ -1.000527715581597, 35.087393499710231 ], [ -1.271622280443921, 35.195655626006328 ], [ -1.337664761119868, 35.190823879818197 ], [ -1.320663215435047, 35.283789780973564 ], [ -1.361975709870848, 35.319898656042554 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-19", "NAME_1": "Sétif" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 4.838444044756841, 36.304967760054069 ], [ 4.789919874602504, 36.333854885294727 ], [ 4.801081983746542, 36.379743557119468 ], [ 4.922573275835987, 36.38940705039505 ], [ 4.986807082281018, 36.462425849173599 ], [ 4.949858433320003, 36.502991848395084 ], [ 5.045304803174133, 36.524179186020604 ], [ 5.076517368381815, 36.574305324830846 ], [ 5.187621697683198, 36.53448863444288 ], [ 5.165555860914253, 36.494155178318806 ], [ 5.187776727314088, 36.408423976785059 ], [ 5.255421176645996, 36.378865057976043 ], [ 5.303221877287797, 36.389303696708225 ], [ 5.327871534643577, 36.449300035268379 ], [ 5.457889439346161, 36.499917101393066 ], [ 5.475459424912515, 36.590635076947194 ], [ 5.542793816781227, 36.523972480445593 ], [ 5.737200554861715, 36.555133367910571 ], [ 5.776164585427239, 36.439998277198697 ], [ 5.872386101637289, 36.401886909153518 ], [ 5.866029901158981, 36.255410061125417 ], [ 5.933519320859943, 36.224972643172919 ], [ 5.89936119985947, 36.16967886049099 ], [ 5.983335402207047, 36.058471178402158 ], [ 5.976669141567641, 35.915792547787817 ], [ 5.94018558060003, 35.862126573082833 ], [ 5.822880079552021, 35.914733181491101 ], [ 5.745623813787972, 35.89021271444517 ], [ 5.711620720619749, 35.849672552746085 ], [ 5.761901889960257, 35.810915229554155 ], [ 5.736425409405115, 35.771046861423486 ], [ 5.575091586707345, 35.818279120385057 ], [ 5.476854688892786, 35.736087754746052 ], [ 5.423472935027917, 35.635654609072901 ], [ 5.226223993042822, 35.664024970376033 ], [ 5.065768670387797, 35.762081000138039 ], [ 5.054916618706955, 35.820862941871269 ], [ 5.139976026672286, 35.856623033326287 ], [ 5.184056024266113, 36.115366929739992 ], [ 5.25356082467232, 36.197377428225707 ], [ 5.212426384669925, 36.196653957813908 ], [ 5.188086785676603, 36.245514023853104 ], [ 5.125558301574358, 36.232362372425541 ], [ 5.034762810755126, 36.310884710960636 ], [ 4.838444044756841, 36.304967760054069 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-34", "NAME_1": "Bordj Bou Arréridj" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 4.801081983746542, 36.379743557119468 ], [ 4.785217318724278, 36.346644802415653 ], [ 4.821235792597747, 36.306853950449408 ], [ 5.034762810755126, 36.310884710960636 ], [ 5.125558301574358, 36.232362372425541 ], [ 5.188086785676603, 36.245514023853104 ], [ 5.212426384669925, 36.196653957813908 ], [ 5.25356082467232, 36.197377428225707 ], [ 5.184056024266113, 36.115366929739992 ], [ 5.139976026672286, 35.856623033326287 ], [ 5.066543816743717, 35.840474148363228 ], [ 5.05445153161287, 35.777325548435272 ], [ 4.854928827403455, 35.873159491917022 ], [ 4.620782912401467, 35.823601792988313 ], [ 4.466683791124069, 35.832283434333021 ], [ 4.449268833389965, 35.869697171287385 ], [ 4.540839470565118, 35.942147529285023 ], [ 4.494123976440335, 36.02844717070036 ], [ 4.379143913560767, 35.991085109690061 ], [ 4.214812860025461, 36.016613267987907 ], [ 4.136316359012767, 35.995632635937397 ], [ 4.084329868228849, 36.029093125847055 ], [ 4.356354608178606, 36.340443631568348 ], [ 4.423378939886163, 36.22616119977954 ], [ 4.511383905442869, 36.225747789528839 ], [ 4.630084669571829, 36.253343004476051 ], [ 4.635820754224426, 36.338454088385447 ], [ 4.748010288244188, 36.420748805912638 ], [ 4.801081983746542, 36.379743557119468 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-10", "NAME_1": "Bouira" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 4.398729281631006, 36.373697415003676 ], [ 4.093631626298532, 36.046068834009475 ], [ 4.035133905405416, 35.870808214427541 ], [ 3.954156934794696, 35.890755316804416 ], [ 3.850804070851154, 35.861971544351263 ], [ 3.735513949609071, 35.929977728889071 ], [ 3.628440382617555, 35.922794705211459 ], [ 3.578934359632967, 35.999353338985372 ], [ 3.551752556735096, 36.298301500313983 ], [ 3.583120150674404, 36.338660793960457 ], [ 3.555524936626512, 36.422945055569812 ], [ 3.500127801157078, 36.438964749323702 ], [ 3.422664828918698, 36.407855536004149 ], [ 3.283035108683293, 36.49017609285238 ], [ 3.321792432774544, 36.550379136987544 ], [ 3.456357862825087, 36.576837470372936 ], [ 3.510049675951791, 36.623165391769419 ], [ 3.477441846763782, 36.688561917298614 ], [ 3.635726759082672, 36.694401352940076 ], [ 3.680736931763988, 36.666806138892184 ], [ 3.641927930829354, 36.600401923010338 ], [ 3.733653599434035, 36.607610785109614 ], [ 3.888786247687165, 36.488522447353034 ], [ 4.37092736200816, 36.47265778323009 ], [ 4.398729281631006, 36.373697415003676 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-10", "NAME_1": "Bouira" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 4.356354608178606, 36.340443631568348 ], [ 4.093631626298532, 36.046068834009475 ], [ 4.035133905405416, 35.870808214427541 ], [ 3.954156934794696, 35.890755316804416 ], [ 3.850804070851154, 35.861971544351263 ], [ 3.735513949609071, 35.929977728889071 ], [ 3.628440382617555, 35.922794705211459 ], [ 3.578934359632967, 35.999353338985372 ], [ 3.551752556735096, 36.298301500313983 ], [ 3.583120150674404, 36.338660793960457 ], [ 3.555524936626512, 36.422945055569812 ], [ 3.500127801157078, 36.438964749323702 ], [ 3.422664828918698, 36.407855536004149 ], [ 3.283035108683293, 36.49017609285238 ], [ 3.321792432774544, 36.550379136987544 ], [ 3.456357862825087, 36.576837470372936 ], [ 3.510049675951791, 36.623165391769419 ], [ 3.477441846763782, 36.688561917298614 ], [ 3.635726759082672, 36.694401352940076 ], [ 3.680736931763988, 36.666806138892184 ], [ 3.641927930829354, 36.600401923010338 ], [ 3.733653599434035, 36.607610785109614 ], [ 3.888786247687165, 36.488522447353034 ], [ 4.37092736200816, 36.47265778323009 ], [ 4.398729281631006, 36.373697415003676 ], [ 4.356354608178606, 36.340443631568348 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-09", "NAME_1": "Blida" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 3.477441846763782, 36.688561917298614 ], [ 3.510049675951791, 36.623165391769419 ], [ 3.467364943237556, 36.583167833328844 ], [ 3.321792432774544, 36.550379136987544 ], [ 3.283035108683293, 36.49017609285238 ], [ 3.203246698276473, 36.474905707032747 ], [ 3.008064812940745, 36.356437486001141 ], [ 2.909827915126186, 36.415245266156091 ], [ 2.824458448798339, 36.365170803289914 ], [ 2.661109246294643, 36.363103745741228 ], [ 2.64338423019808, 36.397261868540397 ], [ 2.52251305483361, 36.363982244884653 ], [ 2.476262647802969, 36.420619614703412 ], [ 2.499723747652808, 36.470384020106394 ], [ 2.602456495770639, 36.467438463414339 ], [ 2.837377557128548, 36.637867336808142 ], [ 3.02992394413468, 36.662232774223128 ], [ 3.025118036368269, 36.71005931238733 ], [ 3.097413363835642, 36.649933784416589 ], [ 3.323652784748219, 36.712307237089306 ], [ 3.477441846763782, 36.688561917298614 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-38", "NAME_1": "Tissemsilt" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 1.638484328189179, 35.934292711139676 ], [ 1.853561639058398, 35.860498766005207 ], [ 1.964510938728836, 35.879825750757789 ], [ 2.008745965054231, 35.917911282179887 ], [ 2.171681756407907, 35.917601222918108 ], [ 2.311776563737396, 35.86695832017034 ], [ 2.241961704069354, 35.744123440044689 ], [ 2.158194207296845, 35.698234768219947 ], [ 2.261030308202123, 35.605578925427039 ], [ 2.249713169427196, 35.559612739236513 ], [ 1.744937777556402, 35.551938788244456 ], [ 1.692021111684937, 35.580490016700878 ], [ 1.658069696259474, 35.547933865255629 ], [ 1.559057651189619, 35.606095689364565 ], [ 1.492343376945257, 35.594881904276463 ], [ 1.469192336357253, 35.648031114144601 ], [ 1.346460809019106, 35.68981151019301 ], [ 1.292458936630624, 35.617645372136167 ], [ 1.245588412874952, 35.647333482154465 ], [ 1.275715773364254, 35.775491033983997 ], [ 1.37720828623344, 35.788048408007626 ], [ 1.447953321888292, 35.914268093497697 ], [ 1.504900750069567, 35.935222887126542 ], [ 1.548360630039042, 36.003952542076149 ], [ 1.638484328189179, 35.934292711139676 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-38", "NAME_1": "Tissemsilt" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 1.712691685372988, 35.92341482193649 ], [ 1.853561639058398, 35.860498766005207 ], [ 1.964510938728836, 35.879825750757789 ], [ 2.008745965054231, 35.917911282179887 ], [ 2.171681756407907, 35.917601222918108 ], [ 2.311776563737396, 35.86695832017034 ], [ 2.241961704069354, 35.744123440044689 ], [ 2.158194207296845, 35.698234768219947 ], [ 2.261030308202123, 35.605578925427039 ], [ 2.249713169427196, 35.559612739236513 ], [ 1.744937777556402, 35.551938788244456 ], [ 1.692021111684937, 35.580490016700878 ], [ 1.658069696259474, 35.547933865255629 ], [ 1.559057651189619, 35.606095689364565 ], [ 1.492343376945257, 35.594881904276463 ], [ 1.469192336357253, 35.648031114144601 ], [ 1.346460809019106, 35.68981151019301 ], [ 1.292458936630624, 35.617645372136167 ], [ 1.245588412874952, 35.647333482154465 ], [ 1.275715773364254, 35.775491033983997 ], [ 1.37720828623344, 35.788048408007626 ], [ 1.447953321888292, 35.914268093497697 ], [ 1.504900750069567, 35.935222887126542 ], [ 1.548360630039042, 36.003952542076149 ], [ 1.638484328189179, 35.934292711139676 ], [ 1.712691685372988, 35.92341482193649 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-44", "NAME_1": "Aïn Defla" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 1.853561639058398, 35.860498766005207 ], [ 1.712691685372988, 35.92341482193649 ], [ 1.717187533877564, 35.973205063962496 ], [ 1.588513218110506, 36.143298041471439 ], [ 1.628717482126092, 36.2114850940618 ], [ 1.594921095432198, 36.234377753130786 ], [ 1.603292676615695, 36.279077867449587 ], [ 1.553166537805453, 36.367677110410284 ], [ 1.647786086258861, 36.443150540365139 ], [ 1.716722445884159, 36.36666942005769 ], [ 1.793048536560718, 36.43198843032178 ], [ 2.476262647802969, 36.420619614703412 ], [ 2.52251305483361, 36.363982244884653 ], [ 2.652685988267706, 36.39209422556803 ], [ 2.647880079601975, 36.33251129815784 ], [ 2.526853874606616, 36.300006821757393 ], [ 2.486494581859461, 36.241974188857682 ], [ 2.531659784171666, 36.189574286024481 ], [ 2.577445103208959, 36.211795152424315 ], [ 2.639043410425018, 36.181564439147508 ], [ 2.571553988925416, 36.099088853567707 ], [ 2.459364454905653, 36.036999619936466 ], [ 2.311776563737396, 35.86695832017034 ], [ 2.171681756407907, 35.917601222918108 ], [ 2.008745965054231, 35.917911282179887 ], [ 1.964510938728836, 35.879825750757789 ], [ 1.853561639058398, 35.860498766005207 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-48", "NAME_1": "Relizane" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 1.411624789652308, 35.841559353081664 ], [ 1.361808709204581, 35.776524562758368 ], [ 1.275715773364254, 35.775491033983997 ], [ 1.245588412874952, 35.647333482154465 ], [ 1.010977409879558, 35.575038153787773 ], [ 1.01795372888148, 35.544238999730055 ], [ 0.959456007988365, 35.54775299720302 ], [ 0.866076693884281, 35.436080227120783 ], [ 0.532298617986839, 35.5099775250427 ], [ 0.456851027353025, 35.558139959991138 ], [ 0.319081659091296, 35.557778224785238 ], [ 0.229371372091123, 35.695935167573907 ], [ 0.423984815746621, 35.817633165238362 ], [ 0.451735060324779, 35.951966051292231 ], [ 0.529043002932212, 35.968063259411849 ], [ 0.542840610405847, 36.048962713858145 ], [ 0.704071079416792, 36.186525377444184 ], [ 0.897392611879525, 36.197635809744781 ], [ 0.948138869213437, 36.032012844117446 ], [ 1.040949740737915, 36.025449937164865 ], [ 1.159340448303055, 35.94597158512056 ], [ 1.288893263213538, 35.965867011553314 ], [ 1.356227655082193, 35.868146878575544 ], [ 1.411624789652308, 35.841559353081664 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-32", "NAME_1": "El Bayadh" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 0.340785759755022, 34.234138088701798 ], [ 0.729495884027813, 34.435469468940823 ], [ 0.973718702556084, 34.382087714176635 ], [ 1.031958041930068, 34.339351305518335 ], [ 1.023689812634757, 34.191246650412552 ], [ 1.231480747038859, 34.191892605559303 ], [ 1.409144320953658, 33.961803290389526 ], [ 1.793978713446847, 33.816411647979123 ], [ 2.019287956573919, 33.499040839463134 ], [ 2.031380242604087, 33.392535712353208 ], [ 1.975207960778732, 33.292180081045899 ], [ 2.054221225728952, 33.108909613687729 ], [ 2.174937370563214, 32.97499013878388 ], [ 2.291467726154622, 32.695989080731465 ], [ 2.297513869169734, 32.377300523500367 ], [ 2.344849480918811, 32.172791043471875 ], [ 2.315652297315637, 32.062177638786977 ], [ 2.215296665109008, 31.923297227385092 ], [ 2.24211673280098, 31.791186428510855 ], [ 2.199432000086745, 31.735246689782912 ], [ 2.085382114093306, 31.658584703221436 ], [ 0.842460565302815, 31.096939399333792 ], [ 0.399128451916511, 30.708694363054406 ], [ -0.154016078679092, 31.123785305447484 ], [ -0.400822719592838, 31.390668239947274 ], [ -0.003585985404925, 32.443963120901287 ], [ -0.012887742575288, 32.500006212416736 ], [ -0.083942836592655, 32.524397488253442 ], [ -0.10156449990177, 32.567108059389398 ], [ -0.006686570828606, 32.804767970965088 ], [ -0.027047085254765, 33.02062042908949 ], [ 0.063490024045336, 33.308096422012227 ], [ 0.050622592558568, 33.803104966920671 ], [ 0.086020948807743, 34.048309638279136 ], [ 0.235417515106235, 34.225869859406487 ], [ 0.340785759755022, 34.234138088701798 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-20", "NAME_1": "Saïda" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 0.856878288602104, 34.499858303218105 ], [ 0.351482781804918, 34.238117174168224 ], [ 0.235417515106235, 34.225869859406487 ], [ 0.086020948807743, 34.048309638279136 ], [ -0.044100307782969, 33.987253933422267 ], [ -0.081772427155784, 34.08153758419212 ], [ -0.220213588985928, 34.059445909001454 ], [ -0.346614141629345, 33.955214545015224 ], [ -0.356122606173358, 33.915036119421359 ], [ -0.499679735031748, 34.193158678330292 ], [ -0.060895147892779, 34.550087795715172 ], [ -0.056502651276332, 34.609050605501011 ], [ -0.114380256343793, 34.682431139485459 ], [ -0.36805986167326, 34.869654852989072 ], [ -0.272716843707371, 34.904329738826391 ], [ -0.299278529880269, 34.98233531342396 ], [ -0.236284958683882, 35.023805650210591 ], [ -0.24687862794633, 35.098891506537768 ], [ -0.135567593069993, 35.12764944146852 ], [ -0.111693081170756, 35.160593167440709 ], [ -0.091022508382082, 35.107909043767336 ], [ -0.014334683398943, 35.06385488549455 ], [ 0.112220899774684, 35.096100979476603 ], [ 0.189993931274898, 35.023495591848075 ], [ 0.625057813567253, 35.095920112323313 ], [ 0.754197218227034, 35.007837633300142 ], [ 0.783032668422891, 34.948151353102446 ], [ 0.669757927886053, 34.855495510309538 ], [ 0.673478630934085, 34.789375515267807 ], [ 0.856878288602104, 34.499858303218105 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-20", "NAME_1": "Saïda" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 0.75760786291255, 34.438182480736884 ], [ 0.351482781804918, 34.238117174168224 ], [ 0.235417515106235, 34.225869859406487 ], [ 0.086020948807743, 34.048309638279136 ], [ -0.044100307782969, 33.987253933422267 ], [ -0.081772427155784, 34.08153758419212 ], [ -0.220213588985928, 34.059445909001454 ], [ -0.346614141629345, 33.955214545015224 ], [ -0.356122606173358, 33.915036119421359 ], [ -0.499679735031748, 34.193158678330292 ], [ -0.060895147892779, 34.550087795715172 ], [ -0.056502651276332, 34.609050605501011 ], [ -0.114380256343793, 34.682431139485459 ], [ -0.36805986167326, 34.869654852989072 ], [ -0.272716843707371, 34.904329738826391 ], [ -0.299278529880269, 34.98233531342396 ], [ -0.236284958683882, 35.023805650210591 ], [ -0.24687862794633, 35.098891506537768 ], [ -0.135567593069993, 35.12764944146852 ], [ -0.111693081170756, 35.160593167440709 ], [ -0.091022508382082, 35.107909043767336 ], [ -0.014334683398943, 35.06385488549455 ], [ 0.112220899774684, 35.096100979476603 ], [ 0.189993931274898, 35.023495591848075 ], [ 0.625057813567253, 35.095920112323313 ], [ 0.754197218227034, 35.007837633300142 ], [ 0.783032668422891, 34.948151353102446 ], [ 0.669757927886053, 34.855495510309538 ], [ 0.673478630934085, 34.789375515267807 ], [ 0.856878288602104, 34.499858303218105 ], [ 0.75760786291255, 34.438182480736884 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-22", "NAME_1": "Sidi Bel Abbès" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -0.16517818782313, 35.122921047168575 ], [ -0.250702683781867, 35.093568833934512 ], [ -0.236284958683882, 35.023805650210591 ], [ -0.299278529880269, 34.98233531342396 ], [ -0.272716843707371, 34.904329738826391 ], [ -0.36805986167326, 34.869654852989072 ], [ -0.114380256343793, 34.682431139485459 ], [ -0.055004035407933, 34.60403799126027 ], [ -0.071902228305191, 34.529701442867292 ], [ -0.485727097927224, 34.229332180036067 ], [ -0.523140834881644, 34.290362047370593 ], [ -0.578176235145122, 34.312944648077007 ], [ -0.836196662046348, 34.215121161412469 ], [ -1.120417039015138, 34.271293443237766 ], [ -0.880018276322403, 34.467612210135371 ], [ -0.903789435434135, 34.547193914967181 ], [ -0.829582078250326, 34.578380641753199 ], [ -0.758475308288837, 34.711912543029428 ], [ -0.775631882705227, 34.737440701327273 ], [ -0.886116095281579, 34.74302175634898 ], [ -0.89056026874141, 34.843609930753701 ], [ -0.941358201120067, 34.842576401979329 ], [ -0.924149949860293, 34.881514594123189 ], [ -0.96611121306205, 34.922726549390745 ], [ -0.929834356770186, 35.047163398172245 ], [ -1.000527715581597, 35.087393499710231 ], [ -0.890456915953905, 35.159895535450573 ], [ -0.880845098622387, 35.254308377429652 ], [ -0.753669398723787, 35.318257962135249 ], [ -0.668868374076226, 35.332003892765442 ], [ -0.632281460321167, 35.298155829228108 ], [ -0.569184536337275, 35.405229397118944 ], [ -0.464436408413519, 35.429388128958976 ], [ -0.463661261158279, 35.358048814101494 ], [ -0.361807013083194, 35.358772284513293 ], [ -0.189207729353257, 35.299318549211648 ], [ -0.216957973931358, 35.190927231706382 ], [ -0.16517818782313, 35.122921047168575 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-29", "NAME_1": "Mascara" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 0.229371372091123, 35.695935167573907 ], [ 0.319081659091296, 35.557778224785238 ], [ 0.456851027353025, 35.558139959991138 ], [ 0.532298617986839, 35.5099775250427 ], [ 0.827887810573998, 35.443469957272669 ], [ 0.878168979914506, 35.374740302323062 ], [ 0.778536818119676, 35.279371445935453 ], [ 0.684330681715551, 35.299551093208379 ], [ 0.617771437102135, 35.276038316515098 ], [ 0.448789503632668, 35.059074815250483 ], [ 0.209837680864268, 35.02148021114283 ], [ 0.112220899774684, 35.096100979476603 ], [ -0.014334683398943, 35.06385488549455 ], [ -0.091022508382082, 35.107909043767336 ], [ -0.105956997417479, 35.159740505819684 ], [ -0.16517818782313, 35.122921047168575 ], [ -0.216957973931358, 35.190927231706382 ], [ -0.189207729353257, 35.299318549211648 ], [ -0.361807013083194, 35.358772284513293 ], [ -0.463661261158279, 35.358048814101494 ], [ -0.464436408413519, 35.429388128958976 ], [ -0.545361701281479, 35.411973172124192 ], [ -0.302482468990775, 35.669399318923411 ], [ -0.220575324191884, 35.683558660703625 ], [ -0.114380256343793, 35.783759264178684 ], [ -0.043996954995464, 35.714461168448167 ], [ 0.104469435316162, 35.756732490012382 ], [ 0.152115106327074, 35.682628486515398 ], [ 0.229371372091123, 35.695935167573907 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-47", "NAME_1": "Ghardaïa" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 2.085382114093306, 31.658584703221436 ], [ 2.239326205739815, 31.77353892678002 ], [ 2.215296665109008, 31.923297227385092 ], [ 2.34329918820697, 32.132664292922755 ], [ 2.297513869169734, 32.377300523500367 ], [ 2.291467726154622, 32.695989080731465 ], [ 3.100668979789589, 32.830683701991234 ], [ 3.321327344781139, 32.780790107177666 ], [ 3.427470736685166, 32.87551300751926 ], [ 3.738614535932072, 33.000466620238285 ], [ 3.884445427914159, 32.981863104998297 ], [ 4.176210564665837, 33.029637966319058 ], [ 4.978280469667936, 32.840760606416836 ], [ 4.738863559805452, 32.531890571192889 ], [ 4.359145135239771, 32.255990099463475 ], [ 4.110839877558305, 31.791393134085865 ], [ 3.802538283115268, 29.989849352465626 ], [ 3.721509636560484, 29.863345445236064 ], [ 3.479612257999293, 29.636847642804469 ], [ 3.397550082670193, 29.364874578798776 ], [ 3.34447838716784, 29.306557725958271 ], [ 2.9859989761718, 29.123545641018495 ], [ 2.079335971977514, 29.036367499560413 ], [ 1.943426954790141, 30.094804185964392 ], [ 2.071739536250561, 30.859692898008802 ], [ 2.085382114093306, 31.658584703221436 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-03", "NAME_1": "Laghouat" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 2.291467726154622, 32.695989080731465 ], [ 2.174937370563214, 32.97499013878388 ], [ 2.054221225728952, 33.108909613687729 ], [ 1.975207960778732, 33.292180081045899 ], [ 2.031380242604087, 33.392535712353208 ], [ 2.019287956573919, 33.499040839463134 ], [ 1.776925490019323, 33.831346137014577 ], [ 1.428109572298922, 33.951183782705414 ], [ 1.336228874962615, 34.068928534224426 ], [ 1.64158491361286, 34.256152249526679 ], [ 1.691090935698128, 34.345810858784091 ], [ 1.806226027309322, 34.449086209261111 ], [ 1.981099074162955, 34.506989650951596 ], [ 2.220567660868824, 34.674679674127617 ], [ 2.357716912405579, 34.692869778217641 ], [ 2.295808546826947, 34.439035143257229 ], [ 2.360972528359468, 34.352735500942572 ], [ 2.393735386279047, 34.225301419524897 ], [ 2.48339399643578, 34.113576971699842 ], [ 2.458279250187218, 34.003247788754436 ], [ 2.474867383822698, 33.92371775986669 ], [ 2.639198439156644, 33.911005357111549 ], [ 2.650205518669736, 34.09797068909603 ], [ 2.817637160326683, 34.138846747579294 ], [ 2.949412062416741, 34.263619493145086 ], [ 3.093537632056041, 34.240029202085964 ], [ 3.14924482588799, 34.074767970765208 ], [ 3.232133822617811, 33.947411403713318 ], [ 3.640997755741807, 33.564334011305561 ], [ 4.059060093248718, 33.252518419389503 ], [ 4.176210564665837, 33.029637966319058 ], [ 3.884445427914159, 32.981863104998297 ], [ 3.738614535932072, 33.000466620238285 ], [ 3.427470736685166, 32.87551300751926 ], [ 3.321327344781139, 32.780790107177666 ], [ 3.100668979789589, 32.830683701991234 ], [ 2.291467726154622, 32.695989080731465 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-26", "NAME_1": "Médéa" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 2.285731642401345, 35.439077459756959 ], [ 2.261030308202123, 35.605578925427039 ], [ 2.169511346071772, 35.666815497437256 ], [ 2.16269005670074, 35.708983466213965 ], [ 2.241961704069354, 35.744123440044689 ], [ 2.459364454905653, 36.036999619936466 ], [ 2.571553988925416, 36.099088853567707 ], [ 2.643694288560539, 36.191537990785605 ], [ 2.526388786613211, 36.192778225134987 ], [ 2.486494581859461, 36.241974188857682 ], [ 2.526853874606616, 36.300006821757393 ], [ 2.614083692908082, 36.309670315032974 ], [ 2.661109246294643, 36.363103745741228 ], [ 2.845284051217959, 36.369227403122125 ], [ 2.909827915126186, 36.415245266156091 ], [ 2.996282586172413, 36.353285223734076 ], [ 3.203246698276473, 36.474905707032747 ], [ 3.283035108683293, 36.49017609285238 ], [ 3.422664828918698, 36.407855536004149 ], [ 3.500127801157078, 36.438964749323702 ], [ 3.555524936626512, 36.422945055569812 ], [ 3.583120150674404, 36.338660793960457 ], [ 3.552217644728501, 36.30827505105276 ], [ 3.56436160580347, 36.05097809546271 ], [ 3.633866408008316, 35.889540920876755 ], [ 3.60869998491637, 35.837373562040227 ], [ 3.540900505953573, 35.803938910552233 ], [ 3.543225945920653, 35.672396552458906 ], [ 3.411037631781255, 35.805928452835815 ], [ 3.379515008211058, 35.751203110934796 ], [ 3.336726921809998, 35.747534084730205 ], [ 3.255078159429502, 35.797401842021372 ], [ 3.196425408905498, 35.694462389227851 ], [ 3.081910434918598, 35.67187978852138 ], [ 2.960264113198264, 35.804739895329874 ], [ 2.88424808088422, 35.780503648224737 ], [ 2.893704867685472, 35.620823472825009 ], [ 2.671031121089356, 35.44657054269635 ], [ 2.527008905136825, 35.501192531809863 ], [ 2.285731642401345, 35.439077459756959 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-26", "NAME_1": "Médéa" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 2.249713169427196, 35.559612739236513 ], [ 2.261030308202123, 35.605578925427039 ], [ 2.169511346071772, 35.666815497437256 ], [ 2.16269005670074, 35.708983466213965 ], [ 2.241961704069354, 35.744123440044689 ], [ 2.459364454905653, 36.036999619936466 ], [ 2.571553988925416, 36.099088853567707 ], [ 2.643694288560539, 36.191537990785605 ], [ 2.526388786613211, 36.192778225134987 ], [ 2.486494581859461, 36.241974188857682 ], [ 2.526853874606616, 36.300006821757393 ], [ 2.614083692908082, 36.309670315032974 ], [ 2.661109246294643, 36.363103745741228 ], [ 2.845284051217959, 36.369227403122125 ], [ 2.909827915126186, 36.415245266156091 ], [ 2.996282586172413, 36.353285223734076 ], [ 3.203246698276473, 36.474905707032747 ], [ 3.283035108683293, 36.49017609285238 ], [ 3.422664828918698, 36.407855536004149 ], [ 3.500127801157078, 36.438964749323702 ], [ 3.555524936626512, 36.422945055569812 ], [ 3.583120150674404, 36.338660793960457 ], [ 3.552217644728501, 36.30827505105276 ], [ 3.56436160580347, 36.05097809546271 ], [ 3.633866408008316, 35.889540920876755 ], [ 3.60869998491637, 35.837373562040227 ], [ 3.540900505953573, 35.803938910552233 ], [ 3.543225945920653, 35.672396552458906 ], [ 3.411037631781255, 35.805928452835815 ], [ 3.379515008211058, 35.751203110934796 ], [ 3.336726921809998, 35.747534084730205 ], [ 3.255078159429502, 35.797401842021372 ], [ 3.196425408905498, 35.694462389227851 ], [ 3.081910434918598, 35.67187978852138 ], [ 2.960264113198264, 35.804739895329874 ], [ 2.88424808088422, 35.780503648224737 ], [ 2.893704867685472, 35.620823472825009 ], [ 2.671031121089356, 35.44657054269635 ], [ 2.527008905136825, 35.501192531809863 ], [ 2.285731642401345, 35.439077459756959 ], [ 2.249713169427196, 35.559612739236513 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-14", "NAME_1": "Tiaret" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 1.346460809019106, 35.68981151019301 ], [ 1.469192336357253, 35.648031114144601 ], [ 1.492343376945257, 35.594881904276463 ], [ 1.559057651189619, 35.606095689364565 ], [ 1.634608594610938, 35.5504660098984 ], [ 1.692021111684937, 35.580490016700878 ], [ 1.744937777556402, 35.551938788244456 ], [ 2.249713169427196, 35.559612739236513 ], [ 2.286816847119781, 35.329265042547661 ], [ 2.360042352372659, 35.287975572015 ], [ 2.379317661181119, 35.205009060020075 ], [ 2.620284864654764, 35.031143704418412 ], [ 2.523443230820419, 34.999104316011369 ], [ 2.460294630892463, 34.870171616926541 ], [ 2.369964227167316, 34.776301378206028 ], [ 2.357716912405579, 34.692869778217641 ], [ 2.220567660868824, 34.674679674127617 ], [ 1.981099074162955, 34.506989650951596 ], [ 1.806226027309322, 34.449086209261111 ], [ 1.691090935698128, 34.345810858784091 ], [ 1.64158491361286, 34.256152249526679 ], [ 1.336228874962615, 34.068928534224426 ], [ 1.231480747038859, 34.191892605559303 ], [ 1.023689812634757, 34.191246650412552 ], [ 1.031958041930068, 34.339351305518335 ], [ 0.973718702556084, 34.382087714176635 ], [ 0.75760786291255, 34.438182480736884 ], [ 0.856878288602104, 34.499858303218105 ], [ 0.693529086997728, 34.736329658187174 ], [ 0.667432488818292, 34.84221466677343 ], [ 0.781637404442677, 34.972697659469361 ], [ 0.652549675726959, 35.089796454043096 ], [ 0.492249382702823, 35.086334133413516 ], [ 0.617771437102135, 35.276038316515098 ], [ 0.684330681715551, 35.299551093208379 ], [ 0.778536818119676, 35.279371445935453 ], [ 0.819981317383906, 35.30611400016096 ], [ 0.880494418982266, 35.386367499460505 ], [ 0.827887810573998, 35.443469957272669 ], [ 0.881114535707241, 35.443702501269399 ], [ 0.959456007988365, 35.54775299720302 ], [ 1.01795372888148, 35.544238999730055 ], [ 1.010977409879558, 35.575038153787773 ], [ 1.206210972058727, 35.645679837554439 ], [ 1.292458936630624, 35.617645372136167 ], [ 1.346460809019106, 35.68981151019301 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-28", "NAME_1": "M'Sila" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 4.854928827403455, 35.873159491917022 ], [ 5.226223993042822, 35.664024970376033 ], [ 5.379702995796606, 35.632915757955857 ], [ 5.1737724142655, 35.510339260248657 ], [ 5.022980583986737, 35.520674547092653 ], [ 4.963242628744297, 35.4887901883165 ], [ 4.876477899335555, 35.519150091903214 ], [ 4.888880242828918, 35.299861152470214 ], [ 4.779016146977597, 35.172840481303126 ], [ 5.038638543434047, 35.083311062355619 ], [ 5.041119012132697, 34.918644111136814 ], [ 4.932805209892535, 34.838907375774738 ], [ 4.394646844276451, 34.746587428866746 ], [ 4.184427118017084, 34.508204046879257 ], [ 4.316460401626216, 34.251785590432632 ], [ 4.213727655307025, 34.232200222362337 ], [ 4.047691277630349, 34.354079088079459 ], [ 4.036374138855422, 34.441929023105899 ], [ 3.921084019411978, 34.672664293422372 ], [ 3.914417758772515, 34.758602200531129 ], [ 3.67112511623111, 34.790279852832896 ], [ 3.599243198115062, 34.886320501889656 ], [ 3.571803012798796, 35.005227973392209 ], [ 3.608079868191339, 35.0659736189873 ], [ 3.532838983132535, 35.054449775536682 ], [ 3.47356611498418, 35.157957669111113 ], [ 3.664872266741725, 35.294900214173538 ], [ 3.657585890276607, 35.374146023120431 ], [ 3.695929803217837, 35.472047024150811 ], [ 3.436927524385737, 35.671983141308885 ], [ 3.379515008211058, 35.751203110934796 ], [ 3.394294467615566, 35.793836167704967 ], [ 3.432276646250216, 35.796600857243732 ], [ 3.543225945920653, 35.672396552458906 ], [ 3.536714714912137, 35.792234199049005 ], [ 3.60869998491637, 35.837373562040227 ], [ 3.628440382617555, 35.922794705211459 ], [ 3.735513949609071, 35.929977728889071 ], [ 3.850804070851154, 35.861971544351263 ], [ 3.954156934794696, 35.890755316804416 ], [ 4.022266473019329, 35.866854967382835 ], [ 4.084329868228849, 36.029093125847055 ], [ 4.136316359012767, 35.995632635937397 ], [ 4.214812860025461, 36.016613267987907 ], [ 4.379143913560767, 35.991085109690061 ], [ 4.494123976440335, 36.02844717070036 ], [ 4.540839470565118, 35.942147529285023 ], [ 4.449268833389965, 35.869697171287385 ], [ 4.466683791124069, 35.832283434333021 ], [ 4.620782912401467, 35.823601792988313 ], [ 4.854928827403455, 35.873159491917022 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-28", "NAME_1": "M'Sila" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 5.05445153161287, 35.777325548435272 ], [ 5.226223993042822, 35.664024970376033 ], [ 5.379702995796606, 35.632915757955857 ], [ 5.1737724142655, 35.510339260248657 ], [ 5.022980583986737, 35.520674547092653 ], [ 4.963242628744297, 35.4887901883165 ], [ 4.876477899335555, 35.519150091903214 ], [ 4.888880242828918, 35.299861152470214 ], [ 4.779016146977597, 35.172840481303126 ], [ 5.038638543434047, 35.083311062355619 ], [ 5.041119012132697, 34.918644111136814 ], [ 4.932805209892535, 34.838907375774738 ], [ 4.394646844276451, 34.746587428866746 ], [ 4.184427118017084, 34.508204046879257 ], [ 4.316460401626216, 34.251785590432632 ], [ 4.213727655307025, 34.232200222362337 ], [ 4.047691277630349, 34.354079088079459 ], [ 4.036374138855422, 34.441929023105899 ], [ 3.921084019411978, 34.672664293422372 ], [ 3.914417758772515, 34.758602200531129 ], [ 3.67112511623111, 34.790279852832896 ], [ 3.599243198115062, 34.886320501889656 ], [ 3.571803012798796, 35.005227973392209 ], [ 3.608079868191339, 35.0659736189873 ], [ 3.532838983132535, 35.054449775536682 ], [ 3.47356611498418, 35.157957669111113 ], [ 3.664872266741725, 35.294900214173538 ], [ 3.657585890276607, 35.374146023120431 ], [ 3.695929803217837, 35.472047024150811 ], [ 3.436927524385737, 35.671983141308885 ], [ 3.379515008211058, 35.751203110934796 ], [ 3.394294467615566, 35.793836167704967 ], [ 3.432276646250216, 35.796600857243732 ], [ 3.543225945920653, 35.672396552458906 ], [ 3.536714714912137, 35.792234199049005 ], [ 3.60869998491637, 35.837373562040227 ], [ 3.628440382617555, 35.922794705211459 ], [ 3.735513949609071, 35.929977728889071 ], [ 3.850804070851154, 35.861971544351263 ], [ 3.954156934794696, 35.890755316804416 ], [ 4.022266473019329, 35.866854967382835 ], [ 4.084329868228849, 36.029093125847055 ], [ 4.136316359012767, 35.995632635937397 ], [ 4.214812860025461, 36.016613267987907 ], [ 4.379143913560767, 35.991085109690061 ], [ 4.494123976440335, 36.02844717070036 ], [ 4.540839470565118, 35.942147529285023 ], [ 4.449268833389965, 35.869697171287385 ], [ 4.466683791124069, 35.832283434333021 ], [ 4.620782912401467, 35.823601792988313 ], [ 4.854928827403455, 35.873159491917022 ], [ 5.05445153161287, 35.777325548435272 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-05", "NAME_1": "Batna" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 5.94018558060003, 35.862126573082833 ], [ 5.976669141567641, 35.915792547787817 ], [ 6.275513950108689, 35.866079820127595 ], [ 6.351064894429328, 35.884735012211024 ], [ 6.47694868313522, 35.840396633098123 ], [ 6.487025587560822, 35.781433824211604 ], [ 6.649496290921093, 35.796678372508836 ], [ 6.769127231036862, 35.683920395909524 ], [ 6.781684605060491, 35.562558295029248 ], [ 6.592703892370707, 35.436958727163528 ], [ 6.584022251026056, 35.34332103154037 ], [ 6.628360630138957, 35.316862698154978 ], [ 6.514930860870493, 35.119717108957389 ], [ 6.512140333809327, 35.052382717088676 ], [ 6.598595004855554, 34.900350654259285 ], [ 6.58102501838988, 34.769660955988286 ], [ 6.508729689123868, 34.775500393428388 ], [ 6.439845005442635, 34.838261419728724 ], [ 6.459533726300435, 35.016984360839558 ], [ 6.429303013023627, 35.064785061481359 ], [ 6.279079624425094, 35.049256293243332 ], [ 6.166580031143496, 34.992076321065326 ], [ 6.072218866007859, 35.058583888835358 ], [ 5.993102248270134, 34.989363308369946 ], [ 5.927008090750803, 35.061684475158359 ], [ 5.932279087409938, 35.143565782434905 ], [ 6.005349562132551, 35.226273912011436 ], [ 5.830631544909863, 35.288440660008405 ], [ 5.554886101912075, 35.191392319699787 ], [ 5.610748325374914, 35.126383367798155 ], [ 5.596640658639501, 35.091760158804277 ], [ 5.306012404348962, 35.021428534299389 ], [ 5.184831169722713, 35.031040350731587 ], [ 5.004325391903308, 35.08545563427009 ], [ 4.925053745434013, 35.142196356876354 ], [ 4.782426791663113, 35.166871853553175 ], [ 4.888880242828918, 35.299861152470214 ], [ 4.880198602383587, 35.525402940493279 ], [ 4.963242628744297, 35.4887901883165 ], [ 5.022980583986737, 35.520674547092653 ], [ 5.1737724142655, 35.510339260248657 ], [ 5.438200717589041, 35.641855780819583 ], [ 5.476854688892786, 35.736087754746052 ], [ 5.575091586707345, 35.818279120385057 ], [ 5.736425409405115, 35.771046861423486 ], [ 5.761901889960257, 35.810915229554155 ], [ 5.71379113095594, 35.860421250740103 ], [ 5.80009077327054, 35.913467108720056 ], [ 5.94018558060003, 35.862126573082833 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-40", "NAME_1": "Khenchela" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 6.599680209573989, 35.327818101724006 ], [ 6.592703892370707, 35.436958727163528 ], [ 6.781684605060491, 35.562558295029248 ], [ 6.769127231036862, 35.683920395909524 ], [ 6.82498945539902, 35.623562323942053 ], [ 7.00327314603885, 35.629815172532176 ], [ 7.051228875412278, 35.577182725702187 ], [ 7.141197543931469, 35.617464504982877 ], [ 7.267546420630822, 35.621236883974973 ], [ 7.30335818892928, 35.600695502395524 ], [ 7.315037062010788, 35.535247300922151 ], [ 7.385161980940666, 35.571756700311425 ], [ 7.462263217973145, 35.539123032701752 ], [ 7.544842157239771, 35.447810777045675 ], [ 7.413067254250393, 35.38662588097958 ], [ 7.411516960639233, 35.156097317137437 ], [ 7.222226189586991, 34.94629100202809 ], [ 7.26553103992552, 34.832344468822157 ], [ 7.264445835207141, 34.940632433539974 ], [ 7.30831912632658, 34.977193507973993 ], [ 7.365886672132206, 34.962465725412869 ], [ 7.402835321093164, 34.74604482740682 ], [ 7.274057650739962, 34.540734360802105 ], [ 7.135771519440084, 34.16057668756406 ], [ 6.960588413324615, 34.193933823786892 ], [ 6.714970329916753, 34.326613064341416 ], [ 6.696521844307654, 34.475828761687978 ], [ 6.748456659147507, 34.726097724130625 ], [ 6.714350213191778, 34.788522854546102 ], [ 6.587897982805657, 34.778549302908004 ], [ 6.570483025970873, 34.839010728562243 ], [ 6.598595004855554, 34.900350654259285 ], [ 6.512140333809327, 35.052382717088676 ], [ 6.569707878715633, 35.253455714909308 ], [ 6.628360630138957, 35.316862698154978 ], [ 6.599680209573989, 35.327818101724006 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-24", "NAME_1": "Guelma" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 7.010559523403288, 36.155157783504876 ], [ 7.04564782128989, 36.275770575551576 ], [ 6.93795413577476, 36.432866930364526 ], [ 7.075723504036489, 36.502552598823343 ], [ 7.077118768016703, 36.545263169959298 ], [ 7.123989291772375, 36.585829169180784 ], [ 7.314727003648272, 36.651613268337655 ], [ 7.3672819352131, 36.721376451162257 ], [ 7.456837191683064, 36.693057765803246 ], [ 7.475595736553998, 36.638849188739073 ], [ 7.533783399983918, 36.615388088889176 ], [ 7.697029249700108, 36.657736924819176 ], [ 7.771546665246376, 36.581643378139347 ], [ 7.98786421136424, 36.484388333155039 ], [ 7.871437208560337, 36.432582709524411 ], [ 7.795266148413987, 36.440980130028947 ], [ 7.835005324436167, 36.334862576546641 ], [ 7.813869662754712, 36.304916083210628 ], [ 7.410431755920797, 36.181254380785049 ], [ 7.357515090049333, 36.129758816416199 ], [ 7.223776483198151, 36.144099026249023 ], [ 7.194889357058173, 36.080743719846794 ], [ 7.133601108204573, 36.053148504899582 ], [ 7.066421745966807, 36.047283229937079 ], [ 7.010559523403288, 36.155157783504876 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-25", "NAME_1": "Constantine" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 6.93795413577476, 36.432866930364526 ], [ 7.048283318720166, 36.238408515440597 ], [ 7.010559523403288, 36.155157783504876 ], [ 6.898525018115095, 36.10689199486967 ], [ 6.87992150197573, 36.146424465316784 ], [ 6.772692905353267, 36.192442328350751 ], [ 6.577149285710959, 36.10919159641503 ], [ 6.468370396376656, 36.273806870790452 ], [ 6.317733594829519, 36.36783214004123 ], [ 6.47849897584706, 36.428965359263884 ], [ 6.465579868416171, 36.490641180845785 ], [ 6.504078810089027, 36.577715969516362 ], [ 6.599370151211474, 36.57399526646833 ], [ 6.652441846713828, 36.607895005949729 ], [ 6.777188754757219, 36.556270250371711 ], [ 6.920125766890635, 36.557148749515136 ], [ 6.904106073136745, 36.529036769731135 ], [ 6.845453321713421, 36.5314138865416 ], [ 6.818013136397099, 36.466818345789989 ], [ 6.93795413577476, 36.432866930364526 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-43", "NAME_1": "Mila" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 5.872386101637289, 36.401886909153518 ], [ 5.766242709733262, 36.45260732716639 ], [ 5.737200554861715, 36.555133367910571 ], [ 5.904322137256827, 36.545521552377693 ], [ 6.023229607860117, 36.605931301188605 ], [ 6.215310906872844, 36.582625230070278 ], [ 6.294582554241458, 36.624069729334508 ], [ 6.504078810089027, 36.577715969516362 ], [ 6.465579868416171, 36.490641180845785 ], [ 6.47849897584706, 36.428965359263884 ], [ 6.317733594829519, 36.36783214004123 ], [ 6.468370396376656, 36.273806870790452 ], [ 6.525007765296095, 36.204663804690824 ], [ 6.516326124850764, 36.16564809818118 ], [ 6.406151970636927, 36.11030263955513 ], [ 6.378556755689715, 36.013254299246512 ], [ 6.170300734191528, 35.951242580880375 ], [ 6.149216750252833, 35.900392970758958 ], [ 5.976669141567641, 35.915792547787817 ], [ 5.983335402207047, 36.058471178402158 ], [ 5.89936119985947, 36.16967886049099 ], [ 5.933519320859943, 36.224972643172919 ], [ 5.866029901158981, 36.255410061125417 ], [ 5.872386101637289, 36.401886909153518 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-43", "NAME_1": "Mila" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 5.810167676796823, 36.431135768700756 ], [ 5.766242709733262, 36.45260732716639 ], [ 5.737200554861715, 36.555133367910571 ], [ 5.904322137256827, 36.545521552377693 ], [ 6.023229607860117, 36.605931301188605 ], [ 6.215310906872844, 36.582625230070278 ], [ 6.294582554241458, 36.624069729334508 ], [ 6.504078810089027, 36.577715969516362 ], [ 6.465579868416171, 36.490641180845785 ], [ 6.47849897584706, 36.428965359263884 ], [ 6.317733594829519, 36.36783214004123 ], [ 6.468370396376656, 36.273806870790452 ], [ 6.525007765296095, 36.204663804690824 ], [ 6.516326124850764, 36.16564809818118 ], [ 6.406151970636927, 36.11030263955513 ], [ 6.378556755689715, 36.013254299246512 ], [ 6.170300734191528, 35.951242580880375 ], [ 6.149216750252833, 35.900392970758958 ], [ 5.976669141567641, 35.915792547787817 ], [ 5.983335402207047, 36.058471178402158 ], [ 5.89936119985947, 36.16967886049099 ], [ 5.933519320859943, 36.224972643172919 ], [ 5.866029901158981, 36.255410061125417 ], [ 5.872386101637289, 36.401886909153518 ], [ 5.810167676796823, 36.431135768700756 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-04", "NAME_1": "Oum el Bouaghi" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 6.275513950108689, 35.866079820127595 ], [ 6.149216750252833, 35.900392970758958 ], [ 6.151387159689648, 35.935274563070607 ], [ 6.378556755689715, 36.013254299246512 ], [ 6.406151970636927, 36.11030263955513 ], [ 6.477103712766109, 36.158620103235137 ], [ 6.516326124850764, 36.16564809818118 ], [ 6.584797397381976, 36.108700669999905 ], [ 6.772692905353267, 36.192442328350751 ], [ 6.87992150197573, 36.146424465316784 ], [ 6.898525018115095, 36.10689199486967 ], [ 7.010559523403288, 36.155157783504876 ], [ 7.078979119990379, 36.042503159693069 ], [ 7.194889357058173, 36.080743719846794 ], [ 7.223776483198151, 36.144099026249023 ], [ 7.290800814905651, 36.143323879893103 ], [ 7.325889112792311, 36.127407538027455 ], [ 7.324958936805501, 36.080020250334258 ], [ 7.398339470789949, 35.967003893115134 ], [ 7.577243279953393, 35.833497830260626 ], [ 7.660132276683214, 35.834195462250761 ], [ 7.734598016285418, 35.934499416714687 ], [ 7.865856154437949, 35.858715929296693 ], [ 7.823378127298724, 35.739007473016443 ], [ 7.840741408189444, 35.615164903437517 ], [ 7.544842157239771, 35.447810777045675 ], [ 7.462263217973145, 35.539123032701752 ], [ 7.385161980940666, 35.571756700311425 ], [ 7.315037062010788, 35.535247300922151 ], [ 7.30335818892928, 35.600695502395524 ], [ 7.267546420630822, 35.621236883974973 ], [ 7.141197543931469, 35.617464504982877 ], [ 7.083009881400869, 35.576872667339728 ], [ 6.992576124888217, 35.631985581968991 ], [ 6.82498945539902, 35.623562323942053 ], [ 6.649496290921093, 35.796678372508836 ], [ 6.487025587560822, 35.781433824211604 ], [ 6.466820102765496, 35.846391100169114 ], [ 6.339437697291885, 35.88680206975971 ], [ 6.275513950108689, 35.866079820127595 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-07", "NAME_1": "Biskra" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 4.434696078661091, 34.772658189523781 ], [ 4.932805209892535, 34.838907375774738 ], [ 5.028406610276818, 34.900195623729019 ], [ 5.038638543434047, 35.083311062355619 ], [ 5.272474399174143, 35.018431300763893 ], [ 5.536747673766115, 35.0663611917156 ], [ 5.613538853335399, 35.1064362663206 ], [ 5.554886101912075, 35.191392319699787 ], [ 5.830631544909863, 35.288440660008405 ], [ 6.005349562132551, 35.226273912011436 ], [ 5.932279087409938, 35.143565782434905 ], [ 5.927008090750803, 35.061684475158359 ], [ 5.993102248270134, 34.989363308369946 ], [ 6.072218866007859, 35.058583888835358 ], [ 6.166580031143496, 34.992076321065326 ], [ 6.279079624425094, 35.049256293243332 ], [ 6.429303013023627, 35.064785061481359 ], [ 6.459533726300435, 35.016984360839558 ], [ 6.439845005442635, 34.838261419728724 ], [ 6.540407342324954, 34.765681871421236 ], [ 6.714350213191778, 34.788522854546102 ], [ 6.749541863865886, 34.712222602291263 ], [ 6.695901726683303, 34.376093248004963 ], [ 6.558545769571595, 34.317027086330938 ], [ 6.211435174193923, 34.428415636472437 ], [ 5.998528272761575, 34.394955146562722 ], [ 5.477164748154564, 34.449835517195368 ], [ 5.311748488102239, 34.498256334562143 ], [ 5.29903608624636, 34.449060369940128 ], [ 5.217232293335655, 34.411956692247543 ], [ 5.185451287347007, 34.356662909565614 ], [ 5.359859246207236, 34.166209418529832 ], [ 5.37381188331176, 34.058386541805419 ], [ 5.334847852746179, 33.845996406109236 ], [ 5.121785922582262, 33.572679754966714 ], [ 4.406635775720474, 33.927645169389052 ], [ 4.335063917765581, 33.986039537494662 ], [ 4.298477004010522, 34.135797838099734 ], [ 4.210627068984024, 34.174606839034368 ], [ 4.213727655307025, 34.232200222362337 ], [ 4.316460401626216, 34.251785590432632 ], [ 4.184427118017084, 34.508204046879257 ], [ 4.273310580918576, 34.625845444711501 ], [ 4.434696078661091, 34.772658189523781 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-07", "NAME_1": "Biskra" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 4.83208784337927, 34.819244493338658 ], [ 4.932805209892535, 34.838907375774738 ], [ 5.028406610276818, 34.900195623729019 ], [ 5.038638543434047, 35.083311062355619 ], [ 5.272474399174143, 35.018431300763893 ], [ 5.536747673766115, 35.0663611917156 ], [ 5.613538853335399, 35.1064362663206 ], [ 5.554886101912075, 35.191392319699787 ], [ 5.830631544909863, 35.288440660008405 ], [ 6.005349562132551, 35.226273912011436 ], [ 5.932279087409938, 35.143565782434905 ], [ 5.927008090750803, 35.061684475158359 ], [ 5.993102248270134, 34.989363308369946 ], [ 6.072218866007859, 35.058583888835358 ], [ 6.166580031143496, 34.992076321065326 ], [ 6.279079624425094, 35.049256293243332 ], [ 6.429303013023627, 35.064785061481359 ], [ 6.459533726300435, 35.016984360839558 ], [ 6.439845005442635, 34.838261419728724 ], [ 6.540407342324954, 34.765681871421236 ], [ 6.714350213191778, 34.788522854546102 ], [ 6.749541863865886, 34.712222602291263 ], [ 6.695901726683303, 34.376093248004963 ], [ 6.558545769571595, 34.317027086330938 ], [ 6.211435174193923, 34.428415636472437 ], [ 5.998528272761575, 34.394955146562722 ], [ 5.477164748154564, 34.449835517195368 ], [ 5.311748488102239, 34.498256334562143 ], [ 5.29903608624636, 34.449060369940128 ], [ 5.217232293335655, 34.411956692247543 ], [ 5.185451287347007, 34.356662909565614 ], [ 5.359859246207236, 34.166209418529832 ], [ 5.37381188331176, 34.058386541805419 ], [ 5.334847852746179, 33.845996406109236 ], [ 5.121785922582262, 33.572679754966714 ], [ 4.406635775720474, 33.927645169389052 ], [ 4.335063917765581, 33.986039537494662 ], [ 4.298477004010522, 34.135797838099734 ], [ 4.210627068984024, 34.174606839034368 ], [ 4.213727655307025, 34.232200222362337 ], [ 4.316460401626216, 34.251785590432632 ], [ 4.184427118017084, 34.508204046879257 ], [ 4.273310580918576, 34.625845444711501 ], [ 4.434696078661091, 34.772658189523781 ], [ 4.83208784337927, 34.819244493338658 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "DZ-17", "NAME_1": "Djelfa" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 4.978280469667936, 32.840760606416836 ], [ 4.176210564665837, 33.029637966319058 ], [ 4.059060093248718, 33.252518419389503 ], [ 3.640997755741807, 33.564334011305561 ], [ 3.232133822617811, 33.947411403713318 ], [ 3.14924482588799, 34.074767970765208 ], [ 3.093537632056041, 34.240029202085964 ], [ 2.949412062416741, 34.263619493145086 ], [ 2.817637160326683, 34.138846747579294 ], [ 2.650205518669736, 34.09797068909603 ], [ 2.639198439156644, 33.911005357111549 ], [ 2.474867383822698, 33.92371775986669 ], [ 2.458279250187218, 34.003247788754436 ], [ 2.48339399643578, 34.113576971699842 ], [ 2.393735386279047, 34.225301419524897 ], [ 2.293483106859867, 34.474950263443873 ], [ 2.309451124669636, 34.563420315195344 ], [ 2.364383172145665, 34.639591376241015 ], [ 2.369964227167316, 34.776301378206028 ], [ 2.460294630892463, 34.870171616926541 ], [ 2.523443230820419, 34.999104316011369 ], [ 2.620284864654764, 35.031143704418412 ], [ 2.379317661181119, 35.205009060020075 ], [ 2.360042352372659, 35.287975572015 ], [ 2.286816847119781, 35.329265042547661 ], [ 2.285731642401345, 35.439077459756959 ], [ 2.527008905136825, 35.501192531809863 ], [ 2.619819776661359, 35.445692044452244 ], [ 2.687154167630695, 35.453701891329217 ], [ 2.893704867685472, 35.620823472825009 ], [ 2.88424808088422, 35.780503648224737 ], [ 2.960264113198264, 35.804739895329874 ], [ 3.081910434918598, 35.67187978852138 ], [ 3.196425408905498, 35.694462389227851 ], [ 3.255078159429502, 35.797401842021372 ], [ 3.305979444595664, 35.754587918097911 ], [ 3.379515008211058, 35.751203110934796 ], [ 3.449691603085, 35.659115708922798 ], [ 3.680426873401473, 35.495559800844092 ], [ 3.664872266741725, 35.294900214173538 ], [ 3.47356611498418, 35.157957669111113 ], [ 3.532838983132535, 35.054449775536682 ], [ 3.608079868191339, 35.0659736189873 ], [ 3.572113071161311, 35.001688137497524 ], [ 3.599243198115062, 34.886320501889656 ], [ 3.658826124625932, 34.805576077074249 ], [ 3.914417758772515, 34.758602200531129 ], [ 3.921084019411978, 34.672664293422372 ], [ 4.036374138855422, 34.441929023105899 ], [ 4.047691277630349, 34.354079088079459 ], [ 4.213727655307025, 34.232200222362337 ], [ 4.218068475080031, 34.166235256052232 ], [ 4.298477004010522, 34.135797838099734 ], [ 4.362400750294398, 33.957384955351415 ], [ 5.121785922582262, 33.572679754966714 ], [ 5.008046094951283, 33.441886704807587 ], [ 4.95311404837463, 33.24148550145469 ], [ 4.978280469667936, 32.840760606416836 ] ] ] } }
]
}

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