mirror of
https://github.com/apache/superset.git
synced 2026-06-12 19:19:20 +00:00
Compare commits
39 Commits
chat-proto
...
sc-103156-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0193dbab6 | ||
|
|
4c99cd68b6 | ||
|
|
0c79581ee9 | ||
|
|
d0520f6766 | ||
|
|
77236afa14 | ||
|
|
9d5a459840 | ||
|
|
1ac9e50836 | ||
|
|
80b8891e39 | ||
|
|
77c373616e | ||
|
|
40653d52da | ||
|
|
59045f8cfe | ||
|
|
76bbb18fdb | ||
|
|
f4a18cfe98 | ||
|
|
18abb81fe7 | ||
|
|
9e580c699d | ||
|
|
a62d85d798 | ||
|
|
8a46573018 | ||
|
|
a0546b8a43 | ||
|
|
0afeda46a0 | ||
|
|
7ce5f1d0e7 | ||
|
|
9bc95ef819 | ||
|
|
801d58687b | ||
|
|
8fe9a8ce4e | ||
|
|
f7d73e2e1b | ||
|
|
be01e4552c | ||
|
|
0a9fa1ac85 | ||
|
|
58a1a1a8d1 | ||
|
|
fef0a64b21 | ||
|
|
7867f30a23 | ||
|
|
118161b0a0 | ||
|
|
3408a6f6c0 | ||
|
|
254e826307 | ||
|
|
9465e3b675 | ||
|
|
65a3491861 | ||
|
|
56c36fde54 | ||
|
|
0d95b41aed | ||
|
|
6086d9c52a | ||
|
|
cc20fe7cae | ||
|
|
5958e12fc0 |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -38,7 +38,7 @@
|
||||
|
||||
# Notify translation maintainers of changes to translations
|
||||
|
||||
/superset/translations/ @sfirke @rusackas
|
||||
/superset/translations/ @sfirke
|
||||
|
||||
# Notify PMC members of changes to extension-related files
|
||||
|
||||
|
||||
16
.github/SECURITY.md
vendored
16
.github/SECURITY.md
vendored
@@ -33,21 +33,13 @@ We kindly ask you to include the following information in your report to assist
|
||||
- Expected vs. Actual Behavior: A clear description of the intended system behavior versus the observed vulnerability.
|
||||
- Detailed Reproduction Steps: Clear, manual steps to reproduce the vulnerability.
|
||||
|
||||
**Vulnerability Definition**
|
||||
|
||||
Apache Superset considers a security vulnerability to be a demonstrable issue that has meaningful impact on confidentiality, integrity, or availability beyond the intended security model. Low-impact boundary variations or technical edge cases in existing access controls may be classified as hardening improvements rather than vulnerabilities, even if exploitable.
|
||||
|
||||
**Out of Scope Vulnerabilities**
|
||||
|
||||
To prioritize engineering efforts on genuine architectural risks, the following scenarios are explicitly out of scope and will not be issued a CVE:
|
||||
- **Attacks requiring Admin privileges**: (e.g., CSS injection, template manipulation, dashboard ownership overrides, or modifying global system settings). Per the CVE vulnerability definition in CNA Operational Rules 4.1, a qualifying vulnerability must allow violation of a security policy. The Admin role is a fully trusted operational boundary defined by Apache Superset's security policy; actions within this boundary do not violate that policy and are therefore considered intended capabilities 'by design,' not vulnerabilities.
|
||||
- **Brute Force and Rate Limiting**: Reports targeting a lack of resource exhaustion protections, generic rate-limiting, or volumetric Denial of Service (DoS) attempts.
|
||||
- **Theoretical attack vectors**: Issues without a demonstrable, reproducible exploit path.
|
||||
- **Non-Exploitable Findings**: Missing security headers, generic banner disclosures, or descriptive error messages that do not lead to a direct, documented exploit.
|
||||
- **User enumeration**: API responses, timing differences, or error messages that reveal whether user accounts, IDs, dashboards, or datasets exist.
|
||||
- **Information disclosure (low impact)**: Software version disclosure, generic error messages, stack traces without sensitive data exposure, or system configuration details that don't enable further exploitation.
|
||||
- **Resource exhaustion requiring authentication**: Denial of Service attacks that require valid user credentials and don't bypass rate limiting or resource controls.
|
||||
- **Missing security headers**: Without demonstration of a concrete exploit scenario that leverages the missing header.
|
||||
- Attacks requiring Admin privileges: (e.g., CSS injection, template manipulation, dashboard ownership overrides, or modifying global system settings). Per the CVE vulnerability definition in CNA Operational Rules 4.1, a qualifying vulnerability must allow violation of a security policy. The Admin role is a fully trusted operational boundary defined by Apache Superset's security policy; actions within this boundary do not violate that policy and are therefore considered intended capabilities 'by design,' not vulnerabilities.
|
||||
- Brute Force and Rate Limiting: Reports targeting a lack of resource exhaustion protections, generic rate-limiting, or volumetric Denial of Service (DoS) attempts.
|
||||
- Theoretical attack vectors: Issues without a demonstrable, reproducible exploit path.
|
||||
- Non-Exploitable Findings: Missing security headers, generic banner disclosures, or descriptive error messages that do not lead to a direct, documented exploit.
|
||||
|
||||
**Outcome of Reports**
|
||||
|
||||
|
||||
5
.github/dependabot.yml
vendored
5
.github/dependabot.yml
vendored
@@ -62,11 +62,6 @@ updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
open-pull-requests-limit: 10
|
||||
# Bump the lower bound to the new version, not just widen the upper
|
||||
# bound. Without this, a `sqlglot>=28.10.0, <29` constraint upgraded
|
||||
# to `<30` would keep the stale lower bound forever, dragging
|
||||
# transitively-resolved versions with it. See #40186 (review thread).
|
||||
versioning-strategy: increase
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: steps.check.outputs.superset-extensions-cli
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
flags: superset-extensions-cli
|
||||
|
||||
2
.github/workflows/superset-frontend.yml
vendored
2
.github/workflows/superset-frontend.yml
vendored
@@ -128,7 +128,7 @@ jobs:
|
||||
run: npx nyc merge coverage/ merged-output/coverage-summary.json
|
||||
|
||||
- name: Upload Code Coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
with:
|
||||
flags: javascript
|
||||
use_oidc: true
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
with:
|
||||
flags: python,mysql
|
||||
verbose: true
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
with:
|
||||
flags: python,postgres
|
||||
verbose: true
|
||||
@@ -219,7 +219,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
with:
|
||||
flags: python,sqlite
|
||||
verbose: true
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
with:
|
||||
flags: python,presto
|
||||
verbose: true
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
pip install -e .[hive]
|
||||
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
with:
|
||||
flags: python,hive
|
||||
verbose: true
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100
|
||||
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
with:
|
||||
flags: python,unit
|
||||
verbose: true
|
||||
|
||||
@@ -53,7 +53,7 @@ extension-pkg-whitelist=pyarrow
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
disable=all
|
||||
enable=disallowed-sql-import,consider-using-transaction
|
||||
enable=json-import,disallowed-sql-import,consider-using-transaction
|
||||
|
||||
|
||||
[REPORTS]
|
||||
|
||||
@@ -202,8 +202,6 @@ RUN mkdir -p /app/data && chown -R superset:superset /app/data
|
||||
|
||||
# Copy compiled things from previous stages
|
||||
COPY --from=superset-node /app/superset/static/assets superset/static/assets
|
||||
# Copy service.worker.js optionall as it doesn't exist when DEV_MODE=true
|
||||
COPY --from=superset-node /app/superset/static/service-worker.j[s] superset/static/service-worker.js
|
||||
|
||||
# TODO, when the next version comes out, use --exclude superset/translations
|
||||
COPY superset superset
|
||||
|
||||
290
UPDATING.md
290
UPDATING.md
@@ -24,6 +24,56 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### Entity version history for charts, dashboards, and datasets
|
||||
|
||||
Saves of charts, dashboards, and datasets now automatically produce a version history — browsable and restorable via new API endpoints. No frontend UI in this release; the backend plumbing is the deliverable.
|
||||
|
||||
**New endpoints** (per entity type — same pattern for `chart`, `dashboard`, and `dataset`):
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/v1/{resource}/<uuid>/versions/` | List the entity's version history (0-based `version_number`, `version_uuid`, `issued_at`, `changed_by`) |
|
||||
| `GET` | `/api/v1/{resource}/<uuid>/versions/<version_uuid>/` | Get a single version snapshot (scalar fields at that version; plus `columns` / `metrics` for datasets) |
|
||||
| `POST` | `/api/v1/{resource}/<uuid>/versions/<version_uuid>/restore` | Restore the entity to the state captured by that version |
|
||||
|
||||
`<version_uuid>` is a deterministic `UUIDv5` derived from the entity's UUID and the Continuum transaction id — stable across replicas and retention pruning. Authorisation reuses the resource's existing `can_write` permission; workspace admins can list/restore any entity.
|
||||
|
||||
**Version response shape — `changes` array:**
|
||||
|
||||
Each entry returned by `GET /api/v1/{resource}/<uuid>/versions/` and `GET .../versions/<version_uuid>/` includes a `changes` array describing what changed relative to the previous version:
|
||||
|
||||
```json
|
||||
"changes": [
|
||||
{"kind": "field", "path": "slice_name", "from_value": "Old", "to_value": "New"}
|
||||
]
|
||||
```
|
||||
|
||||
The array is empty for baseline (`operation_type=0`) transactions. `kind` enumerates structured record types (`field`, layout-walker records for dashboards, dataset child diffs for `TableColumn` / `SqlMetric`); `path` is a dotted JSON-pointer-style locator; `from_value` / `to_value` are JSON-safe scalars or compact records.
|
||||
|
||||
**Save-response and ETag headers:**
|
||||
|
||||
- Save responses (`PUT /api/v1/{resource}/<pk>`) include `old_version_uuid` and `new_version_uuid` body fields so the client can correlate a save with its resulting version row.
|
||||
- All entity GETs (`GET /api/v1/{chart,dashboard,dataset}/<pk>`), version-list GETs, single-version GETs, and save responses emit an `ETag: "<version_uuid>"` header reflecting the entity's current live version. The default `CORS_OPTIONS` now sets `expose_headers: ["ETag"]` so cross-origin browser clients can read the header. **No `If-Match` enforcement in v1** — `ETag` is informational; concurrent-edit detection is deferred to a follow-up SIP.
|
||||
- **Operators overriding `CORS_OPTIONS` in `superset_config.py` MUST include `"expose_headers": ["ETag"]`** (or merge with the default) for cross-origin clients to read the ETag. A bare `CORS_OPTIONS = {"origins": [...]}` will silently drop the expose-headers default.
|
||||
|
||||
**Behaviour changes on save:**
|
||||
|
||||
- Every save of a chart, dashboard, or dataset produces one new version row. Rows preserve the full post-save state (scalar fields for all three entity types; `TableColumn` / `SqlMetric` children for datasets; `dashboard_slices` chart membership for dashboards — children versioned via SQLAlchemy-Continuum shadow tables `table_columns_version`, `sql_metrics_version`, and `dashboard_slices_version`).
|
||||
- First save after an entity already exists in the DB creates a retroactive baseline version so the UI can show "what this looked like before I edited it."
|
||||
- Tags, owners, and roles are **not** versioned in v1 (ADR-005). A restore leaves those at their live values.
|
||||
|
||||
**New config key:**
|
||||
|
||||
| Key | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `SUPERSET_VERSION_HISTORY_RETENTION_DAYS` | `30` | Versions older than this many days are pruned by a nightly Celery beat task (`superset.tasks.version_history_retention.prune_old_versions`). Each entity's live row (`end_transaction_id IS NULL`) is always preserved; closed historical rows including the baseline age out with the rest. Set to `0` to disable retention entirely. |
|
||||
|
||||
**Impact on external integrations:**
|
||||
|
||||
- New tables populated on every save — `dashboards_version`, `slices_version`, `tables_version` (parent shadow tables for the three entity types), `table_columns_version`, `sql_metrics_version`, `dashboard_slices_version` (child shadow tables), plus the shared `version_transaction` and `version_changes` tables. External tooling that queries Superset's DB directly will see writes to these tables proportional to save traffic.
|
||||
- Existing entity endpoints (`GET`/`PUT /api/v1/{chart,dashboard,dataset}/<pk>`) gain an `ETag` response header and the save response gains `old_version_uuid` / `new_version_uuid` body fields. No existing fields are removed or repurposed.
|
||||
- Version capture is always active — no feature flag.
|
||||
|
||||
### Granular Export Controls
|
||||
|
||||
A new feature flag `GRANULAR_EXPORT_CONTROLS` introduces three fine-grained permissions that replace the legacy `can_csv` permission:
|
||||
@@ -310,6 +360,246 @@ See `superset/mcp_service/PRODUCTION.md` for deployment guides.
|
||||
}
|
||||
```
|
||||
|
||||
### Composite primary keys on many-to-many association tables
|
||||
|
||||
The eight M:N association tables listed below have been changed from a synthetic surrogate `id INTEGER PRIMARY KEY` to a composite `PRIMARY KEY (fk1, fk2)` on the two foreign-key columns. The `id` column is dropped, and the two tables that previously carried a redundant `UNIQUE (fk1, fk2)` constraint have that constraint removed (it is now subsumed by the composite primary key).
|
||||
|
||||
**Affected tables and their composite-PK column pairs:**
|
||||
|
||||
| Table | Composite PK |
|
||||
|---|---|
|
||||
| `dashboard_roles` | `(dashboard_id, role_id)` |
|
||||
| `dashboard_slices` | `(dashboard_id, slice_id)` |
|
||||
| `dashboard_user` | `(user_id, dashboard_id)` |
|
||||
| `report_schedule_user` | `(user_id, report_schedule_id)` |
|
||||
| `rls_filter_roles` | `(role_id, rls_filter_id)` |
|
||||
| `rls_filter_tables` | `(table_id, rls_filter_id)` |
|
||||
| `slice_user` | `(user_id, slice_id)` |
|
||||
| `sqlatable_user` | `(user_id, table_id)` |
|
||||
|
||||
**Impact on external readers:** Any BI tool, custom report, backup script, or external integration that references these tables by their old surrogate `id` column (e.g., `SELECT id FROM dashboard_slices WHERE …`, `WHERE dashboard_slices.id IN (…)`) will break. Update such queries to project or filter on the FK pair (`dashboard_id, slice_id`) instead. The FK columns themselves are unchanged.
|
||||
|
||||
**Pre-flight inventory queries.** Before applying the upgrade, operators are encouraged to run the queries below against their database to assess what the migration will change. Two classes of pre-existing data are not preserved by the migration: duplicate `(fk1, fk2)` rows (the migration keeps `MIN(id)` and deletes the rest) and rows with `NULL` in either FK column (the migration deletes them, since FK columns are promoted to `NOT NULL` for the composite PK). Compliance- or audit-sensitive operators should also `\copy` (Postgres) or `SELECT … INTO OUTFILE` (MySQL) the affected rows for their own records before upgrading.
|
||||
|
||||
```sql
|
||||
-- Duplicate (fk1, fk2) pairs (the migration will keep MIN(id) per group, delete the rest)
|
||||
SELECT dashboard_id, role_id, COUNT(*) FROM dashboard_roles GROUP BY dashboard_id, role_id HAVING COUNT(*) > 1;
|
||||
SELECT dashboard_id, slice_id, COUNT(*) FROM dashboard_slices GROUP BY dashboard_id, slice_id HAVING COUNT(*) > 1;
|
||||
SELECT user_id, dashboard_id, COUNT(*) FROM dashboard_user GROUP BY user_id, dashboard_id HAVING COUNT(*) > 1;
|
||||
SELECT user_id, report_schedule_id, COUNT(*) FROM report_schedule_user GROUP BY user_id, report_schedule_id HAVING COUNT(*) > 1;
|
||||
SELECT role_id, rls_filter_id, COUNT(*) FROM rls_filter_roles GROUP BY role_id, rls_filter_id HAVING COUNT(*) > 1;
|
||||
SELECT table_id, rls_filter_id, COUNT(*) FROM rls_filter_tables GROUP BY table_id, rls_filter_id HAVING COUNT(*) > 1;
|
||||
SELECT user_id, slice_id, COUNT(*) FROM slice_user GROUP BY user_id, slice_id HAVING COUNT(*) > 1;
|
||||
SELECT user_id, table_id, COUNT(*) FROM sqlatable_user GROUP BY user_id, table_id HAVING COUNT(*) > 1;
|
||||
|
||||
-- Rows with a NULL in either FK (the migration will delete these)
|
||||
SELECT COUNT(*) FROM dashboard_roles WHERE dashboard_id IS NULL OR role_id IS NULL;
|
||||
SELECT COUNT(*) FROM dashboard_slices WHERE dashboard_id IS NULL OR slice_id IS NULL;
|
||||
SELECT COUNT(*) FROM dashboard_user WHERE user_id IS NULL OR dashboard_id IS NULL;
|
||||
SELECT COUNT(*) FROM report_schedule_user WHERE user_id IS NULL OR report_schedule_id IS NULL;
|
||||
SELECT COUNT(*) FROM rls_filter_roles WHERE role_id IS NULL OR rls_filter_id IS NULL;
|
||||
SELECT COUNT(*) FROM rls_filter_tables WHERE table_id IS NULL OR rls_filter_id IS NULL;
|
||||
SELECT COUNT(*) FROM slice_user WHERE user_id IS NULL OR slice_id IS NULL;
|
||||
SELECT COUNT(*) FROM sqlatable_user WHERE user_id IS NULL OR table_id IS NULL;
|
||||
```
|
||||
|
||||
**Sizing the maintenance window on PostgreSQL.** The queries above are dialect-portable but only count rows. Operators on PostgreSQL can run the diagnostic queries below to characterize the migration's runtime cost ahead of time: per-table row count and on-disk size, an aggregated duplicate roll-up, the external-FK pre-flight check (the migration runs the same check and aborts if it returns rows), and a rewrite-time estimate for the two tables that go through the slower full-table-rebuild path.
|
||||
|
||||
```sql
|
||||
-- Per-table size, row count, and which migration path each will take.
|
||||
-- Two tables ("dashboard_slices", "report_schedule_user") have a
|
||||
-- redundant UNIQUE constraint that the migration drops via a full
|
||||
-- table rewrite (op.batch_alter_table(recreate="always")). The other
|
||||
-- six use direct ALTER TABLE, which is much cheaper.
|
||||
WITH affected(name, has_unique) AS (
|
||||
VALUES
|
||||
('dashboard_roles', false),
|
||||
('dashboard_slices', true),
|
||||
('dashboard_user', false),
|
||||
('report_schedule_user', true),
|
||||
('rls_filter_roles', false),
|
||||
('rls_filter_tables', false),
|
||||
('slice_user', false),
|
||||
('sqlatable_user', false)
|
||||
)
|
||||
SELECT
|
||||
a.name AS table_name,
|
||||
CASE WHEN a.has_unique THEN 'recreate (full rewrite)'
|
||||
ELSE 'direct ALTER' END AS migration_path,
|
||||
c.reltuples::bigint AS estimated_rows,
|
||||
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
|
||||
pg_size_pretty(pg_relation_size(c.oid)) AS heap_size,
|
||||
pg_size_pretty(pg_indexes_size(c.oid)) AS index_size
|
||||
FROM affected a
|
||||
JOIN pg_class c ON c.relname = a.name AND c.relkind = 'r'
|
||||
ORDER BY pg_total_relation_size(c.oid) DESC;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Aggregated duplicate-row roll-up.
|
||||
-- "dup_groups" is the number of (fk1, fk2) pairs that appear more
|
||||
-- than once; "rows_dropped" is the total number of rows the
|
||||
-- migration will delete during the dedupe pass (it keeps MIN(id) per
|
||||
-- group and discards the rest).
|
||||
SELECT 'dashboard_roles' AS t, COUNT(*) AS dup_groups, SUM(c) - COUNT(*) AS rows_dropped
|
||||
FROM (SELECT COUNT(*) c FROM dashboard_roles GROUP BY dashboard_id, role_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'dashboard_slices', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM dashboard_slices GROUP BY dashboard_id, slice_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'dashboard_user', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM dashboard_user GROUP BY user_id, dashboard_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'report_schedule_user',COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM report_schedule_user GROUP BY user_id, report_schedule_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'rls_filter_roles', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM rls_filter_roles GROUP BY role_id, rls_filter_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'rls_filter_tables', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM rls_filter_tables GROUP BY table_id, rls_filter_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'slice_user', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM slice_user GROUP BY user_id, slice_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'sqlatable_user', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM sqlatable_user GROUP BY user_id, table_id HAVING COUNT(*) > 1) g
|
||||
ORDER BY rows_dropped DESC NULLS LAST;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- External-FK pre-flight check.
|
||||
-- The migration runs the equivalent check at upgrade time and aborts
|
||||
-- if any external FK references one of the soon-to-be-removed `id`
|
||||
-- columns. Running it ahead of time lets you discover (and migrate)
|
||||
-- any such reference before the maintenance window. On a stock
|
||||
-- Superset install this should return zero rows. (Default schema
|
||||
-- only; multi-schema deployments need to broaden the lookup.)
|
||||
SELECT
|
||||
rc.constraint_name,
|
||||
kcu.table_schema || '.' || kcu.table_name AS referencing_table,
|
||||
kcu.column_name AS referencing_column,
|
||||
ccu.table_name AS referenced_table,
|
||||
ccu.column_name AS referenced_column
|
||||
FROM information_schema.referential_constraints rc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON kcu.constraint_name = rc.constraint_name
|
||||
AND kcu.constraint_schema = rc.constraint_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = rc.constraint_name
|
||||
AND ccu.constraint_schema = rc.constraint_schema
|
||||
WHERE ccu.table_name IN (
|
||||
'dashboard_roles','dashboard_slices','dashboard_user',
|
||||
'report_schedule_user','rls_filter_roles','rls_filter_tables',
|
||||
'slice_user','sqlatable_user')
|
||||
AND ccu.column_name = 'id';
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Lock-window estimate for the two full-rewrite tables.
|
||||
-- recreate="always" takes ACCESS EXCLUSIVE on the table for the full
|
||||
-- rewrite. Use heap size combined with your hardware's effective
|
||||
-- write throughput (~100-200 MB/s on commodity SSD; faster on NVMe)
|
||||
-- to size the maintenance window. The other six tables use direct
|
||||
-- ALTER and are dominated by composite-index build time, typically
|
||||
-- seconds for tables in the low millions of rows.
|
||||
SELECT
|
||||
c.relname AS table_name,
|
||||
pg_size_pretty(pg_relation_size(c.oid)) AS heap_size,
|
||||
pg_relation_size(c.oid) / 1024 / 1024 AS heap_size_mb,
|
||||
ROUND(pg_relation_size(c.oid) / 1024 / 1024 / 100.0, 1) AS est_rewrite_seconds_at_100mbs
|
||||
FROM pg_class c
|
||||
WHERE c.relname IN ('dashboard_slices', 'report_schedule_user');
|
||||
```
|
||||
|
||||
**Sizing the maintenance window on MySQL.** Equivalent diagnostic queries for MySQL/InnoDB. One important difference from PostgreSQL: InnoDB rebuilds the clustered index on every PK change, so *all eight* tables undergo a full table rebuild on MySQL — not just the two that go through the explicit `recreate="always"` path. The lock-window estimate query below therefore covers all eight tables.
|
||||
|
||||
```sql
|
||||
-- Per-table size, row count, and which migration path each will take.
|
||||
-- TABLE_ROWS is an InnoDB estimate (analogous to PostgreSQL's reltuples);
|
||||
-- run SELECT COUNT(*) per table for an exact count if needed.
|
||||
SELECT
|
||||
TABLE_NAME AS table_name,
|
||||
CASE WHEN TABLE_NAME IN ('dashboard_slices', 'report_schedule_user')
|
||||
THEN 'recreate (explicit, drops UNIQUE)'
|
||||
ELSE 'direct ALTER (still rebuilds InnoDB clustered index)'
|
||||
END AS migration_path,
|
||||
TABLE_ROWS AS estimated_rows,
|
||||
CONCAT(ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 1), ' MB') AS total_size,
|
||||
CONCAT(ROUND(DATA_LENGTH / 1024 / 1024, 1), ' MB') AS heap_size,
|
||||
CONCAT(ROUND(INDEX_LENGTH / 1024 / 1024, 1), ' MB') AS index_size
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME IN (
|
||||
'dashboard_roles', 'dashboard_slices', 'dashboard_user',
|
||||
'report_schedule_user', 'rls_filter_roles', 'rls_filter_tables',
|
||||
'slice_user', 'sqlatable_user'
|
||||
)
|
||||
ORDER BY (DATA_LENGTH + INDEX_LENGTH) DESC;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Aggregated duplicate-row roll-up. Same SQL as the PostgreSQL version
|
||||
-- (standard SQL); included here for copy-paste convenience.
|
||||
SELECT 'dashboard_roles' AS t, COUNT(*) AS dup_groups, SUM(c) - COUNT(*) AS rows_dropped
|
||||
FROM (SELECT COUNT(*) c FROM dashboard_roles GROUP BY dashboard_id, role_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'dashboard_slices', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM dashboard_slices GROUP BY dashboard_id, slice_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'dashboard_user', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM dashboard_user GROUP BY user_id, dashboard_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'report_schedule_user',COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM report_schedule_user GROUP BY user_id, report_schedule_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'rls_filter_roles', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM rls_filter_roles GROUP BY role_id, rls_filter_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'rls_filter_tables', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM rls_filter_tables GROUP BY table_id, rls_filter_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'slice_user', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM slice_user GROUP BY user_id, slice_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'sqlatable_user', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM sqlatable_user GROUP BY user_id, table_id HAVING COUNT(*) > 1) g
|
||||
ORDER BY rows_dropped DESC;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- External-FK pre-flight check. KEY_COLUMN_USAGE on MySQL carries
|
||||
-- both sides of the FK in a single row, so this is simpler than the
|
||||
-- PostgreSQL version. Should return zero rows on a stock install.
|
||||
SELECT
|
||||
CONSTRAINT_NAME,
|
||||
CONCAT(TABLE_SCHEMA, '.', TABLE_NAME) AS referencing_table,
|
||||
COLUMN_NAME AS referencing_column,
|
||||
REFERENCED_TABLE_NAME AS referenced_table,
|
||||
REFERENCED_COLUMN_NAME AS referenced_column
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND REFERENCED_TABLE_NAME IN (
|
||||
'dashboard_roles', 'dashboard_slices', 'dashboard_user',
|
||||
'report_schedule_user', 'rls_filter_roles', 'rls_filter_tables',
|
||||
'slice_user', 'sqlatable_user'
|
||||
)
|
||||
AND REFERENCED_COLUMN_NAME = 'id';
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Lock-window estimate for ALL EIGHT tables (InnoDB rebuilds the
|
||||
-- clustered index on PK change, so even "direct ALTER" is a rewrite).
|
||||
-- ADD PRIMARY KEY is INPLACE but not LOCK=NONE — it allows concurrent
|
||||
-- reads but blocks writes. Use heap size combined with your effective
|
||||
-- rebuild throughput (~100-200 MB/s on commodity SSD; higher on NVMe).
|
||||
SELECT
|
||||
TABLE_NAME AS table_name,
|
||||
CONCAT(ROUND(DATA_LENGTH / 1024 / 1024, 1), ' MB') AS heap_size,
|
||||
ROUND(DATA_LENGTH / 1024 / 1024, 1) AS heap_size_mb,
|
||||
ROUND(DATA_LENGTH / 1024 / 1024 / 100.0, 1) AS est_rewrite_seconds_at_100mbs
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME IN (
|
||||
'dashboard_roles', 'dashboard_slices', 'dashboard_user',
|
||||
'report_schedule_user', 'rls_filter_roles', 'rls_filter_tables',
|
||||
'slice_user', 'sqlatable_user'
|
||||
)
|
||||
ORDER BY DATA_LENGTH DESC;
|
||||
```
|
||||
|
||||
**Restoring an old `pg_dump` (or equivalent) against the new schema.** A dump taken before the migration includes `INSERT` statements that populate the now-removed `id` column. Restoring such a dump against the post-migration schema will fail. The supported workaround is to dump only the schema and reference data, then re-create the M:N associations from application data after restore — for example with `pg_dump --exclude-table-data` (or per-table `--exclude-table-data=dashboard_slices` etc.) for the eight junction tables, restore the rest, then run a one-shot script that re-INSERTs `(fk1, fk2)` pairs derived from your application export. Operators who need to restore an old dump verbatim should restore against a pre-migration Superset and then re-run the upgrade.
|
||||
|
||||
**Intentional downgrade asymmetry.** The migration's `downgrade()` restores the surrogate `id` column and (for `dashboard_slices` and `report_schedule_user`) the original `UNIQUE (fk1, fk2)` constraint, but it does **not** restore the original `NULL`-allowed state on the FK columns — they remain `NOT NULL`. This is intentional: under SQLAlchemy's `secondary=` semantics, a `NULL` in either FK column of a junction table is meaningless (it cannot participate in either side of the relationship). Operators downgrading are not expected to need this restored. The asymmetry is documented for completeness so that round-trip schema diffs are not mistaken for migration bugs.
|
||||
|
||||
**Constraint-name divergence between upgrade and downgrade.** The composite primary key created on upgrade is named `pk_<table>` (Alembic's default for `op.create_primary_key("pk_<table>", ...)`), while the surrogate `id` primary key restored on downgrade is named `<table>_pkey` (PostgreSQL's default convention for `PrimaryKeyConstraint("id")`). The two names alternate so that a round-trip (upgrade → downgrade → upgrade) does not collide on a pre-existing constraint name. Operators using schema-comparison tools (e.g. `pg_diff`, `migra`) against a downgraded database may see this as drift versus a fresh-install schema. It is cosmetic — no application code references either constraint name.
|
||||
|
||||
## 6.0.0
|
||||
- [33055](https://github.com/apache/superset/pull/33055): Upgrades Flask-AppBuilder to 5.0.0. The AUTH_OID authentication type has been deprecated and is no longer available as an option in Flask-AppBuilder. OpenID (OID) is considered a deprecated authentication protocol - if you are using AUTH_OID, you will need to migrate to an alternative authentication method such as OAuth, LDAP, or database authentication before upgrading.
|
||||
- [34871](https://github.com/apache/superset/pull/34871): Fixed Jest test hanging issue from Ant Design v5 upgrade. MessageChannel is now mocked in test environment to prevent rc-overflow from causing Jest to hang. Test environment only - no production impact.
|
||||
|
||||
117
docker-compose-mysql.yml
Normal file
117
docker-compose-mysql.yml
Normal file
@@ -0,0 +1,117 @@
|
||||
# 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.
|
||||
#
|
||||
# Compose override that swaps the default Postgres metadata DB for MySQL 8.
|
||||
# Useful for evaluating dialect-specific behaviour (e.g., DDL-migration
|
||||
# cost on a deployment whose production metadata DB is MySQL).
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose-mysql.yml up
|
||||
# docker compose -f docker-compose.yml -f docker-compose-mysql.yml down
|
||||
#
|
||||
# To switch back to Postgres, just drop the second `-f` flag — the MySQL
|
||||
# data lives in a separate volume (`db_home_mysql`) so neither side is
|
||||
# corrupted by switching dialects.
|
||||
#
|
||||
# Notes:
|
||||
# - Mirrors the connection settings used by CI's `test-mysql` shard:
|
||||
# dialect ``mysql+mysqldb``, charset utf8mb4 with binary_prefix.
|
||||
# - Host port 13306 (configurable via DATABASE_PORT_MYSQL) to avoid
|
||||
# colliding with a native MySQL install on 3306.
|
||||
# - The Postgres-specific init scripts under
|
||||
# docker/docker-entrypoint-initdb.d/ are not mounted (they are
|
||||
# postgres-only); examples / cypress fixtures still load via
|
||||
# `superset-init`'s post-startup steps.
|
||||
|
||||
# Shared environment override applied to every Superset-side service that
|
||||
# connects to the metadata DB. ``environment:`` takes precedence over the
|
||||
# values inherited from the env_file in docker-compose.yml.
|
||||
x-mysql-env: &mysql-env
|
||||
DATABASE_DIALECT: mysql+mysqldb
|
||||
DATABASE_HOST: db
|
||||
DATABASE_PORT: "3306"
|
||||
DATABASE_DB: superset
|
||||
DATABASE_USER: superset
|
||||
DATABASE_PASSWORD: superset
|
||||
SQLALCHEMY_DATABASE_URI: "mysql+mysqldb://superset:superset@db:3306/superset?charset=utf8mb4&binary_prefix=true"
|
||||
# Override the analytics-examples DB connection too. ``EXAMPLES_PORT``
|
||||
# in docker/.env is hardcoded to 5432 (the Postgres port); without
|
||||
# this override the examples connection would try MySQL on 5432 and
|
||||
# fail. The examples user/DB are created by docker/mysql-init/
|
||||
# examples-init.sql on first MySQL boot.
|
||||
EXAMPLES_HOST: db
|
||||
EXAMPLES_PORT: "3306"
|
||||
EXAMPLES_DB: examples
|
||||
EXAMPLES_USER: examples
|
||||
EXAMPLES_PASSWORD: examples
|
||||
SUPERSET__SQLALCHEMY_EXAMPLES_URI: "mysql+mysqldb://examples:examples@db:3306/examples?charset=utf8mb4&binary_prefix=true"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: mysql:8.0
|
||||
environment:
|
||||
MYSQL_DATABASE: superset
|
||||
MYSQL_USER: superset
|
||||
MYSQL_PASSWORD: superset
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
# The original 5432 port mapping is harmless on a MySQL container
|
||||
# (nothing listens on 5432 inside it) but we add 13306->3306 so the
|
||||
# MySQL port is reachable from the host without colliding with a
|
||||
# native MySQL on 3306. Compose merges port lists.
|
||||
ports:
|
||||
- "127.0.0.1:${DATABASE_PORT_MYSQL:-13306}:3306"
|
||||
# Override the init-scripts mount by re-binding the same target path
|
||||
# to a MySQL-compatible directory. Compose merges volume lists by
|
||||
# target path; later definitions win on conflict, so this displaces
|
||||
# the Postgres-specific ``./docker/docker-entrypoint-initdb.d`` mount
|
||||
# from docker-compose.yml. Without this, MySQL would try to run
|
||||
# ``cypress-init.sh`` (which invokes ``psql``, not in the MySQL
|
||||
# image), abort the init phase, and never create the ``examples``
|
||||
# database. Add the MySQL data volume separately.
|
||||
volumes:
|
||||
- db_home_mysql:/var/lib/mysql
|
||||
- ./docker/mysql-init:/docker-entrypoint-initdb.d
|
||||
command:
|
||||
- --default-authentication-plugin=caching_sha2_password
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_0900_ai_ci
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -proot --silent"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
superset:
|
||||
environment: *mysql-env
|
||||
|
||||
superset-init:
|
||||
environment: *mysql-env
|
||||
|
||||
superset-worker:
|
||||
environment: *mysql-env
|
||||
|
||||
superset-worker-beat:
|
||||
environment: *mysql-env
|
||||
|
||||
superset-node:
|
||||
environment: *mysql-env
|
||||
|
||||
superset-tests-worker:
|
||||
environment: *mysql-env
|
||||
|
||||
volumes:
|
||||
db_home_mysql:
|
||||
32
docker/mysql-init/examples-init.sql
Normal file
32
docker/mysql-init/examples-init.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- 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.
|
||||
|
||||
-- MySQL counterpart to docker/docker-entrypoint-initdb.d/examples-init.sh.
|
||||
-- Creates the analytics-examples database and user that Superset's
|
||||
-- ``load-examples`` command writes to. Mounted by docker-compose-mysql.yml
|
||||
-- at /docker-entrypoint-initdb.d/ so the MySQL image's first-boot
|
||||
-- entrypoint runs it automatically. (The Postgres init scripts under
|
||||
-- docker/docker-entrypoint-initdb.d/ are NOT mounted on the MySQL
|
||||
-- service — they invoke psql, which doesn't exist in the MySQL image.)
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS examples
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE USER IF NOT EXISTS 'examples'@'%' IDENTIFIED BY 'examples';
|
||||
GRANT ALL PRIVILEGES ON examples.* TO 'examples'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
@@ -36,9 +36,9 @@ Screenshots will be taken but no messages actually sent as long as `ALERT_REPORT
|
||||
#### In your `Dockerfile`
|
||||
|
||||
You'll need to extend the Superset image to include a headless browser. Your options include:
|
||||
- Use Playwright with Chromium: this is the recommended approach as of version 4.1.x or greater. Playwright always uses Chromium — the `WEBDRIVER_TYPE` config setting has no effect when Playwright is active. A working example of a Dockerfile that installs these tools is provided under "Building your own production Docker image" on the [Docker Builds](/admin-docs/installation/docker-builds#building-your-own-production-docker-image) page. Enable the `PLAYWRIGHT_REPORTS_AND_THUMBNAILS` feature flag in your config to activate it.
|
||||
- Use Firefox (Selenium): you'll need to install geckodriver and Firefox. Set `WEBDRIVER_TYPE` to `"firefox"` in your `superset_config.py`.
|
||||
- Use Chrome (Selenium): you'll need to install Chrome. Set `WEBDRIVER_TYPE` to `"chrome"` in your `superset_config.py`.
|
||||
- Use Playwright with Chrome: this is the recommended approach as of version 4.1.x or greater. A working example of a Dockerfile that installs these tools is provided under "Building your own production Docker image" on the [Docker Builds](/admin-docs/installation/docker-builds#building-your-own-production-docker-image) page. Read the code comments there as you'll also need to change a feature flag in your config.
|
||||
- Use Firefox: you'll need to install geckodriver and Firefox.
|
||||
- Use Chrome without Playwright: you'll need to install Chrome and set the value of `WEBDRIVER_TYPE` to `"chrome"` in your `superset_config.py`.
|
||||
|
||||
In Superset versions <=4.0x, users installed Firefox or Chrome and that was documented here.
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.33",
|
||||
"antd": "^6.4.3",
|
||||
"baseline-browser-mapping": "^2.10.31",
|
||||
"baseline-browser-mapping": "^2.10.30",
|
||||
"caniuse-lite": "^1.0.30001793",
|
||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.0.2",
|
||||
@@ -109,8 +109,8 @@
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"webpack": "^5.107.1"
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"webpack": "^5.106.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -206,26 +206,12 @@ async function downloadBadge(url, staticDir) {
|
||||
badgeCache.set(url, webPath);
|
||||
return webPath;
|
||||
} catch (error) {
|
||||
// Soft fallback: keep the original remote URL in the rendered output
|
||||
// so the badge still appears for readers, and the docs build continues.
|
||||
// External badge services (notably img.shields.io) rate-limit CI IPs
|
||||
// aggressively, and a transient fetch failure shouldn't take the whole
|
||||
// docs build down with it. Set REMARK_BADGES_STRICT=true to opt back
|
||||
// into hard-fail-the-build behavior (e.g. for release builds where you
|
||||
// want to catch genuinely broken badge URLs).
|
||||
if (process.env.REMARK_BADGES_STRICT === 'true') {
|
||||
throw new Error(
|
||||
`[remark-localize-badges] Failed to download badge: ${url}\n` +
|
||||
`Error: ${error.message}\n` +
|
||||
`Build cannot continue with broken badges (REMARK_BADGES_STRICT=true).`,
|
||||
);
|
||||
}
|
||||
console.warn(
|
||||
`[remark-localize-badges] Could not localize ${url} ` +
|
||||
`(${error.message}); falling back to remote URL.`,
|
||||
// Fail the build on badge download failure
|
||||
throw new Error(
|
||||
`[remark-localize-badges] Failed to download badge: ${url}\n` +
|
||||
`Error: ${error.message}\n` +
|
||||
`Build cannot continue with broken badges. Please fix the badge URL or remove it.`,
|
||||
);
|
||||
badgeCache.set(url, url);
|
||||
return url;
|
||||
} finally {
|
||||
// Clean up the in-flight tracker
|
||||
inFlightDownloads.delete(url);
|
||||
|
||||
237
docs/yarn.lock
237
docs/yarn.lock
@@ -4455,6 +4455,22 @@
|
||||
dependencies:
|
||||
"@types/ms" "*"
|
||||
|
||||
"@types/eslint-scope@^3.7.7":
|
||||
version "3.7.7"
|
||||
resolved "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz"
|
||||
integrity sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==
|
||||
dependencies:
|
||||
"@types/eslint" "*"
|
||||
"@types/estree" "*"
|
||||
|
||||
"@types/eslint@*":
|
||||
version "9.6.1"
|
||||
resolved "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz"
|
||||
integrity sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==
|
||||
dependencies:
|
||||
"@types/estree" "*"
|
||||
"@types/json-schema" "*"
|
||||
|
||||
"@types/estree-jsx@^1.0.0":
|
||||
version "1.0.5"
|
||||
resolved "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz"
|
||||
@@ -4596,7 +4612,7 @@
|
||||
resolved "https://registry.npmjs.org/@types/json-bigint/-/json-bigint-1.0.4.tgz"
|
||||
integrity sha512-ydHooXLbOmxBbubnA7Eh+RpBzuaIiQjh8WGJYQB50JFGFrdxW7JzVlyEV7fAXw0T2sqJ1ysTneJbiyNLqZRAag==
|
||||
|
||||
"@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
|
||||
"@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
|
||||
version "7.0.15"
|
||||
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz"
|
||||
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
|
||||
@@ -4812,100 +4828,100 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@8.59.4", "@typescript-eslint/eslint-plugin@^8.59.3":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz#c67bfee32caae9cb587dce1ac59c3bf43b659707"
|
||||
integrity sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==
|
||||
"@typescript-eslint/eslint-plugin@8.59.3", "@typescript-eslint/eslint-plugin@^8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz#5d6da7e7b236b46452fa00d3904bb6f59615bfde"
|
||||
integrity sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.59.4"
|
||||
"@typescript-eslint/type-utils" "8.59.4"
|
||||
"@typescript-eslint/utils" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
"@typescript-eslint/scope-manager" "8.59.3"
|
||||
"@typescript-eslint/type-utils" "8.59.3"
|
||||
"@typescript-eslint/utils" "8.59.3"
|
||||
"@typescript-eslint/visitor-keys" "8.59.3"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/parser@8.59.4", "@typescript-eslint/parser@^8.59.3":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.4.tgz#77d99e3b27663e7a22cf12c3fb769db509e5e93c"
|
||||
integrity sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==
|
||||
"@typescript-eslint/parser@8.59.3", "@typescript-eslint/parser@^8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.3.tgz#f46cbc70ae0a25119ef94eac9ecd46714788e1a1"
|
||||
integrity sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
"@typescript-eslint/scope-manager" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/typescript-estree" "8.59.3"
|
||||
"@typescript-eslint/visitor-keys" "8.59.3"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.4.tgz#5830535a0e7a3ae806e2669964f47a74c4bc6b0e"
|
||||
integrity sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==
|
||||
"@typescript-eslint/project-service@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.3.tgz#1be5ae152aad987a156c9a1a9b4256e75cfbbe0c"
|
||||
integrity sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.59.4"
|
||||
"@typescript-eslint/types" "^8.59.4"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.59.3"
|
||||
"@typescript-eslint/types" "^8.59.3"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz#507d1258c758147dac1adee9517a205a8ac1e046"
|
||||
integrity sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==
|
||||
"@typescript-eslint/scope-manager@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz#91a60f66803fe9dae0696fbab2451f5723f119d2"
|
||||
integrity sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/visitor-keys" "8.59.3"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.59.4", "@typescript-eslint/tsconfig-utils@^8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz#218ba229d96dde35212e3a76a7d0a6bc831398be"
|
||||
integrity sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==
|
||||
"@typescript-eslint/tsconfig-utils@8.59.3", "@typescript-eslint/tsconfig-utils@^8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz#88ca9036b42ccdd1e630cfdafd2e042c2ca6a835"
|
||||
integrity sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==
|
||||
|
||||
"@typescript-eslint/type-utils@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz#359fc53ba39a1f1860fddda40ebe5bfe0d87faed"
|
||||
integrity sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==
|
||||
"@typescript-eslint/type-utils@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz#421fb2448bdfeb301d134a01cd02503f67fd8192"
|
||||
integrity sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/utils" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/typescript-estree" "8.59.3"
|
||||
"@typescript-eslint/utils" "8.59.3"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/types@8.59.4", "@typescript-eslint/types@^8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.4.tgz#c29d5c21bfbaa8347ddc677d3ac1fcd2db0f848e"
|
||||
integrity sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==
|
||||
"@typescript-eslint/types@8.59.3", "@typescript-eslint/types@^8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.3.tgz#b7ca539c5e302fdde9a7cadb73caed107ef8f2cd"
|
||||
integrity sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz#d005e5e1fb425526f39685594bed34a04ad755ea"
|
||||
integrity sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==
|
||||
"@typescript-eslint/typescript-estree@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz#e6bb1408e00b47e431427a40268db4e86cb121ab"
|
||||
integrity sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.59.4"
|
||||
"@typescript-eslint/tsconfig-utils" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
"@typescript-eslint/project-service" "8.59.3"
|
||||
"@typescript-eslint/tsconfig-utils" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/visitor-keys" "8.59.3"
|
||||
debug "^4.4.3"
|
||||
minimatch "^10.2.2"
|
||||
semver "^7.7.3"
|
||||
tinyglobby "^0.2.15"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/utils@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.4.tgz#8ccd2b08aecc72c7efc0d7ac6695631d199d256e"
|
||||
integrity sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==
|
||||
"@typescript-eslint/utils@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.3.tgz#f693f979deb4dc3994de03ff8b23976d625c36c5"
|
||||
integrity sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/scope-manager" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/typescript-estree" "8.59.3"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz#1ac23b747b011f5cbdb449da97769f6c5f3a9355"
|
||||
integrity sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==
|
||||
"@typescript-eslint/visitor-keys@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz#820843b1b5ca4290009cf189382abcf6fe00dfa6"
|
||||
integrity sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@ungap/structured-clone@^1.0.0":
|
||||
@@ -5568,10 +5584,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.10.31, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.31"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz#9c6825f052601ce6974a90dd49683b1726887b0b"
|
||||
integrity sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==
|
||||
baseline-browser-mapping@^2.10.30, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.30"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz#58915c74388b05f3b3504026194ea9fa98f6e6b6"
|
||||
integrity sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
@@ -7253,13 +7269,13 @@ encodeurl@~2.0.0:
|
||||
resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz"
|
||||
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
|
||||
|
||||
enhanced-resolve@^5.21.4:
|
||||
version "5.21.5"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.21.5.tgz#8f80167d009d8f01267ad61035e59fe5c94ac3a6"
|
||||
integrity sha512-mLCNbrQli11K1ySUmuNt4ZUB3OpGIDq4q2vTBTf5cL2lpsRjI9QKqSD0ndjW8FyvcW/Jj46gMe9syyHAsvMa/A==
|
||||
enhanced-resolve@^5.20.0:
|
||||
version "5.20.0"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz#323c2a70d2aa7fb4bdfd6d3c24dfc705c581295d"
|
||||
integrity sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==
|
||||
dependencies:
|
||||
graceful-fs "^4.2.4"
|
||||
tapable "^2.3.3"
|
||||
tapable "^2.3.0"
|
||||
|
||||
entities@^2.0.0:
|
||||
version "2.2.0"
|
||||
@@ -7375,10 +7391,10 @@ es-iterator-helpers@^1.2.1:
|
||||
iterator.prototype "^1.1.4"
|
||||
safe-array-concat "^1.1.3"
|
||||
|
||||
es-module-lexer@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.1.0.tgz#1dfcbb5ea3bbfb63f28e1fc3676c3676d1c9624c"
|
||||
integrity sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==
|
||||
es-module-lexer@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz"
|
||||
integrity sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==
|
||||
|
||||
es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
|
||||
version "1.1.1"
|
||||
@@ -9639,10 +9655,10 @@ liquid-json@0.3.1:
|
||||
resolved "https://registry.npmjs.org/liquid-json/-/liquid-json-0.3.1.tgz"
|
||||
integrity sha512-wUayTU8MS827Dam6MxgD72Ui+KOSF+u/eIqpatOtjnvgJ0+mnDq33uC2M7J0tPK+upe/DpUAuK4JUU89iBoNKQ==
|
||||
|
||||
loader-runner@^4.3.2:
|
||||
version "4.3.2"
|
||||
resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.2.tgz#9913d3a15971f8f635915e601fb5c9d495d918e9"
|
||||
integrity sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==
|
||||
loader-runner@^4.3.1:
|
||||
version "4.3.1"
|
||||
resolved "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz"
|
||||
integrity sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==
|
||||
|
||||
loader-utils@^1.2.3:
|
||||
version "1.4.2"
|
||||
@@ -14112,15 +14128,15 @@ synckit@^0.11.12:
|
||||
dependencies:
|
||||
"@pkgr/core" "^0.2.9"
|
||||
|
||||
tapable@^2.0.0, tapable@^2.2.1, tapable@^2.3.0, tapable@^2.3.3:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.3.tgz#5da7c9992c46038221267985ab28421a8879f160"
|
||||
integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==
|
||||
tapable@^2.0.0, tapable@^2.2.1, tapable@^2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz"
|
||||
integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==
|
||||
|
||||
terser-webpack-plugin@^5.3.9, terser-webpack-plugin@^5.5.0:
|
||||
version "5.6.0"
|
||||
resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz#8e7caad248183ab9e91ff08a83b0fc9f0439c3c3"
|
||||
integrity sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==
|
||||
terser-webpack-plugin@^5.3.17, terser-webpack-plugin@^5.3.9:
|
||||
version "5.3.17"
|
||||
resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.17.tgz#75ea98876297fbb190d2fbb395e982582b859a67"
|
||||
integrity sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw==
|
||||
dependencies:
|
||||
"@jridgewell/trace-mapping" "^0.3.25"
|
||||
jest-worker "^27.4.5"
|
||||
@@ -14391,15 +14407,15 @@ types-ramda@^0.30.1:
|
||||
dependencies:
|
||||
ts-toolbelt "^9.6.0"
|
||||
|
||||
typescript-eslint@^8.59.4:
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.59.4.tgz#834e3b53f4d1a764a985ceb8592c4a95d6a8da7c"
|
||||
integrity sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==
|
||||
typescript-eslint@^8.59.3:
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.59.3.tgz#4a41d9007faa539a66292189e2795eeb0b9fca29"
|
||||
integrity sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin" "8.59.4"
|
||||
"@typescript-eslint/parser" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/utils" "8.59.4"
|
||||
"@typescript-eslint/eslint-plugin" "8.59.3"
|
||||
"@typescript-eslint/parser" "8.59.3"
|
||||
"@typescript-eslint/typescript-estree" "8.59.3"
|
||||
"@typescript-eslint/utils" "8.59.3"
|
||||
|
||||
typescript@~6.0.3:
|
||||
version "6.0.3"
|
||||
@@ -14954,21 +14970,22 @@ webpack-merge@^6.0.1:
|
||||
flat "^5.0.2"
|
||||
wildcard "^2.0.1"
|
||||
|
||||
webpack-sources@^3.4.1:
|
||||
version "3.4.1"
|
||||
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.4.1.tgz#009d110999ebd9fb3a6fa8d32eec6f84d940e65d"
|
||||
integrity sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==
|
||||
webpack-sources@^3.3.4:
|
||||
version "3.3.4"
|
||||
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.4.tgz#a338b95eb484ecc75fbb196cbe8a2890618b4891"
|
||||
integrity sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==
|
||||
|
||||
webpack-virtual-modules@^0.6.2:
|
||||
version "0.6.2"
|
||||
resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz"
|
||||
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
|
||||
|
||||
webpack@^5.107.1, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.107.1"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.107.1.tgz#01ad63131b7c413f607cc00a8136f467c1f10af0"
|
||||
integrity sha512-mvdIWxj/H6QsfgDdH9djne3a5dYcmEmtsXGESkypaGN5jXjF/b+9KDlmTDQ2TKlFUeA2fI9Y65kihD30JOdB+Q==
|
||||
webpack@^5.106.2, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.106.2"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.106.2.tgz#ca8174b4fd80f055cc5a45fcc5577d6db76c8ac5"
|
||||
integrity sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==
|
||||
dependencies:
|
||||
"@types/eslint-scope" "^3.7.7"
|
||||
"@types/estree" "^1.0.8"
|
||||
"@types/json-schema" "^7.0.15"
|
||||
"@webassemblyjs/ast" "^1.14.1"
|
||||
@@ -14978,20 +14995,20 @@ webpack@^5.107.1, webpack@^5.88.1, webpack@^5.95.0:
|
||||
acorn-import-phases "^1.0.3"
|
||||
browserslist "^4.28.1"
|
||||
chrome-trace-event "^1.0.2"
|
||||
enhanced-resolve "^5.21.4"
|
||||
es-module-lexer "^2.1.0"
|
||||
enhanced-resolve "^5.20.0"
|
||||
es-module-lexer "^2.0.0"
|
||||
eslint-scope "5.1.1"
|
||||
events "^3.2.0"
|
||||
glob-to-regexp "^0.4.1"
|
||||
graceful-fs "^4.2.11"
|
||||
loader-runner "^4.3.2"
|
||||
loader-runner "^4.3.1"
|
||||
mime-db "^1.54.0"
|
||||
neo-async "^2.6.2"
|
||||
schema-utils "^4.3.3"
|
||||
tapable "^2.3.0"
|
||||
terser-webpack-plugin "^5.5.0"
|
||||
terser-webpack-plugin "^5.3.17"
|
||||
watchpack "^2.5.1"
|
||||
webpack-sources "^3.4.1"
|
||||
webpack-sources "^3.3.4"
|
||||
|
||||
webpackbar@^7.0.0:
|
||||
version "7.0.0"
|
||||
|
||||
@@ -58,7 +58,7 @@ dependencies = [
|
||||
"flask-wtf>=1.1.0, <2.0",
|
||||
"geopy",
|
||||
"greenlet>=3.0.3, <=3.5.0",
|
||||
"gunicorn>=25.3.0, <26; sys_platform != 'win32'",
|
||||
"gunicorn>=22.0.0; sys_platform != 'win32'",
|
||||
"hashids>=1.3.1, <2",
|
||||
# holidays>=0.45 required for security fix
|
||||
"holidays>=0.45, <1",
|
||||
@@ -100,6 +100,7 @@ dependencies = [
|
||||
"simplejson>=3.15.0",
|
||||
"slack_sdk>=3.19.0, <4",
|
||||
"sqlalchemy>=1.4, <2",
|
||||
"sqlalchemy-continuum>=1.6.0, <2.0.0",
|
||||
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlglot>=28.10.0, <29",
|
||||
# newer pandas needs 0.9+
|
||||
@@ -137,7 +138,7 @@ databricks = [
|
||||
db2 = ["ibm-db-sa>0.3.8, <=0.4.4"]
|
||||
denodo = ["denodo-sqlalchemy>=1.0.6,<2.1.0"]
|
||||
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
|
||||
drill = ["sqlalchemy-drill>=1.1.10, <2"]
|
||||
drill = ["sqlalchemy-drill>=1.1.4, <2"]
|
||||
druid = ["pydruid>=0.6.5,<0.7"]
|
||||
duckdb = ["duckdb>=1.4.2,<2", "duckdb-engine>=0.17.0"]
|
||||
dynamodb = ["pydynamodb>=0.4.2"]
|
||||
|
||||
@@ -166,7 +166,7 @@ greenlet==3.1.1
|
||||
# apache-superset (pyproject.toml)
|
||||
# shillelagh
|
||||
# sqlalchemy
|
||||
gunicorn==25.3.0
|
||||
gunicorn==23.0.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
h11==0.16.0
|
||||
# via wsproto
|
||||
@@ -409,7 +409,10 @@ sqlalchemy==1.4.54
|
||||
# flask-sqlalchemy
|
||||
# marshmallow-sqlalchemy
|
||||
# shillelagh
|
||||
# sqlalchemy-continuum
|
||||
# sqlalchemy-utils
|
||||
sqlalchemy-continuum==1.6.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
sqlalchemy-utils==0.42.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
|
||||
@@ -388,7 +388,7 @@ grpcio==1.71.0
|
||||
# grpcio-status
|
||||
grpcio-status==1.60.1
|
||||
# via google-api-core
|
||||
gunicorn==25.3.0
|
||||
gunicorn==23.0.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -976,9 +976,14 @@ sqlalchemy==1.4.54
|
||||
# marshmallow-sqlalchemy
|
||||
# shillelagh
|
||||
# sqlalchemy-bigquery
|
||||
# sqlalchemy-continuum
|
||||
# sqlalchemy-utils
|
||||
sqlalchemy-bigquery==1.15.0
|
||||
# via apache-superset
|
||||
sqlalchemy-continuum==1.6.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
sqlalchemy-utils==0.42.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
|
||||
682
scripts/seed_junction_load.py
Normal file
682
scripts/seed_junction_load.py
Normal file
@@ -0,0 +1,682 @@
|
||||
#!/usr/bin/env python3
|
||||
# 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.
|
||||
#
|
||||
# ----------------------------------------------------------------------
|
||||
# Stress-test data generator for the composite-PK migration (sc-105349).
|
||||
#
|
||||
# Bulk-inserts synthetic parent rows and many-to-many junction rows for
|
||||
# the eight association tables that the composite-PK migration touches.
|
||||
# Useful for measuring migration runtime at varying scales — run this at
|
||||
# 100K / 1M / 5M / 10M rows and time the migration at each scale to
|
||||
# verify the O(N log N) extrapolation.
|
||||
#
|
||||
# Idempotent: rerunning with the same target is a no-op; rerunning with
|
||||
# a higher target adds rows up to the new total. Batched bulk INSERTs
|
||||
# (10K rows per statement) make it fast on Postgres, MySQL, and SQLite.
|
||||
#
|
||||
# Usage (inside the Superset container):
|
||||
#
|
||||
# docker exec superset-superset-1 \\
|
||||
# /app/.venv/bin/python /app/scripts/seed_junction_load.py \\
|
||||
# --dashboard-slices 1000000 \\
|
||||
# --slice-user 100000 \\
|
||||
# --dashboard-user 100000
|
||||
#
|
||||
# Run with no flags for the defaults shown below. Use ``--dry-run`` to
|
||||
# print the planned inserts without writing anything.
|
||||
#
|
||||
# The script connects via Superset's standard ``DATABASE_*`` env vars
|
||||
# (or ``SUPERSET__SQLALCHEMY_DATABASE_URI`` if set), so it works
|
||||
# automatically inside the Superset container regardless of which
|
||||
# metadata DB backend is in use.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
from uuid import uuid4
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.engine import Connection, Engine
|
||||
|
||||
logger = logging.getLogger("seed_junction_load")
|
||||
|
||||
# Bulk INSERT batch size. Larger values = fewer statements but more memory.
|
||||
BATCH = 10_000
|
||||
|
||||
# Default per-junction-table target row counts. Tuned to mimic the shape
|
||||
# of a large multi-team Superset install. Override via CLI flags.
|
||||
DEFAULTS: dict[str, int] = {
|
||||
"dashboard_slices": 1_000_000,
|
||||
"slice_user": 100_000,
|
||||
"dashboard_user": 100_000,
|
||||
"dashboard_roles": 10_000,
|
||||
}
|
||||
|
||||
# (junction_table, fk1_col, fk2_col, parent1_table, parent2_table)
|
||||
# parents reference id columns; we generate (fk1, fk2) pairs by sampling
|
||||
# from the parents' existing IDs.
|
||||
JUNCTIONS: list[tuple[str, str, str, str, str]] = [
|
||||
("dashboard_slices", "dashboard_id", "slice_id", "dashboards", "slices"),
|
||||
("slice_user", "user_id", "slice_id", "ab_user", "slices"),
|
||||
("dashboard_user", "user_id", "dashboard_id", "ab_user", "dashboards"),
|
||||
("dashboard_roles", "dashboard_id", "role_id", "dashboards", "ab_role"),
|
||||
]
|
||||
|
||||
# Junction tables that originally carried ``UNIQUE(fk1, fk2)`` and therefore
|
||||
# cannot accept duplicate ``(fk1, fk2)`` pairs even on the pre-migration
|
||||
# (downgrade) schema. The other JUNCTIONS allow duplicates pre-migration.
|
||||
JUNCTIONS_WITH_UNIQUE: set[str] = {"dashboard_slices", "report_schedule_user"}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Connection setup
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_engine() -> Engine:
|
||||
"""Build a SQLAlchemy engine from Superset env vars."""
|
||||
if uri := os.environ.get("SUPERSET__SQLALCHEMY_DATABASE_URI"):
|
||||
logger.info("Using SUPERSET__SQLALCHEMY_DATABASE_URI from env")
|
||||
return sa.create_engine(uri)
|
||||
|
||||
try:
|
||||
dialect = os.environ["DATABASE_DIALECT"]
|
||||
user = os.environ["DATABASE_USER"]
|
||||
password = os.environ["DATABASE_PASSWORD"]
|
||||
host = os.environ["DATABASE_HOST"]
|
||||
port = os.environ["DATABASE_PORT"]
|
||||
db = os.environ["DATABASE_DB"]
|
||||
except KeyError as exc:
|
||||
sys.exit(
|
||||
f"Missing env var {exc}; either set DATABASE_DIALECT/USER/PASSWORD/"
|
||||
f"HOST/PORT/DB or SUPERSET__SQLALCHEMY_DATABASE_URI before running."
|
||||
)
|
||||
|
||||
uri = f"{dialect}://{user}:{password}@{host}:{port}/{db}"
|
||||
logger.info(
|
||||
"Built URI from DATABASE_* env vars (dialect=%s, host=%s)", dialect, host
|
||||
)
|
||||
return sa.create_engine(uri)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def uuid_value(dialect_name: str) -> bytes | str:
|
||||
"""Return a UUID in the form the active dialect expects.
|
||||
|
||||
MySQL stores UUIDs as ``BINARY(16)`` (16 raw bytes); Postgres has a
|
||||
native ``UUID`` type that accepts strings; SQLite stores them as
|
||||
BLOB/TEXT and accepts either. Branching here keeps the seed script
|
||||
backend-agnostic without depending on Superset's custom column types.
|
||||
"""
|
||||
if dialect_name.startswith("mysql"):
|
||||
return uuid4().bytes
|
||||
return str(uuid4())
|
||||
|
||||
|
||||
@contextmanager
|
||||
def time_phase(name: str) -> Iterator[None]:
|
||||
"""Log elapsed wall time for a named phase."""
|
||||
start = time.monotonic()
|
||||
logger.info("[%s] starting", name)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
elapsed = time.monotonic() - start
|
||||
logger.info("[%s] done in %.2fs", name, elapsed)
|
||||
|
||||
|
||||
def count_rows(conn: Connection, table: str) -> int:
|
||||
return conn.scalar(sa.text(f"SELECT COUNT(*) FROM {table}")) or 0 # noqa: S608
|
||||
|
||||
|
||||
def existing_ids(conn: Connection, table: str, limit: int | None = None) -> list[int]:
|
||||
sql = f"SELECT id FROM {table} ORDER BY id" # noqa: S608
|
||||
if limit is not None:
|
||||
sql += f" LIMIT {limit}"
|
||||
return [row[0] for row in conn.execute(sa.text(sql))]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Parent seeders
|
||||
#
|
||||
# Each function ensures the named parent table has at least ``target``
|
||||
# rows by inserting synthetic ones with minimal-but-valid columns.
|
||||
# Returns nothing; subsequent code reads back IDs via ``existing_ids``.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def seed_dashboards(conn: Connection, target: int, dry_run: bool) -> None:
|
||||
current = count_rows(conn, "dashboards")
|
||||
if current >= target:
|
||||
logger.info(
|
||||
"dashboards: %d rows (target %d) — no insert needed", current, target
|
||||
)
|
||||
return
|
||||
needed = target - current
|
||||
logger.info("dashboards: %d → %d (+%d)", current, target, needed)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
dialect = conn.engine.dialect.name
|
||||
sql = sa.text(
|
||||
"INSERT INTO dashboards (uuid, dashboard_title, slug, published) "
|
||||
"VALUES (:uuid, :title, :slug, :published)"
|
||||
)
|
||||
for batch_start in range(0, needed, BATCH):
|
||||
rows = [
|
||||
{
|
||||
"uuid": uuid_value(dialect),
|
||||
"title": f"seed_dashboard_{current + i}",
|
||||
"slug": f"seed-dashboard-{current + i}-{uuid4().hex[:8]}",
|
||||
"published": False,
|
||||
}
|
||||
for i in range(batch_start, min(batch_start + BATCH, needed))
|
||||
]
|
||||
conn.execute(sql, rows)
|
||||
logger.info(" dashboards: inserted %d / %d", batch_start + len(rows), needed)
|
||||
|
||||
|
||||
def seed_dbs(conn: Connection, dry_run: bool) -> int:
|
||||
"""Ensure at least one row exists in ``dbs`` (parent of ``tables``).
|
||||
Returns the id to use as ``database_id`` when seeding ``tables``."""
|
||||
ids = existing_ids(conn, "dbs", limit=1)
|
||||
if ids:
|
||||
return ids[0]
|
||||
if dry_run:
|
||||
return -1 # placeholder
|
||||
dialect = conn.engine.dialect.name
|
||||
logger.info("dbs: inserting one synthetic database (no rows present)")
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO dbs (uuid, database_name, sqlalchemy_uri, expose_in_sqllab) "
|
||||
"VALUES (:uuid, :name, :uri, :expose)"
|
||||
),
|
||||
{
|
||||
"uuid": uuid_value(dialect),
|
||||
"name": f"seed_db_{uuid4().hex[:8]}",
|
||||
"uri": "sqlite:///seed.db",
|
||||
"expose": False,
|
||||
},
|
||||
)
|
||||
return existing_ids(conn, "dbs", limit=1)[0]
|
||||
|
||||
|
||||
def seed_tables(conn: Connection, target: int, dry_run: bool) -> None:
|
||||
current = count_rows(conn, "tables")
|
||||
if current >= target:
|
||||
logger.info("tables: %d rows (target %d) — no insert needed", current, target)
|
||||
return
|
||||
needed = target - current
|
||||
logger.info("tables: %d → %d (+%d)", current, target, needed)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
database_id = seed_dbs(conn, dry_run=False)
|
||||
dialect = conn.engine.dialect.name
|
||||
sql = sa.text(
|
||||
"INSERT INTO tables (uuid, table_name, database_id) "
|
||||
"VALUES (:uuid, :name, :db_id)"
|
||||
)
|
||||
for batch_start in range(0, needed, BATCH):
|
||||
rows = [
|
||||
{
|
||||
"uuid": uuid_value(dialect),
|
||||
"name": f"seed_table_{current + i}",
|
||||
"db_id": database_id,
|
||||
}
|
||||
for i in range(batch_start, min(batch_start + BATCH, needed))
|
||||
]
|
||||
conn.execute(sql, rows)
|
||||
logger.info(" tables: inserted %d / %d", batch_start + len(rows), needed)
|
||||
|
||||
|
||||
def seed_slices(conn: Connection, target: int, dry_run: bool) -> None:
|
||||
current = count_rows(conn, "slices")
|
||||
if current >= target:
|
||||
logger.info("slices: %d rows (target %d) — no insert needed", current, target)
|
||||
return
|
||||
needed = target - current
|
||||
logger.info("slices: %d → %d (+%d)", current, target, needed)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
# Slices reference tables.id; ensure at least one ``tables`` row exists
|
||||
# so the FK is satisfiable (datasource_id is nullable but we set it for
|
||||
# realism). The migration test doesn't care, but a real Superset that
|
||||
# re-renders these slices does.
|
||||
seed_tables(conn, target=1, dry_run=False)
|
||||
table_id = existing_ids(conn, "tables", limit=1)[0]
|
||||
dialect = conn.engine.dialect.name
|
||||
sql = sa.text(
|
||||
"INSERT INTO slices "
|
||||
"(uuid, slice_name, datasource_id, datasource_type, viz_type) "
|
||||
"VALUES (:uuid, :name, :ds_id, :ds_type, :viz)"
|
||||
)
|
||||
for batch_start in range(0, needed, BATCH):
|
||||
rows = [
|
||||
{
|
||||
"uuid": uuid_value(dialect),
|
||||
"name": f"seed_slice_{current + i}",
|
||||
"ds_id": table_id,
|
||||
"ds_type": "table",
|
||||
"viz": "table",
|
||||
}
|
||||
for i in range(batch_start, min(batch_start + BATCH, needed))
|
||||
]
|
||||
conn.execute(sql, rows)
|
||||
logger.info(" slices: inserted %d / %d", batch_start + len(rows), needed)
|
||||
|
||||
|
||||
def seed_users(conn: Connection, target: int, dry_run: bool) -> None:
|
||||
current = count_rows(conn, "ab_user")
|
||||
if current >= target:
|
||||
logger.info("ab_user: %d rows (target %d) — no insert needed", current, target)
|
||||
return
|
||||
needed = target - current
|
||||
logger.info("ab_user: %d → %d (+%d)", current, target, needed)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
sql = sa.text(
|
||||
"INSERT INTO ab_user (first_name, last_name, username, email, active) "
|
||||
"VALUES (:first, :last, :username, :email, :active)"
|
||||
)
|
||||
for batch_start in range(0, needed, BATCH):
|
||||
rows = [
|
||||
{
|
||||
"first": "seed",
|
||||
"last": f"user_{current + i}",
|
||||
"username": f"seed_user_{current + i}_{uuid4().hex[:8]}",
|
||||
"email": f"seed_user_{current + i}_{uuid4().hex[:8]}@example.invalid",
|
||||
"active": True,
|
||||
}
|
||||
for i in range(batch_start, min(batch_start + BATCH, needed))
|
||||
]
|
||||
conn.execute(sql, rows)
|
||||
logger.info(" ab_user: inserted %d / %d", batch_start + len(rows), needed)
|
||||
|
||||
|
||||
def seed_roles(conn: Connection, target: int, dry_run: bool) -> None:
|
||||
current = count_rows(conn, "ab_role")
|
||||
if current >= target:
|
||||
logger.info("ab_role: %d rows (target %d) — no insert needed", current, target)
|
||||
return
|
||||
needed = target - current
|
||||
logger.info("ab_role: %d → %d (+%d)", current, target, needed)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
sql = sa.text("INSERT INTO ab_role (name) VALUES (:name)")
|
||||
for batch_start in range(0, needed, BATCH):
|
||||
rows = [
|
||||
{"name": f"seed_role_{current + i}_{uuid4().hex[:8]}"}
|
||||
for i in range(batch_start, min(batch_start + BATCH, needed))
|
||||
]
|
||||
conn.execute(sql, rows)
|
||||
logger.info(" ab_role: inserted %d / %d", batch_start + len(rows), needed)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Junction seeder
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_existing_pairs(
|
||||
conn: Connection, junction: str, fk1_col: str, fk2_col: str
|
||||
) -> set[tuple[int, int]]:
|
||||
"""Load existing ``(fk1, fk2)`` pairs from a junction table into a set.
|
||||
|
||||
Used so the seeder can skip them when generating new pairs (junction
|
||||
tables enforce uniqueness on the FK pair). Memory is ~32 bytes/tuple
|
||||
on CPython, so 10M existing pairs is ~320MB — acceptable for a dev
|
||||
machine. The junction / column names come from ``JUNCTIONS``, not
|
||||
user input, so the f-string interpolation is safe.
|
||||
"""
|
||||
sql_text = f"SELECT {fk1_col}, {fk2_col} FROM {junction}" # noqa: S608
|
||||
return {(row[0], row[1]) for row in conn.execute(sa.text(sql_text))}
|
||||
|
||||
|
||||
def _generate_new_pairs(
|
||||
p1_ids: list[int],
|
||||
p2_ids: list[int],
|
||||
existing_pairs: set[tuple[int, int]],
|
||||
) -> Iterator[tuple[int, int]]:
|
||||
"""Yield ``(fk1, fk2)`` pairs from the parent1 × parent2 cross-product
|
||||
that are not already in ``existing_pairs``."""
|
||||
for fk1 in p1_ids:
|
||||
for fk2 in p2_ids:
|
||||
if (fk1, fk2) not in existing_pairs:
|
||||
yield (fk1, fk2)
|
||||
|
||||
|
||||
def seed_junction(
|
||||
conn: Connection,
|
||||
junction: str,
|
||||
fk1_col: str,
|
||||
fk2_col: str,
|
||||
parent1: str,
|
||||
parent2: str,
|
||||
target: int,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""Bulk-insert junction rows up to ``target`` rows total.
|
||||
|
||||
Generates ``(fk1, fk2)`` pairs by walking the cross-product of
|
||||
parent1 IDs × parent2 IDs in row-major order, skipping pairs that
|
||||
already exist. Walking the cross-product deterministically keeps
|
||||
the script replayable: re-running with the same target is a no-op,
|
||||
and re-running with a higher target appends new pairs in a stable
|
||||
order regardless of how many runs preceded.
|
||||
"""
|
||||
current = count_rows(conn, junction)
|
||||
if current >= target:
|
||||
logger.info(
|
||||
"%s: %d rows (target %d) — no insert needed", junction, current, target
|
||||
)
|
||||
return
|
||||
needed = target - current
|
||||
logger.info("%s: %d → %d (+%d)", junction, current, target, needed)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
p1_ids = existing_ids(conn, parent1)
|
||||
p2_ids = existing_ids(conn, parent2)
|
||||
max_pairs = len(p1_ids) * len(p2_ids)
|
||||
if max_pairs < target:
|
||||
sys.exit(
|
||||
f"Cannot reach {target} rows in {junction}: "
|
||||
f"only {max_pairs} unique pairs available "
|
||||
f"({len(p1_ids)} × {len(p2_ids)}). "
|
||||
f"Increase parent targets and rerun."
|
||||
)
|
||||
|
||||
existing_pairs: set[tuple[int, int]] = (
|
||||
_load_existing_pairs(conn, junction, fk1_col, fk2_col) if current > 0 else set()
|
||||
)
|
||||
if existing_pairs:
|
||||
logger.info(
|
||||
" %s: loaded %d existing pairs into avoidance set",
|
||||
junction,
|
||||
len(existing_pairs),
|
||||
)
|
||||
|
||||
insert_sql = sa.text(
|
||||
f"INSERT INTO {junction} ({fk1_col}, {fk2_col}) " # noqa: S608
|
||||
f"VALUES (:fk1, :fk2)"
|
||||
)
|
||||
|
||||
inserted = 0
|
||||
batch: list[dict[str, int]] = []
|
||||
for fk1, fk2 in _generate_new_pairs(p1_ids, p2_ids, existing_pairs):
|
||||
batch.append({"fk1": fk1, "fk2": fk2})
|
||||
inserted += 1
|
||||
if len(batch) == BATCH or inserted == needed:
|
||||
conn.execute(insert_sql, batch)
|
||||
logger.info(" %s: inserted %d / %d", junction, inserted, needed)
|
||||
batch = []
|
||||
if inserted == needed:
|
||||
return
|
||||
if inserted < needed:
|
||||
sys.exit(
|
||||
f"Ran out of unique pairs at {inserted}/{needed} for {junction} "
|
||||
f"(parents have {len(p1_ids)} × {len(p2_ids)} = {max_pairs} pairs, "
|
||||
f"{len(existing_pairs)} already present)"
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Orchestration
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def required_parent_count(target_pairs: int, other_parent: int) -> int:
|
||||
"""How many rows we need in this parent so that
|
||||
(this_parent × other_parent) ≥ target_pairs."""
|
||||
if other_parent == 0:
|
||||
# Bootstrapping: assume we'll create at least 1
|
||||
other_parent = 1
|
||||
return -(-target_pairs // other_parent) # ceil(target_pairs / other_parent)
|
||||
|
||||
|
||||
def _compute_parent_requirements(targets: dict[str, int]) -> dict[str, int]:
|
||||
"""For each parent table, return the minimum row count needed so that
|
||||
parent1 × parent2 ≥ target for every junction it participates in.
|
||||
|
||||
Allocates ceil(sqrt(target)) rows per parent, balanced across the two
|
||||
parents of each junction. The actual junction seeder will then walk
|
||||
the cross-product to produce the target number of unique pairs.
|
||||
"""
|
||||
parent_req: dict[str, int] = {}
|
||||
for junction, _, _, p1, p2 in JUNCTIONS:
|
||||
target = targets.get(junction, 0)
|
||||
if target == 0:
|
||||
continue
|
||||
sqrt_n = int(target**0.5) + 1
|
||||
parent_req[p1] = max(parent_req.get(p1, 0), sqrt_n)
|
||||
parent_req[p2] = max(parent_req.get(p2, 0), sqrt_n)
|
||||
return parent_req
|
||||
|
||||
|
||||
def _seed_parents(conn: Connection, parent_req: dict[str, int], dry_run: bool) -> None:
|
||||
"""Seed parent tables in dependency order:
|
||||
independent parents (ab_user, ab_role) first, then dashboards / slices /
|
||||
tables (which transitively depend on dbs, seeded inside seed_tables)."""
|
||||
if "ab_user" in parent_req:
|
||||
seed_users(conn, parent_req["ab_user"], dry_run)
|
||||
if "ab_role" in parent_req:
|
||||
seed_roles(conn, parent_req["ab_role"], dry_run)
|
||||
if "dashboards" in parent_req:
|
||||
seed_dashboards(conn, parent_req["dashboards"], dry_run)
|
||||
if "slices" in parent_req:
|
||||
seed_slices(conn, parent_req["slices"], dry_run)
|
||||
if "tables" in parent_req:
|
||||
seed_tables(conn, parent_req["tables"], dry_run)
|
||||
|
||||
|
||||
def _seed_all_junctions(
|
||||
conn: Connection, targets: dict[str, int], dry_run: bool
|
||||
) -> None:
|
||||
for junction, fk1, fk2, p1, p2 in JUNCTIONS:
|
||||
target = targets.get(junction, 0)
|
||||
if target == 0:
|
||||
continue
|
||||
with time_phase(f"junction:{junction}"):
|
||||
seed_junction(conn, junction, fk1, fk2, p1, p2, target, dry_run)
|
||||
|
||||
|
||||
def inject_duplicates(
|
||||
conn: Connection,
|
||||
junction: str,
|
||||
fk1_col: str,
|
||||
fk2_col: str,
|
||||
pct: float,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""Insert duplicate ``(fk1, fk2)`` rows on a non-UNIQUE junction table.
|
||||
|
||||
Used to stress-test the migration's ``_dedupe_by_min_id`` phase, which
|
||||
is otherwise a no-op on cleanly-seeded data. Computes ``count =
|
||||
current_rows * pct / 100`` and inserts that many rows by re-sampling
|
||||
existing ``(fk1, fk2)`` pairs in row-major order. The synthetic
|
||||
duplicates land on top of distinct existing pairs (one duplicate per
|
||||
distinct pair, then wraps), so the migration's dedupe finds and
|
||||
deletes them.
|
||||
|
||||
**Pre-condition: the table must NOT have UNIQUE on (fk1, fk2)**, i.e.,
|
||||
the schema must be the pre-migration shape (after running
|
||||
``superset db downgrade``). On the post-migration schema the composite
|
||||
PK rejects duplicates and this function will error.
|
||||
"""
|
||||
if pct == 0:
|
||||
return
|
||||
current = count_rows(conn, junction)
|
||||
count = int(current * pct / 100)
|
||||
if count == 0:
|
||||
logger.info(
|
||||
"%s: 0 duplicates to inject (current=%d, pct=%g)",
|
||||
junction,
|
||||
current,
|
||||
pct,
|
||||
)
|
||||
return
|
||||
logger.info(
|
||||
"%s: injecting %d duplicate rows (%g%% of %d existing)",
|
||||
junction,
|
||||
count,
|
||||
pct,
|
||||
current,
|
||||
)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
select_sql = sa.text(
|
||||
f"SELECT {fk1_col}, {fk2_col} FROM {junction} ORDER BY id LIMIT :n" # noqa: S608
|
||||
)
|
||||
sample = conn.execute(select_sql, {"n": count}).fetchall()
|
||||
if not sample:
|
||||
logger.warning("%s: no rows to duplicate (table is empty)", junction)
|
||||
return
|
||||
|
||||
insert_sql = sa.text(
|
||||
f"INSERT INTO {junction} ({fk1_col}, {fk2_col}) " # noqa: S608
|
||||
f"VALUES (:fk1, :fk2)"
|
||||
)
|
||||
inserted = 0
|
||||
while inserted < count:
|
||||
batch: list[dict[str, int]] = []
|
||||
while len(batch) < BATCH and inserted < count:
|
||||
row = sample[inserted % len(sample)]
|
||||
batch.append({"fk1": row[0], "fk2": row[1]})
|
||||
inserted += 1
|
||||
conn.execute(insert_sql, batch)
|
||||
logger.info(" %s: injected %d / %d duplicates", junction, inserted, count)
|
||||
|
||||
|
||||
def _inject_dirty_data(conn: Connection, dirty_pct: float, dry_run: bool) -> None:
|
||||
"""Inject duplicate rows on every non-UNIQUE seeded junction.
|
||||
|
||||
The two tables that originally carried ``UNIQUE(fk1, fk2)`` are
|
||||
skipped because their composite-PK successor (and their pre-migration
|
||||
UNIQUE constraint) both reject duplicate inserts.
|
||||
"""
|
||||
if dirty_pct == 0:
|
||||
return
|
||||
for junction, fk1, fk2, _, _ in JUNCTIONS:
|
||||
if junction in JUNCTIONS_WITH_UNIQUE:
|
||||
logger.info(
|
||||
"%s: skipping duplicate injection (table has UNIQUE on FK pair)",
|
||||
junction,
|
||||
)
|
||||
continue
|
||||
with time_phase(f"dirty:{junction}"):
|
||||
inject_duplicates(conn, junction, fk1, fk2, dirty_pct, dry_run)
|
||||
|
||||
|
||||
def run(targets: dict[str, int], dry_run: bool, dirty_duplicates_pct: float) -> None:
|
||||
engine = build_engine()
|
||||
with engine.begin() as conn:
|
||||
parent_req = _compute_parent_requirements(targets)
|
||||
logger.info("Required parent row counts: %s", parent_req)
|
||||
|
||||
with time_phase("parents"):
|
||||
_seed_parents(conn, parent_req, dry_run)
|
||||
|
||||
with time_phase("junctions"):
|
||||
_seed_all_junctions(conn, targets, dry_run)
|
||||
|
||||
if dirty_duplicates_pct > 0:
|
||||
with time_phase("dirty-duplicates"):
|
||||
_inject_dirty_data(conn, dirty_duplicates_pct, dry_run)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# CLI
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
for table, default in DEFAULTS.items():
|
||||
parser.add_argument(
|
||||
f"--{table.replace('_', '-')}",
|
||||
type=int,
|
||||
default=default,
|
||||
help=f"target row count for {table} (default: {default:,})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
"-n",
|
||||
action="store_true",
|
||||
help="print planned inserts without writing to the DB",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dirty-duplicates-pct",
|
||||
type=float,
|
||||
default=0,
|
||||
help=(
|
||||
"after seeding distinct pairs, inject this percentage of duplicate "
|
||||
"rows on each non-UNIQUE junction (slice_user, dashboard_user, "
|
||||
"dashboard_roles). Stress-tests the migration's _dedupe_by_min_id "
|
||||
"phase. Requires the DB to be at the pre-migration revision "
|
||||
"(33d7e0e21daa) — the post-migration composite PK rejects "
|
||||
"duplicates and this will error. Default: 0 (no duplicates)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
"-v",
|
||||
action="store_true",
|
||||
help="increase log verbosity",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
|
||||
targets = {table: getattr(args, table) for table in DEFAULTS}
|
||||
|
||||
logger.info("Targets: %s", targets)
|
||||
logger.info("Dry run: %s", args.dry_run)
|
||||
logger.info("Dirty duplicates pct: %g", args.dirty_duplicates_pct)
|
||||
|
||||
with time_phase("total"):
|
||||
run(
|
||||
targets,
|
||||
dry_run=args.dry_run,
|
||||
dirty_duplicates_pct=args.dirty_duplicates_pct,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -92,26 +92,6 @@ class Dimension:
|
||||
grain: Grain | None = None
|
||||
|
||||
|
||||
class AggregationType(str, enum.Enum):
|
||||
"""
|
||||
Aggregation function applied by a metric.
|
||||
|
||||
Additivity (across an arbitrary set of grouping dimensions):
|
||||
* ``SUM``, ``COUNT``: fully additive — sub-group sums roll up via ``sum``.
|
||||
* ``MIN``, ``MAX``: roll up via ``min`` / ``max`` of sub-group values.
|
||||
* ``AVG``, ``COUNT_DISTINCT``, ``OTHER``: not safely roll-uppable from
|
||||
sub-aggregates without auxiliary data.
|
||||
"""
|
||||
|
||||
SUM = "SUM"
|
||||
COUNT = "COUNT"
|
||||
MIN = "MIN"
|
||||
MAX = "MAX"
|
||||
AVG = "AVG"
|
||||
COUNT_DISTINCT = "COUNT_DISTINCT"
|
||||
OTHER = "OTHER"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Metric:
|
||||
id: str
|
||||
@@ -120,7 +100,6 @@ class Metric:
|
||||
|
||||
definition: str
|
||||
description: str | None = None
|
||||
aggregation: AggregationType | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -17,8 +17,12 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { ReactNode, SyntheticEvent } from 'react';
|
||||
import { ResizableBox, ResizeCallbackData } from 'react-resizable';
|
||||
import { PropsWithChildren, ReactNode, SyntheticEvent } from 'react';
|
||||
import {
|
||||
ResizableBox,
|
||||
ResizableBoxProps,
|
||||
ResizeCallbackData,
|
||||
} from 'react-resizable';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
|
||||
import 'react-resizable/css/styles.css';
|
||||
@@ -42,16 +46,14 @@ export type Size = ResizeCallbackData['size'];
|
||||
|
||||
export default function ResizablePanel({
|
||||
children,
|
||||
heading,
|
||||
heading = undefined,
|
||||
initialSize = { width: 500, height: 300 },
|
||||
minConstraints = [100, 100] as [number, number],
|
||||
onResize,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
...props
|
||||
}: PropsWithChildren<Omit<ResizableBoxProps, 'width' | 'height'>> & {
|
||||
heading?: ReactNode;
|
||||
initialSize?: Size;
|
||||
minConstraints?: [number, number];
|
||||
onResize?: (e: SyntheticEvent, data: ResizeCallbackData) => void;
|
||||
}) {
|
||||
const { width, height } = initialSize;
|
||||
return (
|
||||
@@ -59,14 +61,16 @@ export default function ResizablePanel({
|
||||
className="panel"
|
||||
width={width}
|
||||
height={height}
|
||||
axis="both"
|
||||
minConstraints={minConstraints}
|
||||
maxConstraints={[Infinity, Infinity]}
|
||||
handleSize={[20, 20]}
|
||||
lockAspectRatio={false}
|
||||
resizeHandles={['se']}
|
||||
transformScale={1}
|
||||
onResize={onResize}
|
||||
onResize={
|
||||
onResize
|
||||
? (e: SyntheticEvent, data: ResizeCallbackData) => {
|
||||
const { size } = data;
|
||||
onResize(e, { ...data, size });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<>
|
||||
{heading ? <div className="panel-heading">{heading}</div> : null}
|
||||
|
||||
561
superset-frontend/package-lock.json
generated
561
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -112,7 +112,7 @@
|
||||
"@fontsource/fira-code": "^5.2.7",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@googleapis/sheets": "^13.0.2",
|
||||
"@googleapis/sheets": "^13.0.1",
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
"@jsonforms/react": "^3.7.0",
|
||||
@@ -166,7 +166,7 @@
|
||||
"antd": "^5.26.0",
|
||||
"chrono-node": "^2.9.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.0",
|
||||
"content-disposition": "^1.1.0",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.20",
|
||||
@@ -190,8 +190,8 @@
|
||||
"json-bigint": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^2.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"mapbox-gl": "^3.24.0",
|
||||
"markdown-to-jsx": "^9.8.1",
|
||||
"mapbox-gl": "^3.23.1",
|
||||
"markdown-to-jsx": "^9.8.0",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^5.2.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
@@ -276,7 +276,7 @@
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@swc/plugin-emotion": "^14.10.0",
|
||||
"@swc/plugin-emotion": "^14.9.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -289,12 +289,12 @@
|
||||
"@types/js-levenshtein": "^1.1.3",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
"@types/react-redux": "^7.1.10",
|
||||
"@types/react-resizable": "^4.0.0",
|
||||
"@types/react-resizable": "^3.0.8",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-transition-group": "^4.4.12",
|
||||
"@types/react-window": "^1.8.8",
|
||||
@@ -303,14 +303,14 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.59.3",
|
||||
"babel-jest": "^30.4.1",
|
||||
"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.31",
|
||||
"baseline-browser-mapping": "^2.10.29",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -350,13 +350,13 @@
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxlint": "^1.66.0",
|
||||
"oxlint": "^1.65.0",
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "3.8.3",
|
||||
"prettier-plugin-packagejson": "^3.0.2",
|
||||
"process": "^0.11.10",
|
||||
"react-refresh": "^0.18.0",
|
||||
"react-resizable": "^4.0.1",
|
||||
"react-resizable": "^3.1.3",
|
||||
"redux-mock-store": "^1.5.4",
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
@@ -365,14 +365,14 @@
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.0",
|
||||
"ts-jest": "^29.4.11",
|
||||
"ts-jest": "^29.4.9",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.22.3",
|
||||
"tsx": "^4.22.0",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"wait-on": "^9.0.10",
|
||||
"webpack": "^5.107.1",
|
||||
"webpack": "^5.106.2",
|
||||
"webpack-bundle-analyzer": "^5.3.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.4",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"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": {
|
||||
|
||||
@@ -17,12 +17,23 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Manifest schema for Superset extension contributions.
|
||||
*
|
||||
* This module defines the aggregate interfaces used by the extension.json
|
||||
* manifest and the `superset-extensions` build command. Individual metadata
|
||||
* types are defined in their respective namespace modules (commands, views,
|
||||
* menus, editors) and re-exported here for the manifest schema.
|
||||
*/
|
||||
|
||||
import { Command } from '../commands';
|
||||
import { View } from '../views';
|
||||
import type { ChatbotView } from '../views';
|
||||
import { Menu } from '../menus';
|
||||
import { Editor } from '../editors';
|
||||
|
||||
export type { ChatbotView };
|
||||
|
||||
/**
|
||||
* Valid locations within SQL Lab.
|
||||
*/
|
||||
export type SqlLabLocation =
|
||||
| 'leftSidebar'
|
||||
| 'rightSidebar'
|
||||
@@ -32,14 +43,43 @@ export type SqlLabLocation =
|
||||
| 'results'
|
||||
| 'queryHistory';
|
||||
|
||||
/** Valid locations within the app shell (persist across all routes). */
|
||||
export type AppLocation = 'chatbot';
|
||||
|
||||
/**
|
||||
* Nested structure for view contributions by scope and location.
|
||||
* @example
|
||||
* {
|
||||
* sqllab: {
|
||||
* panels: [{ id: "my-ext.panel", name: "My Panel" }],
|
||||
* leftSidebar: [{ id: "my-ext.sidebar", name: "My Sidebar" }]
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export interface ViewContributions {
|
||||
sqllab?: Partial<Record<SqlLabLocation, View[]>>;
|
||||
app?: Partial<Record<AppLocation, View[]>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nested structure for menu contributions by scope and location.
|
||||
* @example
|
||||
* {
|
||||
* sqllab: {
|
||||
* editor: { primary: [...], secondary: [...] }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export interface MenuContributions {
|
||||
sqllab?: Partial<Record<SqlLabLocation, Menu>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
|
||||
*/
|
||||
export interface Contributions {
|
||||
/** List of commands. */
|
||||
commands: Command[];
|
||||
/** Nested mapping of menu contributions by scope and location. */
|
||||
menus: MenuContributions;
|
||||
/** Nested mapping of view contributions by scope and location. */
|
||||
views: ViewContributions;
|
||||
/** List of editors. */
|
||||
editors?: Editor[];
|
||||
}
|
||||
|
||||
@@ -20,12 +20,19 @@
|
||||
/**
|
||||
* @fileoverview Views registration API for Superset extensions.
|
||||
*
|
||||
* Extensions register React views at named locations using `registerView`.
|
||||
* Registrations happen as module-level side effects at import time.
|
||||
* This module provides functions for registering custom React views
|
||||
* at specific locations in the Superset UI. Views are registered as
|
||||
* module-level side effects at import time.
|
||||
*
|
||||
* Built-in locations:
|
||||
* - `sqllab.panels` / `sqllab.rightSidebar` / … — SQL Lab surface
|
||||
* - `superset.chatbot` — app-shell chatbot bubble (singleton; host renders one)
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { views } from '@apache-superset/core';
|
||||
*
|
||||
* views.registerView(
|
||||
* { id: 'my_ext.result_stats', name: 'Result Stats', location: 'sqllab.panels' },
|
||||
* () => <ResultStatsPanel />,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
@@ -41,23 +48,20 @@ export interface View {
|
||||
name: string;
|
||||
/** Optional description of the view, for display in contribution manifests. */
|
||||
description?: string;
|
||||
/**
|
||||
* Optional icon identifier for the view, used in admin pickers and manifest
|
||||
* listings. Static — set once at registerView() time.
|
||||
* Dynamic icon states (e.g. notification badge) are the extension's concern.
|
||||
*/
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a custom view at a specific UI location.
|
||||
*
|
||||
* @param view The view descriptor (id, name, and optional icon/description).
|
||||
* @param location The location where this view should appear.
|
||||
* The view provider function is called when the UI renders the location,
|
||||
* and should return a React element to display.
|
||||
*
|
||||
* @param view The view descriptor (id and name).
|
||||
* @param location The location where this view should appear (e.g. "sqllab.panels").
|
||||
* @param provider A function that returns the React element to render.
|
||||
* @returns A Disposable that unregisters the view when disposed.
|
||||
*
|
||||
* @example SQL Lab panel
|
||||
* @example
|
||||
* ```typescript
|
||||
* views.registerView(
|
||||
* { id: 'my_ext.result_stats', name: 'Result Stats' },
|
||||
@@ -65,15 +69,6 @@ export interface View {
|
||||
* () => <ResultStatsPanel />,
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @example Chatbot bubble (`superset.chatbot` — singleton, host renders one)
|
||||
* ```typescript
|
||||
* views.registerView(
|
||||
* { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' },
|
||||
* 'superset.chatbot',
|
||||
* () => <ChatbotApp />,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export declare function registerView(
|
||||
view: View,
|
||||
@@ -81,21 +76,6 @@ export declare function registerView(
|
||||
provider: () => ReactElement,
|
||||
): Disposable;
|
||||
|
||||
/**
|
||||
* Narrowed descriptor for chatbot contributions (`superset.chatbot` location).
|
||||
*
|
||||
* Extension authors should use this type when calling `registerView` for the
|
||||
* chatbot area. It is identical to {@link View} but makes the registration
|
||||
* intent explicit and allows future narrowing (e.g. required `icon`).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const chatbot: ChatbotView = { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' };
|
||||
* views.registerView(chatbot, 'superset.chatbot', () => <ChatbotApp />);
|
||||
* ```
|
||||
*/
|
||||
export type ChatbotView = View;
|
||||
|
||||
/**
|
||||
* Retrieves all views registered at a specific location.
|
||||
*
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-ultimate-pagination": "^1.3.2",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
@@ -76,7 +76,7 @@
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/jquery": "^4.0.0",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-table": "^7.7.20",
|
||||
|
||||
@@ -371,37 +371,3 @@ test('should handle large datasets with pagination', () => {
|
||||
expect(screen.getByRole('list')).toBeInTheDocument();
|
||||
expect(screen.getByText('1-10 of 100')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should reset to first page when data reduces below current page', async () => {
|
||||
// Start with 30 items, 10 per page = 3 pages
|
||||
const initialData = Array.from({ length: 30 }, (_, i) => ({
|
||||
id: i,
|
||||
age: 20 + i,
|
||||
name: `Person ${i}`,
|
||||
}));
|
||||
|
||||
const props = {
|
||||
...mockedProps,
|
||||
data: initialData,
|
||||
pageSize: 10,
|
||||
};
|
||||
|
||||
const { rerender } = render(<TableView {...props} />);
|
||||
|
||||
// Navigate to page 3 (last page)
|
||||
const page3 = screen.getByRole('listitem', { name: '3' });
|
||||
await userEvent.click(page3);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('21-30 of 30')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Reduce data to only 5 items (fewer than current page would show)
|
||||
const reducedData = initialData.slice(0, 5);
|
||||
rerender(<TableView {...props} data={reducedData} />);
|
||||
|
||||
// Should reset to page 1 since page 3 no longer exists
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1-5 of 5')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -246,21 +246,6 @@ const RawTableView = ({
|
||||
}
|
||||
}, [initialSortBy, onServerPagination, serverPagination, sortBy]);
|
||||
|
||||
// Reset to first page when current page exceeds available pages
|
||||
// (e.g., when filtering reduces the data below the current page)
|
||||
const pageCount = Math.ceil(data.length / effectivePageSize);
|
||||
useEffect(() => {
|
||||
if (
|
||||
withPagination &&
|
||||
!serverPagination &&
|
||||
!loading &&
|
||||
pageIndex > pageCount - 1 &&
|
||||
pageCount > 0
|
||||
) {
|
||||
setPageIndex(0);
|
||||
}
|
||||
}, [withPagination, serverPagination, loading, pageIndex, pageCount]);
|
||||
|
||||
return (
|
||||
<TableViewStyles {...props} ref={tableRef}>
|
||||
<TableCollection
|
||||
|
||||
@@ -63,8 +63,7 @@ export default async function parseResponse<T extends ParseMethod = 'json'>(
|
||||
(value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) ||
|
||||
value?.isLessThan?.(Number.MIN_SAFE_INTEGER))
|
||||
) {
|
||||
// toFixed() avoids scientific notation, which BigInt() rejects.
|
||||
return BigInt(value.toFixed());
|
||||
return BigInt(value);
|
||||
}
|
||||
// // `json-bigint` could not handle floats well, see sidorares/json-bigint#62
|
||||
// // TODO: clean up after json-bigint>1.0.1 is released
|
||||
|
||||
@@ -183,26 +183,6 @@ describe('parseResponse()', () => {
|
||||
expect(responseBigNumber.json.constructor).toEqual('constructor');
|
||||
});
|
||||
|
||||
test('handles big numbers in scientific notation when `parseMethod=json-bigint`', async () => {
|
||||
const mockScientificUrl = '/mock/get/scientific';
|
||||
const mockScientificPayload =
|
||||
'{ "big_double": 4.799703045723905e+32, "negative_big": -4.799703045723905e+32, "small": 1 }';
|
||||
fetchMock.get(mockScientificUrl, mockScientificPayload);
|
||||
|
||||
const responseBigNumber = await parseResponse(
|
||||
callApi({ url: mockScientificUrl, method: 'GET' }),
|
||||
'json-bigint',
|
||||
);
|
||||
|
||||
expect(`${responseBigNumber.json.big_double}`).toEqual(
|
||||
'479970304572390500000000000000000',
|
||||
);
|
||||
expect(`${responseBigNumber.json.negative_big}`).toEqual(
|
||||
'-479970304572390500000000000000000',
|
||||
);
|
||||
expect(responseBigNumber.json.small).toEqual(1);
|
||||
});
|
||||
|
||||
test('rejects if request.ok=false', async () => {
|
||||
expect.assertions(3);
|
||||
const mockNotOkayUrl = '/mock/notokay/url';
|
||||
|
||||
@@ -95,11 +95,8 @@ class FakeMessageChannel {
|
||||
const port2 = new FakeMessagePort();
|
||||
port1.otherPort = port2;
|
||||
port2.otherPort = port1;
|
||||
// FakeMessagePort only implements the subset of MessagePort that
|
||||
// Switchboard exercises; cast at the boundary so the fake satisfies
|
||||
// the consumer signature without weakening the production type.
|
||||
this.port1 = port1 as unknown as MessagePort;
|
||||
this.port2 = port2 as unknown as MessagePort;
|
||||
this.port1 = port1;
|
||||
this.port2 = port2;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ function isError(message: Message): message is ErrorMessage {
|
||||
* Calling methods on the switchboard causes messages to be sent through the channel.
|
||||
*/
|
||||
export class Switchboard {
|
||||
port!: MessagePort;
|
||||
port: MessagePort;
|
||||
|
||||
name = '';
|
||||
|
||||
@@ -97,9 +97,9 @@ export class Switchboard {
|
||||
// used to make unique ids
|
||||
incrementor = 1;
|
||||
|
||||
debugMode = false;
|
||||
debugMode: boolean;
|
||||
|
||||
private isInitialised = false;
|
||||
private isInitialised: boolean;
|
||||
|
||||
constructor(params?: Params) {
|
||||
if (!params) {
|
||||
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
VisualMapComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
type DataZoomComponentOption,
|
||||
ToolboxComponent,
|
||||
GraphicComponent,
|
||||
AriaComponent,
|
||||
@@ -281,56 +280,12 @@ function Echart(
|
||||
|
||||
const notMerge = !isDashboardRefreshing;
|
||||
chartRef.current?.dispatchAction({ type: 'hideTip' });
|
||||
// setOption(notMerge:true) replaces the dataZoom config, dropping any
|
||||
// range the user has engaged. Preserve it across the call.
|
||||
const previousZoom = notMerge
|
||||
? (chartRef.current?.getOption() as { dataZoom?: DataZoomComponentOption[] })
|
||||
?.dataZoom
|
||||
: undefined;
|
||||
chartRef.current?.setOption(themedEchartOptions, {
|
||||
notMerge,
|
||||
replaceMerge: notMerge ? undefined : ['series'],
|
||||
// lazyUpdate defers render, causing tooltip crashes on stale shapes (#39247)
|
||||
lazyUpdate: false,
|
||||
});
|
||||
if (previousZoom?.length) {
|
||||
// Skip restore when the new option reshapes dataZoom (different count
|
||||
// means index-based restore could land on the wrong component).
|
||||
const newZoom = (
|
||||
chartRef.current?.getOption() as {
|
||||
dataZoom?: DataZoomComponentOption[];
|
||||
}
|
||||
)?.dataZoom;
|
||||
if (newZoom?.length === previousZoom.length) {
|
||||
const batch = previousZoom
|
||||
.map((dz, dataZoomIndex) => ({
|
||||
dataZoomIndex,
|
||||
start: dz.start,
|
||||
end: dz.end,
|
||||
startValue: dz.startValue,
|
||||
endValue: dz.endValue,
|
||||
}))
|
||||
.filter(b => {
|
||||
const hasAny =
|
||||
b.start !== undefined ||
|
||||
b.end !== undefined ||
|
||||
b.startValue !== undefined ||
|
||||
b.endValue !== undefined;
|
||||
if (!hasAny) return false;
|
||||
// Default full-range zoom is functionally identical to the
|
||||
// fresh state setOption already produces — skip the dispatch.
|
||||
const isDefaultRange =
|
||||
b.start === 0 &&
|
||||
b.end === 100 &&
|
||||
b.startValue === undefined &&
|
||||
b.endValue === undefined;
|
||||
return !isDefaultRange;
|
||||
});
|
||||
if (batch.length) {
|
||||
chartRef.current?.dispatchAction({ type: 'dataZoom', batch });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- isDashboardRefreshing intentionally excluded to prevent extra setOption calls
|
||||
}, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme, vizType]);
|
||||
|
||||
@@ -1,192 +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 type { EChartsCoreOption } from 'echarts/core';
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
|
||||
const setOption = jest.fn();
|
||||
const on = jest.fn();
|
||||
const off = jest.fn();
|
||||
const resize = jest.fn();
|
||||
const dispose = jest.fn();
|
||||
const dispatchAction = jest.fn();
|
||||
const getOption = jest.fn();
|
||||
|
||||
const mockInstance = {
|
||||
setOption,
|
||||
on,
|
||||
off,
|
||||
resize,
|
||||
dispose,
|
||||
dispatchAction,
|
||||
getOption,
|
||||
getZr: () => ({ on: jest.fn(), off: jest.fn() }),
|
||||
};
|
||||
|
||||
jest.mock('echarts/core', () => ({
|
||||
__esModule: true,
|
||||
use: jest.fn(),
|
||||
init: jest.fn(() => mockInstance),
|
||||
registerLocale: jest.fn(),
|
||||
}));
|
||||
jest.mock('echarts/charts', () => ({}));
|
||||
jest.mock('echarts/renderers', () => ({}));
|
||||
jest.mock('echarts/components', () => ({}));
|
||||
jest.mock('echarts/features', () => ({}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import Echart from '../../src/components/Echart';
|
||||
|
||||
const renderEchart = (echartOptions: EChartsCoreOption) => {
|
||||
const refs = { divRef: undefined };
|
||||
return render(
|
||||
<Echart
|
||||
width={400}
|
||||
height={300}
|
||||
echartOptions={echartOptions}
|
||||
refs={refs}
|
||||
/>,
|
||||
{ useRedux: true, useTheme: true },
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setOption.mockClear();
|
||||
on.mockClear();
|
||||
off.mockClear();
|
||||
resize.mockClear();
|
||||
dispatchAction.mockClear();
|
||||
getOption.mockReset();
|
||||
});
|
||||
|
||||
test('preserves user dataZoom range across setOption(notMerge)', async () => {
|
||||
// After the user has zoomed, ECharts reports the current dataZoom range
|
||||
// via getOption().dataZoom. We expect Echart to capture this before
|
||||
// setOption replaces the option payload, then restore it via dispatchAction.
|
||||
getOption.mockReturnValue({
|
||||
dataZoom: [{ start: 12, end: 48 }],
|
||||
});
|
||||
|
||||
const { rerender } = renderEchart({ xAxis: {}, series: [] });
|
||||
|
||||
// Trigger another setOption call by changing the echartOptions reference
|
||||
rerender(
|
||||
<Echart
|
||||
width={400}
|
||||
height={300}
|
||||
echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
|
||||
refs={{ divRef: undefined }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(dispatchAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'dataZoom',
|
||||
batch: [
|
||||
expect.objectContaining({ dataZoomIndex: 0, start: 12, end: 48 }),
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not restore when no prior zoom range exists', async () => {
|
||||
// Fresh chart with no engaged zoom: dataZoom config has no start/end.
|
||||
getOption.mockReturnValue({
|
||||
dataZoom: [{ type: 'slider', show: true }],
|
||||
});
|
||||
|
||||
const { rerender } = renderEchart({ xAxis: {}, series: [] });
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(
|
||||
<Echart
|
||||
width={400}
|
||||
height={300}
|
||||
echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
|
||||
refs={{ divRef: undefined }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(2));
|
||||
const dataZoomCalls = dispatchAction.mock.calls.filter(
|
||||
([action]) => action?.type === 'dataZoom',
|
||||
);
|
||||
expect(dataZoomCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does not restore when prior zoom is at default full range', async () => {
|
||||
// ECharts populates start:0/end:100 on slider dataZoom by default, so
|
||||
// every untouched timeseries would otherwise dispatch a redundant action
|
||||
// on each re-render. Skip the dispatch when the range is just the default.
|
||||
getOption.mockReturnValue({
|
||||
dataZoom: [{ type: 'slider', show: true, start: 0, end: 100 }],
|
||||
});
|
||||
|
||||
const { rerender } = renderEchart({ xAxis: {}, series: [] });
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(
|
||||
<Echart
|
||||
width={400}
|
||||
height={300}
|
||||
echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
|
||||
refs={{ divRef: undefined }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(2));
|
||||
const dataZoomCalls = dispatchAction.mock.calls.filter(
|
||||
([action]) => action?.type === 'dataZoom',
|
||||
);
|
||||
expect(dataZoomCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does not restore when the new option reshapes dataZoom', async () => {
|
||||
// 1st render starts with no engaged zoom; 2nd render captures an engaged
|
||||
// range but the post-setOption dataZoom has a different count, so
|
||||
// index-based restore could write to the wrong component. Skip in that case.
|
||||
getOption
|
||||
// 1st render: previousZoom + newZoom (no engaged values, nothing to dispatch)
|
||||
.mockReturnValueOnce({ dataZoom: [{ type: 'slider' }] })
|
||||
.mockReturnValueOnce({ dataZoom: [{ type: 'slider' }] })
|
||||
// 2nd render: previousZoom has user range, but newZoom has 2 entries
|
||||
.mockReturnValueOnce({ dataZoom: [{ start: 12, end: 48 }] })
|
||||
.mockReturnValueOnce({
|
||||
dataZoom: [{ start: 12, end: 48 }, { type: 'inside' }],
|
||||
});
|
||||
|
||||
const { rerender } = renderEchart({ xAxis: {}, series: [] });
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(
|
||||
<Echart
|
||||
width={400}
|
||||
height={300}
|
||||
echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
|
||||
refs={{ divRef: undefined }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(2));
|
||||
const dataZoomCalls = dispatchAction.mock.calls.filter(
|
||||
([action]) => action?.type === 'dataZoom',
|
||||
);
|
||||
expect(dataZoomCalls).toHaveLength(0);
|
||||
});
|
||||
@@ -27,7 +27,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.24.0",
|
||||
"mapbox-gl": "^3.23.1",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"supercluster": "^8.0.1"
|
||||
|
||||
@@ -27,7 +27,7 @@ jest.mock('../../DeckGLContainer', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('../../factory', () => ({
|
||||
createCategoricalDeckGLComponent: jest.fn(() => () => null),
|
||||
createDeckGLComponent: jest.fn(() => () => null),
|
||||
GetLayerType: {},
|
||||
}));
|
||||
|
||||
@@ -53,14 +53,6 @@ const mockPayload = {
|
||||
},
|
||||
};
|
||||
|
||||
const mockLayerParams = {
|
||||
onContextMenu: jest.fn(),
|
||||
filterState: undefined,
|
||||
setDataMask: jest.fn(),
|
||||
setTooltip: jest.fn(),
|
||||
emitCrossFilters: false,
|
||||
};
|
||||
|
||||
test('getLayer uses line_width_unit from formData', () => {
|
||||
const layer = getLayer({
|
||||
formData: mockFormData,
|
||||
@@ -125,518 +117,3 @@ test('getPoints extracts points from path data', () => {
|
||||
expect(points[0]).toEqual([0, 0]);
|
||||
expect(points[2]).toEqual([2, 2]);
|
||||
});
|
||||
|
||||
test('Fixed width mode returns constant width for all paths', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 5,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: 5,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
width: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
const widths = data.map(d => d.width);
|
||||
|
||||
widths.forEach(width => {
|
||||
expect(width).toBe(widths[0]);
|
||||
});
|
||||
});
|
||||
|
||||
test('Fixed width mode applies multiplier correctly', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
line_width_multiplier: 3,
|
||||
min_width: 1,
|
||||
max_width: 100,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
expect(data[0].width).toBe(15);
|
||||
});
|
||||
|
||||
test('Fixed width mode enforces minimum width bound', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 0.1,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
min_width: 2,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
expect(data[0].width).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('Fixed width mode enforces maximum width bound', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
expect(data[0].width).toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
test('Fixed width mode defaults width to 1 when no width is provided', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
line_width: undefined,
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
expect(data[0].width).toBe(1);
|
||||
});
|
||||
|
||||
test('Metric mode normalizes widths proportionally between min and max bounds', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
width: 300,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
line_width: { type: 'metric', value: 'some_metric' },
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
const widths = data.map((d: any) => d.width);
|
||||
|
||||
expect(widths[0]).toBeCloseTo(1);
|
||||
expect(widths[1]).toBeCloseTo(10.5);
|
||||
expect(widths[2]).toBeCloseTo(20);
|
||||
});
|
||||
|
||||
test('Metric mode applies multiplier after normalization', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
line_width: { type: 'metric', value: 'some_metric' },
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 2,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
expect(data[0].width).toBeCloseTo(2);
|
||||
expect(data[1].width).toBe(20);
|
||||
});
|
||||
|
||||
test('Metric mode enforces bounds after multiplier', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: 500,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
min_width: 5,
|
||||
max_width: 15,
|
||||
line_width_multiplier: 10,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
data.forEach((d: any) => {
|
||||
expect(d.width).toBeGreaterThanOrEqual(5);
|
||||
expect(d.width).toBeLessThanOrEqual(15);
|
||||
});
|
||||
});
|
||||
|
||||
test('Metric mode handles equal width values.', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
expect(data[0].width).toBe(data[1].width);
|
||||
});
|
||||
|
||||
test('Metric mode handles null width values', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: null,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
width: 300,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
line_width: { type: 'metric', value: 'some_metric' },
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
expect(data[1].width).toBe(1);
|
||||
expect(data[0].width).toBeCloseTo(1);
|
||||
expect(data[2].width).toBeCloseTo(20);
|
||||
});
|
||||
|
||||
test('Fixed color mode returns same color for all paths', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
color_picker: { r: 255, g: 100, b: 50, a: 1 },
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
const expectedColor = [255, 100, 50, 255];
|
||||
|
||||
data.forEach((d: any) => {
|
||||
expect(d.color).toEqual(expectedColor);
|
||||
});
|
||||
});
|
||||
|
||||
test('Categorical mode preserves distinct colors for selected categories', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
color: [255, 0, 0, 255],
|
||||
cat_color: 'A',
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
color: [0, 0, 255, 255],
|
||||
cat_color: 'B',
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
color: [255, 0, 0, 255],
|
||||
cat_color: 'A',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: mockFormData,
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
expect(data[0].color).toEqual(data[2].color);
|
||||
expect(data[0].color).not.toEqual(data[1].color);
|
||||
});
|
||||
|
||||
test('Breakpoint mode preserves colors assigned by addColor based on metric ranges', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
color: [255, 0, 0, 255],
|
||||
metric: 50,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
color: [0, 0, 255, 255],
|
||||
metric: 200,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
color: [255, 0, 0, 255],
|
||||
metric: 75,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: mockFormData,
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
expect(data[0].color).toEqual(data[2].color);
|
||||
expect(data[0].color).not.toEqual(data[1].color);
|
||||
});
|
||||
|
||||
@@ -21,14 +21,13 @@ import { PathLayer } from '@deck.gl/layers';
|
||||
import { JsonObject, QueryFormData } from '@superset-ui/core';
|
||||
import { commonLayerProps } from '../common';
|
||||
import sandboxedEval from '../../utils/sandbox';
|
||||
import { GetLayerType, createCategoricalDeckGLComponent } from '../../factory';
|
||||
import { GetLayerType, createDeckGLComponent } from '../../factory';
|
||||
import { Point } from '../../types';
|
||||
import {
|
||||
createTooltipContent,
|
||||
CommonTooltipRows,
|
||||
} from '../../utilities/tooltipUtils';
|
||||
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
|
||||
import { isMetricValue } from '../utils/metricUtils';
|
||||
|
||||
function setTooltipContent(formData: QueryFormData) {
|
||||
const defaultTooltipGenerator = (o: JsonObject) => (
|
||||
@@ -51,69 +50,14 @@ export const getLayer: GetLayerType<PathLayer> = function ({
|
||||
emitCrossFilters,
|
||||
}) {
|
||||
const fd = formData;
|
||||
let data = payload.data.features.map((feature: JsonObject) => {
|
||||
if (feature.color) {
|
||||
return { ...feature };
|
||||
}
|
||||
|
||||
const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
|
||||
const color = [c.r, c.g, c.b, 255 * c.a];
|
||||
|
||||
return {
|
||||
...feature,
|
||||
path: feature.path,
|
||||
color,
|
||||
};
|
||||
});
|
||||
|
||||
// Variables for width scaling and normalization
|
||||
const minWidth = Number(fd.min_width) || 1; // defaulted to 1
|
||||
const maxWidth = Number(fd.max_width) || 20; // defaulted to 20
|
||||
const multiplier = Number(fd.line_width_multiplier) || 1; // defaulted to 1
|
||||
|
||||
const widths = data.map((d: JsonObject) => d.width).filter(Number.isFinite);
|
||||
|
||||
// Metric or fixed value
|
||||
const isMetricWidth = isMetricValue(fd.line_width);
|
||||
|
||||
if (isMetricWidth) {
|
||||
// Get minimum and maximum widths in data set
|
||||
const minVal = widths.length > 0 ? Math.min(...widths) : minWidth;
|
||||
const maxVal = widths.length > 0 ? Math.max(...widths) : maxWidth;
|
||||
|
||||
data = data.map((d: JsonObject) => {
|
||||
if (d.width == null) return { ...d, width: minWidth };
|
||||
|
||||
const normalized =
|
||||
maxVal === minVal ? 0.5 : (d.width - minVal) / (maxVal - minVal);
|
||||
|
||||
// Map within range of min + max
|
||||
let width = minWidth + normalized * (maxWidth - minWidth);
|
||||
|
||||
// Apply scaling multiplier
|
||||
width *= multiplier;
|
||||
|
||||
// Enforce minimum and maximum width bounds
|
||||
width = Math.max(minWidth, Math.min(maxWidth, width));
|
||||
|
||||
return { ...d, width };
|
||||
});
|
||||
} else {
|
||||
// Fixed width mode
|
||||
// Allows for use with legacy charts
|
||||
const fixedWidth =
|
||||
typeof fd.line_width === 'number'
|
||||
? fd.line_width
|
||||
: typeof fd.line_width === 'object' && fd.line_width?.type === 'fix'
|
||||
? Number(fd.line_width.value)
|
||||
: undefined;
|
||||
|
||||
data = data.map((d: JsonObject) => {
|
||||
let width = (d.width ?? fixedWidth ?? 1) * multiplier;
|
||||
width = Math.max(minWidth, Math.min(maxWidth, width));
|
||||
return { ...d, width };
|
||||
});
|
||||
}
|
||||
const c = fd.color_picker;
|
||||
const fixedColor = [c.r, c.g, c.b, 255 * c.a];
|
||||
let data = payload.data.features.map((feature: JsonObject) => ({
|
||||
...feature,
|
||||
path: feature.path,
|
||||
width: fd.line_width,
|
||||
color: fixedColor,
|
||||
}));
|
||||
|
||||
if (fd.js_data_mutator) {
|
||||
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
|
||||
@@ -122,15 +66,13 @@ export const getLayer: GetLayerType<PathLayer> = function ({
|
||||
|
||||
return new PathLayer({
|
||||
id: `path-layer-${fd.slice_id}` as const,
|
||||
getColor: (d: any) => d.color || [0, 0, 0, 255],
|
||||
getColor: (d: any) => d.color,
|
||||
getPath: (d: any) => d.path,
|
||||
getWidth: (d: any) => d.width,
|
||||
data,
|
||||
rounded: true,
|
||||
widthScale: 1,
|
||||
widthUnits: fd.line_width_unit,
|
||||
widthMinPixels: Number(fd.min_width) || undefined,
|
||||
widthMaxPixels: Number(fd.max_width) || undefined,
|
||||
...commonLayerProps({
|
||||
formData: fd,
|
||||
setTooltip,
|
||||
@@ -159,23 +101,13 @@ export const getHighlightLayer: GetLayerType<PathLayer> = function ({
|
||||
filterState,
|
||||
}) {
|
||||
const fd = formData;
|
||||
const minWidth = Number(fd.min_width) || 1;
|
||||
const maxWidth = Number(fd.max_width) || 20;
|
||||
const multiplier = Number(fd.line_width_multiplier) || 1;
|
||||
const fixedColor = HIGHLIGHT_COLOR_ARRAY;
|
||||
let data = payload.data.features.map((feature: JsonObject) => {
|
||||
const baseWidth = Number.isFinite(feature.width) ? feature.width : 1;
|
||||
let width = baseWidth * multiplier;
|
||||
|
||||
width = Math.max(minWidth, Math.min(maxWidth, width));
|
||||
|
||||
return {
|
||||
...feature,
|
||||
path: feature.path,
|
||||
width,
|
||||
color: fixedColor,
|
||||
};
|
||||
});
|
||||
let data = payload.data.features.map((feature: JsonObject) => ({
|
||||
...feature,
|
||||
path: feature.path,
|
||||
width: fd.line_width,
|
||||
color: fixedColor,
|
||||
}));
|
||||
|
||||
if (fd.js_data_mutator) {
|
||||
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
|
||||
@@ -196,13 +128,7 @@ export const getHighlightLayer: GetLayerType<PathLayer> = function ({
|
||||
rounded: true,
|
||||
widthScale: 1,
|
||||
widthUnits: fd.line_width_unit,
|
||||
widthMinPixels: Number(fd.min_width) || undefined,
|
||||
widthMaxPixels: Number(fd.max_width) || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export default createCategoricalDeckGLComponent(
|
||||
getLayer,
|
||||
getPoints,
|
||||
getHighlightLayer,
|
||||
);
|
||||
export default createDeckGLComponent(getLayer, getPoints, getHighlightLayer);
|
||||
|
||||
@@ -1,355 +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 buildQuery, { DeckPathFormData } from './buildQuery';
|
||||
|
||||
const baseFormData: DeckPathFormData = {
|
||||
datasource: '1__table',
|
||||
viz_type: 'deck_path',
|
||||
line_column: 'path_json',
|
||||
line_type: 'json',
|
||||
row_limit: 100,
|
||||
};
|
||||
|
||||
test('Path buildQuery should not include metric when line_width is fixed type', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'fix',
|
||||
value: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle numeric line_width value with fixed type', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'fix',
|
||||
value: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle missing line_width', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should include metric when line_width is metric type', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'COUNT(*)',
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContain('COUNT(*)');
|
||||
});
|
||||
|
||||
test('Path buildQuery should add line_column to groupby when using width metric', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'SUM(distance)',
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.groupby).toContain('path_json');
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle adhoc SQL metric for line_width', () => {
|
||||
const adhocMetric = {
|
||||
label: 'custom_width',
|
||||
expressionType: 'SQL' as const,
|
||||
sqlExpression: 'SUM(weight) / COUNT(*)',
|
||||
};
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: adhocMetric,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContainEqual(adhocMetric);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle adhoc SIMPLE metric for line_width', () => {
|
||||
const adhocMetric = {
|
||||
label: 'AVG(traffic)',
|
||||
expressionType: 'SIMPLE' as const,
|
||||
column: { column_name: 'traffic' },
|
||||
aggregate: 'AVG' as const,
|
||||
};
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: adhocMetric,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContainEqual(adhocMetric);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle metric type with undefined value', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should not duplicate width metric if already in metrics', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
metrics: ['AVG(weight)'],
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'AVG(weight)',
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('Path buildQuery should preserve existing metrics when adding width metric', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
metrics: ['COUNT(*)'],
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'AVG(weight)',
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContain('COUNT(*)');
|
||||
expect(query.metrics).toContain('AVG(weight)');
|
||||
expect(query.metrics).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('Path buildQuery should not modify existing metrics for fixed width', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
metrics: ['COUNT(*)', 'SUM(value)'],
|
||||
line_width: {
|
||||
type: 'fix',
|
||||
value: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual(['COUNT(*)', 'SUM(value)']);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle undefined value in metric type gracefully', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
// Should not add anything when value is undefined
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle line_width with undefined type', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: undefined,
|
||||
value: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
// ─── Dimension (categorical color) ───
|
||||
|
||||
test('Path buildQuery should include dimension column when specified', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
dimension: 'route_type',
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.columns).toContain('route_type');
|
||||
});
|
||||
|
||||
test('Path buildQuery should include breakpoint_metric when specified', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
breakpoint_metric: 'AVG(speed)',
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContain('AVG(speed)');
|
||||
});
|
||||
|
||||
test('Path buildQuery should add line_column to groupby when using breakpoint metric', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
breakpoint_metric: 'AVG(speed)',
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.groupby).toContain('path_json');
|
||||
});
|
||||
|
||||
test('Path buildQuery should not duplicate breakpoint metric if already in metrics', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
metrics: ['AVG(speed)'],
|
||||
breakpoint_metric: 'AVG(speed)',
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toHaveLength(1);
|
||||
expect(query.metrics).toContain('AVG(speed)');
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle breakpoint_metric and line_width metric together', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'SUM(distance)',
|
||||
},
|
||||
breakpoint_metric: 'AVG(speed)',
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContain('SUM(distance)');
|
||||
expect(query.metrics).toContain('AVG(speed)');
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle adhoc breakpoint metric', () => {
|
||||
const adhocMetric = {
|
||||
label: 'avg_speed',
|
||||
expressionType: 'SQL' as const,
|
||||
sqlExpression: 'AVG(speed_mph)',
|
||||
};
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
breakpoint_metric: adhocMetric,
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContainEqual(adhocMetric);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle missing breakpoint_metric', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle line_width and breakpoint_metrics together together', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'SUM(distance)',
|
||||
},
|
||||
breakpoint_metric: 'AVG(speed)',
|
||||
js_columns: ['color'],
|
||||
tooltip_contents: ['name'],
|
||||
row_limit: 500,
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContain('SUM(distance)');
|
||||
expect(query.metrics).toContain('AVG(speed)');
|
||||
expect(query.columns).toContain('color');
|
||||
expect(query.columns).toContain('name');
|
||||
expect(query.row_limit).toBe(500);
|
||||
});
|
||||
@@ -19,13 +19,10 @@
|
||||
import {
|
||||
buildQueryContext,
|
||||
ensureIsArray,
|
||||
getMetricLabel,
|
||||
SqlaFormData,
|
||||
QueryFormColumn,
|
||||
QueryFormMetric,
|
||||
} from '@superset-ui/core';
|
||||
import { addNullFilters, addTooltipColumnsToQuery } from '../buildQueryUtils';
|
||||
import { isMetricValue } from '../utils/metricUtils';
|
||||
|
||||
export interface DeckPathFormData extends SqlaFormData {
|
||||
line_column?: string;
|
||||
@@ -35,26 +32,10 @@ export interface DeckPathFormData extends SqlaFormData {
|
||||
js_columns?: string[];
|
||||
tooltip_contents?: unknown[];
|
||||
tooltip_template?: string;
|
||||
line_width?:
|
||||
| string
|
||||
| { type?: 'fix' | 'metric'; value?: QueryFormMetric | number };
|
||||
line_width_multiplier?: number;
|
||||
min_width?: number;
|
||||
max_width?: number;
|
||||
dimension?: string;
|
||||
breakpoint_metric?: QueryFormMetric;
|
||||
}
|
||||
|
||||
export default function buildQuery(formData: DeckPathFormData) {
|
||||
const {
|
||||
line_column,
|
||||
metric,
|
||||
js_columns,
|
||||
tooltip_contents,
|
||||
line_width,
|
||||
dimension,
|
||||
breakpoint_metric,
|
||||
} = formData;
|
||||
const { line_column, metric, js_columns, tooltip_contents } = formData;
|
||||
|
||||
if (!line_column) {
|
||||
throw new Error('Line column is required for Path charts');
|
||||
@@ -65,7 +46,7 @@ export default function buildQuery(formData: DeckPathFormData) {
|
||||
const columns = ensureIsArray(
|
||||
baseQueryObject.columns || [],
|
||||
) as QueryFormColumn[];
|
||||
let metrics = ensureIsArray(baseQueryObject.metrics || []);
|
||||
const metrics = ensureIsArray(baseQueryObject.metrics || []);
|
||||
const groupby = ensureIsArray(
|
||||
baseQueryObject.groupby || [],
|
||||
) as QueryFormColumn[];
|
||||
@@ -82,49 +63,6 @@ export default function buildQuery(formData: DeckPathFormData) {
|
||||
columns.push(line_column);
|
||||
}
|
||||
|
||||
// Include dimension column for categorical color mode
|
||||
if (dimension && !columns.includes(dimension)) {
|
||||
columns.push(dimension);
|
||||
}
|
||||
|
||||
// Add metric if line_width is a metric type
|
||||
const isMetric = isMetricValue(line_width);
|
||||
const rawWidthValue =
|
||||
typeof line_width === 'string'
|
||||
? line_width
|
||||
: typeof line_width === 'number'
|
||||
? undefined
|
||||
: line_width?.value;
|
||||
const widthMetric: QueryFormMetric | null =
|
||||
isMetric &&
|
||||
rawWidthValue !== undefined &&
|
||||
typeof rawWidthValue !== 'number'
|
||||
? (rawWidthValue as QueryFormMetric)
|
||||
: null;
|
||||
|
||||
// ensure metric is not added to metric array twice
|
||||
const existingLabels = new Set(metrics.map(m => getMetricLabel(m)));
|
||||
if (widthMetric && !existingLabels.has(getMetricLabel(widthMetric))) {
|
||||
metrics = [...metrics, widthMetric];
|
||||
}
|
||||
|
||||
// ensure line_column is in groupby when aggregating by width metric
|
||||
if (widthMetric && !groupby.includes(line_column)) {
|
||||
groupby.push(line_column);
|
||||
}
|
||||
|
||||
if (breakpoint_metric) {
|
||||
const breakpointLabel = getMetricLabel(breakpoint_metric);
|
||||
const currentLabels = new Set(metrics.map(m => getMetricLabel(m)));
|
||||
if (!currentLabels.has(breakpointLabel)) {
|
||||
metrics = [...metrics, breakpoint_metric];
|
||||
}
|
||||
// ensure line_column is in groupby when aggregating
|
||||
if (!groupby.includes(line_column)) {
|
||||
groupby.push(line_column);
|
||||
}
|
||||
}
|
||||
|
||||
jsColumns.forEach(col => {
|
||||
if (!columns.includes(col) && !groupby.includes(col)) {
|
||||
columns.push(col);
|
||||
|
||||
@@ -1,242 +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 type {
|
||||
ControlPanelSectionConfig,
|
||||
ControlSetRow,
|
||||
ControlSetItem,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import controlPanel from './controlPanel';
|
||||
|
||||
test('controlPanel should have Path Size section', () => {
|
||||
const pathSizeSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
expect(pathSizeSection).toBeDefined();
|
||||
expect(pathSizeSection?.expanded).toBe(true);
|
||||
});
|
||||
|
||||
test('controlPanel should include pathLineWidthFixedOrMetric control', () => {
|
||||
const pathSizeSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
const control = pathSizeSection?.controlSetRows
|
||||
.flat()
|
||||
.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'line_width',
|
||||
) as any;
|
||||
|
||||
expect(control).toBeDefined();
|
||||
expect(control.config.type).toBe('FixedOrMetricControl');
|
||||
expect(control.config.default).toEqual({ type: 'fix', value: 1 });
|
||||
});
|
||||
|
||||
test('controlPanel should include line_width_unit control with pixels as default', () => {
|
||||
const pathSizeSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
const lineWidthRow = pathSizeSection?.controlSetRows.find(
|
||||
(row: ControlSetRow) =>
|
||||
row.some(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'line_width_unit',
|
||||
),
|
||||
);
|
||||
|
||||
const lineWidthControl = lineWidthRow?.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'line_width_unit',
|
||||
) as any;
|
||||
|
||||
expect(lineWidthControl).toBeDefined();
|
||||
expect(lineWidthControl?.config?.default).toBe('pixels');
|
||||
});
|
||||
|
||||
test('controlPanel should include min_width control with default of 1', () => {
|
||||
const minWidthSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
const minWidthRow = minWidthSection?.controlSetRows.find(
|
||||
(row: ControlSetRow) =>
|
||||
row.some(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'min_width',
|
||||
),
|
||||
);
|
||||
|
||||
const minWidthControl = minWidthRow?.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'min_width',
|
||||
) as any;
|
||||
|
||||
expect(minWidthControl).toBeDefined();
|
||||
expect(minWidthControl?.config?.default).toBe(1);
|
||||
});
|
||||
|
||||
test('controlPanel should include max_width control with default of 20', () => {
|
||||
const maxWidthSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
const maxWidthRow = maxWidthSection?.controlSetRows.find(
|
||||
(row: ControlSetRow) =>
|
||||
row.some(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'max_width',
|
||||
),
|
||||
);
|
||||
|
||||
const maxWidthControl = maxWidthRow?.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'max_width',
|
||||
) as any;
|
||||
|
||||
expect(maxWidthControl).toBeDefined();
|
||||
expect(maxWidthControl?.config?.default).toBe(20);
|
||||
});
|
||||
|
||||
test('controlPanel should include line_width_multiplier control with default of 1', () => {
|
||||
const lineWidthMultiplierSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
const lineWidthMultiplierRow =
|
||||
lineWidthMultiplierSection?.controlSetRows.find((row: ControlSetRow) =>
|
||||
row.some(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'line_width_multiplier',
|
||||
),
|
||||
);
|
||||
|
||||
const lineWidthMultiplierControl = lineWidthMultiplierRow?.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'line_width_multiplier',
|
||||
) as any;
|
||||
|
||||
expect(lineWidthMultiplierControl).toBeDefined();
|
||||
expect(lineWidthMultiplierControl?.config?.default).toBe(1);
|
||||
});
|
||||
|
||||
test('controlPanel should have Path Color section', () => {
|
||||
const pathColorSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Color',
|
||||
);
|
||||
|
||||
expect(pathColorSection).toBeDefined();
|
||||
expect(pathColorSection?.expanded).toBe(true);
|
||||
});
|
||||
|
||||
test('controlPanel should have Path Color section with color scheme controls', () => {
|
||||
const pathColorSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Color',
|
||||
);
|
||||
|
||||
const controlNames = pathColorSection?.controlSetRows
|
||||
.flat()
|
||||
.filter(
|
||||
(control: ControlSetItem) =>
|
||||
control && typeof control === 'object' && 'name' in control,
|
||||
)
|
||||
.map((control: any) => control.name);
|
||||
|
||||
expect(controlNames).toContain('color_scheme_type');
|
||||
expect(controlNames).toContain('color_picker');
|
||||
expect(controlNames).toContain('dimension');
|
||||
expect(controlNames).toContain('color_scheme');
|
||||
expect(controlNames).toContain('breakpoint_metric');
|
||||
expect(controlNames).toContain('default_breakpoint_color');
|
||||
expect(controlNames).toContain('color_breakpoints');
|
||||
});
|
||||
|
||||
test('color_scheme_type should default to fixed_color', () => {
|
||||
const pathColorSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Color',
|
||||
);
|
||||
|
||||
const schemeTypeControl = pathColorSection?.controlSetRows
|
||||
.flat()
|
||||
.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'color_scheme_type',
|
||||
) as any;
|
||||
|
||||
expect(schemeTypeControl).toBeDefined();
|
||||
expect(schemeTypeControl?.config?.default).toBe('fixed_color');
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
jsTooltip,
|
||||
jsOnclickHref,
|
||||
viewport,
|
||||
lineWidth,
|
||||
lineType,
|
||||
reverseLongLat,
|
||||
mapboxStyle,
|
||||
@@ -33,12 +34,8 @@ import {
|
||||
mapProvider,
|
||||
tooltipContents,
|
||||
tooltipTemplate,
|
||||
pathLineWidthFixedOrMetric,
|
||||
generateDeckGLColorSchemeControls,
|
||||
} from '../../utilities/Shared_DeckGL';
|
||||
import { dndLineColumn } from '../../utilities/sharedDndControls';
|
||||
import { validateNonEmpty } from '@superset-ui/core';
|
||||
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
||||
|
||||
const config: ControlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
@@ -74,83 +71,25 @@ const config: ControlPanelConfig = {
|
||||
[mapboxStyle],
|
||||
[maplibreStyle],
|
||||
[viewport],
|
||||
[reverseLongLat],
|
||||
[autozoom],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Path Size'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[pathLineWidthFixedOrMetric],
|
||||
['color_picker'],
|
||||
[lineWidth],
|
||||
[
|
||||
{
|
||||
name: 'line_width_unit',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('Line width unit'),
|
||||
default: 'pixels',
|
||||
default: 'meters',
|
||||
choices: [
|
||||
['meters', t('meters')],
|
||||
['pixels', t('pixels')],
|
||||
],
|
||||
renderTrigger: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'min_width',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Minimum Width'),
|
||||
isFloat: true,
|
||||
validators: [validateNonEmpty],
|
||||
renderTrigger: true,
|
||||
default: 1,
|
||||
description: t(
|
||||
'Minimum width size of the path, in pixels or meters.',
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'max_width',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Maximum Width'),
|
||||
isFloat: true,
|
||||
validators: [validateNonEmpty],
|
||||
renderTrigger: true,
|
||||
default: 20,
|
||||
description: t(
|
||||
'Maximum width size of the path, in pixels or meters.',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'line_width_multiplier',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Width scale multiplier'),
|
||||
renderTrigger: true,
|
||||
isFloat: true,
|
||||
default: 1,
|
||||
description: t(
|
||||
'Scale factor applied to metric-driven line widths',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Path Color'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
...generateDeckGLColorSchemeControls({
|
||||
defaultSchemeType: COLOR_SCHEME_TYPES.fixed_color,
|
||||
}),
|
||||
[reverseLongLat],
|
||||
[autozoom],
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,364 +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 { ChartProps, DatasourceType } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
|
||||
interface PathFeature {
|
||||
path: [number, number][];
|
||||
width?: number;
|
||||
metric?: number;
|
||||
cat_color?: string;
|
||||
extraProps?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const samplePath1 = JSON.stringify([
|
||||
[-122.4, 37.8],
|
||||
[-122.3, 37.9],
|
||||
]);
|
||||
const samplePath2 = JSON.stringify([
|
||||
[-122.5, 37.7],
|
||||
[-122.4, 37.8],
|
||||
]);
|
||||
const samplePath3 = JSON.stringify([
|
||||
[-122.6, 37.6],
|
||||
[-122.5, 37.7],
|
||||
]);
|
||||
|
||||
const mockChartProps: Partial<ChartProps> = {
|
||||
rawFormData: {
|
||||
line_column: 'path_json',
|
||||
line_type: 'json',
|
||||
viewport: {},
|
||||
},
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
path_json: samplePath1,
|
||||
'AVG(weight)': 100,
|
||||
'SUM(distance)': 500,
|
||||
route_type: 'express',
|
||||
},
|
||||
{
|
||||
path_json: samplePath2,
|
||||
'AVG(weight)': 200,
|
||||
'SUM(distance)': 1000,
|
||||
route_type: 'local',
|
||||
},
|
||||
{
|
||||
path_json: samplePath3,
|
||||
'AVG(weight)': 50,
|
||||
'SUM(distance)': 250,
|
||||
route_type: 'express',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
datasource: {
|
||||
type: DatasourceType.Table,
|
||||
id: 1,
|
||||
name: 'test_datasource',
|
||||
columns: [],
|
||||
metrics: [],
|
||||
},
|
||||
height: 400,
|
||||
width: 600,
|
||||
hooks: {},
|
||||
filterState: {},
|
||||
emitCrossFilters: false,
|
||||
};
|
||||
|
||||
test('Path transformProps should parse JSON paths correctly', () => {
|
||||
const result = transformProps(mockChartProps as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features.length).toBe(3);
|
||||
features.forEach(f => {
|
||||
expect(f.path).toBeDefined();
|
||||
expect(Array.isArray(f.path)).toBe(true);
|
||||
expect(f.path.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Path transformProps should handle empty records', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
queriesData: [{ data: [] }],
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Path transformProps should handle missing line_column', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
line_column: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Path transformProps should handle invalid JSON path data', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
queriesData: [
|
||||
{
|
||||
data: [{ path_json: 'not valid json' }, { path_json: '12345' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features.length).toBe(2);
|
||||
// Should not throw, paths should be empty arrays
|
||||
features.forEach(f => {
|
||||
expect(Array.isArray(f.path)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('Path transformProps should use fixed width value when line_width type is "fix"', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
line_width: {
|
||||
type: 'fix',
|
||||
value: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features.length).toBe(3);
|
||||
features.forEach(f => {
|
||||
expect(f.width).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
test('Path transformProps should use fixed width with string value', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
line_width: {
|
||||
type: 'fix',
|
||||
value: '10',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
features.forEach(f => {
|
||||
expect(f.width).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
test('Path transformProps should not set width when line_width is missing', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
line_width: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
features.forEach(f => {
|
||||
expect(f.width).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('Path transformProps should use metric value for width when line_width type is "metric"', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'AVG(weight)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features).toHaveLength(3);
|
||||
expect(features[0]?.width).toBe(50);
|
||||
});
|
||||
|
||||
test('Path transformProps should include metric from breakpoint_metric', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
breakpoint_metric: 'AVG(weight)',
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
const metrics = features
|
||||
.map(f => f.metric)
|
||||
.filter((m): m is number => m !== undefined)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
expect(metrics).toEqual([50, 100, 200]);
|
||||
});
|
||||
|
||||
test('Path transformProps should fall back to base metric when breakpoint_metric is missing', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
metric: 'AVG(weight)',
|
||||
breakpoint_metric: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
const metrics = features
|
||||
.map(f => f.metric)
|
||||
.filter((m): m is number => m !== undefined)
|
||||
.sort((a, b) => a - b);
|
||||
expect(metrics).toEqual([50, 100, 200]);
|
||||
});
|
||||
|
||||
test('Path transformProps should include both breakpoint_metric and width metrics if they are different', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
breakpoint_metric: 'AVG(weight)',
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'SUM(distance)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features).toHaveLength(3);
|
||||
expect(result.payload.data.metricLabels).toEqual([
|
||||
'AVG(weight)',
|
||||
'SUM(distance)',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Path transformProps should not include both breakpoint_metric and width metrics if they are the same', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
breakpoint_metric: 'SUM(distance)',
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'SUM(distance)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
|
||||
expect(result.payload.data.metricLabels).toEqual(['SUM(distance)']);
|
||||
});
|
||||
|
||||
test('Path transformProps should set cat_color from dimension column', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
dimension: 'route_type',
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features).toHaveLength(3);
|
||||
expect(features[0]?.cat_color).toBe('express');
|
||||
expect(features[1]?.cat_color).toBe('local');
|
||||
expect(features[2]?.cat_color).toBe('express');
|
||||
});
|
||||
|
||||
test('Path transformProps should include metric labels when breakpoint_metric is set', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
breakpoint_metric: 'AVG(weight)',
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
|
||||
expect(result.payload.data.metricLabels).toContain('AVG(weight)');
|
||||
});
|
||||
|
||||
test('Path transformProps should include metric labels from base metric', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
metric: 'SUM(distance)',
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
|
||||
expect(result.payload.data.metricLabels).toContain('SUM(distance)');
|
||||
});
|
||||
|
||||
test('Path transformProps should have empty metric labels when no metric is set', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
metric: undefined,
|
||||
breakpoint_metric: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
|
||||
expect(result.payload.data.metricLabels).toEqual([]);
|
||||
});
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ChartProps, DTTM_ALIAS, getMetricLabel } from '@superset-ui/core';
|
||||
import { ChartProps, DTTM_ALIAS } from '@superset-ui/core';
|
||||
import { addJsColumnsToExtraProps, DataRecord } from '../spatialUtils';
|
||||
import {
|
||||
createBaseTransformResult,
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
addPropertiesToFeature,
|
||||
} from '../transformUtils';
|
||||
import { DeckPathFormData } from './buildQuery';
|
||||
import { isFixedValue, getFixedValue } from '../utils/metricUtils';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -49,8 +48,6 @@ interface PathFeature {
|
||||
path: [number, number][];
|
||||
metric?: number;
|
||||
timestamp?: unknown;
|
||||
width?: number;
|
||||
cat_color?: string;
|
||||
extraProps?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -94,9 +91,6 @@ function processPathData(
|
||||
reverseLongLat: boolean = false,
|
||||
metricLabel?: string,
|
||||
jsColumns?: string[],
|
||||
widthMetricLabel?: string,
|
||||
fixedWidthValue?: number | string | null,
|
||||
categoryColumn?: string,
|
||||
): PathFeature[] {
|
||||
if (!records.length || !lineColumn) {
|
||||
return [];
|
||||
@@ -109,8 +103,6 @@ function processPathData(
|
||||
'timestamp',
|
||||
DTTM_ALIAS,
|
||||
metricLabel,
|
||||
widthMetricLabel,
|
||||
categoryColumn,
|
||||
...(jsColumns || []),
|
||||
].filter(Boolean) as string[],
|
||||
);
|
||||
@@ -138,24 +130,6 @@ function processPathData(
|
||||
feature.metric = metricValue;
|
||||
}
|
||||
}
|
||||
// Set width from metric or fixed value
|
||||
if (fixedWidthValue != null) {
|
||||
// Use fixed width
|
||||
const parsedFixedWidth = parseMetricValue(fixedWidthValue);
|
||||
if (parsedFixedWidth !== undefined) {
|
||||
feature.width = parsedFixedWidth;
|
||||
}
|
||||
} else if (widthMetricLabel && record[widthMetricLabel] != null) {
|
||||
// Use metric value for width
|
||||
const widthValue = parseMetricValue(record[widthMetricLabel]);
|
||||
if (widthValue !== undefined) {
|
||||
feature.width = widthValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (categoryColumn && record[categoryColumn] != null) {
|
||||
feature.cat_color = String(record[categoryColumn]);
|
||||
}
|
||||
|
||||
feature = addJsColumnsToExtraProps(feature, record, jsColumns);
|
||||
feature = addPropertiesToFeature(feature, record, excludeKeys);
|
||||
@@ -169,37 +143,11 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
line_column,
|
||||
line_type = 'json',
|
||||
metric,
|
||||
line_width,
|
||||
dimension,
|
||||
reverse_long_lat = false,
|
||||
js_columns,
|
||||
breakpoint_metric,
|
||||
} = formData as DeckPathTransformPropsFormData;
|
||||
|
||||
// Check so legacy values still work
|
||||
const fixedWidthValue =
|
||||
typeof line_width === 'number'
|
||||
? line_width
|
||||
: isFixedValue(line_width)
|
||||
? getFixedValue(line_width)
|
||||
: undefined;
|
||||
|
||||
const widthMetricLabel = getMetricLabelFromFormData(line_width);
|
||||
|
||||
const breakpointMetricLabel = breakpoint_metric
|
||||
? getMetricLabel(breakpoint_metric)
|
||||
: undefined;
|
||||
const baseMetricLabel = getMetricLabelFromFormData(metric);
|
||||
const metricLabel = breakpointMetricLabel || baseMetricLabel;
|
||||
|
||||
// ensure all metric labels are included
|
||||
const metricLabels = [
|
||||
...(metricLabel ? [metricLabel] : []),
|
||||
...(widthMetricLabel && widthMetricLabel !== metricLabel
|
||||
? [widthMetricLabel]
|
||||
: []),
|
||||
];
|
||||
|
||||
const metricLabel = getMetricLabelFromFormData(metric);
|
||||
const records = getRecordsFromQuery(chartProps.queriesData);
|
||||
const features = processPathData(
|
||||
records,
|
||||
@@ -208,10 +156,11 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
reverse_long_lat,
|
||||
metricLabel,
|
||||
js_columns,
|
||||
widthMetricLabel,
|
||||
fixedWidthValue,
|
||||
dimension,
|
||||
).reverse();
|
||||
|
||||
return createBaseTransformResult(chartProps, features, metricLabels);
|
||||
return createBaseTransformResult(
|
||||
chartProps,
|
||||
features,
|
||||
metricLabel ? [metricLabel] : [],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -285,22 +285,6 @@ export const lineWidth = {
|
||||
},
|
||||
};
|
||||
|
||||
// created new const so as not to break lineWidth usages in other charts
|
||||
export const pathLineWidthFixedOrMetric = {
|
||||
name: 'line_width',
|
||||
config: {
|
||||
type: 'FixedOrMetricControl', // using existing type
|
||||
label: t('Line width'),
|
||||
default: { type: 'fix', value: 1 }, // kept same default as before
|
||||
description: t(
|
||||
'The width of the lines as either a fixed value or variable width based on a metric.',
|
||||
),
|
||||
mapStateToProps: (state: ControlPanelState) => ({
|
||||
datasource: state.datasource,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const fillColorPicker: CustomControlItem = {
|
||||
name: 'fill_color_picker',
|
||||
config: {
|
||||
@@ -689,24 +673,6 @@ export const deckGLColorBreakpointsSelect: CustomControlItem = {
|
||||
},
|
||||
};
|
||||
|
||||
export const deckGLBreakpointMetric: CustomControlItem = {
|
||||
name: 'breakpoint_metric',
|
||||
config: {
|
||||
...sharedControls.metric,
|
||||
label: t('Breakpoint Metric'),
|
||||
default: null,
|
||||
validators: [],
|
||||
description: t(
|
||||
'Select the metric used to determine which color breakpoint range each path falls into.',
|
||||
),
|
||||
// mapStateToProps: (state: ControlPanelState) => ({
|
||||
// datasource: state.datasource,
|
||||
// }),
|
||||
visibility: ({ controls }: { controls: any }) =>
|
||||
isColorSchemeTypeVisible(controls, COLOR_SCHEME_TYPES.color_breakpoints),
|
||||
},
|
||||
};
|
||||
|
||||
export const breakpointsDefaultColor: CustomControlItem = {
|
||||
name: 'default_breakpoint_color',
|
||||
config: {
|
||||
@@ -759,7 +725,6 @@ export const generateDeckGLColorSchemeControls = ({
|
||||
[deckGLFixedColor],
|
||||
disableCategoricalColumn ? [] : [deckGLCategoricalColor],
|
||||
[deckGLCategoricalColorSchemeSelect],
|
||||
[deckGLBreakpointMetric],
|
||||
[breakpointsDefaultColor],
|
||||
[deckGLColorBreakpointsSelect],
|
||||
];
|
||||
|
||||
@@ -97,7 +97,7 @@ export function createWrapper(options?: Options) {
|
||||
}
|
||||
|
||||
if (useDnd) {
|
||||
// @ts-ignore react-dnd's DndProviderProps omits `children` under React 18 types
|
||||
// @ts-expect-error react-dnd types not updated for React 18
|
||||
result = <DndProvider backend={HTML5Backend}>{result}</DndProvider>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1671,7 +1671,7 @@ export interface VizOptions {
|
||||
|
||||
export function createDatasource(
|
||||
vizOptions: VizOptions,
|
||||
): SqlLabThunkAction<Promise<{ id: number }>> {
|
||||
): SqlLabThunkAction<Promise<unknown>> {
|
||||
return (dispatch: AppDispatch) => {
|
||||
dispatch(createDatasourceStarted());
|
||||
const { dbId, catalog, schema, datasourceName, sql, templateParams } =
|
||||
@@ -1691,10 +1691,9 @@ export function createDatasource(
|
||||
}),
|
||||
})
|
||||
.then(({ json }) => {
|
||||
const result = json as { id: number };
|
||||
dispatch(createDatasourceSuccess(result));
|
||||
dispatch(createDatasourceSuccess(json as { id: number }));
|
||||
|
||||
return result;
|
||||
return Promise.resolve(json);
|
||||
})
|
||||
.catch(error => {
|
||||
getClientErrorObject(error).then(e => {
|
||||
@@ -1713,7 +1712,7 @@ export function createDatasource(
|
||||
|
||||
export function createCtasDatasource(
|
||||
vizOptions: Record<string, unknown>,
|
||||
): SqlLabThunkAction<Promise<{ table_id: number }>> {
|
||||
): SqlLabThunkAction<Promise<{ id: number }>> {
|
||||
return (dispatch: AppDispatch) => {
|
||||
dispatch(createDatasourceStarted());
|
||||
return SupersetClient.post({
|
||||
@@ -1721,14 +1720,9 @@ export function createCtasDatasource(
|
||||
jsonPayload: vizOptions,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
const result = json.result as { table_id: number };
|
||||
// The endpoint's `result.table_id` IS the dataset id; normalize so
|
||||
// createDatasourceSuccess's `${data.id}__table` resolves correctly.
|
||||
// Without this, the CTAS Explore button silently produced
|
||||
// `"undefined__table"` because `result.id` doesn't exist.
|
||||
dispatch(createDatasourceSuccess({ id: result.table_id }));
|
||||
dispatch(createDatasourceSuccess(json.result));
|
||||
|
||||
return result;
|
||||
return json.result;
|
||||
})
|
||||
.catch(() => {
|
||||
const errorMsg = t('An error occurred while creating the data source');
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
|
||||
import { useRef, useEffect, FC, useMemo } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import {
|
||||
SqlLabRootState,
|
||||
@@ -87,7 +86,7 @@ const EditorAutoSync: FC = () => {
|
||||
const editorTabLastUpdatedAt = useSelector<SqlLabRootState, number>(
|
||||
state => state.sqlLab.editorTabLastUpdatedAt,
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
const lastSavedTimestampRef = useRef<number>(editorTabLastUpdatedAt);
|
||||
|
||||
const currentQueryEditorId = useSelector<SqlLabRootState, string>(
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { usePrevious } from '@superset-ui/core';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { Global } from '@emotion/react';
|
||||
@@ -137,7 +136,7 @@ const EditorWrapper = ({
|
||||
height,
|
||||
hotkeys,
|
||||
}: EditorWrapperProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
const queryEditor = useQueryEditor(queryEditorId, [
|
||||
'id',
|
||||
'dbId',
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useStore } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useDispatch, useStore } from 'react-redux';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { getExtensionsRegistry } from '@superset-ui/core';
|
||||
|
||||
@@ -69,7 +68,7 @@ export function useKeywords(
|
||||
catalog,
|
||||
schema,
|
||||
});
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
const hasFetchedKeywords = useRef(false);
|
||||
// skipFetch is used to prevent re-evaluating memoized keywords
|
||||
// due to updated api results by skip flag
|
||||
|
||||
@@ -16,10 +16,9 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { VizType } from '@superset-ui/core';
|
||||
import { JsonObject, VizType } from '@superset-ui/core';
|
||||
import {
|
||||
createCtasDatasource,
|
||||
addInfoToast,
|
||||
@@ -46,7 +45,7 @@ const ExploreCtasResultsButton = ({
|
||||
const errorMessage = useSelector(
|
||||
(state: SqlLabRootState) => state.sqlLab.errorMessage,
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch<(dispatch: any) => Promise<JsonObject>>();
|
||||
|
||||
const buildVizOptions = {
|
||||
table_name: table,
|
||||
@@ -57,7 +56,7 @@ const ExploreCtasResultsButton = ({
|
||||
|
||||
const visualize = () => {
|
||||
dispatch(createCtasDatasource(buildVizOptions))
|
||||
.then(data => {
|
||||
.then((data: { table_id: number }) => {
|
||||
const formData = {
|
||||
datasource: `${data.table_id}__table`,
|
||||
metrics: ['count'],
|
||||
|
||||
@@ -47,13 +47,6 @@ const Title = styled.h4`
|
||||
font-weight: ${({ theme }) => theme.fontWeightStrong};
|
||||
`;
|
||||
|
||||
const StyledTabs = styled(Tabs)`
|
||||
margin-top: ${({ theme }) => theme.sizeUnit * -8}px;
|
||||
.ant-tabs-nav {
|
||||
margin-bottom: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
}
|
||||
`;
|
||||
|
||||
const shrinkSql = (sql: string, maxLines: number, maxWidth: number) => {
|
||||
const ssql = sql || '';
|
||||
let lines = ssql.split('\n');
|
||||
@@ -101,7 +94,7 @@ function HighlightSqlModal({ rawSql, sql }: HighlightedSqlModalTypes) {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledTabs
|
||||
<Tabs
|
||||
defaultActiveKey="executed"
|
||||
items={[
|
||||
{
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import URI from 'urijs';
|
||||
import { pick } from 'lodash';
|
||||
import { useComponentDidUpdate } from '@superset-ui/core';
|
||||
@@ -50,7 +49,7 @@ const PopEditorTab: React.FC<{ children?: React.ReactNode }> = ({
|
||||
({ sqlLab: { tabHistory } }) => tabHistory.slice(-1)[0],
|
||||
);
|
||||
const [updatedUrl, setUpdatedUrl] = useState<string>(SQL_LAB_URL);
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
useComponentDidUpdate(() => {
|
||||
setQueryEditorId(assigned => assigned ?? activeQueryEditorId);
|
||||
if (activeQueryEditorId) {
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useRef } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { isObject } from 'lodash';
|
||||
import rison from 'rison';
|
||||
import {
|
||||
@@ -83,7 +82,7 @@ function QueryAutoRefresh({
|
||||
.map(({ id }) => id),
|
||||
),
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const checkForRefresh = () => {
|
||||
const shouldRequestChecking = shouldCheckForQueries(queries);
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Dropdown, Button } from '@superset-ui/core/components';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
@@ -75,7 +75,7 @@ const QueryLimitSelect = ({
|
||||
maxRow,
|
||||
defaultQueryLimit,
|
||||
}: QueryLimitSelectProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const queryEditor = useQueryEditor(queryEditorId, ['id', 'queryLimit']);
|
||||
const queryLimit = queryEditor.queryLimit || defaultQueryLimit;
|
||||
|
||||
@@ -30,8 +30,7 @@ import ProgressBar from '@superset-ui/core/components/ProgressBar';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { QueryResponse, QueryState } from '@superset-ui/core';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
queryEditorSetSql,
|
||||
@@ -93,7 +92,7 @@ const QueryTable = ({
|
||||
latestQueryId,
|
||||
}: QueryTableProps) => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
const [selectedQuery, setSelectedQuery] = useState<QueryResponse | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -27,8 +27,7 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { pick } from 'lodash';
|
||||
import {
|
||||
@@ -232,7 +231,7 @@ const ResultSet = ({
|
||||
canCopyClipboardSqlLab: canCopyClipboard,
|
||||
} = usePermissions();
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
const logAction = useLogAction({ queryId, sqlEditorId: query.sqlEditorId });
|
||||
const { showConfirm, ConfirmModal } = useConfirmModal();
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { act, type ComponentProps } from 'react';
|
||||
import * as reactRedux from 'react-redux';
|
||||
import { act } from 'react';
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
@@ -39,19 +40,6 @@ const mockedProps = {
|
||||
datasource: testQuery,
|
||||
};
|
||||
|
||||
// Render with the SqlLab user fixture preloaded into the mock store so the
|
||||
// component's useSelector(state => state.user) returns a useful value.
|
||||
// Previously this test used jest.spyOn(reactRedux, 'useSelector') to inject
|
||||
// the user directly, which can't intercept calls routed through the typed
|
||||
// useAppSelector hook.
|
||||
const renderModal = (
|
||||
props: Partial<ComponentProps<typeof SaveDatasetModal>> = {},
|
||||
) =>
|
||||
render(<SaveDatasetModal {...mockedProps} {...props} />, {
|
||||
useRedux: true,
|
||||
initialState: { user },
|
||||
});
|
||||
|
||||
fetchMock.get('glob:*/api/v1/dataset/?*', {
|
||||
result: mockdatasets,
|
||||
dataset_count: 3,
|
||||
@@ -59,17 +47,17 @@ fetchMock.get('glob:*/api/v1/dataset/?*', {
|
||||
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
|
||||
// Mock the user
|
||||
const useSelectorMock = jest.spyOn(reactRedux, 'useSelector');
|
||||
beforeEach(() => {
|
||||
useSelectorMock.mockClear();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Mock createDatasource to return a thunk that resolves with the dataset's
|
||||
// new id. The test's mock store includes redux-thunk middleware (from RTK's
|
||||
// getDefaultMiddleware), so dispatch(createDatasource(...)) properly unwraps
|
||||
// the thunk and the production code's .then((data) => clearDatasetCache(data.id))
|
||||
// chain receives `{ id: 123 }`. Individual tests can override per-call as needed.
|
||||
// Mock the createDatasource action
|
||||
const useDispatchMock = jest.spyOn(reactRedux, 'useDispatch');
|
||||
jest.mock('src/SqlLab/actions/sqlLab', () => ({
|
||||
createDatasource: jest.fn(() => () => Promise.resolve({ id: 123 })),
|
||||
createDatasource: jest.fn(),
|
||||
}));
|
||||
jest.mock('src/explore/exploreUtils/formData', () => ({
|
||||
postFormData: jest.fn(),
|
||||
@@ -82,7 +70,7 @@ jest.mock('src/utils/cachedSupersetGet', () => ({
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('SaveDatasetModal', () => {
|
||||
test('renders a "Save as new" field', () => {
|
||||
renderModal();
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
|
||||
const saveRadioBtn = screen.getByRole('radio', {
|
||||
name: /save as new/i,
|
||||
@@ -99,7 +87,7 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('renders an "Overwrite existing" field', () => {
|
||||
renderModal();
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
|
||||
const overwriteRadioBtn = screen.getByRole('radio', {
|
||||
name: /overwrite existing/i,
|
||||
@@ -115,20 +103,20 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('renders a close button', () => {
|
||||
renderModal();
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
|
||||
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders a save button when "Save as new" is selected', () => {
|
||||
renderModal();
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
|
||||
// "Save as new" is selected when the modal opens by default
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders an overwrite button when "Overwrite existing" is selected', () => {
|
||||
renderModal();
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
|
||||
// Click the overwrite radio button to reveal the overwrite confirmation and back buttons
|
||||
const overwriteRadioBtn = screen.getByRole('radio', {
|
||||
@@ -142,7 +130,8 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('renders the overwrite button as disabled until an existing dataset is selected', async () => {
|
||||
renderModal();
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
|
||||
// Click the overwrite radio button
|
||||
const overwriteRadioBtn = screen.getByRole('radio', {
|
||||
@@ -179,7 +168,8 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('renders a confirm overwrite screen when overwrite is clicked', async () => {
|
||||
renderModal();
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
|
||||
// Click the overwrite radio button
|
||||
const overwriteRadioBtn = screen.getByRole('radio', {
|
||||
@@ -225,7 +215,11 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('sends the schema when creating the dataset', async () => {
|
||||
renderModal();
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({});
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
@@ -246,9 +240,17 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('sends the catalog when creating the dataset', async () => {
|
||||
renderModal({
|
||||
datasource: { ...mockedProps.datasource, catalog: 'public' },
|
||||
});
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({});
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
|
||||
render(
|
||||
<SaveDatasetModal
|
||||
{...mockedProps}
|
||||
datasource={{ ...mockedProps.datasource, catalog: 'public' }}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
@@ -269,7 +271,7 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('does not renders a checkbox button when template processing is disabled', () => {
|
||||
renderModal();
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -278,7 +280,7 @@ describe('SaveDatasetModal', () => {
|
||||
global.featureFlags = {
|
||||
[FeatureFlag.EnableTemplateProcessing]: true,
|
||||
};
|
||||
renderModal();
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
expect(screen.getByRole('checkbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -287,11 +289,15 @@ describe('SaveDatasetModal', () => {
|
||||
global.featureFlags = {
|
||||
[FeatureFlag.EnableTemplateProcessing]: true,
|
||||
};
|
||||
renderModal({
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12 }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
@@ -318,11 +324,15 @@ describe('SaveDatasetModal', () => {
|
||||
global.featureFlags = {
|
||||
[FeatureFlag.EnableTemplateProcessing]: true,
|
||||
};
|
||||
renderModal({
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12 }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
@@ -383,11 +393,19 @@ describe('SaveDatasetModal', () => {
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockResolvedValue({ json: { result: { id: 0 } } } as any);
|
||||
|
||||
renderModal({
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({});
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12, _filters: 'foo' }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Check the "Include Template Parameters" checkbox
|
||||
@@ -425,11 +443,19 @@ describe('SaveDatasetModal', () => {
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockResolvedValue({ json: { result: { id: 0 } } } as any);
|
||||
|
||||
renderModal({
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({});
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12 }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Do NOT check the "Include Template Parameters" checkbox
|
||||
@@ -463,9 +489,12 @@ describe('SaveDatasetModal', () => {
|
||||
'postFormData',
|
||||
);
|
||||
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({ id: 123 });
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
postFormData.mockResolvedValue('chart_key_123');
|
||||
|
||||
renderModal();
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
|
||||
@@ -34,6 +34,7 @@ import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
SupersetClient,
|
||||
JsonResponse,
|
||||
JsonObject,
|
||||
QueryResponse,
|
||||
QueryFormData,
|
||||
VizType,
|
||||
@@ -43,14 +44,16 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates';
|
||||
import { useAppDispatch, useAppSelector } from 'src/views/store';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import rison from 'rison';
|
||||
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { UserWithPermissionsAndRoles as User } from 'src/types/bootstrapTypes';
|
||||
import {
|
||||
DatasetRadioState,
|
||||
EXPLORE_CHART_DEFAULT,
|
||||
DatasetOwner,
|
||||
SqlLabRootState,
|
||||
} from 'src/SqlLab/types';
|
||||
import { mountExploreUrl } from 'src/explore/exploreUtils';
|
||||
import { postFormData } from 'src/explore/exploreUtils/formData';
|
||||
@@ -218,7 +221,7 @@ export const SaveDatasetModal = ({
|
||||
openWindow = true,
|
||||
formData = {},
|
||||
}: SaveDatasetModalProps) => {
|
||||
const defaultVizType = useAppSelector(
|
||||
const defaultVizType = useSelector<SqlLabRootState, string>(
|
||||
state => state.common?.conf?.DEFAULT_VIZ_TYPE || VizType.Table,
|
||||
);
|
||||
|
||||
@@ -237,8 +240,8 @@ export const SaveDatasetModal = ({
|
||||
>(undefined);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const user = useAppSelector(state => state.user);
|
||||
const dispatch = useAppDispatch();
|
||||
const user = useSelector<SqlLabRootState, User>(state => state.user);
|
||||
const dispatch = useDispatch<(dispatch: any) => Promise<JsonObject>>();
|
||||
const [includeTemplateParameters, setIncludeTemplateParameters] =
|
||||
useState(false);
|
||||
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { createRef, useCallback, useMemo } from 'react';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { nanoid } from 'nanoid';
|
||||
import Tabs from '@superset-ui/core/components/Tabs';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
@@ -106,7 +105,7 @@ const SouthPane = ({
|
||||
const { id, tabViewId } = useQueryEditor(queryEditorId, ['tabViewId']);
|
||||
const editorId = tabViewId ?? id;
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
const viewItems = views.getViews(ViewLocations.sqllab.panels) || [];
|
||||
const { offline, tables } = useSelector(
|
||||
({ sqlLab: { offline, tables } }: SqlLabRootState) => ({
|
||||
|
||||
@@ -30,8 +30,7 @@ import {
|
||||
|
||||
import type { editors } from '@apache-superset/core';
|
||||
import useEffectEvent from 'src/hooks/useEffectEvent';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
@@ -238,7 +237,7 @@ const SqlEditor: FC<Props> = ({
|
||||
scheduleQueryWarning,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { database, latestQuery, currentQueryEditorId, hasSqlStatement } =
|
||||
useSelector<
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { resetState } from 'src/SqlLab/actions/sqlLab';
|
||||
import {
|
||||
@@ -69,7 +69,7 @@ const SqlEditorLeftBar = ({ queryEditorId }: SqlEditorLeftBarProps) => {
|
||||
const { db, catalog, schema, onDbChange, onCatalogChange, onSchemaChange } =
|
||||
dbSelectorProps;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
const shouldShowReset = window.location.search === '?reset=1';
|
||||
|
||||
// Modal state for Database/Catalog/Schema selector
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
import { useMemo, FC } from 'react';
|
||||
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { useSelector, shallowEqual } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
|
||||
import { MenuDotsDropdown } from '@superset-ui/core/components';
|
||||
import { Menu, MenuItemType } from '@superset-ui/core/components/Menu';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
@@ -91,7 +90,7 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
|
||||
);
|
||||
const StatusIcon = queryState ? STATE_ICONS[queryState] : STATE_ICONS.running;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
bindActionCreators(
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useCallback, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { SqlLabRootState } from 'src/SqlLab/types';
|
||||
import {
|
||||
@@ -42,7 +41,7 @@ export default function useDatabaseSelector(queryEditorId: string) {
|
||||
SqlLabRootState,
|
||||
SqlLabRootState['sqlLab']['databases']
|
||||
>(({ sqlLab }) => sqlLab.databases);
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
const queryEditor = useQueryEditor(queryEditorId, [
|
||||
'dbId',
|
||||
'catalog',
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { QueryEditor, SqlLabRootState, Table } from 'src/SqlLab/types';
|
||||
import {
|
||||
ButtonGroup,
|
||||
@@ -76,7 +75,7 @@ const Fade = styled.div`
|
||||
const TableElement = ({ table, ...props }: TableElementProps) => {
|
||||
const { dbId, catalog, schema, name, expanded, id } = table;
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
currentData: tableMetadata,
|
||||
isSuccess: isMetadataSuccess,
|
||||
|
||||
@@ -25,8 +25,7 @@ import {
|
||||
type ChangeEvent,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { useSelector, shallowEqual } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
|
||||
import { styled, css, useTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
@@ -164,7 +163,7 @@ const savePinnedSchemasToStorage = (
|
||||
};
|
||||
|
||||
const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
const theme = useTheme();
|
||||
const treeRef = useRef<TreeApi<TreeNodeData>>(null);
|
||||
const tables = useSelector(
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo, useReducer, useCallback } from 'react';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
Table,
|
||||
@@ -130,7 +130,7 @@ const useTreeData = ({
|
||||
catalog,
|
||||
pinnedTables,
|
||||
}: UseTreeDataParams): UseTreeDataResult => {
|
||||
const reduxDispatch = useAppDispatch();
|
||||
const reduxDispatch = useDispatch();
|
||||
// Schema data from API
|
||||
const {
|
||||
currentData: schemaData,
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { type FC, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ClientErrorObject, getExtensionsRegistry } from '@superset-ui/core';
|
||||
@@ -111,7 +110,7 @@ const renderWell = (partitions: TableMetaData['partitions']) => {
|
||||
};
|
||||
|
||||
const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
const theme = useTheme();
|
||||
const [databaseName, backend, disableDataPreview] = useSelector<
|
||||
SqlLabRootState,
|
||||
|
||||
@@ -1,91 +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 React from 'react';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import { views } from 'src/core';
|
||||
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||
import ChatbotMount from '.';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
disposables.forEach(d => d.dispose());
|
||||
disposables.length = 0;
|
||||
});
|
||||
|
||||
test('renders nothing when no chatbot extension is registered', () => {
|
||||
render(<ChatbotMount />);
|
||||
|
||||
expect(screen.queryByTestId('chatbot-mount')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the registered chatbot inside the fixed mount slot', () => {
|
||||
const provider = () => React.createElement('div', null, 'My Chatbot Bubble');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatbotMount />);
|
||||
|
||||
expect(screen.getByTestId('chatbot-mount')).toBeInTheDocument();
|
||||
expect(screen.getByText('My Chatbot Bubble')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders only the first-to-register chatbot when several are installed', () => {
|
||||
const firstProvider = () => React.createElement('div', null, 'First Bubble');
|
||||
const secondProvider = () =>
|
||||
React.createElement('div', null, 'Second Bubble');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'first.chatbot', name: 'First Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
firstProvider,
|
||||
),
|
||||
views.registerView(
|
||||
{ id: 'second.chatbot', name: 'Second Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
secondProvider,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatbotMount />);
|
||||
|
||||
expect(screen.getByText('First Bubble')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('isolates a failing chatbot so it does not crash the host', () => {
|
||||
const FailingChatbot = () => {
|
||||
throw new Error('chatbot blew up');
|
||||
};
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
() => React.createElement(FailingChatbot),
|
||||
),
|
||||
);
|
||||
|
||||
// The host-owned error boundary catches the failure; render does not throw.
|
||||
expect(() => render(<ChatbotMount />)).not.toThrow();
|
||||
});
|
||||
@@ -1,82 +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.
|
||||
*/
|
||||
/**
|
||||
* @fileoverview Host mount point for the singleton `superset.chatbot`
|
||||
* contribution area.
|
||||
*
|
||||
* The host owns the slot: a fixed bottom-right anchor that persists across all
|
||||
* routes, with a managed z-index. The extension owns everything rendered
|
||||
* inside it — the collapsed bubble, the expanded panel, all open/close state,
|
||||
* animations, and behavior (SIP §3.2 "Component contract").
|
||||
*
|
||||
* Singleton resolution (which of possibly several registered chatbots renders)
|
||||
* is delegated to `getActiveChatbot`. If no chatbot extension is registered,
|
||||
* this component renders nothing and the corner stays empty.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||
import { getActiveChatbot } from 'src/core/chatbot';
|
||||
import { subscribeToLocation } from 'src/core/views';
|
||||
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||
|
||||
const CHATBOT_EDGE_MARGIN = 24;
|
||||
|
||||
/**
|
||||
* Renders the active chatbot extension into a fixed bottom-right slot.
|
||||
*
|
||||
* Mounted once at the app root so the bubble persists across routes.
|
||||
* Re-resolves when the chatbot registry changes (extension activated or
|
||||
* deactivated at runtime via the P1.A lifecycle contract).
|
||||
* Renders null when no chatbot extension is registered.
|
||||
*/
|
||||
const ChatbotMount = () => {
|
||||
const theme = useTheme();
|
||||
const [activeChatbot, setActiveChatbot] = useState(getActiveChatbot);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeToLocation(CHATBOT_LOCATION, () =>
|
||||
setActiveChatbot(getActiveChatbot()),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
if (!activeChatbot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-test="chatbot-mount"
|
||||
css={css`
|
||||
position: fixed;
|
||||
right: ${CHATBOT_EDGE_MARGIN}px;
|
||||
bottom: ${CHATBOT_EDGE_MARGIN}px;
|
||||
/* Above dashboard content and the toast layer, below modal dialogs. */
|
||||
z-index: ${theme.zIndexPopupBase + 2};
|
||||
`}
|
||||
>
|
||||
<ErrorBoundary>{activeChatbot.provider()}</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatbotMount;
|
||||
@@ -28,7 +28,7 @@ import fetchMock from 'fetch-mock';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import mockDatasource from 'spec/fixtures/mockDatasource';
|
||||
import React from 'react';
|
||||
import DatasourceModalComponent, { buildExtraJsonObject } from '.';
|
||||
import DatasourceModalComponent from '.';
|
||||
|
||||
// Cast to accept partial mock props in tests
|
||||
const DatasourceModal = DatasourceModalComponent as unknown as React.FC<
|
||||
@@ -309,35 +309,3 @@ describe('DatasourceModal', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExtraJsonObject', () => {
|
||||
test('returns "{}" for an item with no warning and no certification', () => {
|
||||
expect(buildExtraJsonObject({} as any)).toBe('{}');
|
||||
});
|
||||
|
||||
test('drops warning_markdown when its value is null', () => {
|
||||
expect(buildExtraJsonObject({ warning_markdown: null } as any)).toBe('{}');
|
||||
});
|
||||
|
||||
test('drops warning_markdown when its value is an empty string', () => {
|
||||
expect(buildExtraJsonObject({ warning_markdown: '' } as any)).toBe('{}');
|
||||
});
|
||||
|
||||
test('preserves a non-empty warning_markdown verbatim', () => {
|
||||
expect(buildExtraJsonObject({ warning_markdown: '⚠ caveat' } as any)).toBe(
|
||||
'{"warning_markdown":"⚠ caveat"}',
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves certification and drops null warning_markdown', () => {
|
||||
expect(
|
||||
buildExtraJsonObject({
|
||||
certified_by: 'data-team',
|
||||
certification_details: 'verified',
|
||||
warning_markdown: null,
|
||||
} as any),
|
||||
).toBe(
|
||||
'{"certification":{"certified_by":"data-team","details":"verified"}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ const StyledDatasourceModal = styled(Modal)`
|
||||
}
|
||||
`;
|
||||
|
||||
export function buildExtraJsonObject(
|
||||
function buildExtraJsonObject(
|
||||
item: DatasetObject['metrics'][0] | DatasetObject['columns'][0],
|
||||
) {
|
||||
const certification =
|
||||
@@ -83,7 +83,7 @@ export function buildExtraJsonObject(
|
||||
: undefined;
|
||||
return JSON.stringify({
|
||||
certification,
|
||||
warning_markdown: item?.warning_markdown || undefined,
|
||||
warning_markdown: item?.warning_markdown,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,267 +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 { createRef } from 'react';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
selectOption,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { ListViewFilterOperator } from '../types';
|
||||
import UIFilters from './index';
|
||||
import SelectFilter from './Select';
|
||||
import type { FilterHandler } from './types';
|
||||
|
||||
const mockUpdateFilterValue = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockUpdateFilterValue.mockClear();
|
||||
});
|
||||
|
||||
test('select filter with ReactNode label uses option title when serializing selection', async () => {
|
||||
// Regression for sc-104554: the chart-list Owner filter renders options
|
||||
// with ReactNode labels (name + email). The value passed to
|
||||
// updateFilterValue is serialized into URL / filter state and re-used to
|
||||
// render the filter pill on return. It must carry the plain-text name
|
||||
// (from `title`) and not fall back to the numeric user id.
|
||||
const ReactNodeLabel = (
|
||||
<div>
|
||||
<span>John Doe</span>
|
||||
<span>john@example.com</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const fetchSelects = jest.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
label: ReactNodeLabel,
|
||||
value: 42,
|
||||
title: 'John Doe',
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
});
|
||||
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owners',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects,
|
||||
paginate: true,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('John Doe', 'Owner');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: 'John Doe',
|
||||
value: 42,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('select filter falls back to stringified value when no string label or title is available', async () => {
|
||||
const fetchSelects = jest.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
label: <span>123</span>,
|
||||
value: 123,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
});
|
||||
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Something',
|
||||
key: 'something',
|
||||
id: 'something',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('123', 'Something');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: '123',
|
||||
value: 123,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('plain select with string label passes label through unchanged', async () => {
|
||||
// Happy-path coverage for the typeof-string branch in onChange, exercised
|
||||
// through the non-async Select wrapper (selects array, no fetchSelects).
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Status',
|
||||
key: 'status',
|
||||
id: 'status',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.Equals,
|
||||
unfilteredLabel: 'All',
|
||||
selects: [
|
||||
{ label: 'Published', value: 7 },
|
||||
{ label: 'Draft', value: 8 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('Published', 'Status');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: 'Published',
|
||||
value: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('plain select with ReactNode label uses option title when serializing selection', async () => {
|
||||
// Parallel coverage to the AsyncSelect ReactNode-with-title test, against
|
||||
// the non-async Select wrapper. Guards against the two wrappers ever
|
||||
// diverging on antd's two-arg onChange shape.
|
||||
const ReactNodeLabel = (
|
||||
<div>
|
||||
<span>Jane Roe</span>
|
||||
<span>jane@example.com</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owners',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
unfilteredLabel: 'All',
|
||||
selects: [{ label: ReactNodeLabel, value: 99, title: 'Jane Roe' }],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('Jane Roe', 'Owner');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: 'Jane Roe',
|
||||
value: 99,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('clearFilter notifies onSelect with undefined and isClear=true', () => {
|
||||
// The isClear flag is what allows the parent (Filters/index) to suppress
|
||||
// onFilterUpdate side-effects when the user clears the filter rather than
|
||||
// picking a new value. Lock that contract in.
|
||||
const mockOnSelect = jest.fn();
|
||||
const ref = createRef<FilterHandler>();
|
||||
|
||||
render(
|
||||
<SelectFilter
|
||||
Header="Owner"
|
||||
initialValue={{ label: 'John Doe', value: 42 }}
|
||||
onSelect={mockOnSelect}
|
||||
selects={[{ label: 'John Doe', value: 42, title: 'John Doe' }]}
|
||||
ref={ref}
|
||||
/>,
|
||||
);
|
||||
|
||||
ref.current?.clearFilter();
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(undefined, true);
|
||||
});
|
||||
|
||||
test('rehydrates filter pill from initialValue with plain-string label', async () => {
|
||||
// The user-visible regression: after URL/state rehydration the filter pill
|
||||
// must render the human-readable name, not the numeric user id. The fix
|
||||
// ensures the persisted label is a string; this test asserts that string
|
||||
// is what surfaces in the rendered combobox selection.
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owners',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects: jest.fn().mockResolvedValue({ data: [], totalCount: 0 }),
|
||||
paginate: true,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'owners',
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
value: { label: 'John Doe', value: 42 },
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -58,22 +58,14 @@ function SelectFilter(
|
||||
) {
|
||||
const [selectedOption, setSelectedOption] = useState(initialValue);
|
||||
|
||||
const onChange = (selected: SelectOption, option?: SelectOption) => {
|
||||
// antd's `onChange` (with `labelInValue`) passes the `{label, value}`
|
||||
// labeled-value as the first arg and the full option (which carries
|
||||
// `title` and any other fields) as the second. Options may supply a
|
||||
// ReactNode label (e.g. OwnerSelectLabel for the chart list Owner
|
||||
// filter). Since this object is serialized into the URL and rehydrated
|
||||
// as the filter pill on return, we need a plain string. Prefer `title`
|
||||
// (set by callers to the human-readable name) before falling back to
|
||||
// the value.
|
||||
const onChange = (selected: SelectOption) => {
|
||||
onSelect(
|
||||
selected
|
||||
? {
|
||||
label:
|
||||
typeof selected.label === 'string'
|
||||
? selected.label
|
||||
: (option?.title ?? String(selected.value)),
|
||||
: String(selected.value),
|
||||
value: selected.value,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -26,10 +26,6 @@ export interface SortColumn {
|
||||
export interface SelectOption {
|
||||
label: ReactNode;
|
||||
value: any;
|
||||
// Plain-text representation of the option. Callers should set this when
|
||||
// `label` is a ReactNode so that the option can be serialized (e.g. into
|
||||
// URL filter state) without losing the human-readable name.
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,96 +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 React from 'react';
|
||||
import { views } from 'src/core/views';
|
||||
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||
import { getActiveChatbot } from './index';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
disposables.forEach(d => d.dispose());
|
||||
disposables.length = 0;
|
||||
});
|
||||
|
||||
test('getActiveChatbot returns undefined when no chatbot is registered', () => {
|
||||
expect(getActiveChatbot()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getActiveChatbot resolves the single registered chatbot', () => {
|
||||
const provider = () => React.createElement('div', null, 'Chatbot');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
const active = getActiveChatbot();
|
||||
expect(active).toEqual({ id: 'superset.chatbot', provider });
|
||||
});
|
||||
|
||||
test('getActiveChatbot picks the first-to-register when multiple are installed', () => {
|
||||
const firstProvider = () => React.createElement('div', null, 'First');
|
||||
const secondProvider = () => React.createElement('div', null, 'Second');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'first.chatbot', name: 'First Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
firstProvider,
|
||||
),
|
||||
views.registerView(
|
||||
{ id: 'second.chatbot', name: 'Second Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
secondProvider,
|
||||
),
|
||||
);
|
||||
|
||||
const active = getActiveChatbot();
|
||||
expect(active?.id).toBe('first.chatbot');
|
||||
expect(active?.provider).toBe(firstProvider);
|
||||
});
|
||||
|
||||
test('getActiveChatbot ignores views registered at other locations', () => {
|
||||
const provider = () => React.createElement('div', null, 'Panel');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'some.panel', name: 'Some Panel' },
|
||||
'sqllab.panels',
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getActiveChatbot()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getActiveChatbot stops resolving a chatbot once it is disposed', () => {
|
||||
const provider = () => React.createElement('div', null, 'Chatbot');
|
||||
const disposable = views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
provider,
|
||||
);
|
||||
|
||||
expect(getActiveChatbot()?.id).toBe('superset.chatbot');
|
||||
|
||||
disposable.dispose();
|
||||
|
||||
expect(getActiveChatbot()).toBeUndefined();
|
||||
});
|
||||
@@ -1,77 +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.
|
||||
*/
|
||||
/**
|
||||
* @fileoverview Host-internal resolver for the exclusive `superset.chatbot`
|
||||
* contribution area.
|
||||
*
|
||||
* `superset.chatbot` is a singleton contribution area: multiple chatbot
|
||||
* extensions may register a view there, but the host renders exactly one.
|
||||
* This module owns the host-side selection policy.
|
||||
*
|
||||
* This is host-internal infrastructure — it is NOT part of the public
|
||||
* `@apache-superset/core` API. Extensions register via the public
|
||||
* `views.registerView()`; only the host resolves which one is active.
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||
import { getRegisteredViewIds, getViewProvider } from 'src/core/views';
|
||||
|
||||
/**
|
||||
* The resolved active chatbot: a view id paired with its renderable provider.
|
||||
*/
|
||||
export interface ActiveChatbot {
|
||||
/** The registered view id of the selected chatbot. */
|
||||
id: string;
|
||||
/** The provider that renders the chatbot's React element. */
|
||||
provider: () => ReactElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves which single chatbot extension is currently active.
|
||||
*
|
||||
* Selection policy (P1):
|
||||
* - If no chatbot is registered, returns `undefined` — the corner stays empty.
|
||||
* - If one or more chatbots are registered, the first one to register wins.
|
||||
*
|
||||
* `Set` preserves insertion order, so "first to register" is deterministic.
|
||||
*
|
||||
* This is the P1 fallback policy. P2 introduces an admin "Default chatbot"
|
||||
* setting (SIP §4 option (c)); when that lands, the admin-selected id takes
|
||||
* precedence here and this first-to-register behavior remains only as the
|
||||
* fallback used when no admin setting is configured.
|
||||
*
|
||||
* @returns The active chatbot's id and provider, or `undefined` if none.
|
||||
*/
|
||||
export const getActiveChatbot = (): ActiveChatbot | undefined => {
|
||||
const registeredIds = getRegisteredViewIds(CHATBOT_LOCATION);
|
||||
if (registeredIds.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Deterministic first-to-register fallback. P2 will consult the admin
|
||||
// "Default chatbot" setting before this point.
|
||||
const [selectedId] = registeredIds;
|
||||
const provider = getViewProvider(CHATBOT_LOCATION, selectedId);
|
||||
if (!provider) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { id: selectedId, provider };
|
||||
};
|
||||
@@ -17,12 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
views,
|
||||
resolveView,
|
||||
getViewProvider,
|
||||
getRegisteredViewIds,
|
||||
} from './index';
|
||||
import { views, resolveView } from './index';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
@@ -115,59 +110,3 @@ test('dispose removes the view registration', () => {
|
||||
|
||||
expect(views.getViews('sqllab.panels')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getViewProvider returns the registered provider for a matching location', () => {
|
||||
const provider = () => React.createElement('div', null, 'Test');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'test.provider', name: 'Test Provider' },
|
||||
'superset.chatbot',
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getViewProvider('superset.chatbot', 'test.provider')).toBe(provider);
|
||||
});
|
||||
|
||||
test('getViewProvider returns undefined when the location does not match', () => {
|
||||
const provider = () => React.createElement('div', null, 'Test');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'test.provider', name: 'Test Provider' },
|
||||
'sqllab.panels',
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
// Registered, but at a different location.
|
||||
expect(getViewProvider('superset.chatbot', 'test.provider')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getViewProvider returns undefined for an unknown id', () => {
|
||||
expect(getViewProvider('superset.chatbot', 'nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getRegisteredViewIds returns ids in registration order', () => {
|
||||
const provider = () => React.createElement('div', null, 'Test');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'first.chatbot', name: 'First' },
|
||||
'superset.chatbot',
|
||||
provider,
|
||||
),
|
||||
views.registerView(
|
||||
{ id: 'second.chatbot', name: 'Second' },
|
||||
'superset.chatbot',
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getRegisteredViewIds('superset.chatbot')).toEqual([
|
||||
'first.chatbot',
|
||||
'second.chatbot',
|
||||
]);
|
||||
});
|
||||
|
||||
test('getRegisteredViewIds returns an empty array for an unused location', () => {
|
||||
expect(getRegisteredViewIds('superset.chatbot')).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -39,27 +39,6 @@ const viewRegistry: Map<
|
||||
|
||||
const locationIndex: Map<string, Set<string>> = new Map();
|
||||
|
||||
/** Listeners notified whenever a view is registered or unregistered at a location. */
|
||||
const locationListeners: Map<string, Set<() => void>> = new Map();
|
||||
|
||||
const notifyListeners = (location: string) => {
|
||||
locationListeners.get(location)?.forEach(fn => fn());
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to registration changes at a specific location.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
export const subscribeToLocation = (
|
||||
location: string,
|
||||
listener: () => void,
|
||||
): (() => void) => {
|
||||
const listeners = locationListeners.get(location) ?? new Set();
|
||||
listeners.add(listener);
|
||||
locationListeners.set(location, listeners);
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
|
||||
const registerView: typeof viewsApi.registerView = (
|
||||
view: View,
|
||||
location: string,
|
||||
@@ -73,12 +52,9 @@ const registerView: typeof viewsApi.registerView = (
|
||||
ids.add(id);
|
||||
locationIndex.set(location, ids);
|
||||
|
||||
notifyListeners(location);
|
||||
|
||||
return new Disposable(() => {
|
||||
viewRegistry.delete(id);
|
||||
locationIndex.get(location)?.delete(id);
|
||||
notifyListeners(location);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -101,53 +77,6 @@ const getViews: typeof viewsApi.getViews = (
|
||||
.filter((c): c is View => !!c);
|
||||
};
|
||||
|
||||
/**
|
||||
* Host-internal accessor that returns the registered `provider` for a view id
|
||||
* at a given location.
|
||||
*
|
||||
* This is deliberately NOT part of the public `@apache-superset/core` `views`
|
||||
* API. The public `getViews` returns descriptors only (`id`/`name`/...), so an
|
||||
* extension can discover what is registered but cannot obtain — and therefore
|
||||
* cannot render — another extension's view outside the host's mount point,
|
||||
* lifecycle, and fault-isolation boundary.
|
||||
*
|
||||
* The host uses this accessor to render exclusive (singleton) contribution
|
||||
* areas such as `superset.chatbot`, where it must enumerate the candidates and
|
||||
* then render exactly one. See `getActiveChatbot` in `src/core/chatbot`.
|
||||
*
|
||||
* @param location The contribution location (e.g. `superset.chatbot`).
|
||||
* @param id The registered view id.
|
||||
* @returns The provider function, or undefined if no matching view is
|
||||
* registered at that location.
|
||||
*/
|
||||
export const getViewProvider = (
|
||||
location: string,
|
||||
id: string,
|
||||
): (() => ReactElement) | undefined => {
|
||||
const entry = viewRegistry.get(id);
|
||||
if (entry?.location !== location) {
|
||||
return undefined;
|
||||
}
|
||||
return entry.provider;
|
||||
};
|
||||
|
||||
/**
|
||||
* Host-internal accessor that returns the ordered list of view ids registered
|
||||
* at a location, in registration order.
|
||||
*
|
||||
* Registration order is meaningful for exclusive locations: the host's
|
||||
* deterministic fallback policy ("first to register wins") relies on it.
|
||||
* Like {@link getViewProvider}, this is host-internal and not part of the
|
||||
* public API.
|
||||
*
|
||||
* @param location The contribution location.
|
||||
* @returns View ids in registration order, or an empty array if none.
|
||||
*/
|
||||
export const getRegisteredViewIds = (location: string): string[] => {
|
||||
const ids = locationIndex.get(location);
|
||||
return ids ? Array.from(ids) : [];
|
||||
};
|
||||
|
||||
export const views: typeof viewsApi = {
|
||||
registerView,
|
||||
getViews,
|
||||
|
||||
@@ -37,7 +37,7 @@ export const useDashboardMetadataBar = (dashboardInfo: DashboardInfo) => {
|
||||
type: MetadataType.Owner as const,
|
||||
createdBy: getOwnerName(dashboardInfo.created_by) || t('Not available'),
|
||||
owners:
|
||||
dashboardInfo.owners.length > 0
|
||||
dashboardInfo.owners && dashboardInfo.owners.length > 0
|
||||
? dashboardInfo.owners.map(getOwnerName)
|
||||
: t('None'),
|
||||
createdOn: dashboardInfo.created_on_delta_humanized,
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
SupersetClient,
|
||||
} from '@superset-ui/core';
|
||||
import { MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { parse as parseContentDisposition } from 'content-disposition';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { useDownloadScreenshot } from 'src/dashboard/hooks/useDownloadScreenshot';
|
||||
import { MenuKeys } from 'src/dashboard/types';
|
||||
import downloadAsPdf from 'src/utils/downloadAsPdf';
|
||||
@@ -122,7 +122,7 @@ export const useDownloadMenuItems = (
|
||||
|
||||
if (disposition) {
|
||||
try {
|
||||
const parsed = parseContentDisposition(disposition);
|
||||
const parsed = contentDisposition.parse(disposition);
|
||||
if (parsed?.parameters?.filename) {
|
||||
fileName = parsed.parameters.filename;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useSelector } from 'react-redux';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import { last } from 'lodash';
|
||||
import rison from 'rison';
|
||||
import { parse as parseContentDisposition } from 'content-disposition';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { SupersetClient, SupersetApiError } from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
@@ -105,7 +105,7 @@ export const useDownloadScreenshot = (
|
||||
|
||||
if (disposition) {
|
||||
try {
|
||||
const parsed = parseContentDisposition(disposition);
|
||||
const parsed = contentDisposition.parse(disposition);
|
||||
if (parsed?.parameters?.filename) {
|
||||
fileName = parsed.parameters.filename;
|
||||
}
|
||||
|
||||
@@ -62,8 +62,6 @@ const StyledSyntaxContainer = styled.div`
|
||||
|
||||
const StyledThemedSyntaxHighlighter = styled(CodeSyntaxHighlighter)`
|
||||
flex: 1;
|
||||
height: ${({ theme }) => theme.sizeUnit * 26}px;
|
||||
margin-top: 0;
|
||||
`;
|
||||
|
||||
const StyledFooter = styled.div`
|
||||
@@ -165,12 +163,7 @@ const ViewQuery: FC<ViewQueryProps> = props => {
|
||||
) : (
|
||||
<StyledThemedSyntaxHighlighter
|
||||
language={language}
|
||||
customStyle={{
|
||||
flex: 1,
|
||||
marginBottom: theme.sizeUnit * 3,
|
||||
fontSize: theme.fontSize * 0.75,
|
||||
padding: 0,
|
||||
}}
|
||||
customStyle={{ flex: 1, marginBottom: theme.sizeUnit * 3 }}
|
||||
>
|
||||
{currentSQL}
|
||||
</StyledThemedSyntaxHighlighter>
|
||||
|
||||
@@ -17,11 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import type { common as core } from '@apache-superset/core';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { store } from 'src/views/store';
|
||||
|
||||
type Extension = core.Extension;
|
||||
|
||||
@@ -39,9 +36,6 @@ class ExtensionsLoader {
|
||||
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
|
||||
/** Disposables returned by contribution registrations, keyed by extension id. */
|
||||
private extensionDisposables: Map<string, (() => void)[]> = new Map();
|
||||
|
||||
// eslint-disable-next-line no-useless-constructor
|
||||
private constructor() {
|
||||
// Private constructor for singleton pattern
|
||||
@@ -94,8 +88,7 @@ class ExtensionsLoader {
|
||||
public async initializeExtension(extension: Extension) {
|
||||
try {
|
||||
if (extension.remoteEntry) {
|
||||
const disposables = await this.loadModule(extension);
|
||||
this.extensionDisposables.set(extension.id, disposables);
|
||||
await this.loadModule(extension);
|
||||
}
|
||||
this.extensionIndex.set(extension.id, extension);
|
||||
} catch (error) {
|
||||
@@ -103,31 +96,15 @@ class ExtensionsLoader {
|
||||
`Failed to initialize extension ${extension.name}\n`,
|
||||
error,
|
||||
);
|
||||
store.dispatch(
|
||||
addDangerToast(t('Extension "%s" failed to load.', extension.name)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivates an extension by disposing all of its registered contributions
|
||||
* and removing it from the index.
|
||||
*/
|
||||
public deactivateExtension(id: string): void {
|
||||
const disposables = this.extensionDisposables.get(id);
|
||||
if (disposables) {
|
||||
disposables.forEach(dispose => dispose());
|
||||
this.extensionDisposables.delete(id);
|
||||
}
|
||||
this.extensionIndex.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a single extension module via webpack module federation.
|
||||
* The module's top-level side effects fire contribution registrations.
|
||||
* @param extension The extension to load.
|
||||
*/
|
||||
private async loadModule(extension: Extension): Promise<(() => void)[]> {
|
||||
private async loadModule(extension: Extension): Promise<void> {
|
||||
const { remoteEntry, id } = extension;
|
||||
|
||||
// Load the remote entry script
|
||||
@@ -172,33 +149,8 @@ class ExtensionsLoader {
|
||||
await container.init(__webpack_share_scopes__.default);
|
||||
|
||||
const factory = await container.get('./index');
|
||||
|
||||
// Intercept contribution registrations during module activation so we can
|
||||
// collect the Disposables and drive cleanup on deactivation.
|
||||
const collected: (() => void)[] = [];
|
||||
const originalSuperset = window.superset;
|
||||
window.superset = {
|
||||
...originalSuperset,
|
||||
views: {
|
||||
...originalSuperset.views,
|
||||
registerView: (
|
||||
...args: Parameters<typeof originalSuperset.views.registerView>
|
||||
) => {
|
||||
const disposable = originalSuperset.views.registerView(...args);
|
||||
collected.push(() => disposable.dispose());
|
||||
return disposable;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
// Execute the module factory — side effects fire contribution registrations
|
||||
factory();
|
||||
} finally {
|
||||
window.superset = originalSuperset;
|
||||
}
|
||||
|
||||
return collected;
|
||||
// Execute the module factory - side effects fire registrations
|
||||
factory();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
import * as supersetCore from '@apache-superset/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import {
|
||||
authentication,
|
||||
@@ -81,29 +80,14 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
views,
|
||||
};
|
||||
|
||||
// Isolate unhandled rejections that originate from extension code so they
|
||||
// cannot crash the host application. Extensions load via Module Federation
|
||||
// and their async failures (e.g. failed API calls, unhandled promise
|
||||
// chains) would otherwise surface as uncaught rejections in the host.
|
||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
// Always log so extension authors can diagnose failures.
|
||||
logging.error('[extensions] Unhandled rejection from extension:', event.reason);
|
||||
event.preventDefault();
|
||||
const setup = async () => {
|
||||
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
||||
await ExtensionsLoader.getInstance().initializeExtensions();
|
||||
}
|
||||
setInitialized(true);
|
||||
};
|
||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
|
||||
// Render the host immediately; extension bundles load in the background.
|
||||
// ChatbotMount re-resolves reactively once the chatbot extension registers
|
||||
// (via subscribeToLocation), so the bubble appears without blocking the UI.
|
||||
setInitialized(true);
|
||||
|
||||
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
||||
ExtensionsLoader.getInstance().initializeExtensions();
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
};
|
||||
setup();
|
||||
}, [initialized, userId]);
|
||||
|
||||
if (!initialized) {
|
||||
|
||||
@@ -75,7 +75,7 @@ export const useLanguageMenuItems = ({
|
||||
type: 'submenu' as const,
|
||||
label: (
|
||||
<span className="f16" aria-label={t('Languages')}>
|
||||
<i className={`flag ${languages[locale]?.flag ?? 'us'}`} />
|
||||
<i className={`flag ${languages[locale]?.flag ?? ''}`} />
|
||||
</span>
|
||||
),
|
||||
icon: <Icons.CaretDownOutlined iconSize="xs" />,
|
||||
|
||||
@@ -103,12 +103,10 @@ test('does not allow user to create a report without a name', () => {
|
||||
});
|
||||
|
||||
test('creates a new email report via modal Add button', async () => {
|
||||
// The modal now calls POST /api/v1/report/subscribe; creation_method, owners, and
|
||||
// recipients are derived server-side — the client payload intentionally omits them.
|
||||
fetchMock.post(
|
||||
'glob:*/api/v1/report/subscribe',
|
||||
REPORT_ENDPOINT,
|
||||
{ id: 1, result: {} },
|
||||
{ name: 'post-subscribe' },
|
||||
{ name: 'post-report' },
|
||||
);
|
||||
|
||||
render(<ReportModal {...defaultProps} />, { useRedux: true });
|
||||
@@ -116,22 +114,22 @@ test('creates a new email report via modal Add button', async () => {
|
||||
const addButton = screen.getByRole('button', { name: /add/i });
|
||||
await waitFor(() => userEvent.click(addButton));
|
||||
|
||||
// Verify exactly one POST to the subscribe endpoint
|
||||
// Verify exactly one POST from the modal submit path
|
||||
await waitFor(() => {
|
||||
const postCalls = fetchMock.callHistory.calls('post-subscribe');
|
||||
const postCalls = fetchMock.callHistory.calls('post-report');
|
||||
expect(postCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
const postCalls = fetchMock.callHistory.calls('post-subscribe');
|
||||
const postCalls = fetchMock.callHistory.calls('post-report');
|
||||
const body = JSON.parse(postCalls[0].options.body as string);
|
||||
expect(body.name).toBe('Weekly Report');
|
||||
expect(body.type).toBe('Report');
|
||||
expect(body.creation_method).toBe('dashboards');
|
||||
expect(body.crontab).toBeDefined();
|
||||
// creation_method, owners, and recipients are set server-side; not in the client payload
|
||||
expect(body.creation_method).toBeUndefined();
|
||||
expect(body.recipients).toBeUndefined();
|
||||
expect(body.recipients).toBeDefined();
|
||||
expect(body.recipients[0].type).toBe('Email');
|
||||
|
||||
fetchMock.removeRoute('post-subscribe');
|
||||
fetchMock.removeRoute('post-report');
|
||||
});
|
||||
|
||||
test('text-based chart hides screenshot width and shows message content', () => {
|
||||
|
||||
@@ -174,28 +174,6 @@ export const addReport =
|
||||
throw err;
|
||||
});
|
||||
|
||||
export const SUBSCRIBE_REPORT = 'SUBSCRIBE_REPORT' as const;
|
||||
|
||||
export interface SubscribeReportAction {
|
||||
type: typeof SUBSCRIBE_REPORT;
|
||||
json: ReportApiJsonResponse;
|
||||
}
|
||||
|
||||
export const subscribeReport =
|
||||
(report: Partial<ReportObject>) => (dispatch: Dispatch<AnyAction>) =>
|
||||
SupersetClient.post({
|
||||
endpoint: `/api/v1/report/subscribe`,
|
||||
jsonPayload: report,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
dispatch({ type: SUBSCRIBE_REPORT, json } as SubscribeReportAction);
|
||||
dispatch(addSuccessToast(t('The report has been created')));
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(addDangerToast(t('Failed to create report')));
|
||||
throw err;
|
||||
});
|
||||
|
||||
export const EDIT_REPORT = 'EDIT_REPORT' as const;
|
||||
|
||||
export interface EditReportAction {
|
||||
@@ -277,6 +255,5 @@ export function deleteActiveReport(report: DeletableReport) {
|
||||
export type ReportAction =
|
||||
| SetReportAction
|
||||
| AddReportAction
|
||||
| SubscribeReportAction
|
||||
| EditReportAction
|
||||
| DeleteReportAction;
|
||||
|
||||
@@ -31,8 +31,8 @@ import { Alert } from '@apache-superset/core/components';
|
||||
import { SupersetTheme } from '@apache-superset/core/theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
addReport,
|
||||
editReport,
|
||||
subscribeReport,
|
||||
} from 'src/features/reports/ReportModal/actions';
|
||||
import {
|
||||
Input,
|
||||
@@ -179,13 +179,26 @@ function ReportModal({
|
||||
}, [isEditMode, report]);
|
||||
|
||||
const onSave = async () => {
|
||||
const commonFields: Partial<ReportObject> = {
|
||||
// Create new Report
|
||||
const newReportValues: Partial<ReportObject> = {
|
||||
type: 'Report',
|
||||
active: true,
|
||||
force_screenshot: false,
|
||||
custom_width: currentReport.custom_width,
|
||||
creation_method: creationMethod,
|
||||
dashboard: dashboardId,
|
||||
chart: chart?.id,
|
||||
owners: [userId],
|
||||
recipients: [
|
||||
{
|
||||
recipient_config_json: {
|
||||
target: userEmail,
|
||||
ccTarget: ccEmail,
|
||||
bccTarget: bccEmail,
|
||||
},
|
||||
type: 'Email',
|
||||
},
|
||||
],
|
||||
name: currentReport.name,
|
||||
description: currentReport.description,
|
||||
crontab: currentReport.crontab,
|
||||
@@ -196,27 +209,12 @@ function ReportModal({
|
||||
setCurrentReport({ isSubmitting: true, error: undefined });
|
||||
try {
|
||||
if (isEditMode && currentReport.id) {
|
||||
// Edit path: include all fields, PUT endpoint accepts recipients/owners directly
|
||||
await dispatch(
|
||||
editReport(currentReport.id, {
|
||||
...commonFields,
|
||||
creation_method: creationMethod,
|
||||
owners: [userId],
|
||||
recipients: [
|
||||
{
|
||||
recipient_config_json: {
|
||||
target: userEmail,
|
||||
ccTarget: ccEmail,
|
||||
bccTarget: bccEmail,
|
||||
},
|
||||
type: 'Email',
|
||||
},
|
||||
],
|
||||
} as ReportObject),
|
||||
editReport(currentReport.id, newReportValues as ReportObject),
|
||||
);
|
||||
} else {
|
||||
// Subscribe path: creation_method, owners, and recipients are set server-side.
|
||||
await dispatch(subscribeReport(commonFields as ReportObject));
|
||||
// Create new report (either not in edit mode, or edit mode without valid ID)
|
||||
await dispatch(addReport(newReportValues as ReportObject));
|
||||
}
|
||||
onHide();
|
||||
} catch (e) {
|
||||
|
||||
@@ -21,13 +21,11 @@ import { omit } from 'lodash';
|
||||
import {
|
||||
SET_REPORT,
|
||||
ADD_REPORT,
|
||||
SUBSCRIBE_REPORT,
|
||||
EDIT_REPORT,
|
||||
DELETE_REPORT,
|
||||
ReportAction,
|
||||
SetReportAction,
|
||||
AddReportAction,
|
||||
SubscribeReportAction,
|
||||
EditReportAction,
|
||||
DeleteReportAction,
|
||||
} from './actions';
|
||||
@@ -107,25 +105,6 @@ export default function reportsReducer(
|
||||
};
|
||||
},
|
||||
|
||||
[SUBSCRIBE_REPORT]() {
|
||||
const { result, id } = (action as SubscribeReportAction).json;
|
||||
const report: ReportObject = { ...result, id } as ReportObject;
|
||||
const creationMethod = report.creation_method as ReportCreationMethod;
|
||||
const key = report.dashboard ?? report.chart;
|
||||
|
||||
if (key === undefined) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
[creationMethod]: {
|
||||
...state[creationMethod],
|
||||
[key]: report,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
[EDIT_REPORT]() {
|
||||
const actionTyped = action as EditReportAction;
|
||||
const report: ReportObject = {
|
||||
|
||||
347
superset-frontend/src/pages/ChartList/VersionHistoryDropdown.tsx
Normal file
347
superset-frontend/src/pages/ChartList/VersionHistoryDropdown.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// TEMP: Demo aid for sc-103156 entity-versioning. Lets a user open a
|
||||
// dropdown of recent versions on a chart and restore one. Not part
|
||||
// of the merged feature scope (ADR-005 limits v1 to backend); revert
|
||||
// before pushing the versioning branch.
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Dropdown, Tooltip, Icons } from '@superset-ui/core/components';
|
||||
|
||||
interface Change {
|
||||
kind: string;
|
||||
path: string[];
|
||||
from_value: unknown;
|
||||
to_value: unknown;
|
||||
}
|
||||
|
||||
interface ChangedBy {
|
||||
id: number;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
}
|
||||
|
||||
interface Version {
|
||||
version_uuid: string;
|
||||
version_number: number;
|
||||
transaction_id: number;
|
||||
operation_type: string;
|
||||
issued_at: string;
|
||||
changed_by: ChangedBy | null;
|
||||
changes: Change[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
chartUuid: string;
|
||||
onRestored?: () => void;
|
||||
}
|
||||
|
||||
// Layout-record path verbs (set by ``diff_dashboard_layout`` on the
|
||||
// backend): path = [verb, kind, id]. Same shape across the three
|
||||
// debug widgets so chart/dataset dropdowns also recognise them — even
|
||||
// though they don't normally produce layout records, the formatter
|
||||
// stays uniform.
|
||||
const LAYOUT_VERBS = new Set(['add', 'remove', 'move', 'edit']);
|
||||
|
||||
// Localized labels for the kinds emitted by the backend (layout walker
|
||||
// + dataset child diff). Defined statically so xgettext can extract them.
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
chart: t('chart'),
|
||||
row: t('row'),
|
||||
column: t('column'),
|
||||
tab: t('tab'),
|
||||
tabs: t('tabs'),
|
||||
header: t('header'),
|
||||
markdown: t('markdown'),
|
||||
divider: t('divider'),
|
||||
metric: t('metric'),
|
||||
};
|
||||
const localizedKind = (k: string): string => KIND_LABELS[k] ?? k;
|
||||
|
||||
function summarizeChange(c: Change): string {
|
||||
if (c.path.length === 3 && LAYOUT_VERBS.has(String(c.path[0]))) {
|
||||
const verb = String(c.path[0]);
|
||||
const kind = localizedKind(String(c.path[1]));
|
||||
const payload =
|
||||
((c.to_value ?? c.from_value) as { name?: string } | null) ?? null;
|
||||
const name = payload?.name;
|
||||
if (verb === 'add') {
|
||||
return name
|
||||
? t('Added %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Added %(kind)s', { kind });
|
||||
}
|
||||
if (verb === 'remove') {
|
||||
return name
|
||||
? t('Removed %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Removed %(kind)s', { kind });
|
||||
}
|
||||
if (verb === 'move') {
|
||||
return name
|
||||
? t('Moved %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Moved %(kind)s', { kind });
|
||||
}
|
||||
return name
|
||||
? t('Edited %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Edited %(kind)s', { kind });
|
||||
}
|
||||
|
||||
const isAdd = c.from_value == null && c.to_value != null;
|
||||
const isRemove = c.from_value != null && c.to_value == null;
|
||||
|
||||
if (c.path.length === 2 && (c.kind === 'column' || c.kind === 'metric')) {
|
||||
const kind = localizedKind(c.kind);
|
||||
const name = String(c.path[1]);
|
||||
if (isAdd) return t('Added %(kind)s "%(name)s"', { kind, name });
|
||||
if (isRemove) return t('Removed %(kind)s "%(name)s"', { kind, name });
|
||||
return t('Changed %(kind)s "%(name)s"', { kind, name });
|
||||
}
|
||||
|
||||
if (c.path[0] === 'slices') {
|
||||
const id = String(c.path[1] ?? '');
|
||||
if (isAdd) return t('Added chart %(id)s', { id }).trim();
|
||||
if (isRemove) return t('Removed chart %(id)s', { id }).trim();
|
||||
return t('Changed chart %(id)s', { id }).trim();
|
||||
}
|
||||
|
||||
if (c.kind === 'field') {
|
||||
const fieldName = String(c.path[c.path.length - 1]);
|
||||
const fieldLabel: string =
|
||||
fieldName === 'dashboard_title'
|
||||
? t('title')
|
||||
: fieldName === 'slice_name'
|
||||
? t('chart name')
|
||||
: fieldName === 'table_name'
|
||||
? t('table name')
|
||||
: fieldName;
|
||||
const isShortScalar =
|
||||
c.to_value !== null &&
|
||||
c.to_value !== undefined &&
|
||||
(typeof c.to_value === 'string' ||
|
||||
typeof c.to_value === 'number' ||
|
||||
typeof c.to_value === 'boolean') &&
|
||||
String(c.to_value).length <= 80;
|
||||
if (!isAdd && !isRemove && isShortScalar) {
|
||||
return t('Changed %(field)s to "%(value)s"', {
|
||||
field: fieldLabel,
|
||||
value: String(c.to_value),
|
||||
});
|
||||
}
|
||||
if (isRemove) {
|
||||
return t('Cleared %(field)s', { field: fieldLabel });
|
||||
}
|
||||
if (isAdd && isShortScalar) {
|
||||
return t('Set %(field)s to "%(value)s"', {
|
||||
field: fieldLabel,
|
||||
value: String(c.to_value),
|
||||
});
|
||||
}
|
||||
if (isAdd) return t('Added %(field)s', { field: fieldLabel });
|
||||
if (isRemove) return t('Removed %(field)s', { field: fieldLabel });
|
||||
return t('Changed %(field)s', { field: fieldLabel });
|
||||
}
|
||||
|
||||
const kind = localizedKind(c.kind);
|
||||
if (c.path.length) {
|
||||
const detail = String(c.path[c.path.length - 1]);
|
||||
if (isAdd) return t('Added %(kind)s %(detail)s', { kind, detail });
|
||||
if (isRemove) return t('Removed %(kind)s %(detail)s', { kind, detail });
|
||||
return t('Changed %(kind)s %(detail)s', { kind, detail });
|
||||
}
|
||||
if (isAdd) return t('Added %(kind)s', { kind });
|
||||
if (isRemove) return t('Removed %(kind)s', { kind });
|
||||
return t('Changed %(kind)s', { kind });
|
||||
}
|
||||
|
||||
function formatChangeTitle(changes: Change[]): string {
|
||||
if (!changes.length) return t('Baseline');
|
||||
const first = summarizeChange(changes[0]);
|
||||
if (changes.length === 1) return first;
|
||||
return t('%(first)s (+%(more)s more)', {
|
||||
first,
|
||||
more: changes.length - 1,
|
||||
});
|
||||
}
|
||||
|
||||
function formatUser(by: ChangedBy | null): string {
|
||||
if (!by) return t('system');
|
||||
if (by.first_name || by.last_name) {
|
||||
return `${by.first_name ?? ''} ${by.last_name ?? ''}`.trim();
|
||||
}
|
||||
return by.username;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
// Match the Superset locale set in src/views/App.tsx on
|
||||
// ``document.documentElement.lang`` rather than the browser default.
|
||||
const lang = document.documentElement.lang || undefined;
|
||||
return new Date(iso).toLocaleString(lang);
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export default function VersionHistoryDropdown({
|
||||
chartUuid,
|
||||
onRestored,
|
||||
}: Props) {
|
||||
const [versions, setVersions] = useState<Version[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { json } = await SupersetClient.get({
|
||||
endpoint: `/api/v1/chart/${chartUuid}/versions/`,
|
||||
});
|
||||
const result = (json as { result: Version[] }).result || [];
|
||||
// Newest first (API returns oldest-first)
|
||||
setVersions([...result].reverse().slice(0, 20));
|
||||
} catch (e) {
|
||||
console.error('Failed to load versions', e);
|
||||
setVersions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [chartUuid]);
|
||||
|
||||
const handleRestore = useCallback(
|
||||
async (version: Version) => {
|
||||
const summary = formatChangeTitle(version.changes);
|
||||
if (
|
||||
// eslint-disable-next-line no-alert
|
||||
!window.confirm(
|
||||
t(
|
||||
'Restore this chart to version %(num)s (%(summary)s)? This will overwrite the current state.',
|
||||
{ num: version.version_number, summary },
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await SupersetClient.post({
|
||||
endpoint: `/api/v1/chart/${chartUuid}/versions/${version.version_uuid}/restore`,
|
||||
});
|
||||
// eslint-disable-next-line no-alert
|
||||
window.alert(t('Restored. Reload the page to see the change.'));
|
||||
if (onRestored) onRestored();
|
||||
} catch (e) {
|
||||
console.error('Restore failed', e);
|
||||
// eslint-disable-next-line no-alert
|
||||
window.alert(t('Restore failed — see browser console for details.'));
|
||||
}
|
||||
},
|
||||
[chartUuid, onRestored],
|
||||
);
|
||||
|
||||
const items = (() => {
|
||||
if (loading) {
|
||||
return [{ key: 'loading', label: t('Loading…'), disabled: true }];
|
||||
}
|
||||
if (!versions) {
|
||||
return [
|
||||
{ key: 'empty', label: t('Click to load versions'), disabled: true },
|
||||
];
|
||||
}
|
||||
if (versions.length === 0) {
|
||||
return [{ key: 'empty', label: t('No versions yet'), disabled: true }];
|
||||
}
|
||||
// versions is already newest-first, so [0] is the live/current version.
|
||||
return versions.map((v, idx) => {
|
||||
const isCurrent = idx === 0;
|
||||
return {
|
||||
key: String(v.transaction_id),
|
||||
// antd's `disabled: true` greys the item and blocks default
|
||||
// click handling; combined with the inner div NOT having an
|
||||
// onClick when current, the row becomes informational only.
|
||||
disabled: isCurrent,
|
||||
label: (
|
||||
<div
|
||||
style={{ minWidth: 280, lineHeight: 1.4, padding: '4px 0' }}
|
||||
onClick={isCurrent ? undefined : () => handleRestore(v)}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
#{v.version_number} — {formatChangeTitle(v.changes)}
|
||||
{isCurrent && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontWeight: 400,
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
{t('(current)')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, opacity: 0.75 }}>
|
||||
{formatUser(v.changed_by)} · {formatDate(v.issued_at)}
|
||||
</div>
|
||||
{v.changes.length > 1 && (
|
||||
<ul
|
||||
style={{
|
||||
margin: '4px 0 0 18px',
|
||||
padding: 0,
|
||||
fontSize: 12,
|
||||
opacity: 0.85,
|
||||
listStyle: 'disc',
|
||||
}}
|
||||
>
|
||||
{v.changes.slice(0, 5).map((c, i) => (
|
||||
<li key={i}>{summarizeChange(c)}</li>
|
||||
))}
|
||||
{v.changes.length > 5 && (
|
||||
<li style={{ opacity: 0.6 }}>
|
||||
{t('+%(n)s more', { n: v.changes.length - 5 })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
})();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{ items }}
|
||||
onOpenChange={open => {
|
||||
if (open && versions === null && !loading) loadVersions();
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
id="version-history-tooltip"
|
||||
title={t('Version history (demo)')}
|
||||
placement="bottom"
|
||||
>
|
||||
<span role="button" tabIndex={0} className="action-button">
|
||||
<Icons.HistoryOutlined iconSize="l" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,8 @@ import { QueryObjectColumns } from 'src/views/CRUD/types';
|
||||
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
|
||||
import { Tag } from 'src/components/Tag';
|
||||
import { datasetLabel } from 'src/features/semanticLayers/label';
|
||||
// TEMP: sc-103156 versioning demo. Revert before any commit.
|
||||
import VersionHistoryDropdown from './VersionHistoryDropdown';
|
||||
|
||||
const FlexRowContainer = styled.div`
|
||||
align-items: center;
|
||||
@@ -576,6 +578,13 @@ function ChartList(props: ChartListProps) {
|
||||
)}
|
||||
</ConfirmStatusChange>
|
||||
)}
|
||||
{/* TEMP: sc-103156 versioning demo. Revert before any commit. */}
|
||||
{original.uuid && canEdit && (
|
||||
<VersionHistoryDropdown
|
||||
chartUuid={original.uuid}
|
||||
onRestored={() => refreshData()}
|
||||
/>
|
||||
)}
|
||||
</StyledActions>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// TEMP: Demo aid for sc-103156 entity-versioning. Lets a user open a
|
||||
// dropdown of recent versions on a dashboard and restore one. Not part
|
||||
// of the merged feature scope (ADR-005 limits v1 to backend); revert
|
||||
// before pushing the versioning branch.
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Dropdown, Tooltip, Icons } from '@superset-ui/core/components';
|
||||
|
||||
interface Change {
|
||||
kind: string;
|
||||
path: string[];
|
||||
from_value: unknown;
|
||||
to_value: unknown;
|
||||
}
|
||||
|
||||
interface ChangedBy {
|
||||
id: number;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
}
|
||||
|
||||
interface Version {
|
||||
version_uuid: string;
|
||||
version_number: number;
|
||||
transaction_id: number;
|
||||
operation_type: string;
|
||||
issued_at: string;
|
||||
changed_by: ChangedBy | null;
|
||||
changes: Change[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
dashboardUuid: string;
|
||||
onRestored?: () => void;
|
||||
}
|
||||
|
||||
// Layout-record path verbs (set by ``diff_dashboard_layout`` on the
|
||||
// backend): path = [verb, kind, id].
|
||||
const LAYOUT_VERBS = new Set(['add', 'remove', 'move', 'edit']);
|
||||
|
||||
// Localized labels for the kinds emitted by the backend (layout walker
|
||||
// + dataset child diff). Defined statically so xgettext can extract them.
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
chart: t('chart'),
|
||||
row: t('row'),
|
||||
column: t('column'),
|
||||
tab: t('tab'),
|
||||
tabs: t('tabs'),
|
||||
header: t('header'),
|
||||
markdown: t('markdown'),
|
||||
divider: t('divider'),
|
||||
metric: t('metric'),
|
||||
};
|
||||
const localizedKind = (k: string): string => KIND_LABELS[k] ?? k;
|
||||
|
||||
function summarizeChange(c: Change): string {
|
||||
// Layout record (dashboard): path = [verb, kind, id], with payload
|
||||
// carrying ``name`` / ``chartId`` etc.
|
||||
if (c.path.length === 3 && LAYOUT_VERBS.has(String(c.path[0]))) {
|
||||
const verb = String(c.path[0]);
|
||||
const kind = localizedKind(String(c.path[1]));
|
||||
const payload =
|
||||
((c.to_value ?? c.from_value) as { name?: string } | null) ?? null;
|
||||
const name = payload?.name;
|
||||
if (verb === 'add') {
|
||||
return name
|
||||
? t('Added %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Added %(kind)s', { kind });
|
||||
}
|
||||
if (verb === 'remove') {
|
||||
return name
|
||||
? t('Removed %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Removed %(kind)s', { kind });
|
||||
}
|
||||
if (verb === 'move') {
|
||||
return name
|
||||
? t('Moved %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Moved %(kind)s', { kind });
|
||||
}
|
||||
return name
|
||||
? t('Edited %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Edited %(kind)s', { kind });
|
||||
}
|
||||
|
||||
const isAdd = c.from_value == null && c.to_value != null;
|
||||
const isRemove = c.from_value != null && c.to_value == null;
|
||||
|
||||
// Dataset child: path = [columns | metrics, <name>]. ``kind`` is
|
||||
// ``column`` / ``metric`` so we can rebuild a readable summary.
|
||||
if (c.path.length === 2 && (c.kind === 'column' || c.kind === 'metric')) {
|
||||
const kind = localizedKind(c.kind);
|
||||
const name = String(c.path[1]);
|
||||
if (isAdd) return t('Added %(kind)s "%(name)s"', { kind, name });
|
||||
if (isRemove) return t('Removed %(kind)s "%(name)s"', { kind, name });
|
||||
return t('Changed %(kind)s "%(name)s"', { kind, name });
|
||||
}
|
||||
|
||||
// Slice membership (mostly folded into layout records server-side,
|
||||
// but may still appear if the layout walk didn't catch a chart).
|
||||
if (c.path[0] === 'slices') {
|
||||
const id = String(c.path[1] ?? '');
|
||||
if (isAdd) return t('Added chart %(id)s', { id }).trim();
|
||||
if (isRemove) return t('Removed chart %(id)s', { id }).trim();
|
||||
return t('Changed chart %(id)s', { id }).trim();
|
||||
}
|
||||
|
||||
// Scalar field record: path = [field_name] or [json_field, sub_key].
|
||||
if (c.kind === 'field') {
|
||||
const fieldName = String(c.path[c.path.length - 1]);
|
||||
// Friendly labels for the most user-visible fields.
|
||||
const fieldLabel: string =
|
||||
fieldName === 'dashboard_title'
|
||||
? t('title')
|
||||
: fieldName === 'slice_name'
|
||||
? t('chart name')
|
||||
: fieldName === 'table_name'
|
||||
? t('table name')
|
||||
: fieldName;
|
||||
// If the new value is a short primitive (string/number/bool), show
|
||||
// "Changed <field> to <value>" — much more useful than just naming
|
||||
// the field. Long strings, dicts and arrays fall through to the
|
||||
// generic verb-only summary.
|
||||
const isShortScalar =
|
||||
c.to_value !== null &&
|
||||
c.to_value !== undefined &&
|
||||
(typeof c.to_value === 'string' ||
|
||||
typeof c.to_value === 'number' ||
|
||||
typeof c.to_value === 'boolean') &&
|
||||
String(c.to_value).length <= 80;
|
||||
if (!isAdd && !isRemove && isShortScalar) {
|
||||
return t('Changed %(field)s to "%(value)s"', {
|
||||
field: fieldLabel,
|
||||
value: String(c.to_value),
|
||||
});
|
||||
}
|
||||
if (isRemove) {
|
||||
return t('Cleared %(field)s', { field: fieldLabel });
|
||||
}
|
||||
if (isAdd && isShortScalar) {
|
||||
return t('Set %(field)s to "%(value)s"', {
|
||||
field: fieldLabel,
|
||||
value: String(c.to_value),
|
||||
});
|
||||
}
|
||||
if (isAdd) return t('Added %(field)s', { field: fieldLabel });
|
||||
if (isRemove) return t('Removed %(field)s', { field: fieldLabel });
|
||||
return t('Changed %(field)s', { field: fieldLabel });
|
||||
}
|
||||
|
||||
// Fallback: kind plus the trailing path segment (if any).
|
||||
const kind = localizedKind(c.kind);
|
||||
if (c.path.length) {
|
||||
const detail = String(c.path[c.path.length - 1]);
|
||||
if (isAdd) return t('Added %(kind)s %(detail)s', { kind, detail });
|
||||
if (isRemove) return t('Removed %(kind)s %(detail)s', { kind, detail });
|
||||
return t('Changed %(kind)s %(detail)s', { kind, detail });
|
||||
}
|
||||
if (isAdd) return t('Added %(kind)s', { kind });
|
||||
if (isRemove) return t('Removed %(kind)s', { kind });
|
||||
return t('Changed %(kind)s', { kind });
|
||||
}
|
||||
|
||||
function formatChangeTitle(changes: Change[]): string {
|
||||
if (!changes.length) return t('Baseline');
|
||||
const first = summarizeChange(changes[0]);
|
||||
if (changes.length === 1) return first;
|
||||
return t('%(first)s (+%(more)s more)', {
|
||||
first,
|
||||
more: changes.length - 1,
|
||||
});
|
||||
}
|
||||
|
||||
function formatUser(by: ChangedBy | null): string {
|
||||
if (!by) return t('system');
|
||||
if (by.first_name || by.last_name) {
|
||||
return `${by.first_name ?? ''} ${by.last_name ?? ''}`.trim();
|
||||
}
|
||||
return by.username;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
// Match the Superset locale set in src/views/App.tsx on
|
||||
// ``document.documentElement.lang`` rather than the browser default.
|
||||
const lang = document.documentElement.lang || undefined;
|
||||
return new Date(iso).toLocaleString(lang);
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export default function VersionHistoryDropdown({
|
||||
dashboardUuid,
|
||||
onRestored,
|
||||
}: Props) {
|
||||
const [versions, setVersions] = useState<Version[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { json } = await SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/${dashboardUuid}/versions/`,
|
||||
});
|
||||
const result = (json as { result: Version[] }).result || [];
|
||||
// Newest first (API returns oldest-first)
|
||||
setVersions([...result].reverse().slice(0, 20));
|
||||
} catch (e) {
|
||||
console.error('Failed to load versions', e);
|
||||
setVersions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dashboardUuid]);
|
||||
|
||||
const handleRestore = useCallback(
|
||||
async (version: Version) => {
|
||||
const summary = formatChangeTitle(version.changes);
|
||||
if (
|
||||
// eslint-disable-next-line no-alert
|
||||
!window.confirm(
|
||||
t(
|
||||
'Restore this dashboard to version %(num)s (%(summary)s)? This will overwrite the current state.',
|
||||
{ num: version.version_number, summary },
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await SupersetClient.post({
|
||||
endpoint: `/api/v1/dashboard/${dashboardUuid}/versions/${version.version_uuid}/restore`,
|
||||
});
|
||||
onRestored?.();
|
||||
// Navigate to the dashboard with no URL params. A previous
|
||||
// ``?native_filters_key=…`` (or ``permalink_key`` / ``form_data_key``)
|
||||
// points at a server-cached snapshot from before the restore;
|
||||
// the next page hydration would merge it on top of the freshly
|
||||
// restored ``json_metadata`` and effectively mask the rollback
|
||||
// (e.g. dashboard-level colour scheme changes don't appear).
|
||||
// A clean URL forces hydration from the restored DB state.
|
||||
window.location.href = `/superset/dashboard/${dashboardUuid}/`;
|
||||
} catch (e) {
|
||||
console.error('Restore failed', e);
|
||||
// eslint-disable-next-line no-alert
|
||||
window.alert(t('Restore failed — see browser console for details.'));
|
||||
}
|
||||
},
|
||||
[dashboardUuid, onRestored],
|
||||
);
|
||||
|
||||
const items = (() => {
|
||||
if (loading) {
|
||||
return [{ key: 'loading', label: t('Loading…'), disabled: true }];
|
||||
}
|
||||
if (!versions) {
|
||||
return [
|
||||
{ key: 'empty', label: t('Click to load versions'), disabled: true },
|
||||
];
|
||||
}
|
||||
if (versions.length === 0) {
|
||||
return [{ key: 'empty', label: t('No versions yet'), disabled: true }];
|
||||
}
|
||||
// versions is already newest-first, so [0] is the live/current version.
|
||||
return versions.map((v, idx) => {
|
||||
const isCurrent = idx === 0;
|
||||
return {
|
||||
key: String(v.transaction_id),
|
||||
// antd's `disabled: true` greys the item and blocks default
|
||||
// click handling; combined with the inner div NOT having an
|
||||
// onClick when current, the row becomes informational only.
|
||||
disabled: isCurrent,
|
||||
label: (
|
||||
<div
|
||||
style={{ minWidth: 280, lineHeight: 1.4, padding: '4px 0' }}
|
||||
onClick={isCurrent ? undefined : () => handleRestore(v)}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
#{v.version_number} — {formatChangeTitle(v.changes)}
|
||||
{isCurrent && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontWeight: 400,
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
{t('(current)')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, opacity: 0.75 }}>
|
||||
{formatUser(v.changed_by)} · {formatDate(v.issued_at)}
|
||||
</div>
|
||||
{v.changes.length > 1 && (
|
||||
<ul
|
||||
style={{
|
||||
margin: '4px 0 0 18px',
|
||||
padding: 0,
|
||||
fontSize: 12,
|
||||
opacity: 0.85,
|
||||
listStyle: 'disc',
|
||||
}}
|
||||
>
|
||||
{v.changes.slice(0, 5).map((c, i) => (
|
||||
<li key={i}>{summarizeChange(c)}</li>
|
||||
))}
|
||||
{v.changes.length > 5 && (
|
||||
<li style={{ opacity: 0.6 }}>
|
||||
{t('+%(n)s more', { n: v.changes.length - 5 })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
})();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{ items }}
|
||||
onOpenChange={open => {
|
||||
if (open && versions === null && !loading) loadVersions();
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
id="version-history-tooltip"
|
||||
title={t('Version history (demo)')}
|
||||
placement="bottom"
|
||||
>
|
||||
<span role="button" tabIndex={0} className="action-button">
|
||||
<Icons.HistoryOutlined iconSize="l" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +77,8 @@ import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import { navigateTo } from 'src/utils/navigationUtils';
|
||||
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
|
||||
// TEMP: sc-103156 versioning demo. Revert before any commit.
|
||||
import VersionHistoryDropdown from './VersionHistoryDropdown';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const PASSWORDS_NEEDED_MESSAGE = t(
|
||||
@@ -122,6 +124,10 @@ const Actions = styled.div`
|
||||
|
||||
const DASHBOARD_COLUMNS_TO_FETCH = [
|
||||
'id',
|
||||
// TEMP: sc-103156 versioning demo. The version-history dropdown
|
||||
// calls /api/v1/dashboard/<uuid>/versions/, so the row needs `uuid`.
|
||||
// Revert this entry along with the dropdown component.
|
||||
'uuid',
|
||||
'dashboard_title',
|
||||
'published',
|
||||
'url',
|
||||
@@ -504,6 +510,13 @@ function DashboardList(props: DashboardListProps) {
|
||||
)}
|
||||
</ConfirmStatusChange>
|
||||
)}
|
||||
{/* TEMP: sc-103156 versioning demo. Revert before any commit. */}
|
||||
{original.uuid && canEdit && (
|
||||
<VersionHistoryDropdown
|
||||
dashboardUuid={original.uuid}
|
||||
onRestored={() => refreshData()}
|
||||
/>
|
||||
)}
|
||||
</Actions>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// TEMP: Demo aid for sc-103156 entity-versioning. Lets a user open a
|
||||
// dropdown of recent versions on a dataset and restore one. Not part
|
||||
// of the merged feature scope (ADR-005 limits v1 to backend); revert
|
||||
// before pushing the versioning branch.
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Dropdown, Tooltip, Icons } from '@superset-ui/core/components';
|
||||
|
||||
interface Change {
|
||||
kind: string;
|
||||
path: string[];
|
||||
from_value: unknown;
|
||||
to_value: unknown;
|
||||
}
|
||||
|
||||
interface ChangedBy {
|
||||
id: number;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
}
|
||||
|
||||
interface Version {
|
||||
version_uuid: string;
|
||||
version_number: number;
|
||||
transaction_id: number;
|
||||
operation_type: string;
|
||||
issued_at: string;
|
||||
changed_by: ChangedBy | null;
|
||||
changes: Change[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
datasetUuid: string;
|
||||
onRestored?: () => void;
|
||||
}
|
||||
|
||||
// Layout-record path verbs (set by ``diff_dashboard_layout`` on the
|
||||
// backend): path = [verb, kind, id]. Same shape across the three
|
||||
// debug widgets so chart/dataset dropdowns also recognise them — even
|
||||
// though they don't normally produce layout records, the formatter
|
||||
// stays uniform.
|
||||
const LAYOUT_VERBS = new Set(['add', 'remove', 'move', 'edit']);
|
||||
|
||||
// Localized labels for the kinds emitted by the backend (layout walker
|
||||
// + dataset child diff). Defined statically so xgettext can extract them.
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
chart: t('chart'),
|
||||
row: t('row'),
|
||||
column: t('column'),
|
||||
tab: t('tab'),
|
||||
tabs: t('tabs'),
|
||||
header: t('header'),
|
||||
markdown: t('markdown'),
|
||||
divider: t('divider'),
|
||||
metric: t('metric'),
|
||||
};
|
||||
const localizedKind = (k: string): string => KIND_LABELS[k] ?? k;
|
||||
|
||||
function summarizeChange(c: Change): string {
|
||||
if (c.path.length === 3 && LAYOUT_VERBS.has(String(c.path[0]))) {
|
||||
const verb = String(c.path[0]);
|
||||
const kind = localizedKind(String(c.path[1]));
|
||||
const payload =
|
||||
((c.to_value ?? c.from_value) as { name?: string } | null) ?? null;
|
||||
const name = payload?.name;
|
||||
if (verb === 'add') {
|
||||
return name
|
||||
? t('Added %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Added %(kind)s', { kind });
|
||||
}
|
||||
if (verb === 'remove') {
|
||||
return name
|
||||
? t('Removed %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Removed %(kind)s', { kind });
|
||||
}
|
||||
if (verb === 'move') {
|
||||
return name
|
||||
? t('Moved %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Moved %(kind)s', { kind });
|
||||
}
|
||||
return name
|
||||
? t('Edited %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Edited %(kind)s', { kind });
|
||||
}
|
||||
|
||||
const isAdd = c.from_value == null && c.to_value != null;
|
||||
const isRemove = c.from_value != null && c.to_value == null;
|
||||
|
||||
if (c.path.length === 2 && (c.kind === 'column' || c.kind === 'metric')) {
|
||||
const kind = localizedKind(c.kind);
|
||||
const name = String(c.path[1]);
|
||||
if (isAdd) return t('Added %(kind)s "%(name)s"', { kind, name });
|
||||
if (isRemove) return t('Removed %(kind)s "%(name)s"', { kind, name });
|
||||
return t('Changed %(kind)s "%(name)s"', { kind, name });
|
||||
}
|
||||
|
||||
if (c.path[0] === 'slices') {
|
||||
const id = String(c.path[1] ?? '');
|
||||
if (isAdd) return t('Added chart %(id)s', { id }).trim();
|
||||
if (isRemove) return t('Removed chart %(id)s', { id }).trim();
|
||||
return t('Changed chart %(id)s', { id }).trim();
|
||||
}
|
||||
|
||||
if (c.kind === 'field') {
|
||||
const fieldName = String(c.path[c.path.length - 1]);
|
||||
const fieldLabel: string =
|
||||
fieldName === 'dashboard_title'
|
||||
? t('title')
|
||||
: fieldName === 'slice_name'
|
||||
? t('chart name')
|
||||
: fieldName === 'table_name'
|
||||
? t('table name')
|
||||
: fieldName;
|
||||
const isShortScalar =
|
||||
c.to_value !== null &&
|
||||
c.to_value !== undefined &&
|
||||
(typeof c.to_value === 'string' ||
|
||||
typeof c.to_value === 'number' ||
|
||||
typeof c.to_value === 'boolean') &&
|
||||
String(c.to_value).length <= 80;
|
||||
if (!isAdd && !isRemove && isShortScalar) {
|
||||
return t('Changed %(field)s to "%(value)s"', {
|
||||
field: fieldLabel,
|
||||
value: String(c.to_value),
|
||||
});
|
||||
}
|
||||
if (isRemove) {
|
||||
return t('Cleared %(field)s', { field: fieldLabel });
|
||||
}
|
||||
if (isAdd && isShortScalar) {
|
||||
return t('Set %(field)s to "%(value)s"', {
|
||||
field: fieldLabel,
|
||||
value: String(c.to_value),
|
||||
});
|
||||
}
|
||||
if (isAdd) return t('Added %(field)s', { field: fieldLabel });
|
||||
if (isRemove) return t('Removed %(field)s', { field: fieldLabel });
|
||||
return t('Changed %(field)s', { field: fieldLabel });
|
||||
}
|
||||
|
||||
const kind = localizedKind(c.kind);
|
||||
if (c.path.length) {
|
||||
const detail = String(c.path[c.path.length - 1]);
|
||||
if (isAdd) return t('Added %(kind)s %(detail)s', { kind, detail });
|
||||
if (isRemove) return t('Removed %(kind)s %(detail)s', { kind, detail });
|
||||
return t('Changed %(kind)s %(detail)s', { kind, detail });
|
||||
}
|
||||
if (isAdd) return t('Added %(kind)s', { kind });
|
||||
if (isRemove) return t('Removed %(kind)s', { kind });
|
||||
return t('Changed %(kind)s', { kind });
|
||||
}
|
||||
|
||||
function formatChangeTitle(changes: Change[]): string {
|
||||
if (!changes.length) return t('Baseline');
|
||||
const first = summarizeChange(changes[0]);
|
||||
if (changes.length === 1) return first;
|
||||
return t('%(first)s (+%(more)s more)', {
|
||||
first,
|
||||
more: changes.length - 1,
|
||||
});
|
||||
}
|
||||
|
||||
function formatUser(by: ChangedBy | null): string {
|
||||
if (!by) return t('system');
|
||||
if (by.first_name || by.last_name) {
|
||||
return `${by.first_name ?? ''} ${by.last_name ?? ''}`.trim();
|
||||
}
|
||||
return by.username;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
// Match the Superset locale set in src/views/App.tsx on
|
||||
// ``document.documentElement.lang`` rather than the browser default.
|
||||
const lang = document.documentElement.lang || undefined;
|
||||
return new Date(iso).toLocaleString(lang);
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export default function VersionHistoryDropdown({
|
||||
datasetUuid,
|
||||
onRestored,
|
||||
}: Props) {
|
||||
const [versions, setVersions] = useState<Version[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { json } = await SupersetClient.get({
|
||||
endpoint: `/api/v1/dataset/${datasetUuid}/versions/`,
|
||||
});
|
||||
const result = (json as { result: Version[] }).result || [];
|
||||
// Newest first (API returns oldest-first)
|
||||
setVersions([...result].reverse().slice(0, 20));
|
||||
} catch (e) {
|
||||
console.error('Failed to load versions', e);
|
||||
setVersions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [datasetUuid]);
|
||||
|
||||
const handleRestore = useCallback(
|
||||
async (version: Version) => {
|
||||
const summary = formatChangeTitle(version.changes);
|
||||
if (
|
||||
// eslint-disable-next-line no-alert
|
||||
!window.confirm(
|
||||
t(
|
||||
'Restore this dataset to version %(num)s (%(summary)s)? This will overwrite the current state.',
|
||||
{ num: version.version_number, summary },
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await SupersetClient.post({
|
||||
endpoint: `/api/v1/dataset/${datasetUuid}/versions/${version.version_uuid}/restore`,
|
||||
});
|
||||
// eslint-disable-next-line no-alert
|
||||
window.alert(t('Restored. Reload the page to see the change.'));
|
||||
if (onRestored) onRestored();
|
||||
} catch (e) {
|
||||
console.error('Restore failed', e);
|
||||
// eslint-disable-next-line no-alert
|
||||
window.alert(t('Restore failed — see browser console for details.'));
|
||||
}
|
||||
},
|
||||
[datasetUuid, onRestored],
|
||||
);
|
||||
|
||||
const items = (() => {
|
||||
if (loading) {
|
||||
return [{ key: 'loading', label: t('Loading…'), disabled: true }];
|
||||
}
|
||||
if (!versions) {
|
||||
return [
|
||||
{ key: 'empty', label: t('Click to load versions'), disabled: true },
|
||||
];
|
||||
}
|
||||
if (versions.length === 0) {
|
||||
return [{ key: 'empty', label: t('No versions yet'), disabled: true }];
|
||||
}
|
||||
return versions.map((v, idx) => {
|
||||
const isCurrent = idx === 0;
|
||||
return {
|
||||
key: String(v.transaction_id),
|
||||
disabled: isCurrent,
|
||||
label: (
|
||||
<div
|
||||
style={{ minWidth: 280, lineHeight: 1.4, padding: '4px 0' }}
|
||||
onClick={isCurrent ? undefined : () => handleRestore(v)}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
#{v.version_number} — {formatChangeTitle(v.changes)}
|
||||
{isCurrent && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontWeight: 400,
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
{t('(current)')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, opacity: 0.75 }}>
|
||||
{formatUser(v.changed_by)} · {formatDate(v.issued_at)}
|
||||
</div>
|
||||
{v.changes.length > 1 && (
|
||||
<ul
|
||||
style={{
|
||||
margin: '4px 0 0 18px',
|
||||
padding: 0,
|
||||
fontSize: 12,
|
||||
opacity: 0.85,
|
||||
listStyle: 'disc',
|
||||
}}
|
||||
>
|
||||
{v.changes.slice(0, 5).map((c, i) => (
|
||||
<li key={i}>{summarizeChange(c)}</li>
|
||||
))}
|
||||
{v.changes.length > 5 && (
|
||||
<li style={{ opacity: 0.6 }}>
|
||||
{t('+%(n)s more', { n: v.changes.length - 5 })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
})();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{ items }}
|
||||
onOpenChange={open => {
|
||||
if (open && versions === null && !loading) loadVersions();
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
id="version-history-tooltip"
|
||||
title={t('Version history (demo)')}
|
||||
placement="bottom"
|
||||
>
|
||||
<span role="button" tabIndex={0} className="action-button">
|
||||
<Icons.HistoryOutlined iconSize="l" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -99,6 +99,8 @@ import { useSelector } from 'react-redux';
|
||||
import { QueryObjectColumns } from 'src/views/CRUD/types';
|
||||
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
|
||||
import type { BootstrapData } from 'src/types/bootstrapTypes';
|
||||
// TEMP: sc-103156 versioning demo. Revert before any commit.
|
||||
import VersionHistoryDropdown from './VersionHistoryDropdown';
|
||||
|
||||
const SEMANTIC_LAYERS_FLAG = 'SEMANTIC_LAYERS' as FeatureFlag;
|
||||
type DatasetExtra = {
|
||||
@@ -165,6 +167,7 @@ type Dataset = {
|
||||
source_type?: 'database' | 'semantic_layer';
|
||||
explore_url: string;
|
||||
id: number;
|
||||
uuid?: string;
|
||||
owners: Array<Owner>;
|
||||
schema: string | null;
|
||||
table_name: string;
|
||||
@@ -936,6 +939,13 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* TEMP: sc-103156 versioning demo. Revert before any commit. */}
|
||||
{original.uuid && canEdit && (
|
||||
<VersionHistoryDropdown
|
||||
datasetUuid={original.uuid}
|
||||
onRestored={() => refreshData()}
|
||||
/>
|
||||
)}
|
||||
</Actions>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -117,7 +117,6 @@ type LaunchQueue = {
|
||||
|
||||
const pendingTimerIds = new Set<ReturnType<typeof setTimeout>>();
|
||||
const MAX_CONSUMER_POLL_ATTEMPTS = 50;
|
||||
const consumerPromises: Promise<void>[] = [];
|
||||
|
||||
// Defer the consumer call to a macrotask so it doesn't fire synchronously inside
|
||||
// the component's useEffect — calling it inline deadlocks Jest because the
|
||||
@@ -132,11 +131,7 @@ const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => {
|
||||
if (fileHandle) {
|
||||
const id = setTimeout(() => {
|
||||
pendingTimerIds.delete(id);
|
||||
consumerPromises.push(
|
||||
Promise.resolve(consumer({ files: [fileHandle] })).then(
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
consumer({ files: [fileHandle] });
|
||||
}, 0);
|
||||
pendingTimerIds.add(id);
|
||||
}
|
||||
@@ -170,19 +165,9 @@ beforeEach(() => {
|
||||
.launchQueue;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
afterEach(() => {
|
||||
pendingTimerIds.forEach(id => clearTimeout(id));
|
||||
pendingTimerIds.clear();
|
||||
if (consumerPromises.length > 0) {
|
||||
const results = await Promise.allSettled(consumerPromises);
|
||||
results.forEach(r => {
|
||||
if (r.status === 'rejected') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('LaunchQueue consumer rejected:', r.reason);
|
||||
}
|
||||
});
|
||||
consumerPromises.length = 0;
|
||||
}
|
||||
delete (window as unknown as Window & { launchQueue?: LaunchQueue })
|
||||
.launchQueue;
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user