Compare commits

...

44 Commits

Author SHA1 Message Date
Amin Ghadersohi
468ac195f0 refactor(mcp): rename undelete_dashboard to restore_dashboard
Matches the platform vocabulary: the REST API exposes
POST /api/v1/dashboard/<uuid>/restore, the command layer is
RestoreDashboardCommand/BaseRestoreCommand, and SoftDeleteMixin's
recovery method is restore(). "undelete" appeared nowhere else in
the codebase.
2026-07-10 02:53:37 +00:00
Amin Ghadersohi
af2392d0f4 test(mcp): cover generic restore-failure path in undelete_dashboard 2026-07-09 14:59:10 +00:00
Amin Ghadersohi
50985f89f1 test(mcp): address review feedback on delete/undelete dashboard tools
- Add test for the DashboardDeleteFailedError path (associated alerts/reports)
- Add sanitization tests for DeletedDashboardSummary, DeleteDashboardResponse,
  and UndeleteDashboardResponse schemas
- Annotate test fixture return types
2026-07-08 19:36:28 +00:00
Amin Ghadersohi
4bfbdecd11 feat(mcp): add undelete_dashboard tool and soft-delete-aware delete responses 2026-07-08 19:36:28 +00:00
Amin Ghadersohi
cfc0515931 feat(mcp): add delete_dashboard tool 2026-07-08 19:36:28 +00:00
dependabot[bot]
15f8b8e9b4 chore(deps): bump immer from 11.1.8 to 11.1.9 in /superset-frontend (#41894)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-08 12:26:28 -07:00
dependabot[bot]
31351eb52a chore(deps-dev): bump @types/node from 26.0.1 to 26.1.0 in /superset-frontend (#41896)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-08 12:26:10 -07:00
dependabot[bot]
8e549e4975 chore(deps-dev): update playwright requirement from <2,>=1.60.0 to >=1.61.0,<2 (#41890)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-08 12:11:45 -07:00
Kevin Lan
e65b2c49d7 fix(marshmallow): add compatibility layer for Flask-AppBuilder with marshmallow 4.x (#35920)
Co-authored-by: FLAME <xaustin03@gmail.com>
Co-authored-by: Eyang0612 <eddieyang0612@gmail.com>
2026-07-08 12:09:50 -07:00
Đỗ Trọng Hải
2c8cdbbd9b chore(dependabot): unify npm Dependabot config used for multi-workspace superset-frontend (#41586) 2026-07-09 01:15:15 +07:00
Đỗ Trọng Hải
83716cb8c0 build(websocket): migrate to ESM (#41680) 2026-07-09 01:14:44 +07:00
Ville Brofeldt
33f0fc93ed feat: introduce Subject model and entity editors/viewers (#38831) 2026-07-08 11:00:03 -07:00
Evan Rusackas
01e872c105 refactor: remove the deprecated AVOID_COLORS_COLLISION feature flag (#41800)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-08 09:48:32 -07:00
Daniel
34d4966f8b fix(dataset): resolve duplicate virtual dataset validation error (#37932)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 08:27:13 -07:00
Beto Dealmeida
001d6c9692 fix(semantic-layers): apply time-comparison offset to TEMPORAL_RANGE filters (#41556) 2026-07-08 08:25:42 -07:00
Evan Rusackas
e333135a0d feat(i18n): expose the six newly backfilled locales (#41827)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-07-08 08:25:14 -07:00
Jean Pommier
dc5c5af467 docs: Add geOrchestra project and pi-geosolutions to In the Wild (#41801) 2026-07-08 08:24:03 -07:00
Evan Rusackas
deefe897f1 chore(frontend): remove orphaned eslint-plugin-cypress devDependency (#41866)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 21:52:54 +07:00
Elizabeth Thompson
49ed206381 fix(a11y): add aria-label to dependency-list remove icon (#41878) 2026-07-08 21:36:52 +07:00
Alexandru Soare
8a599d70b4 fix(explore): admin creating new chart crashes on save (#41880) 2026-07-08 16:57:17 +03:00
Enzo Martellucci
d81b135a04 fix(explore): render DragOverlay so dragged metric/column is visible (#41879) 2026-07-08 14:49:45 +02:00
dependabot[bot]
211bcbc635 chore(deps-dev): bump @swc/plugin-emotion from 14.14.0 to 14.14.1 in /superset-frontend (#41875)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-08 04:40:28 -07:00
Alexandru Soare
3b1abf8260 feat(Icons): adding Layout icon (#41853) 2026-07-08 12:25:51 +03:00
yousoph
01cd215202 fix(explore): default Save As for new charts in save dialog (#41314)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 00:44:34 -07:00
jaehoon
2dab31c130 fix(mcp): serialize FAB role permissions (#41404)
Co-authored-by: jaehoon.kim <jaehoon.kim@42dot.ai>
2026-07-07 23:27:30 -07:00
Paul Spitzner
f74de0eb6c fix: Show metric in the default tooltip of deck.gl GeoJson Polygon chart (#41341)
Co-authored-by: Damian Pendrak <dpendrak@gmail.com>
2026-07-07 23:22:15 -07:00
Shaitan
206155f986 fix(explore): apply per-datasource access check on the legacy explore view (#41425)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:08:17 -07:00
innovark
f6d03e4a68 fix(clickhouse): time filters use server timezone for toDateTime literals (#41579) 2026-07-07 23:07:46 -07:00
Maxime Beauchemin
0efcd54250 fix(charts): fix time comparison crash when offsets share a numeric prefix (#39344)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-07 17:46:30 -07:00
Jacobdavis12
3fa000ba0b fix(chart): allow custom date format for week range in Big Number with Trendline (#35752) 2026-07-07 17:46:06 -07:00
Akshat Sinha
9617155289 fix(utils): urls preserve query params (#35936) 2026-07-07 17:11:19 -07:00
Nicolas
e49945fc2f fix(table charts v1 & v2): pagination, translation and search by (#36881)
Co-authored-by: Nicolas <n.berry@urios.com>
2026-07-07 17:10:44 -07:00
JUST.in DO IT
866f8c51ee fix(sqllab): invalid height in ResultTable (#37408) 2026-07-07 17:09:45 -07:00
abdullah reveha
98fc857613 feat(cache): warm up native filter option queries (#41531)
Co-authored-by: Abdullah Sahin <you@example.comclear>
2026-07-07 17:08:40 -07:00
Jay Masiwal
ddca50c820 fix(ui): improve getPopupContainer to avoid double scrollbar without breaking modal behavior (#38792) 2026-07-07 17:04:31 -07:00
mkramer5454
c253b68d7b fix(chart): fix Chart Time Grain option not working if Dataset is not saved (#38766)
Co-authored-by: Matthew Kramer <matthewkramer@Matthews-MacBook-Pro.local>
Co-authored-by: Matthew Kramer <matthewkramer@Matthews-MacBook-Pro-2.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:00:40 -07:00
Jeremy
050acc6c30 fix(databend): support configuring Databend via individual parameters (#41513) 2026-07-07 16:58:33 -07:00
abdullah reveha
7b95d8b17c feat(alerts): add user lookup for email recipients (#41338)
Co-authored-by: Abdullah Sahin <you@example.comclear>
2026-07-07 16:57:35 -07:00
innovark
0d9533fdf8 fix(reports): export server-paginated table row limits (#41103) 2026-07-07 16:57:11 -07:00
Open Loop
6b1311a6c7 fix(sqllab): correct verticalalign CSS typo in SqlEditorTabHeader icon styles (#41052)
Signed-off-by: albraa911sa-lgtm <albraa911sa@gmail.com>
2026-07-07 16:48:31 -07:00
dependabot[bot]
f1c0a461fd chore(deps): bump react-markdown from 9.0.7 to 10.1.0 in /superset-frontend/packages/superset-ui-core (#41805)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:46:07 -07:00
SBIN2010
ef10501e6b feat(table v2): add tooltip to table header (#39287) 2026-07-07 16:44:50 -07:00
jesperct
17ae25b8d4 fix(explore): resolve adhoc Custom SQL dimension in Bubble chart (#41861) 2026-07-07 16:44:24 -07:00
Đỗ Trọng Hải
2a77c0156a fix(frontend/setup): sanitize returned client error message when shown as HTML content (#41768)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-07-07 16:44:04 -07:00
562 changed files with 33775 additions and 19960 deletions

289
.github/dependabot.yml vendored
View File

@@ -46,7 +46,6 @@ updates:
cooldown:
default-days: 7
- package-ecosystem: "pip"
directory: "/"
open-pull-requests-limit: 10
@@ -103,291 +102,3 @@ updates:
versioning-strategy: increase
cooldown:
default-days: 7
# Now for all of our plugins and packages!
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-calendar/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-partition/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-world-map/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-pivot-table/"
ignore:
# TODO: remove below entries until React >= 19.0.0
- dependency-name: "react-icons"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-chord/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-horizon/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-rose/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-preset-chart-deckgl/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-table/"
ignore:
# TODO: remove below entries until React >= 19.0.0
- dependency-name: "react-icons"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-country-map/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-map-box/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-preset-chart-nvd3/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-word-cloud/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-echarts/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-ag-grid-table/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-cartodiagram/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-handlebars/"
ignore:
# `just-handlerbars-helpers` library in plugin-chart-handlebars requires `currencyformatter`` to be < 2
- dependency-name: "currencyformatter.js"
update-types: ["version-update:semver-major"]
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/generator-superset/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/superset-ui-chart-controls/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/superset-ui-core/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/superset-ui-switchboard/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7

View File

@@ -619,14 +619,34 @@ categories:
- name: Symmetrics
url: https://www.symmetrics.fyi
Government:
Government / Public authority:
- name: Centre Régional Auvergnat de l'Information Géographique (France)
url: https://www.craig.fr/
logo: craig.png
- name: City of Ann Arbor, MI
url: https://www.a2gov.org/
contributors: ["@sfirke"]
- name: Métropole Européenne de Lille (France)
url: https://data.lillemetropole.fr/accueil/
logo: mel.jpg
- name: NRLM - Sarathi, India
url: https://pib.gov.in/PressReleasePage.aspx?PRID=1999586
- name: Région Bretagne (France)
url: https://geobretagne.fr/
logo: geobretagne.svg
- name: Région Hauts-de-France (France)
url: https://www.geo2france.fr/
logo: geo2france.svg
- name: Rennes Métropole (France)
url: https://metropole.rennes.fr/
logo: rennes-ville-metropole_noir.svg
- name: RIS3 Strategy of CZ, MIT CR
url: https://www.ris3.cz/
contributors: ["@RIS3CZ"]
@@ -682,6 +702,10 @@ categories:
url: https://automattic.com/
contributors: ["@Khrol", "@Usiel"]
- name: CamptoCamp
url: https://camptocamp.com/
logo: camptocamp.svg
- name: Dropbox
url: https://www.dropbox.com/
contributors: ["@bkyryliuk"]
@@ -690,6 +714,10 @@ categories:
url: https://flowbird.com
contributors: ["@EmmanuelCbd"]
- name: geOrchestra
url: https://www.georchestra.org
logo: georchestra.svg
- name: GEOTAB
url: https://www.geotab.com
contributors: ["@JZ6"]
@@ -706,6 +734,10 @@ categories:
url: https://www.increff.com/
contributors: ["@ishansinghania"]
- name: jDev
url: https://jdev.fr/
logo: jDev.svg
- name: Let's Roam
url: https://www.letsroam.com/
@@ -716,6 +748,11 @@ categories:
url: https://1beat.com/
contributors: ["@GuyAttia"]
- name: pi-Geosolutions
url: https://www.pi-geosolutions.fr/
logo: pi-Geosolutions.png
contributors: ["@jeanpommier"]
- name: X
url: https://x.com/

View File

@@ -24,6 +24,94 @@ assists people when migrating to a new version.
## Next
### Owners, dashboard roles, and RLS roles replaced by Subjects
Superset now uses subject-based access assignments for dashboards, charts, datasets,
alerts/reports, and Row Level Security. A Subject can represent a user, role, or group.
This is a breaking API and metadata change:
- `owners` is replaced by `editors` for dashboards, charts, datasets, and alerts/reports.
- Dashboard `roles` and the `DASHBOARD_RBAC` feature flag are replaced by dashboard/chart
`viewers`, enabled with `ENABLE_VIEWERS`.
- RLS `roles` is replaced by `subjects`.
- The legacy `dashboard_user`, `slice_user`, `sqlatable_user`, `report_schedule_user`,
`dashboard_roles`, and `rls_filter_roles` tables are migrated into subject junction tables
and dropped on upgrade.
For deployments that previously used `DASHBOARD_RBAC` and later disabled it: remove stale rows
from the legacy `dashboard_roles` table before upgrading, otherwise those role assignments will
become active dashboard Viewers after migration.
API clients and automation should send and read `editors`, `viewers`, and `subjects` instead
of the legacy fields.
Subject pickers support users, groups, and roles, but only users and groups are selectable by
default. Roles remain supported as Subject types for backwards compatibility with RLS role
assignments and the previous `DASHBOARD_RBAC` model, but they are not recommended for new
resource-specific assignments. Prefer groups for membership-based access and keep roles focused
on capability grants. Existing Role subject assignments remain effective after migration even when
Roles are hidden from the default dropdown values; configure the relevant `SUBJECTS_RELATED_TYPES_*`
setting to make Roles selectable when editing subject lists. See the [Security documentation](docs/admin_docs/security/security.mdx#subjects)
for the full Subject model and picker configuration guidance.
To make roles selectable everywhere:
```python
from superset.subjects.types import SubjectType
SUBJECTS_RELATED_TYPES = [
SubjectType.USER,
SubjectType.GROUP,
SubjectType.ROLE,
]
```
To make roles selectable for RLS while other pickers keep the user and group default, use the
RLS-specific override:
```python
from superset.subjects.types import SubjectType
SUBJECTS_RELATED_TYPES_RLS = [
SubjectType.USER,
SubjectType.GROUP,
SubjectType.ROLE,
]
```
Entity-specific `SUBJECTS_RELATED_TYPES_*` settings replace `SUBJECTS_RELATED_TYPES` for that
picker.
Deployments using `EXTRA_OWNERS_RESOLVER` must migrate to `EXTRA_EDITORS_RESOLVER`. The new
resolver should return editor Subjects, subject IDs, or dicts with an `id` key instead of FAB
User objects. API responses expose these dynamic assignments as `extra_editors` instead of
`extra_owners`.
`DASHBOARD_RBAC` has been removed. To preserve the previous Dashboard RBAC behavior, enable both
subject viewers and viewer datasource bypass:
```python
FEATURE_FLAGS = {
"ENABLE_VIEWERS": True,
}
VIEWER_PROMISCUOUS_MODE = True
```
Enabling only `ENABLE_VIEWERS` allows assigning dashboard/chart viewer subjects, but viewers still
need normal datasource permissions unless `VIEWER_PROMISCUOUS_MODE` is also enabled.
For backwards compatibility, enabling `ENABLE_VIEWERS` does not change access for dashboards or
charts that have no assigned viewers. Those resources continue to use the implicit dataset-access
model: users who can access the underlying dataset can still see published dashboards that use that
dataset and charts backed by that dataset.
Assigning one or more viewers opts that resource into explicit viewer access for non-editors. To
return a resource to the implicit dataset-access model, remove all viewers from it. Explicit Viewers
are the intended model going forward; deprecating and removing implicit viewership can be considered
in a later major version.
- [41044](https://github.com/apache/superset/issues/41044): Removes the deprecated `AVOID_COLORS_COLLISION` feature flag (it defaulted to `True`). Color-collision avoidance is now permanently enabled; any config override setting it to `False` is ignored.
- [39925](https://github.com/apache/superset/pull/39925): URL prefixing for `SUPERSET_APP_ROOT` subdirectory deployments is now handled automatically by helpers in `src/utils/navigationUtils` (`openInNewTab`, `redirect`, `getShareableUrl`, `<AppLink>`). Direct imports of `ensureAppRoot` / `makeUrl` from `src/utils/pathUtils` are forbidden outside `navigationUtils.ts` (enforced by a static-invariant test); contributors writing new code should use the focused helpers instead. No runtime behaviour change for existing callers — all 19 prior call sites have been migrated and four pre-existing double-prefix and missing-prefix bugs are fixed as part of the migration.
- [39925](https://github.com/apache/superset/pull/39925): `SupersetClient.getUrl()` now strips a single leading application-root segment from the supplied `endpoint` before building the request URL, so a caller that accidentally pre-prefixes its endpoint (for example by wrapping it with `ensureAppRoot` before passing it to the client) no longer produces a doubled `/superset/superset/...` URL under subdirectory deployment. The strip is **single-pass** — a genuine `/superset/superset/<slug>` route is preserved, not collapsed — and **silent** (no console warning); the static-invariant test remains the primary signal for pre-prefixing at the call site, and this runtime strip is a safety net beneath it. Code that intentionally targeted a literal `/<app_root>/<app_root>/...` endpoint through `getUrl` (a configuration that has no legitimate use under the prefixing model) would have its first redundant segment removed.
@@ -320,7 +408,7 @@ With the flag enabled: `DELETE /api/v1/dataset/<id>` no longer hard-deletes the
**Side-effect change for operators.** Because the row is no longer physically deleted, FAB `ab_view_menu` / permission-view rows tied to the dataset are also preserved. Downstream automation that relied on `DELETE /api/v1/dataset/<id>` cleaning up those rows must now react to the new `POST /api/v1/dataset/<uuid>/restore` lifecycle, or call the eventual hard-delete endpoint.
**New endpoint** — `POST /api/v1/dataset/<uuid>/restore` clears `deleted_at` and returns the dataset to active state. Requires `can_write on Dataset` and ownership of the row (or admin). Soft-deleted datasets can also be surfaced in the list endpoint via the new `dataset_deleted_state` rison filter: `include` returns both live and soft-deleted rows, `only` returns just the soft-deleted ones. Any other value is ignored. For non-admin users, soft-deleted rows are limited to datasets they own — the same audience that can restore them.
**New endpoint** — `POST /api/v1/dataset/<uuid>/restore` clears `deleted_at` and returns the dataset to active state. Requires `can_write on Dataset` and editorship of the row (or admin). Soft-deleted datasets can also be surfaced in the list endpoint via the new `dataset_deleted_state` rison filter: `include` returns both live and soft-deleted rows, `only` returns just the soft-deleted ones. Any other value is ignored. For non-admin users, soft-deleted rows are limited to datasets they can edit — the same audience that can restore them.
**Permissions migration:** existing role grants of `can_write on Dataset` cover the new restore endpoint automatically; no role migration is required.
@@ -330,7 +418,7 @@ With the flag enabled: `DELETE /api/v1/dataset/<id>` no longer hard-deletes the
**SQL Lab / dataset-creation flows:** creating a dataset over a table whose dataset sits in the trash is refused. The SQL Lab "save as dataset" flow (`get_or_create_dataset`) and file uploads return a **422 naming the hidden twin and the restore endpoint**; the plain create, update, and duplicate paths currently fail with the generic "already exists" 422. In all cases the remediation is the same: restore the hidden dataset (or use a different table name). Perm-string maintenance also covers hidden rows: renaming a database rewrites `perm`/`schema_perm`/`catalog_perm` on soft-deleted datasets and their charts, so a later restore does not resurrect stale permission strings.
**Importer behavior:** importing a dataset YAML whose UUID matches an existing **soft-deleted** dataset is treated as an implicit restore-with-update — **and this happens even when `overwrite` is not set**. This is a deliberate asymmetry with active rows: an active dataset imported without `overwrite=true` is returned unchanged, but a soft-deleted UUID match is restored *and* has the upload's contents applied regardless of the `overwrite` argument, on the reasoning that re-importing a deleted dataset's exact UUID is an explicit request to bring it back. The restore preserves the original PK, the chart back-reference, `table_columns`, and `sql_metrics`. Non-owners get `ImportFailedError`. Callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
**Importer behavior:** importing a dataset YAML whose UUID matches an existing **soft-deleted** dataset is treated as an implicit restore-with-update — **and this happens even when `overwrite` is not set**. This is a deliberate asymmetry with active rows: an active dataset imported without `overwrite=true` is returned unchanged, but a soft-deleted UUID match is restored *and* has the upload's contents applied regardless of the `overwrite` argument, on the reasoning that re-importing a deleted dataset's exact UUID is an explicit request to bring it back. The restore preserves the original PK, the chart back-reference, `table_columns`, and `sql_metrics`. Non-editors get `ImportFailedError`. Callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
**Uniqueness-validation changes that apply regardless of the feature flag:** two dataset uniqueness checks were tightened alongside this work and are active even with `SOFT_DELETE` off. (1) Create/update uniqueness treats a dataset whose `catalog` is `NULL` as belonging to the database's default catalog, so a legacy twin pair (`catalog=NULL` vs. `catalog=<default>`, same database/schema/name) that older versions allowed now fails validation with "already exists" when either row is edited — resolve by renaming or removing one of the twins. (2) Duplicating a dataset now checks name collisions scoped to the target (database, catalog, schema) instead of globally by name alone: duplicates into other databases that were previously blocked are now allowed.
@@ -344,7 +432,7 @@ With the flag enabled: `DELETE /api/v1/chart/<id>` no longer hard-deletes the ch
**Operational notes:** a report schedule whose target chart is soft-deleted now fails its runs with an explicit error ("The chart this report targets was deleted...") until the chart is restored or the report re-pointed — chart deletion is blocked while a report references the chart, but a validate/commit race or a flag toggle can still produce this state. Dashboards **preserve** their membership rows for soft-deleted charts: saving a dashboard does not sever a trashed member, and restoring the chart re-attaches it to its dashboards.
**New endpoint** — `POST /api/v1/chart/<uuid>/restore` clears `deleted_at` and returns the chart to active state. Requires `can_write on Chart` and ownership of the row (or admin). Soft-deleted charts can also be surfaced in the list endpoint via the new `chart_deleted_state` rison filter: `include` returns both live and soft-deleted rows, `only` returns just the soft-deleted ones. Any other value is ignored. For non-admin users, soft-deleted rows are limited to charts they own — the same audience that can restore them.
**New endpoint** — `POST /api/v1/chart/<uuid>/restore` clears `deleted_at` and returns the chart to active state. Requires `can_write on Chart` and editorship of the row (or admin). Soft-deleted charts can also be surfaced in the list endpoint via the new `chart_deleted_state` rison filter: `include` returns both live and soft-deleted rows, `only` returns just the soft-deleted ones. Any other value is ignored. For non-admin users, soft-deleted rows are limited to charts they can edit — the same audience that can restore them.
**Permissions migration:** existing role grants of `can_write on Chart` cover the new restore endpoint automatically; no role migration is required.
@@ -352,7 +440,7 @@ With the flag enabled: `DELETE /api/v1/chart/<id>` no longer hard-deletes the ch
**Rollback note:** if the application code is rolled back after charts have been soft-deleted, the older code path's visibility filter no longer applies and previously hidden rows become visible to the older code. Pair the rollback with a data decision (restore the rows, hard-delete them, or also downgrade the migration) rather than assuming the old hard-delete semantics still hold. **Downgrading the migration destroys the deletion markers**: `downgrade()` drops the `deleted_at` column, so any not-yet-restored soft-deleted charts silently become live, active charts with no record they were ever deleted. Reconcile the trash (restore or hard-delete each row) *before* downgrading, and disable the `SOFT_DELETE` flag first so no new soft deletes land mid-rollback.
**Importer behavior:** importing a chart YAML whose UUID matches an existing **soft-deleted** chart is treated as an implicit restore-with-update — **and this happens even when `overwrite` is not set**. This is a deliberate asymmetry with active rows: an active chart imported without `overwrite=true` is returned unchanged, but a soft-deleted UUID match is restored *and* has the upload's contents applied regardless of the `overwrite` argument, on the reasoning that re-importing a deleted chart's exact UUID is an explicit request to bring it back. The restore preserves the original PK and all out-of-archive references (`dashboard_slices` junctions, `report.chart_id`, tag rows). The operation is permission-gated: non-owners get `ImportFailedError`, and callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
**Importer behavior:** importing a chart YAML whose UUID matches an existing **soft-deleted** chart is treated as an implicit restore-with-update — **and this happens even when `overwrite` is not set**. This is a deliberate asymmetry with active rows: an active chart imported without `overwrite=true` is returned unchanged, but a soft-deleted UUID match is restored *and* has the upload's contents applied regardless of the `overwrite` argument, on the reasoning that re-importing a deleted chart's exact UUID is an explicit request to bring it back. The restore preserves the original PK and all out-of-archive references (`dashboard_slices` junctions, `report.chart_id`, tag rows). The operation is permission-gated: non-editors get `ImportFailedError`, and callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
- [39914](https://github.com/apache/superset/pull/39914) `ALERT_REPORT_SLACK_V2` now defaults to `True` and the legacy Slack v1 integration (`Slack` recipient type, `files.upload` API) is deprecated for removal in the next major. Slack blocked new apps from `files.upload` in May 2024 and fully retired the method for all apps on November 12, 2025; because the v1 path sends files through `files.upload`, v1 file-bearing sends now fail at the API level — only text-only `chat_postMessage` still works via the legacy path. Grant your Slack bot the `channels:read` and `groups:read` scopes so existing `Slack` recipients can be auto-upgraded to `SlackV2` on next send. Operators who explicitly override the flag to `False`, or whose Slack bot is missing those scopes, will see deprecation warnings while text-only sends continue through the legacy path.
@@ -364,7 +452,7 @@ With the flag enabled: `DELETE /api/v1/chart/<id>` no longer hard-deletes the ch
With the flag enabled: `DELETE /api/v1/dashboard/<id>` no longer hard-deletes the dashboard (the bulk-delete endpoint behaves the same way). The row is marked with a `deleted_at` timestamp and hidden from the dashboard API's list, detail, and lookup endpoints, which return 404 for soft-deleted dashboards. The embedded-dashboard iframe URL (`/embedded/<uuid>`) keeps rendering because it reads only `embedded.allowed_domains` and `embedded.dashboard_id` (the FK column) without dereferencing the parent dashboard; the frontend's subsequent dashboard-API fetch is what sees the 404 and surfaces "dashboard not found" to the user.
**New endpoint** — `POST /api/v1/dashboard/<uuid>/restore` clears `deleted_at` and returns the dashboard to active state. Requires `can_write on Dashboard` and ownership of the row (or admin). Soft-deleted dashboards can also be surfaced in the list endpoint via the new `dashboard_deleted_state` rison filter: `include` returns both live and soft-deleted rows, `only` returns just the soft-deleted ones. Any other value is ignored. For non-admin users, soft-deleted rows are limited to dashboards they own — the same audience that can restore them.
**New endpoint** — `POST /api/v1/dashboard/<uuid>/restore` clears `deleted_at` and returns the dashboard to active state. Requires `can_write on Dashboard` and editorship of the row (or admin). Soft-deleted dashboards can also be surfaced in the list endpoint via the new `dashboard_deleted_state` rison filter: `include` returns both live and soft-deleted rows, `only` returns just the soft-deleted ones. Any other value is ignored. For non-admin users, soft-deleted rows are limited to dashboards they can edit — the same audience that can restore them.
**Permissions migration:** existing role grants of `can_write on Dashboard` cover the new restore endpoint automatically; no role migration is required.
@@ -376,7 +464,7 @@ The partial-index replacement is dialect-dependent: PostgreSQL uses a native `WH
**Slug semantics:** on PostgreSQL and MySQL 8.0.13+, the slug of a soft-deleted dashboard is **free for reuse**. A new active dashboard can claim it immediately. Restoring a soft-deleted dashboard whose slug has since been claimed returns **422 with a clean error** (`DashboardSlugConflictError`) — rename one of the dashboards and retry; the restore is not silently rejected by a database-level constraint violation.
**Importer behavior:** importing a dashboard YAML whose UUID matches an existing **soft-deleted** dashboard is treated as an implicit restore-with-update — **and this happens even when `overwrite` is not set**. This is a deliberate asymmetry with active rows: an active dashboard imported without `overwrite=true` is returned unchanged (the import never mutates it), but a soft-deleted UUID match is restored *and* has the upload's contents applied regardless of the `overwrite` argument, on the reasoning that re-importing a deleted dashboard's exact UUID is an explicit request to bring it back. The restore preserves the original PK and all pre-deletion relationship rows (`dashboard_slices` junctions, role grants, owners, tags) — including role grants that were implicitly revoked by the deletion. Callers whose imports must never mutate existing state should treat bundles that may contain previously deleted UUIDs accordingly. The operation is permission-gated: it requires `can_write` and ownership of the deleted row (or admin) — non-owners get `ImportFailedError`, and callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
**Importer behavior:** importing a dashboard YAML whose UUID matches an existing **soft-deleted** dashboard is treated as an implicit restore-with-update — **and this happens even when `overwrite` is not set**. This is a deliberate asymmetry with active rows: an active dashboard imported without `overwrite=true` is returned unchanged (the import never mutates it), but a soft-deleted UUID match is restored *and* has the upload's contents applied regardless of the `overwrite` argument, on the reasoning that re-importing a deleted dashboard's exact UUID is an explicit request to bring it back. The restore preserves the original PK and all pre-deletion relationship rows (`dashboard_slices` junctions, editor/viewer subjects, tags). Callers whose imports must never mutate existing state should treat bundles that may contain previously deleted UUIDs accordingly. The operation is permission-gated: it requires `can_write` and editorship of the deleted row (or admin) — non-editors get `ImportFailedError`, and callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
### Granular Export Controls

View File

@@ -287,8 +287,10 @@ to specify on behalf of which username to render the dashboards. In general, das
are not accessible to unauthorized requests, that is why the worker needs to take over credentials
of an existing user to take a snapshot.
By default, Alerts and Reports are executed as the owner of the alert/report object. To use a fixed user account,
just change the config as follows (`admin` in this example):
By default, Alerts and Reports are executed as a physical user represented by the alert/report editors
(giving priority to the last modifier, then the creator, then the first direct user editor, then a
deterministic user from editor roles or groups). To use a fixed user account, just change the config
as follows (`admin` in this example):
```python
from superset.tasks.types import FixedExecutor

View File

@@ -139,6 +139,10 @@ from superset.tasks.types import FixedExecutor
THUMBNAIL_EXECUTORS = [FixedExecutor("admin")]
```
When using `ExecutorType.EDITOR`, thumbnails are rendered as a physical user represented by
the dashboard or chart editors. Superset prioritizes the last modifier, then the creator,
then the first direct user editor, then a deterministic user from editor roles or groups.
For this feature you will need a cache system and celery workers. All thumbnails are stored on cache
and are processed asynchronously by the workers.
@@ -179,10 +183,10 @@ WEBDRIVER_BASEURL = "https://superset.company.com"
To control which user account is used for rendering thumbnails and warming up caches, configure
`THUMBNAIL_EXECUTORS` and `CACHE_WARMUP_EXECUTORS`. Each accepts a list of executor types (which
resolve to an owner, creator, modifier, or the currently-logged-in user) and/or a `FixedExecutor`
pinned to a specific username. By default, thumbnails render as the current user
(`ExecutorType.CURRENT_USER`) and cache warmup runs as the chart/dashboard owner
(`ExecutorType.OWNER`).
resolve to an editor, creator, modifier, or the currently-logged-in user) and/or a
`FixedExecutor` pinned to a specific username. By default, thumbnails render as the current user
(`ExecutorType.CURRENT_USER`) and cache warmup uses editor-based execution
(`ExecutorType.EDITOR`) where executor-based cache warmup is used.
To force both to run as a dedicated service account (`admin` in this example):

View File

@@ -60,9 +60,9 @@ There are two approaches to making dashboards publicly accessible:
**Option 2: Dashboard-level access (selective control)**
1. Set `PUBLIC_ROLE_LIKE = "Public"` in `superset_config.py`
2. Add the `'DASHBOARD_RBAC': True` [Feature Flag](/admin-docs/configuration/feature-flags)
3. Edit each dashboard's properties and add the "Public" role
4. Only dashboards with the Public role explicitly assigned are visible to anonymous users
2. Add the `'ENABLE_VIEWERS': True` [Feature Flag](/admin-docs/configuration/feature-flags)
3. Edit each dashboard's properties and add the "Public" role subject as a viewer
4. Only published dashboards with the Public role subject explicitly assigned are visible to anonymous users
See the [Public role documentation](/admin-docs/security/#public) for more details.

View File

@@ -176,7 +176,7 @@ https://superset.apache.org/admin-docs/configuration/configuring-superset/#rotat
### **Appendix C: Secrets Register and Rotation Schedule**
`SUPERSET_SECRET_KEY` is not the only security-critical secret in a Superset deployment. Maintain an inventory of all such secrets, store each in a secrets manager (not in `superset_config.py` or version control), assign an owner, and rotate them on a defined cadence as well as after any suspected compromise.
`SUPERSET_SECRET_KEY` is not the only security-critical secret in a Superset deployment. Maintain an inventory of all such secrets, store each in a secrets manager (not in `superset_config.py` or version control), assign a responsible maintainer, and rotate them on a defined cadence as well as after any suspected compromise.
| Secret | Purpose | Risk if leaked | Suggested rotation |
|---|---|---|---|

View File

@@ -91,24 +91,24 @@ PUBLIC_ROLE_LIKE = "Public"
# PUBLIC_ROLE_LIKE = "Gamma"
```
If you prefer to manually configure the Public role's permissions (or use `DASHBOARD_RBAC` to
grant access at the dashboard level), you do not need to set `PUBLIC_ROLE_LIKE`.
If you prefer to manually configure the Public role's permissions, or use subject-based
dashboard viewer assignments, you do not need to set `PUBLIC_ROLE_LIKE`.
**Important notes:**
- **Data access is still required:** The Public role only grants UI/API permissions. You must
also grant access to specific datasets necessary to view a dashboard. As with other roles,
this can be done in two ways:
- **Data access is still required by default:** The Public role only grants UI/API permissions.
You must also grant access to specific datasets necessary to view a dashboard. As with other
roles, this can be done in two ways:
- **Without `DASHBOARD_RBAC`:** Dashboards only appear in the list and are accessible if
the user has permission to at least one of their datasets. Grant dataset access by editing
the Public role in the Superset UI (Menu Security List Roles Public) and adding the
relevant data sources. All published dashboards using those datasets become visible.
- **Dataset-based access:** Dashboards only appear in the list and are accessible if the user
has permission to at least one of their datasets. Grant dataset access by editing the Public
role in the Superset UI (Menu -> Security -> List Roles -> Public) and adding the relevant
data sources. All published dashboards using those datasets become visible.
- **With `DASHBOARD_RBAC` enabled:** Anonymous users will only see dashboards where the
"Public" role has been explicitly added in the dashboard's properties. Dataset permissions
are not required—DASHBOARD_RBAC handles the cascading permissions check. This provides
fine-grained control over which dashboards are publicly visible.
- **Subject-based dashboard access:** When `ENABLE_VIEWERS` is enabled, anonymous users can
see published dashboards where the "Public" role subject has been explicitly added as a
viewer. If `VIEWER_PROMISCUOUS_MODE` is enabled, viewer access can bypass dataset
permission checks for dashboard rendering.
- **Role synchronization:** Built-in role permissions (Admin, Alpha, Gamma, sql_lab, and Public
when `PUBLIC_ROLE_LIKE = "Public"`) are synchronized when you run `superset init`. Any manual
@@ -131,10 +131,76 @@ tables in the **Permissions** dropdown. To select the data sources you want to a
You can then confirm with users assigned to the **Gamma** role that they see the
objects (dashboards and slices) associated with the tables you just extended them.
### Subjects
A **subject** is a unified identity that can be granted access to Superset resources such as
dashboards, charts, and datasets. Subjects come in three types:
- **User** — an individual Superset user
- **Group** — a FAB group
- **Role** — a FAB role (e.g. Alpha, Gamma, or any custom role)
Subjects are used throughout Superset to assign **editors** (who can modify a resource) and
**viewers** (who can view it). For new resource-specific assignments, Superset defaults to
**Users** and **Groups** in subject pickers. Groups are the recommended way to grant access to a
set of people because they represent organizational membership, while roles remain focused on
capability grants such as "can read dashboards" or "can write charts".
Role subjects are still supported for backwards compatibility with features that previously used
roles directly, including Row Level Security role assignments and `DASHBOARD_RBAC`. Existing role
subject assignments continue to be enforced even when roles are not exposed in the default picker
configuration. However, when an administrator edits a subject list, roles are not available as new
dropdown values unless that picker has been configured to expose role subjects. Roles are not
recommended for new subject assignments, because using roles for both permissions and resource
membership couples two separate concerns.
Administrators can control which subject types are available in pickers with
`SUBJECTS_RELATED_TYPES`. By default, this setting exposes only users and groups:
```python
from superset.subjects.types import SubjectType
SUBJECTS_RELATED_TYPES = [
SubjectType.USER,
SubjectType.GROUP,
]
```
Set `SUBJECTS_RELATED_TYPES = None` to expose all subject types, including roles. To expose roles
only for a specific entity, set that entity's override:
```python
from superset.subjects.types import SubjectType
SUBJECTS_RELATED_TYPES = [
SubjectType.USER,
SubjectType.GROUP,
]
# Leave dashboard, chart, and alert/report overrides unset so they inherit
# the users + groups default. Expose roles only in the RLS subject picker for
# compatibility with existing role-based RLS workflows.
SUBJECTS_RELATED_TYPES_RLS = [
SubjectType.USER,
SubjectType.GROUP,
SubjectType.ROLE,
]
```
Available per-entity overrides are:
- `SUBJECTS_RELATED_TYPES_DASHBOARDS` for dashboard editor/viewer pickers
- `SUBJECTS_RELATED_TYPES_CHARTS` for chart editor/viewer pickers
- `SUBJECTS_RELATED_TYPES_RLS` for Row Level Security subject pickers
- `SUBJECTS_RELATED_TYPES_ALERT_REPORTS` for alert/report editor pickers
When an override is set, it replaces `SUBJECTS_RELATED_TYPES` for that picker. When it is `None`,
the picker inherits the global default.
### Dashboard Access Control
Access to dashboards is managed via owners (users that have edit permissions to the dashboard).
Non-owner user access can be managed in two ways. Note that dashboards must be published to be
Access to dashboards is managed via editors (subjects that have edit permissions to the dashboard).
Non-editor user access can be managed in two ways. Note that dashboards must be published to be
visible to other users.
#### Dataset-Based Access (Default)
@@ -146,30 +212,40 @@ to a role (Menu → Security → List Roles).
This is the simplest approach but provides all-or-nothing access based on dataset permissions—
if a user has access to a dataset, they can see all published dashboards using that dataset.
#### Dashboard-Level Access (DASHBOARD_RBAC)
#### Dashboard and Chart-Level Access
For fine-grained control over which dashboards specific roles can access, enable the
`DASHBOARD_RBAC` feature flag:
For fine-grained control over which dashboards and charts specific users or groups can access,
enable subject-based viewers:
```python
FEATURE_FLAGS = {
"DASHBOARD_RBAC": True,
"ENABLE_VIEWERS": True,
}
```
With this enabled, you can assign specific roles to each dashboard in its properties. Users
will only see dashboards where their role is explicitly added.
With this enabled, you can assign viewer subjects to each dashboard or chart in its properties.
Users will see resources where they are assigned directly, or indirectly through a group subject.
Role subjects can also be enabled for compatibility, but groups are recommended for new viewer
assignments.
`ENABLE_VIEWERS` can be adopted incrementally. Dashboards and charts with no assigned viewers keep
the existing implicit dataset-access behavior: users who can access the underlying dataset can still
see published dashboards that use that dataset and charts backed by that dataset. Assigning one or
more viewers changes that specific resource to explicit viewer access for non-editors. Removing all
viewers returns the resource to the dataset-based fallback. Explicit Viewers are the preferred model
going forward; the implicit fallback may be deprecated and removed in a later major version.
**Important considerations:**
- Dashboard access **bypasses** dataset-level checks—granting a role access to a dashboard
implicitly grants read access to all charts and datasets in that dashboard
- Dashboards without any assigned roles fall back to dataset-based access
- Viewer access uses normal dataset checks unless `VIEWER_PROMISCUOUS_MODE` is enabled
- With `VIEWER_PROMISCUOUS_MODE`, dashboard viewer access can bypass dataset-level checks for
charts and datasets in that dashboard
- Dashboards and charts without assigned viewers fall back to dataset-based access
- The dashboard must still be published to be visible
This feature is particularly useful for:
- Making specific dashboards public while keeping others private
- Granting access to dashboards without exposing the underlying datasets for other uses
- Creating dashboard-specific access patterns that don't align with dataset ownership
- Creating dashboard-specific access patterns that don't align with dataset permissions
### SQL Execution Security Considerations
@@ -301,16 +377,16 @@ based on the roles and permissions that were attributed.
### Row Level Security
Using Row Level Security filters (under the **Security** menu) you can create filters
that are assigned to a particular dataset, as well as a set of roles.
that are assigned to a particular dataset, as well as a set of subjects.
If you want members of the Finance team to only have access to
rows where `department = "finance"`, you could:
- Create a Row Level Security filter with that clause (`department = "finance"`)
- Then assign the clause to the **Finance** role and the dataset it applies to
- Then assign the clause to the **Finance** role subject and the dataset it applies to
The **clause** field, which can contain arbitrary text, is then added to the generated
SQL statement's WHERE clause. So you could even do something like create a filter
for the last 30 days and apply it to a specific role, with a clause
for the last 30 days and apply it to a specific subject, with a clause
like `date_field > DATE_SUB(NOW(), INTERVAL 30 DAY)`. It can also support
multiple conditions: `client_id = 6` AND `advertiser="foo"`, etc.
@@ -321,12 +397,14 @@ RLS clauses also support **Jinja templating** when `ENABLE_TEMPLATE_PROCESSING`
There are two types of RLS filters:
- **Regular** — The filter clause is applied when the querying user belongs to one of the
roles assigned to the filter. Use this to restrict what specific roles can see.
- **Base** — The filter clause is applied to **all** users _except_ those in the assigned
roles. Use this to define a default restriction that privileged roles (e.g. Admin) are
exempt from. For example, a Base filter with clause `1 = 0` and the Admin role would
hide all rows from everyone except Admin — useful as a deny-by-default baseline.
- **Regular** — The filter clause is applied when the querying user matches one of the
subjects assigned to the filter. Use this to restrict what specific users, roles, or groups
can see.
- **Base** — The filter clause is applied to **all** users _except_ those matching the assigned
subjects. Use this to define a default restriction that privileged subjects (e.g. the Admin
role subject) are exempt from. For example, a Base filter with clause `1 = 0` and the Admin
role subject would hide all rows from everyone except Admin - useful as a deny-by-default
baseline.
#### Group Keys and Filter Combination
@@ -416,21 +494,20 @@ GET /api/v1/rowlevelsecurity/
GET /api/v1/rowlevelsecurity/?q=(filters:!((col:tables,opr:rel_m_m,value:<dataset_id>)))
```
**Filter RLS rules by role:**
**Filter RLS rules by subject:**
```
GET /api/v1/rowlevelsecurity/?q=(filters:!((col:roles,opr:rel_m_m,value:<role_id>)))
GET /api/v1/rowlevelsecurity/?q=(filters:!((col:subjects,opr:rel_m_m,value:<subject_id>)))
```
**View details of a specific rule** (including clause, assigned datasets, and roles):
**View details of a specific rule** (including clause, assigned datasets, and subjects):
```
GET /api/v1/rowlevelsecurity/<id>
```
The response includes the filter's `name`, `filter_type` (Regular or Base), `clause`,
`group_key`, assigned `tables` (with id, schema, and table\_name), and assigned `roles`
(with id and name).
`group_key`, assigned `tables` (with id, schema, and table\_name), and assigned `subjects`.
:::tip Auditing RLS for virtual datasets
To find all RLS rules that could affect a particular virtual dataset, query the list

View File

@@ -26,7 +26,7 @@ import { StoryWithControls } from '../../../src/components/StorybookWrapper';
# MetadataBar
MetadataBar displays a row of metadata items (SQL info, owners, last modified, tags, dashboards, etc.) that collapse responsively based on available width.
MetadataBar displays a row of metadata items (SQL info, editors, last modified, tags, dashboards, etc.) that collapse responsively based on available width.
## Live Example
@@ -43,9 +43,9 @@ MetadataBar displays a row of metadata items (SQL info, owners, last modified, t
title: "Click to view query"
},
{
type: "owner",
type: "editor",
createdBy: "Jane Smith",
owners: [
editors: [
"John Doe",
"Mary Wilson"
],
@@ -104,9 +104,9 @@ function Demo() {
const items = [
{ type: 'sql', title: 'Click to view query' },
{
type: 'owner',
type: 'editor',
createdBy: 'Jane Smith',
owners: ['John Doe', 'Mary Wilson'],
editors: ['John Doe', 'Mary Wilson'],
createdOn: 'a week ago',
},
{
@@ -125,7 +125,7 @@ function Demo() {
```tsx live
function MinimalMetadata() {
const items = [
{ type: 'owner', createdBy: 'Admin', owners: ['Admin'], createdOn: 'yesterday' },
{ type: 'editor', createdBy: 'Admin', editors: ['Admin'], createdOn: 'yesterday' },
{ type: 'lastModified', value: '2 hours ago', modifiedBy: 'Admin' },
];
return <MetadataBar items={items} />;
@@ -138,7 +138,7 @@ function MinimalMetadata() {
function FullMetadata() {
const items = [
{ type: 'sql', title: 'SELECT * FROM ...' },
{ type: 'owner', createdBy: 'Jane Smith', owners: ['Jane Smith', 'John Doe', 'Bob Wilson'], createdOn: '2 weeks ago' },
{ type: 'editor', createdBy: 'Jane Smith', editors: ['Jane Smith', 'John Doe', 'Bob Wilson'], createdOn: '2 weeks ago' },
{ type: 'lastModified', value: '3 days ago', modifiedBy: 'John Doe' },
{ type: 'tags', values: ['production', 'finance', 'quarterly'] },
{ type: 'dashboards', title: 'Used in 12 dashboards' },
@@ -158,7 +158,7 @@ function FullMetadata() {
| `createdBy` | `string` | `"Jane Smith"` | - |
| `modifiedBy` | `string` | `"Jane Smith"` | - |
| `description` | `string` | `"To preview the list of dashboards go to More settings."` | - |
| `items` | `any` | `[{"type":"sql","title":"Click to view query"},{"type":"owner","createdBy":"Jane Smith","owners":["John Doe","Mary Wilson"],"createdOn":"a week ago"},{"type":"lastModified","value":"a week ago","modifiedBy":"Jane Smith"},{"type":"tags","values":["management","research","poc"]},{"type":"dashboards","title":"Added to 3 dashboards","description":"To preview the list of dashboards go to More settings."}]` | - |
| `items` | `any` | `[{"type":"sql","title":"Click to view query"},{"type":"editor","createdBy":"Jane Smith","editors":["John Doe","Mary Wilson"],"createdOn":"a week ago"},{"type":"lastModified","value":"a week ago","modifiedBy":"Jane Smith"},{"type":"tags","values":["management","research","poc"]},{"type":"dashboards","title":"Added to 3 dashboards","description":"To preview the list of dashboards go to More settings."}]` | Items displayed in the metadata bar. The `editor` type shows editors/creators. |
## Import

View File

@@ -128,7 +128,7 @@ from superset.dashboards.commands.create import CreateDashboardCommand
def test_create_dashboard_success():
properties = {
"dashboard_title": "Test Dashboard",
"owners": [1]
"editors": [1]
}
command = CreateDashboardCommand(properties)
@@ -275,9 +275,11 @@ def handle_validation_error(error):
from typing import List, Optional, Dict, Any
from superset.models.dashboard import Dashboard
def get_dashboards_by_owner(owner_id: int) -> List[Dashboard]:
"""Get all dashboards owned by a specific user"""
return db.session.query(Dashboard).filter_by(owner_id=owner_id).all()
def get_dashboards_by_editor(editor_id: int) -> List[Dashboard]:
"""Get all dashboards editable by a specific subject"""
return db.session.query(Dashboard).filter(
Dashboard.editors.any(id=editor_id)
).all()
def create_dashboard(properties: Dict[str, Any]) -> Optional[Dashboard]:
"""Create a new dashboard with the given properties"""

View File

@@ -153,10 +153,10 @@ class DashboardDAO:
return db.session.query(Dashboard).filter_by(slug=slug).first()
@classmethod
def find_by_owner(cls, owner_id: int) -> List[Dashboard]:
"""Find all dashboards owned by a user"""
return db.session.query(Dashboard).filter_by(
created_by_fk=owner_id
def find_by_editor(cls, editor_id: int) -> List[Dashboard]:
"""Find all dashboards editable by a subject"""
return db.session.query(Dashboard).filter(
Dashboard.editors.any(id=editor_id)
).all()
@classmethod

View File

@@ -120,7 +120,7 @@ SMTP_MAIL_FROM = "superset@localhost"
# Must match where your frontend is running
WEBDRIVER_BASEURL = "http://localhost:9000/"
ALERT_REPORTS_EXECUTE_AS = [ExecutorType.OWNER]
ALERT_REPORTS_EXECUTORS = [ExecutorType.EDITOR]
FEATURE_FLAGS = {
"ALERT_REPORTS": True,

View File

@@ -211,8 +211,8 @@ slices and dashboards of your own.
### Manage access to Dashboards
Access to dashboards is managed via owners and permissions. Non-owner access can be controlled
through dataset permissions or dashboard-level roles (using the `DASHBOARD_RBAC` feature flag).
Access to dashboards is managed via editors and permissions. Non-editor access can be controlled
through dataset permissions, or through dashboard viewers when `ENABLE_VIEWERS` is enabled.
For detailed information on configuring dashboard access, see the
[Dashboard Access Control](/admin-docs/security/#dashboard-access-control) section in the
@@ -228,7 +228,9 @@ title of your dashboard.
<img src={useBaseUrl("/img/tutorial/publish_button_dashboard.png" )} />
:::warning
Draft dashboards are only visible to the dashboard owners and admins. Published dashboards are visible to all users with access to the underlying datasets or if RBAC is enabled, to the roles that have been granted access to the dashboard.
Draft dashboards are only visible to dashboard editors and admins. Published dashboards are visible
to users with access to the underlying datasets, or, when `ENABLE_VIEWERS` is enabled, to users
who have viewer access directly or through an assigned role or group subject.
:::
### Mark a Dashboard as Favorite

View File

@@ -182,6 +182,13 @@
"description": "Allows users to add a superset:// DB that can query across databases. Experimental with potential security/performance risks. See SUPERSET_META_DB_LIMIT.",
"docs": "https://superset.apache.org/user-docs/databases/supported/superset-meta-database"
},
{
"name": "ENABLE_VIEWERS",
"default": false,
"lifecycle": "testing",
"description": "Subject-based viewer access control for dashboards and charts. When enabled, resources can have explicit viewer Subject assignments.",
"category": "security"
},
{
"name": "ESTIMATE_QUERY_COST",
"default": false,
@@ -256,14 +263,6 @@
"description": "Enables CSS Templates in Settings menu and dashboard forms",
"category": "runtime_config"
},
{
"name": "DASHBOARD_RBAC",
"default": false,
"lifecycle": "stable",
"description": "Role-based access control for dashboards",
"docs": "https://superset.apache.org/docs/using-superset/creating-your-first-dashboard",
"category": "runtime_config"
},
{
"name": "DASHBOARD_VIRTUALIZATION",
"default": true,
@@ -401,12 +400,6 @@
}
],
"deprecated": [
{
"name": "AVOID_COLORS_COLLISION",
"default": true,
"lifecycle": "deprecated",
"description": "Avoid color collisions in charts by using distinct colors"
},
{
"name": "DRILL_TO_DETAIL",
"default": true,

4
docs/static/img/logos/camptocamp.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

BIN
docs/static/img/logos/craig.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
docs/static/img/logos/geo2france.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

2
docs/static/img/logos/geobretagne.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.0 KiB

108
docs/static/img/logos/georchestra.svg vendored Normal file
View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="76.919907mm"
height="26.149284mm"
viewBox="0 0 76.919907 26.149284"
version="1.1"
id="svg855"
inkscape:version="1.1 (c68e22c387, 2021-05-23)"
sodipodi:docname="georchestra-logo.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview857"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:document-units="mm"
showgrid="false"
fit-margin-top="2"
fit-margin-right="2"
fit-margin-bottom="2"
fit-margin-left="2"
inkscape:zoom="4.0920134"
inkscape:cx="120.47859"
inkscape:cy="47.775992"
inkscape:window-width="2400"
inkscape:window-height="1271"
inkscape:window-x="2391"
inkscape:window-y="-9"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs852" />
<g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-73.097929,-135.79112)">
<g
id="g53648"
transform="matrix(1.1048988,0,0,1.1048988,-54.141498,96.632368)">
<path
id="path164290-3-6-4"
style="fill:#85127e;fill-opacity:0.4;stroke-width:0.606168;stroke-linecap:round;stroke-linejoin:round;paint-order:markers stroke fill"
d="m 126.99257,37.251152 a 10.023325,10.023325 0 0 0 -10.0231,10.023355 10.023325,10.023325 0 0 0 10.0231,10.023083 10.023325,10.023325 0 0 0 9.62187,-7.232846 3.0318022,3.5371027 0 0 1 -2.01469,-3.330589 3.0318022,3.5371027 0 0 1 1.70117,-3.175791 10.023325,10.023325 0 0 0 -9.30835,-6.307212 z"
inkscape:export-filename="C:\Users\Agathe\Desktop\georchestra_univers.png"
inkscape:export-xdpi="200"
inkscape:export-ydpi="200" />
<path
id="path20251-5-9-8-2"
style="fill:#85127e;fill-opacity:1;stroke-width:0.337452;stroke-linecap:round;stroke-linejoin:round;paint-order:markers stroke fill"
d="m 138.54037,41.724815 a 4.988567,4.988567 0 0 0 -4.9887,4.988712 4.988567,4.988567 0 0 0 4.9887,4.98844 4.988567,4.988567 0 0 0 4.98844,-4.98844 4.988567,4.988567 0 0 0 -4.98844,-4.988712 z m 0,2.683748 a 2.3048444,2.3048444 0 0 1 2.30472,2.304964 2.3048444,2.3048444 0 0 1 -2.30472,2.304704 2.3048444,2.3048444 0 0 1 -2.30496,-2.304704 2.3048444,2.3048444 0 0 1 2.30496,-2.304964 z"
inkscape:export-filename="C:\Users\Agathe\Desktop\georchestra_univers.png"
inkscape:export-xdpi="200"
inkscape:export-ydpi="200" />
<g
aria-label="ge rchestra"
id="text10089-26-6-9"
style="font-size:9.62958px;line-height:1.25;font-family:Roboto;-inkscape-font-specification:'Roboto, Normal';letter-spacing:0px;fill:#85127e;stroke-width:0.601849">
<path
d="m 123.84851,51.576041 q -0.66682,0 -1.14556,-0.177819 -0.47875,-0.177819 -0.76941,-0.475324 -0.29067,-0.297505 -0.40351,-0.653142 l 1.11478,-0.270148 q 0.0752,0.140203 0.21886,0.287245 0.14362,0.150463 0.38641,0.249631 0.24621,0.102588 0.61895,0.102588 0.52662,0 0.872,-0.25305 0.34537,-0.25305 0.34537,-0.817283 v -0.995103 h -0.0615 q -0.0957,0.191498 -0.28041,0.393254 -0.18123,0.201756 -0.48216,0.33854 -0.2975,0.136784 -0.74889,0.136784 -0.60527,0 -1.09769,-0.283827 -0.489,-0.287246 -0.77967,-0.854899 -0.28724,-0.571072 -0.28724,-1.42939 0,-0.865158 0.28724,-1.460167 0.29067,-0.59843 0.78309,-0.906193 0.49242,-0.311183 1.09769,-0.311183 0.46165,0 0.75915,0.157301 0.30092,0.153882 0.47874,0.372736 0.17782,0.215434 0.27015,0.406932 h 0.0684 v -0.868577 h 1.2208 v 5.337987 q 0,0.659982 -0.32144,1.09769 -0.32144,0.441128 -0.87884,0.656563 -0.55739,0.218854 -1.26525,0.218854 z m 0.0103,-3.104992 q 0.39325,0 0.67024,-0.191498 0.27699,-0.191497 0.42061,-0.550555 0.14362,-0.359057 0.14362,-0.861738 0,-0.495841 -0.14362,-0.868577 -0.1402,-0.372736 -0.41719,-0.577911 -0.27357,-0.208596 -0.67366,-0.208596 -0.41377,0 -0.69076,0.215435 -0.27699,0.215434 -0.41719,0.59159 -0.1402,0.372736 -0.1402,0.848059 0,0.482163 0.1402,0.84464 0.14362,0.359058 0.42061,0.560814 0.28041,0.198337 0.68734,0.198337 z"
id="path1811" />
<path
d="m 129.91145,49.620032 q -0.78993,0 -1.36442,-0.328281 -0.57107,-0.3317 -0.87883,-0.936969 -0.30777,-0.608688 -0.30777,-1.43281 0,-0.810444 0.30777,-1.422552 0.31118,-0.615527 0.86857,-0.957486 0.5574,-0.345379 1.30971,-0.345379 0.48558,0 0.91645,0.157301 0.43429,0.153882 0.76599,0.478743 0.33512,0.324862 0.52662,0.827542 0.19149,0.499261 0.19149,1.19002 v 0.379575 h -4.30527 v -0.834382 h 3.11867 q -0.003,-0.355638 -0.15388,-0.632625 -0.15046,-0.280406 -0.42061,-0.441127 -0.26673,-0.160721 -0.62236,-0.160721 -0.37958,0 -0.66683,0.184658 -0.28724,0.181238 -0.44796,0.478743 -0.1573,0.294085 -0.16072,0.646303 v 0.728374 q 0,0.458226 0.16756,0.786507 0.16756,0.324862 0.46848,0.499261 0.30093,0.17098 0.70444,0.17098 0.27015,0 0.489,-0.07523 0.21885,-0.07865 0.37957,-0.229113 0.16073,-0.150462 0.2428,-0.372736 l 1.15582,0.129944 q -0.10943,0.458226 -0.41719,0.800186 -0.30435,0.33854 -0.77967,0.526617 -0.47532,0.184658 -1.08743,0.184658 z"
id="path1813" />
<path
d="m 145.2449,49.517445 v -5.252498 h 1.20027 v 0.875416 h 0.0547 q 0.14362,-0.454806 0.49242,-0.701017 0.35222,-0.24963 0.8036,-0.24963 0.10259,0 0.22912,0.01026 0.12994,0.0068 0.21543,0.02394 v 1.138725 q -0.0787,-0.02736 -0.24963,-0.04787 -0.16756,-0.02394 -0.32486,-0.02394 -0.33854,0 -0.60869,0.147043 -0.26673,0.143623 -0.42061,0.400092 -0.15388,0.25647 -0.15388,0.59159 v 3.087895 z"
style="font-weight:600;font-family:inter;-inkscape-font-specification:'inter Semi-Bold'"
id="path1815" />
<path
d="m 151.13685,49.620032 q -0.7865,0 -1.35074,-0.345379 -0.56081,-0.345379 -0.86516,-0.954067 -0.30092,-0.612107 -0.30092,-1.408873 0,-0.800185 0.30776,-1.412293 0.30777,-0.615527 0.86858,-0.957486 0.56423,-0.345379 1.33364,-0.345379 0.63947,0 1.13189,0.235952 0.49584,0.232532 0.78993,0.659982 0.29408,0.424029 0.33512,0.991682 h -1.18318 q -0.0718,-0.379575 -0.34196,-0.632625 -0.26673,-0.256469 -0.7147,-0.256469 -0.37957,0 -0.66682,0.205175 -0.28725,0.201756 -0.44797,0.581331 -0.1573,0.379575 -0.1573,0.909613 0,0.536876 0.1573,0.92329 0.1573,0.382995 0.44113,0.59159 0.28725,0.205176 0.67366,0.205176 0.27357,0 0.489,-0.102588 0.21886,-0.106007 0.3659,-0.304344 0.14704,-0.198336 0.20176,-0.482163 h 1.18318 q -0.0445,0.557394 -0.32829,0.988263 -0.28382,0.42745 -0.77282,0.670241 -0.48901,0.239371 -1.14899,0.239371 z"
style="font-weight:600;font-family:inter;-inkscape-font-specification:'inter Semi-Bold'"
id="path1817" />
<path
d="m 155.64387,46.439809 v 3.077636 h -1.2379 v -7.003331 h 1.21054 v 2.643347 h 0.0615 q 0.18466,-0.444547 0.57107,-0.701017 0.38984,-0.259889 0.99169,-0.259889 0.54713,0 0.95406,0.229113 0.40694,0.229113 0.62921,0.67024 0.22569,0.441128 0.22569,1.077173 v 3.344364 h -1.23789 v -3.152867 q 0,-0.530037 -0.27357,-0.824122 -0.27015,-0.297505 -0.75915,-0.297505 -0.32828,0 -0.58817,0.143623 -0.25647,0.140203 -0.40351,0.406932 -0.14362,0.266728 -0.14362,0.646303 z"
style="font-weight:600;font-family:inter;-inkscape-font-specification:'inter Semi-Bold'"
id="path1819" />
<path
d="m 162.6335,49.620032 q -0.78992,0 -1.36441,-0.328281 -0.57108,-0.3317 -0.87884,-0.936969 -0.30776,-0.608688 -0.30776,-1.43281 0,-0.810444 0.30776,-1.422552 0.31118,-0.615527 0.86858,-0.957486 0.55739,-0.345379 1.3097,-0.345379 0.48558,0 0.91645,0.157301 0.43429,0.153882 0.76599,0.478743 0.33512,0.324862 0.52662,0.827542 0.1915,0.499261 0.1915,1.19002 v 0.379575 h -4.30527 v -0.834382 h 3.11867 q -0.003,-0.355638 -0.15388,-0.632625 -0.15047,-0.280406 -0.42061,-0.441127 -0.26673,-0.160721 -0.62237,-0.160721 -0.37958,0 -0.66682,0.184658 -0.28725,0.181238 -0.44797,0.478743 -0.1573,0.294085 -0.16072,0.646303 v 0.728374 q 0,0.458226 0.16756,0.786507 0.16756,0.324862 0.46849,0.499261 0.30092,0.17098 0.70443,0.17098 0.27015,0 0.489,-0.07523 0.21886,-0.07865 0.37958,-0.229113 0.16072,-0.150462 0.24279,-0.372736 l 1.15582,0.129944 q -0.10942,0.458226 -0.41719,0.800186 -0.30434,0.33854 -0.77967,0.526617 -0.47532,0.184658 -1.08743,0.184658 z"
style="font-weight:600;font-family:inter;-inkscape-font-specification:'inter Semi-Bold'"
id="path1821" />
<path
d="m 170.16686,45.653302 -1.12846,0.123106 q -0.0479,-0.17098 -0.16756,-0.321442 -0.11627,-0.150462 -0.31461,-0.242792 -0.19833,-0.09233 -0.48558,-0.09233 -0.38641,0 -0.64972,0.167561 -0.25989,0.16756 -0.25647,0.434288 -0.003,0.229113 0.16756,0.372736 0.1744,0.143623 0.57449,0.235952 l 0.89593,0.191497 q 0.74548,0.160721 1.10795,0.50952 0.3659,0.348799 0.36932,0.913032 -0.003,0.495841 -0.29067,0.875416 -0.28382,0.376156 -0.78992,0.588171 -0.5061,0.212014 -1.16267,0.212014 -0.96432,0 -1.55249,-0.403512 -0.58817,-0.406932 -0.70102,-1.131886 l 1.20712,-0.116266 q 0.0821,0.355638 0.3488,0.536876 0.26673,0.181239 0.69417,0.181239 0.44113,0 0.70786,-0.181239 0.27015,-0.181238 0.27015,-0.447966 0,-0.225694 -0.1744,-0.372736 -0.17098,-0.147043 -0.53346,-0.225694 l -0.89593,-0.188077 q -0.75573,-0.157302 -1.11821,-0.530038 -0.36248,-0.376155 -0.35906,-0.950647 -0.003,-0.485582 0.26331,-0.84122 0.27015,-0.359058 0.74889,-0.553975 0.48217,-0.198336 1.11137,-0.198336 0.92329,0 1.45333,0.393253 0.53346,0.393254 0.65998,1.063495 z"
style="font-weight:600;font-family:inter;-inkscape-font-specification:'inter Semi-Bold'"
id="path1823" />
<path
d="m 173.925,44.264947 v 0.957486 h -3.01951 v -0.957486 z m -2.27403,-1.258411 h 1.23789 v 4.931056 q 0,0.24963 0.0752,0.382994 0.0787,0.129945 0.20518,0.177819 0.12652,0.04788 0.2804,0.04788 0.11627,0 0.21202,-0.0171 0.0992,-0.0171 0.15046,-0.03078 l 0.2086,0.967746 q -0.0992,0.0342 -0.28383,0.07523 -0.18124,0.04104 -0.44455,0.04787 -0.46506,0.01368 -0.8378,-0.140203 -0.37274,-0.157302 -0.59159,-0.485583 -0.21543,-0.328281 -0.21201,-0.820703 z"
style="font-weight:600;font-family:inter;-inkscape-font-specification:'inter Semi-Bold'"
id="path1825" />
<path
d="m 174.96112,49.517445 v -5.252498 h 1.20028 v 0.875416 h 0.0547 q 0.14363,-0.454806 0.49242,-0.701017 0.35222,-0.24963 0.80361,-0.24963 0.10259,0 0.22911,0.01026 0.12995,0.0068 0.21544,0.02394 v 1.138725 q -0.0787,-0.02736 -0.24963,-0.04787 -0.16756,-0.02394 -0.32487,-0.02394 -0.33853,0 -0.60868,0.147043 -0.26673,0.143623 -0.42061,0.400092 -0.15388,0.25647 -0.15388,0.59159 v 3.087895 z"
style="font-weight:600;font-family:inter;-inkscape-font-specification:'inter Semi-Bold'"
id="path1827" />
<path
d="m 180.19309,49.623452 q -0.49926,0 -0.89936,-0.177819 -0.39667,-0.181238 -0.6292,-0.533457 -0.22911,-0.352218 -0.22911,-0.868577 0,-0.444547 0.16414,-0.735213 0.16414,-0.290665 0.44796,-0.465065 0.28383,-0.174399 0.63947,-0.263309 0.35905,-0.09233 0.74205,-0.133364 0.46164,-0.04787 0.74889,-0.08549 0.28725,-0.04104 0.41719,-0.123105 0.13337,-0.08549 0.13337,-0.263309 v -0.02052 q 0,-0.386415 -0.22912,-0.59843 -0.22911,-0.212014 -0.65998,-0.212014 -0.45481,0 -0.72153,0.198336 -0.26331,0.198337 -0.35564,0.468485 L 178.6064,45.64646 q 0.13678,-0.478743 0.45138,-0.800185 0.3146,-0.324862 0.76941,-0.485583 0.45481,-0.16414 1.00536,-0.16414 0.37958,0 0.75573,0.08891 0.37616,0.08891 0.68734,0.294085 0.31118,0.201757 0.49926,0.550555 0.1915,0.348799 0.1915,0.871997 v 3.515344 h -1.19002 V 48.79591 h -0.041 q -0.11284,0.218854 -0.31802,0.410351 -0.20175,0.188078 -0.50952,0.304344 -0.30434,0.112847 -0.71469,0.112847 z m 0.32144,-0.909612 q 0.37274,0 0.6463,-0.147043 0.27357,-0.150462 0.42061,-0.396673 0.15047,-0.246211 0.15047,-0.536876 v -0.618947 q -0.0581,0.04787 -0.19834,0.08891 -0.13679,0.04104 -0.30776,0.07181 -0.17098,0.03078 -0.33854,0.05471 -0.16756,0.02394 -0.29067,0.04104 -0.27699,0.03761 -0.49584,0.123105 -0.21886,0.08549 -0.34538,0.239372 -0.12653,0.150462 -0.12653,0.389834 0,0.341959 0.24963,0.516358 0.24964,0.1744 0.63605,0.1744 z"
style="font-weight:600;font-family:inter;-inkscape-font-specification:'inter Semi-Bold'"
id="path1829" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

1
docs/static/img/logos/jDev.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

BIN
docs/static/img/logos/mel.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="178" height="34" version="1.0" viewBox="27 27 367 70"><switch><foreignObject width="1" height="1" x="0" y="0" requiredExtensions="http://ns.adobe.com/AdobeIllustrator/10.0/"/><g><path d="M221.8 47.9c1.3 4.4 5 7.1 10.9 7.1 6 0 10.1-2.8 10.1-7.9 0-4.2-2.6-6.8-9.9-7.8-4.1-.6-5.5-1.6-5.5-3.6 0-2.2 1.8-3.2 4.5-3.2 3.3 0 5.1 1.5 5.8 4.4l4.2-1.2c-1.1-4.6-4.5-7.2-10.1-7.2-5.5 0-9.1 2.6-9.1 7.5 0 3.5 1.8 6.4 9 7.4 4.7.6 6.2 1.7 6.2 3.9 0 2.4-1.9 3.6-5.1 3.6-3.8 0-6-1.7-7.1-4.7zm-21.1-8.3c.5-4 2.9-6.8 7.1-6.8 3.9 0 6.5 2.4 6.5 6.8zm18.3 1.1c0-7.9-4.4-12.1-11.1-12.1-7.6 0-11.9 5.3-11.9 13.2 0 8 4.3 13.2 12.1 13.2 5.6 0 9.2-2.7 10.5-7l-4.3-1.4c-.9 3.2-3.5 4.1-6.2 4.1-4.9 0-7.1-3.1-7.4-7.2H219zm-44.7 13.8V35.9c2.5 2.4 9.6 12.8 12.4 18.6h4.6V29h-4.5v17.1c-3.3-5.9-7.9-12-12.8-17.1h-4.3v25.5zm-27.5 0V35.9c2.5 2.4 9.6 12.8 12.4 18.6h4.6V29h-4.5v17.1c-3.3-5.9-7.9-12-12.7-17.1h-4.3v25.5zm-27.4-14.9c.5-4 2.9-6.8 7.1-6.8 3.9 0 6.5 2.4 6.5 6.8zm18.2 1.1c0-7.9-4.4-12.1-11.1-12.1-7.6 0-11.9 5.3-11.9 13.2 0 8 4.3 13.2 12.1 13.2 5.6 0 9.2-2.7 10.5-7l-4.3-1.4c-.9 3.2-3.5 4.1-6.2 4.1-4.9 0-7.1-3.1-7.4-7.2h18.3zM101 33.2c3.6 0 5.5 1.7 5.5 4.6 0 2.6-1.6 4.4-5 4.4h-6.1v-9zm4.4 12.3c4.5-1.5 5.9-4.9 5.9-8.2 0-5.2-3.6-8.3-9.8-8.3H90.9v25.5h4.5v-8.3h5.2l1.7 2.8c3 4.9 4.2 5.7 9 5.7v-4.3c-2.5 0-3.4-.8-5.3-3.9zM383.9 71.9c2.8 0 4.8 1.8 4.8 5.1h-9.9c.3-3 2-5.1 5.1-5.1m8.8 6c0-6.1-3.4-9.5-8.8-9.5-6 0-9.4 4.1-9.4 10.3 0 6.1 3.4 10.3 9.7 10.3 4.5 0 7.2-2.1 8.2-5.6l-3.5-1.2c-.6 2-2.3 3-4.7 3-3.5 0-5.1-2.2-5.4-5.2h14v-2.1zm-21.5-15.5H367v26.2h4.2zm-17.4 22.7c-3.3 0-5.3-2.4-5.3-6.4s2.1-6.4 5.3-6.4 5.3 2.4 5.3 6.4c0 3.9-2.1 6.4-5.3 6.4m0 3.8c5.8 0 9.7-3.9 9.7-10.2s-3.9-10.2-9.7-10.2c-5.9 0-9.8 3.9-9.8 10.2.1 6.3 3.9 10.2 9.8 10.2m-22-3.7c-3.3 0-5.4-2.5-5.4-6.5s2.1-6.5 5.4-6.5 5.4 2.5 5.4 6.5c.1 4-2.1 6.5-5.4 6.5M326.4 96V85.5h.2c1.2 2.4 3.7 3.5 6.2 3.5 5 0 8.9-3.9 8.9-10.3s-3.9-10.3-8.9-10.3c-2.5 0-5.1 1.1-6.2 3.5h-.2v-3.1h-4.2V96zm-17.3-10.9c-3.3 0-5.3-2.4-5.3-6.4s2.1-6.4 5.3-6.4 5.3 2.4 5.3 6.4c.1 3.9-2 6.4-5.3 6.4m0 3.8c5.8 0 9.7-3.9 9.7-10.2s-3.9-10.2-9.7-10.2c-5.9 0-9.8 3.9-9.8 10.2.1 6.3 4 10.2 9.8 10.2m-10.8-20.3c-.5-.1-1-.1-1.5-.1-2.5 0-4.4.8-5.4 3.5h-.2v-3.2H287v19.7h4.2v-9.7c0-5.6 2.4-6.1 4.7-6.1.8 0 1.5.1 2.3.2v-4.3zm-17.6 16.2c-3 0-3.4-.9-3.4-6.3v-5.9h5.7v-3.7h-5.7v-4.8H273v4.8h-3.7v3.7h3.7v7c0 7 1.8 9.1 7 9.1 1 0 2.3 0 3.5-.1v-3.8zM259 71.9c2.8 0 4.8 1.8 4.8 5.1h-9.9c.4-3 2.1-5.1 5.1-5.1m8.9 6c0-6.1-3.4-9.5-8.8-9.5-6 0-9.4 4.1-9.4 10.3 0 6.1 3.4 10.3 9.7 10.3 4.5 0 7.2-2.1 8.2-5.6l-3.5-1.2c-.6 2-2.3 3-4.7 3-3.5 0-5.1-2.2-5.4-5.2h14v-2.1zm-3.9-17-9.1 3.2 1.1 3.1 9.1-3.1zm-20 2.2h-4.4c-2.9 5.9-5.5 11.8-7.6 18.6h-.3c-2.1-6.8-4.8-12.7-7.6-18.6h-4.4l-3.1 25.5h4.5l2-17.6c2.4 5.4 4.5 10.9 6.3 16.8h5q2.7-8.7 6.3-16.8l2 17.6h4.5zm-41.2 21.7c-3 0-3.4-.9-3.4-6.3v-5.9h5.7v-3.7h-5.7v-4.8h-4.2v4.8h-3.7v3.7h3.7v7c0 7 1.8 9.1 7 9.1 1 0 2.3 0 3.5-.1v-3.8zm-21.7-12.9c2.8 0 4.8 1.8 4.8 5.1H176c.4-3 2.1-5.1 5.1-5.1m8.9 6c0-6.1-3.4-9.5-8.8-9.5-6 0-9.4 4.1-9.4 10.3 0 6.1 3.4 10.3 9.7 10.3 4.5 0 7.2-2.1 8.2-5.6l-3.5-1.2c-.6 2-2.3 3-4.7 3-3.5 0-5.1-2.2-5.4-5.2h14v-2.1zm-37.3-6c2.8 0 4.8 1.8 4.8 5.1h-9.9c.3-3 2-5.1 5.1-5.1m8.8 6c0-6.1-3.4-9.5-8.8-9.5-6 0-9.3 4.1-9.3 10.3 0 6.1 3.4 10.3 9.7 10.3 4.5 0 7.2-2.1 8.2-5.6l-3.5-1.2c-.6 2-2.3 3-4.7 3-3.5 0-5.1-2.2-5.4-5.2h14v-2.1zm-21.9-15.5h-4.2v26.2h4.2zm-9.1 0h-4.2v26.2h4.2zm-9.4 6.4h-4.2v19.7h4.2zm-2-2.2c1.4 0 2.6-1.2 2.6-2.7 0-1.4-1.2-2.6-2.6-2.6-1.5 0-2.7 1.2-2.7 2.6s1.2 2.7 2.7 2.7m-14.3 22 9-25.5H109l-7.2 21h-.3l-7.2-21h-4.8l9 25.5zM57.4 74.6c-3.7-5.3-6.2-6.9-11.4-7.7v4.9c3.6.5 5.3 2.4 7.6 5.7 6.2 8.9 9.7 12.5 22.3 11.9v-4.7c-9.7.7-13-2.3-18.5-10.1m3.1-2.4c5.3 7.3 8.1 9.8 15.4 9.2v-4.7c-5.4.6-8-2.1-11.7-7.4-4.5-6.4-9.6-9.9-18.2-10.3v4.8c6.1.2 10.1 2.3 14.5 8.4M28.3 88.8h4.9V47.9c-1.5.5-3.4 1.1-4.9 1.8zm13.6-43.2c-1.5.2-3.4.5-4.9.9v42.2h4.9zm26 4.7c0-8.1-6.4-13.5-19-13.5-6.6 0-14.5 1.5-20.6 4v5c5.6-2.5 13.7-4.3 20.6-4.3 9.1 0 14.1 3.1 14.1 8.9 0 3.4-1.7 6.4-4.8 8.3 1.3.7 2.7 1.8 3.8 2.8 3.8-2.4 5.9-6.5 5.9-11.2m-18.4-22c-7 0-14.2 1.5-21.1 3.8v5C36 34.3 43.2 33 49.5 33c14.3 0 22.7 6.5 22.7 16.8 0 6-2.6 11.1-7.5 14.5 1 1.1 2.1 2.4 3 3.5 6.1-4.2 9.4-10.9 9.4-18-.1-13-10.6-21.5-27.6-21.5"/></g></switch></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -188,7 +188,7 @@ ocient = [
oracle = ["oracledb>=2.0.0, <5"]
parseable = ["sqlalchemy-parseable>=0.1.6,<0.2.0"]
pinot = ["pinotdb>=5.0.0, <10.0.0"]
playwright = ["playwright>=1.60.0, <2"]
playwright = ["playwright>=1.61.0, <2"]
postgres = ["psycopg2-binary==2.9.12"]
presto = ["pyhive[presto]>=0.6.5"]
trino = ["trino>=0.337.0"]

View File

@@ -42,7 +42,7 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
"flask-appbuilder>=5.0.2,<6",
"flask-appbuilder>=5.2.0,<6",
"isodate>=0.7.0",
"pyarrow>=16.0.0",
"pydantic>=2.8.0",

View File

@@ -57,9 +57,9 @@ export function saveChartToDashboard(chartName: string, dashboardName: string) {
'.ant-select-selection-search-input[aria-label*="Select a dashboard"]',
)
.type(dashboardName, { force: true });
cy.wrap($modal)
.find(`.ant-select-item-option[title="${dashboardName}"]`)
.click();
cy.get(`.ant-select-item-option[title="${dashboardName}"]`).click();
cy.getBySel('btn-modal-save').click();
cy.wait('@update');
});

View File

@@ -2,7 +2,6 @@
{
"slice_name": "1 - Sample chart",
"description": "chart description",
"owners": [1],
"viz_type": "echarts_timeseries_line",
"cache_timeout": 1000,
"datasource_id": 2,
@@ -12,7 +11,6 @@
{
"slice_name": "2 - Sample chart",
"description": "chart description",
"owners": [1],
"viz_type": "echarts_timeseries_line",
"cache_timeout": 1000,
"datasource_id": 2,
@@ -22,7 +20,6 @@
{
"slice_name": "3 - Sample chart",
"description": "chart description",
"owners": [1],
"viz_type": "echarts_timeseries_line",
"cache_timeout": 1000,
"datasource_id": 2,
@@ -32,7 +29,6 @@
{
"slice_name": "4 - Sample chart",
"description": "chart description",
"owners": [1],
"viz_type": "echarts_timeseries_line",
"cache_timeout": 1000,
"datasource_id": 2,

View File

@@ -102,7 +102,7 @@
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^10.9.0",
"immer": "^11.1.8",
"immer": "^11.1.9",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
"js-levenshtein": "^1.1.6",
@@ -188,7 +188,7 @@
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.43",
"@swc/plugin-emotion": "^14.14.0",
"@swc/plugin-emotion": "^14.14.1",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^9.3.4",
"@testing-library/jest-dom": "^6.9.1",
@@ -202,7 +202,7 @@
"@types/json-bigint": "^1.0.4",
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",
"@types/node": "^26.0.1",
"@types/node": "^26.1.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react-loadable": "^5.5.11",
@@ -231,7 +231,6 @@
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-cypress": "^6.4.2",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
"eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons",
"eslint-plugin-import": "^2.32.0",
@@ -10762,9 +10761,9 @@
}
},
"node_modules/@swc/plugin-emotion": {
"version": "14.14.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.14.0.tgz",
"integrity": "sha512-E62+zotGgiF+EIHjyHHgS1cFfLKy/dIuDVMy8S9aucu9qxwObg4qkrZIYjGsW/rIwVU4nnMU72QJRNM3LHBB5g==",
"version": "14.14.1",
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.14.1.tgz",
"integrity": "sha512-2EgdUStcBO6Zwpept8JVEKmP4AInL6UoYbcWq9G5blc5MSjRW4bn3KUTVEBrxCAklGq/79YBjsV/sqKqgqg4zA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -11678,9 +11677,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.0.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz",
"integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==",
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
"integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
@@ -18967,25 +18966,6 @@
"ms": "^2.1.1"
}
},
"node_modules/eslint-plugin-cypress": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-6.4.2.tgz",
"integrity": "sha512-OIGJP5y6/cyMxBtMASP/iBM7Pv4ZHccE9uI8MhtAZDrdNDYDwHgKa/3Khrr+FGRr9hSIfNYstGRhSmbcLURWYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"globals": "^17.7.0"
},
"peerDependencies": {
"@typescript-eslint/parser": ">=8",
"eslint": ">=9"
},
"peerDependenciesMeta": {
"@typescript-eslint/parser": {
"optional": true
}
}
},
"node_modules/eslint-plugin-i18n-strings": {
"resolved": "eslint-rules/eslint-plugin-i18n-strings",
"link": true
@@ -21719,19 +21699,6 @@
"which": "bin/which"
}
},
"node_modules/globals": {
"version": "17.7.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
"integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globalthis": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
@@ -22871,9 +22838,9 @@
"license": "MIT"
},
"node_modules/immer": {
"version": "11.1.8",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
"version": "11.1.9",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.9.tgz",
"integrity": "sha512-sc/z0Cyti70bZa0ZU4sWfAElfovFb9Ni8tArJZLuklYWxegPiK3pDOql1Rq5H0FIRAW9LSQRG6OX4KqBldbhBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -34018,9 +33985,9 @@
"license": "BSD-3-Clause"
},
"node_modules/react-markdown": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz",
"integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
"integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
@@ -41919,7 +41886,7 @@
"react-draggable": "^4.7.0",
"react-error-boundary": "^6.1.2",
"react-js-cron": "^5.2.0",
"react-markdown": "^9.0.7",
"react-markdown": "^10.1.0",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.1",
"react-ultimate-pagination": "^1.3.2",
@@ -41941,7 +41908,7 @@
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.1",
"@types/lodash": "^4.17.24",
"@types/node": "^26.0.1",
"@types/node": "^26.1.0",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",

View File

@@ -185,7 +185,7 @@
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^10.9.0",
"immer": "^11.1.8",
"immer": "^11.1.9",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
"js-levenshtein": "^1.1.6",
@@ -271,7 +271,7 @@
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.43",
"@swc/plugin-emotion": "^14.14.0",
"@swc/plugin-emotion": "^14.14.1",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^9.3.4",
"@testing-library/jest-dom": "^6.9.1",
@@ -285,7 +285,7 @@
"@types/json-bigint": "^1.0.4",
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",
"@types/node": "^26.0.1",
"@types/node": "^26.1.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react-loadable": "^5.5.11",
@@ -314,7 +314,6 @@
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-cypress": "^6.4.2",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
"eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons",
"eslint-plugin-import": "^2.32.0",

View File

@@ -25,18 +25,35 @@ export { default as __hack_reexport_jed } from './jed';
* Superset supported languages.
*/
export type Locale =
| 'ar'
| 'ca'
| 'cs'
| 'de'
| 'en'
| 'es'
| 'fa'
| 'fi'
| 'fr'
| 'it'
| 'ja'
| 'ko'
| 'lv'
| 'mi'
| 'nl'
| 'pl'
| 'pt'
| 'pt_BR'
| 'ro'
| 'ru'
| 'sk'
| 'sl'
| 'sr'
| 'sr_Latn'
| 'th'
| 'tr'
| 'uk'
| 'zh'
| 'zh_TW'; // supported locales in Superset
| 'zh_TW'; // every locale with a catalog in superset/translations
/**
* Language pack provided to `jed`.

View File

@@ -138,7 +138,7 @@ export const TestDataset: Dataset = {
],
name: 'public.birth_names',
order_by_choices: [],
owners: [
editors: [
{
first_name: 'admin',
id: 1,

View File

@@ -67,7 +67,7 @@ export const renameOperator: PostProcessingFactory<PostProcessingRename> = (
[...metricOffsetMap.entries()].forEach(
([metricWithOffset, metricOnly]) => {
const offsetLabel = timeOffsets.find(offset =>
metricWithOffset.includes(offset),
metricWithOffset.endsWith(`${TIME_COMPARISON_SEPARATOR}${offset}`),
);
renamePairs.push([
formData.comparison_type === ComparisonType.Values

View File

@@ -26,7 +26,7 @@ export const getTimeOffset = (
timeCompare.find(
timeOffset =>
// offset is represented as <offset>, group by list
series.name.includes(`${timeOffset},`) ||
series.name.startsWith(`${timeOffset},`) ||
// offset is represented as <metric>__<offset>
series.name.includes(`__${timeOffset}`) ||
// offset is represented as <metric>, <offset>
@@ -50,7 +50,9 @@ export const getOriginalSeries = (
// offset in the middle: <metric>, <offset>, <dimension>
result = result.replace(`, ${compare},`, ',');
// offset at start: <offset>, <dimension>
result = result.replace(`${compare},`, '');
if (result.startsWith(`${compare},`)) {
result = result.slice(`${compare},`.length);
}
// offset with double underscore: <metric>__<offset>
result = result.replace(`__${compare}`, '');
// offset at end: <metric>, <offset>

View File

@@ -86,7 +86,7 @@ export interface Dataset {
name?: string;
description: string | null;
uid?: string;
owners?: Owner[];
editors?: Owner[];
filter_select?: boolean;
filter_select_enabled?: boolean;
column_names?: string[];

View File

@@ -303,6 +303,30 @@ test('should add renameOperator if multiple metrics exist', () => {
});
});
test('should correctly match offsets that share a numeric prefix', () => {
expect(
renameOperator(
{
...formData,
comparison_type: ComparisonType.Values,
time_compare: ['1 year ago', '11 year ago'],
},
queryObject,
),
).toEqual({
operation: 'rename',
options: {
columns: {
'count(*)__1 year ago': '1 year ago',
'count(*)__11 year ago': '11 year ago',
},
inplace: true,
level: 0,
},
});
});
test('should remove renameOperator', () => {
expect(
renameOperator(

View File

@@ -114,3 +114,26 @@ test('hasTimeOffset returns false when series name is not a string', () => {
const timeCompare = ['1 year ago'];
expect(hasTimeOffset(series, timeCompare)).toBe(false);
});
test('getTimeOffset correctly matches offsets that share a numeric prefix', () => {
const timeCompare = ['1 year ago', '11 year ago'];
expect(
getTimeOffset({ name: '11 year ago, Alexander' }, timeCompare),
).toEqual('11 year ago');
expect(getTimeOffset({ name: '1 year ago, Alexander' }, timeCompare)).toEqual(
'1 year ago',
);
expect(getTimeOffset({ name: 'Births__11 year ago' }, timeCompare)).toEqual(
'11 year ago',
);
});
test('getOriginalSeries correctly strips offsets that share a numeric prefix', () => {
const timeCompare = ['1 year ago', '11 year ago'];
expect(getOriginalSeries('11 year ago, Alexander', timeCompare)).toEqual(
'Alexander',
);
expect(getOriginalSeries('1 year ago, Alexander', timeCompare)).toEqual(
'Alexander',
);
});

View File

@@ -55,7 +55,7 @@
"react-draggable": "^4.7.0",
"react-error-boundary": "^6.1.2",
"react-js-cron": "^5.2.0",
"react-markdown": "^9.0.7",
"react-markdown": "^10.1.0",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.1",
"react-ultimate-pagination": "^1.3.2",
@@ -78,7 +78,7 @@
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.1",
"@types/lodash": "^4.17.24",
"@types/node": "^26.0.1",
"@types/node": "^26.1.0",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",

View File

@@ -154,18 +154,13 @@ class CategoricalColorScale extends ExtensibleFunction {
this.incrementColorRange();
}
if (
// feature flag to be deprecated (will become standard behaviour)
isFeatureEnabled(FeatureFlag.AvoidColorsCollision) &&
this.isColorUsed(color)
) {
if (this.isColorUsed(color)) {
// fallback to least used color
color = this.getNextAvailableColor(cleanedValue, color);
}
}
if (
isFeatureEnabled(FeatureFlag.AvoidColorsCollision) &&
source === LabelsColorMapSource.Dashboard &&
(forcedColor || isExistingLabel)
) {

View File

@@ -91,6 +91,7 @@ import {
InfoCircleFilled,
InsertRowAboveOutlined,
InsertRowBelowOutlined,
LayoutOutlined,
LeftOutlined,
LineChartOutlined,
LineOutlined,
@@ -249,6 +250,7 @@ const AntdIcons = {
InfoCircleFilled,
InsertRowAboveOutlined,
InsertRowBelowOutlined,
LayoutOutlined,
LeftOutlined,
LineChartOutlined,
LineOutlined,

View File

@@ -84,15 +84,15 @@ const config = (contentType: ContentType) => {
),
};
case MetadataType.Owner:
case MetadataType.Editor:
return {
icon: Icons.UserOutlined,
title: contentType.createdBy,
tooltip: (
<div>
<Info header={t('Created by')} text={contentType.createdBy} />
{!!contentType.owners && (
<Info header={t('Owners')} text={contentType.owners} />
{!!contentType.editors && (
<Info header={t('Editors')} text={contentType.editors} />
)}
<Info header={t('Created on')} text={contentType.createdOn} />
</div>

View File

@@ -21,7 +21,7 @@ export enum MetadataType {
Dashboards = 'dashboards',
Description = 'description',
LastModified = 'lastModified',
Owner = 'owner',
Editor = 'editor',
Rows = 'rows',
Sql = 'sql',
Table = 'table',
@@ -48,10 +48,10 @@ export type LastModified = {
onClick?: (type: string) => void;
};
export type Owner = {
type: MetadataType.Owner;
export type Editor = {
type: MetadataType.Editor;
createdBy: string;
owners?: string[] | string;
editors?: string[] | string;
createdOn: string;
onClick?: (type: string) => void;
};
@@ -81,4 +81,4 @@ export type Tags = {
};
export type ContentType =
Dashboards | Description | LastModified | Owner | Rows | Sql | Table | Tags;
Dashboards | Description | LastModified | Editor | Rows | Sql | Table | Tags;

View File

@@ -27,7 +27,7 @@ export default {
docs: {
description: {
component:
'MetadataBar displays a row of metadata items (SQL info, owners, last modified, tags, dashboards, etc.) that collapse responsively based on available width.',
'MetadataBar displays a row of metadata items (SQL info, editors, last modified, tags, dashboards, etc.) that collapse responsively based on available width.',
},
},
},
@@ -75,9 +75,9 @@ Basic.args = {
title: 'Click to view query',
},
{
type: MetadataType.Owner,
type: MetadataType.Editor,
createdBy: 'Jane Smith',
owners: ['John Doe', 'Mary Wilson'],
editors: ['John Doe', 'Mary Wilson'],
createdOn: A_WEEK_AGO,
},
{
@@ -119,9 +119,9 @@ InteractiveMetadataBar.args = {
title: 'Click to view query',
},
{
type: MetadataType.Owner,
type: MetadataType.Editor,
createdBy: 'Jane Smith',
owners: ['John Doe', 'Mary Wilson'],
editors: ['John Doe', 'Mary Wilson'],
createdOn: A_WEEK_AGO,
},
{
@@ -149,9 +149,9 @@ InteractiveMetadataBar.parameters = {
items: [
{ type: 'sql', title: 'Click to view query' },
{
type: 'owner',
type: 'editor',
createdBy: 'Jane Smith',
owners: ['John Doe', 'Mary Wilson'],
editors: ['John Doe', 'Mary Wilson'],
createdOn: 'a week ago',
},
{
@@ -171,9 +171,9 @@ InteractiveMetadataBar.parameters = {
const items = [
{ type: 'sql', title: 'Click to view query' },
{
type: 'owner',
type: 'editor',
createdBy: 'Jane Smith',
owners: ['John Doe', 'Mary Wilson'],
editors: ['John Doe', 'Mary Wilson'],
createdOn: 'a week ago',
},
{
@@ -190,7 +190,7 @@ InteractiveMetadataBar.parameters = {
title: 'Minimal Metadata',
code: `function MinimalMetadata() {
const items = [
{ type: 'owner', createdBy: 'Admin', owners: ['Admin'], createdOn: 'yesterday' },
{ type: 'editor', createdBy: 'Admin', editors: ['Admin'], createdOn: 'yesterday' },
{ type: 'lastModified', value: '2 hours ago', modifiedBy: 'Admin' },
];
return <MetadataBar items={items} />;
@@ -201,7 +201,7 @@ InteractiveMetadataBar.parameters = {
code: `function FullMetadata() {
const items = [
{ type: 'sql', title: 'SELECT * FROM ...' },
{ type: 'owner', createdBy: 'Jane Smith', owners: ['Jane Smith', 'John Doe', 'Bob Wilson'], createdOn: '2 weeks ago' },
{ type: 'editor', createdBy: 'Jane Smith', editors: ['Jane Smith', 'John Doe', 'Bob Wilson'], createdOn: '2 weeks ago' },
{ type: 'lastModified', value: '3 days ago', modifiedBy: 'John Doe' },
{ type: 'tags', values: ['production', 'finance', 'quarterly'] },
{ type: 'dashboards', title: 'Used in 12 dashboards' },

View File

@@ -36,7 +36,7 @@ const SQL_TITLE = 'Click to view query';
const TABLE_TITLE = 'database.schema.table';
const CREATED_BY = 'Jane Smith';
const MODIFIED_BY = 'Jane Smith';
const OWNERS = ['John Doe', 'Mary Wilson'];
const EDITORS = ['John Doe', 'Mary Wilson'];
const TAGS = ['management', 'research', 'poc'];
const A_WEEK_AGO = 'a week ago';
const TWO_DAYS_AGO = '2 days ago';
@@ -71,9 +71,9 @@ const ITEMS: ContentType[] = [
modifiedBy: MODIFIED_BY,
},
{
type: MetadataType.Owner,
type: MetadataType.Editor,
createdBy: CREATED_BY,
owners: OWNERS,
editors: EDITORS,
createdOn: A_WEEK_AGO,
},
{
@@ -211,15 +211,15 @@ test('correctly renders the last modified tooltip', async () => {
expect(within(tooltip).getByText(MODIFIED_BY)).toBeInTheDocument();
});
test('correctly renders the owner tooltip', async () => {
test('correctly renders the editor tooltip', async () => {
render(<MetadataBar items={ITEMS.slice(0, 4)} />);
await userEvent.hover(screen.getByText(CREATED_BY));
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toBeInTheDocument();
expect(within(tooltip).getByText(CREATED_BY)).toBeInTheDocument();
expect(within(tooltip).getByText(A_WEEK_AGO)).toBeInTheDocument();
OWNERS.forEach(owner =>
expect(within(tooltip).getByText(owner)).toBeInTheDocument(),
EDITORS.forEach(editor =>
expect(within(tooltip).getByText(editor)).toBeInTheDocument(),
);
});

View File

@@ -54,7 +54,7 @@ in the code and should be specific for each metadata object.
Content types are predefined and consistent across the whole app. This means that
they will be displayed and behave in a consistent manner, keeping the same ordering,
information formatting, and interactions. For example, the Owner content type will always
have the same icon and when hovered it will present who created the entity, its current owners, and when the entity was created.
have the same icon and when hovered it will present who created the entity, its current editors, and when the entity was created.
To extend the list of content types, a developer needs to request the inclusion of the new type in the design system.
This process is important to make sure the new type is reviewed by the design team, improving Superset consistency.
@@ -125,7 +125,7 @@ export type LastModified = {
export type Owner = {
type: MetadataType.Owner;
createdBy: string;
owners: string[];
editors: string[];
createdOn: Date;
onClick?: (type: string) => void;
};

View File

@@ -34,6 +34,6 @@ export const ORDER = {
rows: 3,
tags: 4,
description: 5,
owner: 6,
editor: 6,
lastModified: 7,
};

View File

@@ -746,7 +746,10 @@ const AsyncSelect = forwardRef(
) => number
}
getPopupContainer={
getPopupContainer || (triggerNode => triggerNode.parentNode)
getPopupContainer ||
((triggerNode: HTMLElement) =>
(triggerNode?.closest('.ant-modal-content') as HTMLElement) ||
(triggerNode.parentNode as HTMLElement))
}
headerPosition={headerPosition}
labelInValue

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { SelectOptionsType, SelectProps } from './types';
import { Select } from '.';
@@ -109,7 +110,7 @@ export const InteractiveSelect = ({
InteractiveSelect.args = {
mode: 'single',
placeholder: 'Select ...',
placeholder: t('Select ...'),
showSearch: true,
allowNewOptions: false,
allowClear: false,
@@ -414,7 +415,7 @@ AdvancedPlayground.args = {
oneLine: false,
optionsCount: options.length,
optionFilterProps: ['value', 'label', 'custom'],
placeholder: 'Select ...',
placeholder: t('Select ...'),
showSearch: true,
};

View File

@@ -811,7 +811,10 @@ const Select = forwardRef(
) => number
}
getPopupContainer={
getPopupContainer || (triggerNode => triggerNode.parentNode)
getPopupContainer ||
((triggerNode: HTMLElement) =>
(triggerNode?.closest('.ant-modal-content') as HTMLElement) ||
(triggerNode.parentNode as HTMLElement))
}
headerPosition={headerPosition}
labelInValue={labelInValue}

View File

@@ -29,13 +29,11 @@ export enum FeatureFlag {
AlertReportWebhook = 'ALERT_REPORT_WEBHOOK',
AlertReportsFilter = 'ALERT_REPORTS_FILTER',
AllowFullCsvExport = 'ALLOW_FULL_CSV_EXPORT',
AvoidColorsCollision = 'AVOID_COLORS_COLLISION',
ChartPluginsExperimental = 'CHART_PLUGINS_EXPERIMENTAL',
ConfirmDashboardDiff = 'CONFIRM_DASHBOARD_DIFF',
CssTemplates = 'CSS_TEMPLATES',
DashboardVirtualization = 'DASHBOARD_VIRTUALIZATION',
DashboardVirtualizationDeferData = 'DASHBOARD_VIRTUALIZATION_DEFER_DATA',
DashboardRbac = 'DASHBOARD_RBAC',
DatapanelClosedByDefault = 'DATAPANEL_CLOSED_BY_DEFAULT',
DatasetFolders = 'DATASET_FOLDERS',
DateRangeTimeshiftsEnabled = 'DATE_RANGE_TIMESHIFTS_ENABLED',
@@ -48,6 +46,7 @@ export enum FeatureFlag {
EmbeddedSuperset = 'EMBEDDED_SUPERSET',
EnableAdvancedDataTypes = 'ENABLE_ADVANCED_DATA_TYPES',
EnableExtensions = 'ENABLE_EXTENSIONS',
EnableViewers = 'ENABLE_VIEWERS',
/** @deprecated */
EnableJavascriptControls = 'ENABLE_JAVASCRIPT_CONTROLS',
EnableTemplateProcessing = 'ENABLE_TEMPLATE_PROCESSING',

View File

@@ -28,6 +28,11 @@ import {
describe('CategoricalColorScale', () => {
beforeEach(() => {
window.featureFlags = {};
// the labels color map is a singleton: start every test from a clean
// explore-sourced state so dashboard-mode tests cannot leak into others
const labelsColorMap = getLabelsColorMap();
labelsColorMap.reset();
labelsColorMap.source = LabelsColorMapSource.Explore;
});
test('exists', () => {
@@ -159,9 +164,14 @@ describe('CategoricalColorScale', () => {
colorSet[color] = 1;
}
});
// collision avoidance redistributes recycled colors, so counts are
// not uniform; every color from the palette is still used and no
// color outside the palette appears
expect(Object.keys(colorSet)).toHaveLength(3);
const total = Object.values(colorSet).reduce((sum, n) => sum + n, 0);
expect(total).toBe(6);
['blue', 'red', 'green'].forEach(color => {
expect(colorSet[color]).toBe(2);
expect(colorSet[color]).toBeGreaterThanOrEqual(1);
});
});
test('get analogous colors when number of items exceed available colors', () => {
@@ -201,9 +211,6 @@ describe('CategoricalColorScale', () => {
expect(returnedColor).toBe(expectedColor);
});
test('reassigns colliding colors when no sliceId is provided', () => {
window.featureFlags = {
[FeatureFlag.AvoidColorsCollision]: true,
};
const PALETTE = ['red', 'blue', 'green'];
const chartAScale = new CategoricalColorScale(PALETTE);
@@ -228,13 +235,10 @@ describe('CategoricalColorScale', () => {
expect(classicCarsColor).not.toBe('red');
} finally {
labelsColorMap.reset();
labelsColorMap.source = LabelsColorMapSource.Dashboard;
labelsColorMap.source = LabelsColorMapSource.Explore;
}
});
test('conditionally calls getNextAvailableColor', () => {
window.featureFlags = {
[FeatureFlag.AvoidColorsCollision]: true,
};
test('calls getNextAvailableColor for new labels with used colors', () => {
scale.labelsColorMapInstance.source = LabelsColorMapSource.Explore;
scale.getColor('testValue1');
@@ -250,19 +254,12 @@ describe('CategoricalColorScale', () => {
getNextAvailableColorSpy.mockClear();
window.featureFlags = {
[FeatureFlag.AvoidColorsCollision]: false,
};
// existing labels reuse their assigned color without reassignment
scale.getColor('testValue3');
expect(getNextAvailableColorSpy).not.toHaveBeenCalled();
});
test('reassigns non-forced labels when a dashboard-synced label would duplicate their color', () => {
window.featureFlags = {
[FeatureFlag.AvoidColorsCollision]: true,
};
const dashScale = new CategoricalColorScale(['red', 'blue', 'green']);
const sliceId = 501;
const colorScheme = 'preset';
@@ -391,11 +388,12 @@ describe('CategoricalColorScale', () => {
test('returns the least used color among all', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
scale.getColor('cat'); // blue
scale.getColor('dog'); // red
scale.getColor('fish'); // green
scale.getColor('puppy'); // blue
scale.getColor('teddy'); // red
// seed usage directly: getColor would itself redistribute collisions
scale.chartLabelsColorMap.set('cat', 'blue');
scale.chartLabelsColorMap.set('dog', 'red');
scale.chartLabelsColorMap.set('fish', 'green');
scale.chartLabelsColorMap.set('puppy', 'blue');
scale.chartLabelsColorMap.set('teddy', 'red');
// All colors used, so the function should return least used
expect(scale.getNextAvailableColor('darling', 'red')).toBe('green');
});
@@ -407,13 +405,14 @@ describe('CategoricalColorScale', () => {
'green',
'yellow',
]);
scale.getColor('cat'); // blue
scale.getColor('dog'); // red
scale.getColor('frog'); // green
scale.getColor('fish'); // yellow
scale.getColor('goat'); // blue
scale.getColor('horse'); // red
scale.getColor('pony'); // green
// seed usage directly: getColor would itself redistribute collisions
scale.chartLabelsColorMap.set('cat', 'blue');
scale.chartLabelsColorMap.set('dog', 'red');
scale.chartLabelsColorMap.set('frog', 'green');
scale.chartLabelsColorMap.set('fish', 'yellow');
scale.chartLabelsColorMap.set('goat', 'blue');
scale.chartLabelsColorMap.set('horse', 'red');
scale.chartLabelsColorMap.set('pony', 'green');
// Yellow is the least used color, so it should be returned.
expect(scale.getNextAvailableColor('pony', 'blue')).toBe('yellow');
@@ -537,9 +536,6 @@ describe('CategoricalColorScale', () => {
let labelsColorMap: ReturnType<typeof getLabelsColorMap>;
beforeEach(() => {
window.featureFlags = {
[FeatureFlag.AvoidColorsCollision]: true,
};
const sentinel = new CategoricalColorScale(['red', 'blue', 'green']);
labelsColorMap = sentinel.labelsColorMapInstance;
labelsColorMap.reset();
@@ -551,29 +547,6 @@ describe('CategoricalColorScale', () => {
labelsColorMap.reset();
});
test('reproduces the bug without the fix: Classic Cars and Trains would both be red', () => {
window.featureFlags = {
[FeatureFlag.AvoidColorsCollision]: false,
};
const PALETTE = ['red', 'blue', 'green'];
const chartAScale = new CategoricalColorScale(PALETTE);
chartAScale.getColor('Trains', 101, 'testScheme');
expect(labelsColorMap.getColorMap().get('Trains')).toBe('red');
const chartBScale = new CategoricalColorScale(PALETTE);
chartBScale.getColor('Classic Cars', 102, 'testScheme');
chartBScale.getColor('Trains', 102, 'testScheme');
const classicCarsColor =
chartBScale.chartLabelsColorMap.get('Classic Cars');
const trainsColor = chartBScale.chartLabelsColorMap.get('Trains');
expect(trainsColor).toBe('red');
expect(classicCarsColor).toBe('red');
});
test('fix: Classic Cars is reassigned when Trains locks red from the dashboard', () => {
const PALETTE = ['red', 'blue', 'green'];
@@ -612,7 +585,6 @@ describe('CategoricalColorScale', () => {
test('fix: increments analogous color range for dashboard collisions when UseAnalogousColors is enabled', () => {
window.featureFlags = {
[FeatureFlag.AvoidColorsCollision]: true,
[FeatureFlag.UseAnalogousColors]: true,
};

View File

@@ -49,7 +49,7 @@ export interface VirtualDatasetCreatePayload {
schema: string | null;
table_name: string;
sql: string;
owners?: number[];
editors?: number[];
}
/**
@@ -65,7 +65,7 @@ export interface DatasetResult {
id: number;
database_name: string;
};
owners?: Array<{ id: number }>;
editors?: Array<{ id: number }>;
dataset_type?: 'physical' | 'virtual';
}
@@ -124,7 +124,7 @@ export async function createTestVirtualDataset(
schema: '',
table_name: name,
sql: "SELECT 1 as id, 'test' as name",
owners: [],
editors: [],
});
if (!response.ok()) {

View File

@@ -44,6 +44,29 @@ import { TIMEOUT } from '../../utils/constants';
const NONADMIN_USERNAME = process.env.PLAYWRIGHT_NONADMIN_USERNAME || 'gamma';
const NONADMIN_PASSWORD = process.env.PLAYWRIGHT_NONADMIN_PASSWORD || 'general';
const SUBJECT_TYPE_USER = 1;
type RelatedSubject = {
value: number;
text?: string;
extra?: {
type?: number;
secondary_label?: string;
};
};
function matchesUsername(subject: RelatedSubject, username: string) {
const normalizedUsername = username.toLowerCase();
const text = subject.text?.toLowerCase() ?? '';
const secondaryLabel = subject.extra?.secondary_label?.toLowerCase() ?? '';
return (
subject.extra?.type === SUBJECT_TYPE_USER &&
(text === normalizedUsername ||
text.includes(normalizedUsername) ||
secondaryLabel.includes(normalizedUsername))
);
}
// Clear storageState so the default page fixture starts unauthenticated.
// Admin API calls use an explicit admin context with saved auth instead.
@@ -87,17 +110,19 @@ test('non-admin user can view a themed dashboard without 403 or infinite spinner
dashboardId = dashBody.id;
expect(dashboardId).toBeTruthy();
// 3. Grant non-admin user access by adding them as a dashboard owner
// 3. Grant non-admin user access by adding them as a dashboard editor
const usersRes = await apiGet(
adminPage,
`api/v1/dashboard/related/owners?q=(filter:'${NONADMIN_USERNAME}')`,
`api/v1/dashboard/related/editors?q=(filter:'${NONADMIN_USERNAME}')`,
);
expect(usersRes.ok()).toBe(true);
const usersBody = await usersRes.json();
const gammaUserId = usersBody.result?.[0]?.value;
expect(gammaUserId).toBeTruthy();
const gammaSubjectId = (
usersBody.result as RelatedSubject[] | undefined
)?.find(subject => matchesUsername(subject, NONADMIN_USERNAME))?.value;
expect(gammaSubjectId).toBeTruthy();
const putRes = await apiPutDashboard(adminPage, dashboardId!, {
owners: [gammaUserId],
editors: [gammaSubjectId],
});
expect(putRes.ok()).toBe(true);

View File

@@ -547,6 +547,7 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
paginationPageSizeSelector={PAGE_SIZE_OPTIONS}
suppressDragLeaveHidesColumns
pinnedBottomRowData={showTotals ? [cleanedTotals] : undefined}
tooltipShowDelay={500}
localeText={{
// Pagination controls
next: t('Next'),

View File

@@ -448,10 +448,18 @@ export default function TableChart<D extends DataRecord = DataRecord>(
/>
);
const columnsForKeys = isUsingTimeComparison
? (filteredColumns as InputColumn[])
: (columns as InputColumn[]);
const descriptionsKey = columnsForKeys
.map(col => `${col.key}:${col.description ?? ''}`)
.join('|');
return (
<StyledChartContainer height={height}>
<AgGridDataTable
gridHeight={gridHeight}
key={descriptionsKey}
data={data || []}
colDefsFromProps={colDefs}
includeSearch={!!includeSearch}

View File

@@ -346,6 +346,7 @@ const processColumns = memoizeOne(function processColumns(
column_config: columnConfig = {},
query_mode: queryMode,
},
rawDatasource,
queriesData,
} = props;
const granularity = extractTimegrain(props.rawFormData);
@@ -385,6 +386,19 @@ const processColumns = memoizeOne(function processColumns(
? config.currencyFormat
: savedCurrency;
// Internal names of metrics expressed as percentages have a "%" prefix,
// however, their storage locations are defined in rawDatasource.metrics using the original names.
const metricLookupKey = key.startsWith('%') ? key.slice(1) : key;
const description =
rawDatasource.columns?.find(
(item: { column_name?: string; description?: string | null }) =>
item.column_name === key,
)?.description ??
rawDatasource.metrics?.find(
(item: { metric_name?: string; description?: string | null }) =>
item.metric_name === metricLookupKey,
)?.description;
let formatter;
if (isTime || config.d3TimeFormat) {
@@ -431,6 +445,7 @@ const processColumns = memoizeOne(function processColumns(
isPercentMetric,
formatter,
config,
description,
};
})
.sort((a, b) => {

View File

@@ -192,6 +192,7 @@ export interface InputColumn {
TimeFormatter | NumberFormatter | CustomFormatter | CurrencyFormatter;
originalLabel?: string;
metricName?: string;
description?: string;
}
export type ValueRange = [number, number] | null;
@@ -222,7 +223,7 @@ export type Dataset = {
created_on_humanized: string;
description: string;
table_name: string;
owners: {
editors: {
first_name: string;
last_name: string;
}[];

View File

@@ -19,12 +19,34 @@
import { isEqualArray } from '@superset-ui/core';
import { TableChartProps } from '../types';
const getDescriptions = (props: TableChartProps) => {
const colnames = props.queriesData?.[0]?.colnames || [];
const columns = props.rawDatasource?.columns || [];
const metrics = props.rawDatasource?.metrics || [];
return colnames.map((key: string) => {
// Internal names of metrics expressed as percentages have a "%" prefix,
// however, their storage locations are defined in rawDatasource.metrics using the original names.
const metricLookupKey = key.startsWith('%') ? key.slice(1) : key;
return (
columns.find((item: { column_name: string }) => item.column_name === key)
?.description ??
metrics.find(
(item: { metric_name: string }) => item.metric_name === metricLookupKey,
)?.description
);
});
};
export default function isEqualColumns(
propsA: TableChartProps[],
propsB: TableChartProps[],
) {
const a = propsA[0];
const b = propsB[0];
const descA = getDescriptions(a);
const descB = getDescriptions(b);
return (
a.datasource.columnFormats === b.datasource.columnFormats &&
a.datasource.currencyFormats === b.datasource.currencyFormats &&
@@ -41,6 +63,7 @@ export default function isEqualColumns(
JSON.stringify(a.formData.extraFormData || null) ===
JSON.stringify(b.formData.extraFormData || null) &&
JSON.stringify(a.rawFormData.column_config || null) ===
JSON.stringify(b.rawFormData.column_config || null)
JSON.stringify(b.rawFormData.column_config || null) &&
JSON.stringify(descA) === JSON.stringify(descB)
);
}

View File

@@ -290,6 +290,7 @@ export const useColDefs = ({
return {
field: colId,
headerName: getHeaderLabel(col),
headerTooltip: col.description,
valueFormatter: p => valueFormatter(p, col),
valueGetter: p => valueGetter(p, col),
cellStyle: p => {

View File

@@ -0,0 +1,266 @@
/**
* 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 transformProps from '../src/transformProps';
import { TableChartProps } from '../src/types';
import { GenericDataType } from '@apache-superset/core/common';
import { QueryMode } from '@superset-ui/core';
function createMockChartProps(
overrides: Partial<TableChartProps> = {},
): TableChartProps {
const defaultProps = {
height: 400,
width: 800,
rawFormData: {
viz_type: 'table',
datasource: '1__table',
query_mode: QueryMode.Aggregate,
metrics: [],
percent_metrics: [],
column_config: {},
table_timestamp_format: '',
granularity_sqla: 'day',
time_range: 'No filter',
},
queriesData: [
{
data: [],
colnames: [],
coltypes: [],
rowcount: 0,
applied_filters: [],
rejected_filters: [],
},
],
datasource: {
columns: [],
metrics: [],
columnFormats: {},
currencyFormats: {},
verboseMap: {},
},
rawDatasource: {
columns: [],
metrics: [],
},
filterState: {},
hooks: { setDataMask: jest.fn(), onChartStateChange: jest.fn() },
ownState: {},
emitCrossFilters: false,
theme: {},
...overrides,
};
return defaultProps as unknown as TableChartProps;
}
test('extracts description from datasource.columns for a regular column', () => {
const props = createMockChartProps({
queriesData: [
{
data: [{ col1: 'value' }],
colnames: ['col1'],
coltypes: [GenericDataType.String],
rowcount: 1,
applied_filters: [],
rejected_filters: [],
} as unknown as TableChartProps['queriesData'][number],
],
rawDatasource: {
columns: [
{ column_name: 'col1', description: 'This is a column description' },
],
metrics: [],
},
});
const result = transformProps(props);
const { columns } = result;
const columnMeta = columns.find(c => c.key === 'col1');
expect(columnMeta).toBeDefined();
expect(columnMeta!.description).toBe('This is a column description');
});
test('extracts description from datasource.metrics for a metric column', () => {
const props = createMockChartProps({
rawFormData: {
viz_type: 'table',
datasource: '1__table',
query_mode: QueryMode.Aggregate,
metrics: ['sum_sales'],
percent_metrics: [],
column_config: {},
table_timestamp_format: '',
granularity_sqla: 'day',
time_range: 'No filter',
},
queriesData: [
{
data: [{ sum_sales: 100 }],
colnames: ['sum_sales'],
coltypes: [GenericDataType.Numeric],
rowcount: 1,
applied_filters: [],
rejected_filters: [],
},
] as unknown as TableChartProps['queriesData'],
rawDatasource: {
columns: [],
metrics: [
{ metric_name: 'sum_sales', description: 'Total sales amount' },
],
},
});
const result = transformProps(props);
const { columns } = result;
const columnMeta = columns.find(c => c.key === 'sum_sales');
expect(columnMeta).toBeDefined();
expect(columnMeta!.description).toBe('Total sales amount');
});
test('prefers column description over metric description when both exist with same key', () => {
const props = createMockChartProps({
rawFormData: {
viz_type: 'table',
datasource: '1__table',
query_mode: QueryMode.Aggregate,
metrics: ['revenue'],
percent_metrics: [],
column_config: {},
table_timestamp_format: '',
granularity_sqla: 'day',
time_range: 'No filter',
},
queriesData: [
{
data: [{ revenue: 500 }],
colnames: ['revenue'],
coltypes: [GenericDataType.Numeric],
rowcount: 1,
applied_filters: [],
rejected_filters: [],
},
] as unknown as TableChartProps['queriesData'],
rawDatasource: {
columns: [{ column_name: 'revenue', description: 'Column desc' }],
metrics: [{ metric_name: 'revenue', description: 'Metric desc' }],
},
});
const result = transformProps(props);
const { columns } = result;
const columnMeta = columns.find(c => c.key === 'revenue');
expect(columnMeta!.description).toBe('Column desc');
});
test('handles percent metrics correctly uses base metric name for lookup', () => {
const props = createMockChartProps({
rawFormData: {
viz_type: 'table',
datasource: '1__table',
query_mode: QueryMode.Aggregate,
metrics: ['profit'],
percent_metrics: ['profit'],
column_config: {},
table_timestamp_format: '',
granularity_sqla: 'day',
time_range: 'No filter',
},
queriesData: [
{
data: [{ '%profit': 0.15 }],
colnames: ['%profit'],
coltypes: [GenericDataType.Numeric],
rowcount: 1,
applied_filters: [],
rejected_filters: [],
},
] as unknown as TableChartProps['queriesData'],
rawDatasource: {
columns: [],
metrics: [
{ metric_name: 'profit', description: 'Profit margin percent' },
],
},
});
const result = transformProps(props);
const { columns } = result;
const columnMeta = columns.find(c => c.key === '%profit');
expect(columnMeta).toBeDefined();
expect(columnMeta!.description).toBe('Profit margin percent');
});
test('sets description to undefined when no matching column or metric is found', () => {
const props = createMockChartProps({
queriesData: [
{
data: [{ unknown_col: 'x' }],
colnames: ['unknown_col'],
coltypes: [GenericDataType.String],
rowcount: 1,
applied_filters: [],
rejected_filters: [],
},
] as unknown as TableChartProps['queriesData'],
rawDatasource: {
columns: [],
metrics: [],
},
});
const result = transformProps(props);
const { columns } = result;
const columnMeta = columns.find(c => c.key === 'unknown_col');
expect(columnMeta!.description).toBeUndefined();
});
test('uses description from column even when verboseMap renames the column', () => {
const props = createMockChartProps({
queriesData: [
{
data: [{ col_x: 10 }],
colnames: ['col_x'],
coltypes: [GenericDataType.Numeric],
rowcount: 1,
applied_filters: [],
rejected_filters: [],
},
] as unknown as TableChartProps['queriesData'],
datasource: {
columns: [],
metrics: [],
columnFormats: {},
currencyFormats: {},
verboseMap: { col_x: 'Custom Label' },
} as unknown as TableChartProps['datasource'],
rawDatasource: {
columns: [
{ column_name: 'col_x', description: 'Original column description' },
],
metrics: [],
},
});
const result = transformProps(props);
const { columns } = result;
const columnMeta = columns.find(c => c.key === 'col_x');
expect(columnMeta!.label).toBe('Custom Label');
expect(columnMeta!.description).toBe('Original column description');
});

View File

@@ -49,7 +49,7 @@ export const getDateFormatter = (
) =>
timeFormat === SMART_DATE_ID
? getTimeFormatterForGranularity(granularity)
: getTimeFormatter(timeFormat ?? fallbackFormat);
: getTimeFormatter(timeFormat ?? fallbackFormat, granularity);
export function getOriginalLabel(
metric: QueryFormMetric,

View File

@@ -23,6 +23,7 @@ import {
CategoricalColorNamespace,
getNumberFormatter,
AxisType,
getColumnLabel,
getMetricLabel,
NumberFormatter,
tooltipHtml,
@@ -140,13 +141,17 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
const xAxisLabel: string = getMetricLabel(x);
const yAxisLabel: string = getMetricLabel(y);
const sizeLabel: string = getMetricLabel(size);
const entityLabel: string = getColumnLabel(entity);
const seriesLabel: string | undefined = bubbleSeries
? getColumnLabel(bubbleSeries)
: undefined;
const refs: Refs = {};
data.forEach(datum => {
const dataName = bubbleSeries ? datum[bubbleSeries] : datum[entity];
const dataName = seriesLabel ? datum[seriesLabel] : datum[entityLabel];
const name = dataName ? String(dataName) : NULL_STRING;
const bubbleSeriesValue = bubbleSeries ? datum[bubbleSeries] : null;
const bubbleSeriesValue = seriesLabel ? datum[seriesLabel] : null;
series.push({
name,
@@ -155,7 +160,7 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
datum[xAxisLabel],
datum[yAxisLabel],
datum[sizeLabel],
datum[entity],
datum[entityLabel],
bubbleSeriesValue as any,
],
],

View File

@@ -134,10 +134,34 @@ describe('BigNumberWithTrendline', () => {
// bigNumberFallback is only set when bigNumber is null after aggregation
expect(transformed.bigNumberFallback).toBeNull();
// should successfully formatTime by granularity
// granularity (QUARTER) is honored, so the default format renders the
// full quarter range rather than only the start date
// @ts-expect-error
expect(transformed.formatTime(new Date('2020-01-01'))).toStrictEqual(
'2020-01-01 00:00:00',
'2020 Q1',
);
});
test('should format the week range with a custom date format', () => {
// Regression test for #35636: a custom date format combined with a Week
// time grain should render the full week range, not just the start date.
const propsWithWeekGranularity = {
...props,
rawFormData: {
...rawFormData,
time_grain_sqla: TimeGranularity.WEEK,
},
formData: {
...props.formData,
timeGrainSqla: TimeGranularity.WEEK,
timeFormat: '%d-%m-%Y',
},
} as unknown as BigNumberWithTrendlineChartProps;
const transformed = transformProps(propsWithWeekGranularity);
// @ts-expect-error
expect(transformed.formatTime(new Date('2025-10-13'))).toStrictEqual(
'13-10-2025 — 19-10-2025',
);
});

View File

@@ -153,6 +153,78 @@ describe('Bubble transformProps', () => {
});
});
describe('adhoc Custom SQL dimension', () => {
const adhocColumn = {
expressionType: 'SQL',
sqlExpression: 'EXTRACT(YEAR FROM ds)',
label: 'year',
};
const adhocQueriesData = [
{
data: [
{
year: 1992,
customer_name: 'AV Stores, Co.',
count: 10,
'SUM(price_each)': 20,
'SUM(sales)': 30,
},
{
year: 1993,
customer_name: 'Alpha Cognac',
count: 40,
'SUM(price_each)': 50,
'SUM(sales)': 60,
},
],
},
];
test('resolves the entity label so an adhoc dimension is not rendered as NULL', () => {
const formData: SqlaFormData = { ...defaultFormData, entity: adhocColumn };
const chartProps = new ChartProps({
...chartConfig,
queriesData: adhocQueriesData,
formData,
});
const result = transformProps(chartProps as EchartsBubbleChartProps);
expect((result.echartOptions.legend as any).data).toEqual(['1992', '1993']);
expect(result.echartOptions.series).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: '1992',
data: [[10, 20, 30, 1992, null]],
}),
]),
);
});
test('resolves the series label so an adhoc bubble series is not rendered as NULL', () => {
const formData: SqlaFormData = {
...defaultFormData,
entity: 'customer_name',
series: adhocColumn,
};
const chartProps = new ChartProps({
...chartConfig,
queriesData: adhocQueriesData,
formData,
});
const result = transformProps(chartProps as EchartsBubbleChartProps);
expect((result.echartOptions.legend as any).data).toEqual(['1992', '1993']);
expect(result.echartOptions.series).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: '1992',
data: [[10, 20, 30, 'AV Stores, Co.', 1992]],
}),
]),
);
});
});
describe('Bubble formatTooltip', () => {
const dollerFormatter = getNumberFormatter('$,.2f');
const percentFormatter = getNumberFormatter(',.1%');

View File

@@ -120,7 +120,7 @@ export default typedMemo(function DataTable<D extends object>({
onServerPaginationChange,
rowCount,
selectPageSize,
noResults: noResultsText = 'No data found',
noResults: noResultsText = t('No data found'),
hooks,
serverPagination,
wrapperRef: userWrapperRef,

View File

@@ -952,6 +952,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
i: number,
): ColumnWithLooseAccessor<D> & {
columnKey: string;
columnLabel: string;
} => {
const {
key,
@@ -1040,6 +1041,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
// typing is incorrect in current version of `@types/react-table`
// so we ask TS not to check.
columnKey: key,
columnLabel: label,
accessor: ((datum: D) => datum[key]) as never,
Cell: ({ value, row }: { value: DataRecordValue; row: Row<D> }) => {
const [isHtml, text] = formatColumnValue(column, value, row.original);
@@ -1457,13 +1459,14 @@ export default function TableChart<D extends DataRecord = DataRecord>(
columns as unknown as ColumnWithLooseAccessor &
{
columnKey: string;
columnLabel: string;
sortType?: string;
}[]
)
.filter(col => col?.sortType === 'alphanumeric')
.map(column => ({
value: column.columnKey,
label: column.columnKey,
label: column.columnLabel,
}));
if (!isEqual(options, searchOptions)) {

View File

@@ -493,4 +493,40 @@ describe('Polygon transformProps', () => {
expect(features.flatMap(p => p?.polygon || [])).toHaveLength(20); // 4 geohashes x 5 corners each
expect(features[0]?.elevation).toBe(1000);
});
test('should include configured metric label and value in extraProps', () => {
const metricProps = {
...mockChartProps,
rawFormData: {
...mockChartProps.rawFormData,
metric: {
expressionType: 'SQL',
sqlExpression: 'SUM(population)',
label: 'SUM(population)',
},
},
queriesData: [
{
data: [
{
geom: JSON.stringify([
[-122.4, 37.8],
[-122.3, 37.8],
[-122.3, 37.9],
[-122.4, 37.9],
]),
'SUM(population)': 50000,
},
],
},
],
};
const result = transformProps(metricProps as ChartProps);
const features = result.payload.data.features as PolygonFeature[];
expect(features).toHaveLength(1);
expect(features[0]?.extraProps?.['SUM(population)']).toBe(50000);
expect(features[0]?.metrics?.['SUM(population)']).toBe(50000);
});
});

View File

@@ -16,12 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ChartProps, getMetricLabel } from '@superset-ui/core';
import { ChartProps, getMetricLabel, QueryFormMetric } from '@superset-ui/core';
import { addJsColumnsToExtraProps, DataRecord } from '../spatialUtils';
import {
createBaseTransformResult,
getRecordsFromQuery,
getMetricLabelFromFormData,
parseMetricValue,
addPropertiesToFeature,
} from '../transformUtils';
@@ -112,7 +111,12 @@ function processPolygonData(
return [];
}
const metricLabel = getMetricLabelFromFormData(metric);
// Metric from GUI Config (Explore) can be an object (saved/adhoc), so normalize it to
// the query-result label; this key is used to read the metric and expose it
// in extraProps for tooltip resolution.
const metricLabel = metric
? getMetricLabel(metric as QueryFormMetric)
: undefined;
let elevationLabel: string | undefined;
let fixedElevationValue: number | undefined;
@@ -221,6 +225,12 @@ function processPolygonData(
typeof metricValue === 'number'
) {
feature.metrics![metricLabel] = metricValue;
// Expose the configured metric under its own label in extraProps so
// tooltip field lookups can resolve it with the same key users select.
feature.extraProps = {
...feature.extraProps,
[metricLabel]: metricValue,
};
}
}

View File

@@ -0,0 +1,90 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { JsonObject, QueryFormData } from '@superset-ui/core';
import { render } from '@testing-library/react';
import { createTooltipContent } from './tooltipUtils';
test('buildFieldBasedTooltipItems does not append configured metric when tooltip_contents already has string metric', () => {
const formData = {
datasource: '1__table',
viz_type: 'deck_polygon',
tooltip_contents: ['sum__value'],
metric: 'sum__value',
} as QueryFormData;
const hoverData = {
object: {
sum__value: 10,
metric: 10,
},
} as JsonObject;
const content = createTooltipContent(formData, () => <div>default</div>);
const { container } = render(content(hoverData));
const metricRows =
container.textContent?.match(/sum__value:\s*10/g)?.length || 0;
expect(metricRows).toBe(1);
});
test('buildFieldBasedTooltipItems appends configured metric when tooltip_contents does not include it', () => {
const formData = {
datasource: '1__table',
viz_type: 'deck_polygon',
tooltip_contents: ['name'],
metric: 'sum__value',
} as QueryFormData;
const hoverData = {
object: {
name: 'North',
metric: 10,
},
} as JsonObject;
const content = createTooltipContent(formData, () => <div>default</div>);
const { container } = render(content(hoverData));
expect(container.textContent).toContain('name: North');
expect(container.textContent).toContain('sum__value: 10');
});
test('buildFieldBasedTooltipItems preserves zero configured metric fallback value', () => {
const formData = {
datasource: '1__table',
viz_type: 'deck_polygon',
tooltip_contents: ['name'],
metric: 'sum__value',
} as QueryFormData;
const hoverData = {
object: {
name: 'North',
metric: 0,
},
} as JsonObject;
const content = createTooltipContent(formData, () => <div>default</div>);
const { container } = render(content(hoverData));
expect(container.textContent).toContain('name: North');
expect(container.textContent).toContain('sum__value: 0');
});

View File

@@ -87,8 +87,12 @@ function extractValue(
fieldName: string,
checkPoints = true,
): any {
// Tooltip fields can come from transformed feature props, extraProps, or
// metric maps depending on chart path, so check all canonical containers.
let value =
o.object?.[fieldName] ||
o.object?.extraProps?.[fieldName] ||
o.object?.metrics?.[fieldName] ||
o.object?.properties?.[fieldName] ||
o.object?.data?.[fieldName] ||
'';
@@ -143,7 +147,9 @@ function buildFieldBasedTooltipItems(
if (!label || !fieldName) return;
let { value } = extractValue(o, fieldName);
if (!value && item.item_type === 'metric') {
// we use an empty string as sentinal for "missing value"
// but want to keep other falsy values like '0'
if (value === '' && item.item_type === 'metric') {
value = o.object?.metric || '';
}
@@ -170,6 +176,46 @@ function buildFieldBasedTooltipItems(
}
});
// For example, in geojson polygon charts, users can only pick columns as tooltip_contents.
// We still include the configured metric, so hover also shows the aggregation value.
// formData.metric represents one configured metric; but depending on its type,
// it might be represented differently:
// 1. String metric (legacy/simple)
// sum__value
// 2. Select option style object
// { label: SUM(value), value: sum__value }
// 3. Adhoc metric style object
// { label: ..., value: ... (optional), expressionType: ..., ... }
const metricAliasCandidates: string[] = [];
if (typeof formData.metric === 'string') {
metricAliasCandidates.push(formData.metric);
} else if (formData.metric) {
const metric = formData.metric as { label?: string; value?: string };
if (metric.label) metricAliasCandidates.push(metric.label);
if (metric.value) metricAliasCandidates.push(metric.value);
}
const foundMetricAlias = metricAliasCandidates[0] || '';
const hasSelectedMetricItem = formData.tooltip_contents.some(
(item: string | { item_type?: string }) =>
(item && typeof item === 'object' && item.item_type === 'metric') ||
(typeof item === 'string' && metricAliasCandidates.includes(item)),
);
if (!hasSelectedMetricItem && foundMetricAlias) {
const { value } = extractValue(o, foundMetricAlias, false);
const metricValue = value === '' ? (o.object?.metric ?? '') : value;
if (metricValue !== '') {
tooltipItems.push(
<TooltipRow
key="tooltip-configured-metric"
label={`${foundMetricAlias}: `}
value={formatValue(metricValue)}
/>,
);
}
}
return tooltipItems;
}
@@ -215,7 +261,7 @@ function processTooltipContentItem(
const extractResult = extractValue(o, fieldName);
let { value } = extractResult;
if (item?.item_type === 'metric' && !value) {
if (item?.item_type === 'metric' && value === '') {
value = o.object?.metric || '';
}

View File

@@ -23,12 +23,6 @@ export interface ChartListChart {
url: string;
last_saved_at: null | string;
last_saved_by: null | { id: number; first_name: string; last_name: string };
owners: {
id: number;
first_name: string;
last_name: string;
username: string;
}[];
dashboards: { id: number; dashboard_title: string }[];
}
@@ -40,7 +34,6 @@ const MOCK_CHART: ChartListChart = {
last_saved_at: null,
dashboards: [],
last_saved_by: null,
owners: [],
};
/**

View File

@@ -44,7 +44,8 @@ export default {
first_name: 'Kay',
last_name: 'Mon',
},
owners: [{ first_name: 'John', last_name: 'Doe', id: 1 }],
editors: [],
viewers: [],
userId: 'mock_user_id',
dash_edit_perm: true,
dash_save_perm: true,

View File

@@ -111,7 +111,8 @@ export const overwriteConfirmMetadata = {
css: ".navbar {\n transition: opacity 0.5s ease;\n opacity: 0.05;\n}\n.navbar:hover {\n opacity: 1;\n}\n.chart-header .header{\n font-weight: @font-weight-normal;\n font-size: 12px;\n}\n/*\nvar bnbColors = [\n //rausch hackb kazan babu lima beach tirol\n '#ff5a5f', '#7b0051', '#007A87', '#00d1c1', '#8ce071', '#ffb400', '#b4a76c',\n '#ff8083', '#cc0086', '#00a1b3', '#00ffeb', '#bbedab', '#ffd266', '#cbc29a',\n '#ff3339', '#ff1ab1', '#005c66', '#00b3a5', '#55d12e', '#b37e00', '#988b4e',\n ];\n*/\n",
dashboard_title: 'FCC New Coder Survey 2018',
slug: null,
owners: [],
editors: [],
viewers: [],
json_metadata:
'{"timed_refresh_immune_slices":[],"expanded_slices":{},"refresh_frequency":0,"default_filters":"{}","color_scheme":"supersetColors","label_colors":{"0":"#FCC700","1":"#A868B7","15":"#3CCCCB","30":"#A38F79","45":"#8FD3E4","age":"#1FA8C9","Yes,":"#1FA8C9","Female":"#454E7C","Prefer":"#5AC189","No,":"#FF7F44","Male":"#666666","Prefer not to say":"#E04355","Ph.D.":"#FCC700","associate\'s degree":"#A868B7","bachelor\'s degree":"#3CCCCB","high school diploma or equivalent (GED)":"#A38F79","master\'s degree (non-professional)":"#8FD3E4","no high school (secondary school)":"#A1A6BD","professional degree (MBA, MD, JD, etc.)":"#ACE1C4","some college credit, no degree":"#FEC0A1","some high school":"#B2B2B2","trade, technical, or vocational training":"#EFA1AA","No, not an ethnic minority":"#1FA8C9","Yes, an ethnic minority":"#454E7C","<NULL>":"#5AC189","Yes":"#FF7F44","No":"#666666","last_yr_income":"#E04355","More":"#A1A6BD","Less":"#ACE1C4","I":"#FEC0A1","expected_earn":"#B2B2B2","Yes: Willing To":"#EFA1AA","No: Not Willing to":"#FDE380","No Answer":"#D3B3DA","In an Office (with Other Developers)":"#9EE5E5","No Preference":"#D1C6BC","From Home":"#1FA8C9"},"color_scheme_domain":["#1FA8C9","#454E7C","#5AC189","#FF7F44","#666666","#E04355","#FCC700","#A868B7","#3CCCCB","#A38F79","#8FD3E4","#A1A6BD","#ACE1C4","#FEC0A1","#B2B2B2","#EFA1AA","#FDE380","#D3B3DA","#9EE5E5","#D1C6BC"],"shared_label_colors":["Male", "Female","<NULL>","Prefer not to say","No Answer","Yes, an ethnic minority","No, not an ethnic minority","age"],"cross_filters_enabled":false,"filter_scopes":{},"chart_configuration":{},"positions":{}}',
},

View File

@@ -181,9 +181,7 @@ export default {
granularity_sqla: [['ds', 'ds']],
main_dttm_col: 'ds',
name: 'birth_names',
owners: [
{ first_name: 'joe', last_name: 'man', id: 1, username: 'joeman' },
],
editors: [{ id: 1, label: 'joe man', type: 1, value: 1 }],
database: {
name: 'main',
backend: 'sqlite',

View File

@@ -25,7 +25,6 @@ export default {
crontab: '0 12 * * 1',
dashboard: dashboardInfo.id,
name: 'Weekly Report',
owners: [user.userId],
recipients: [
{
recipient_config_json: {

View File

@@ -23,6 +23,8 @@ import { sliceId } from './mockChartQueries';
export const filterId = 127;
export const column = 'region';
const getDefaultEditors = () => [{ id: 1, label: 'Admin User', type: 1 }];
export const sliceEntitiesForChart = {
slices: {
[sliceId]: {
@@ -52,6 +54,7 @@ export const sliceEntitiesForChart = {
description_markdown: '',
modified: '23 hours ago',
changed_on: 1529453332615,
editors: getDefaultEditors(),
},
},
isLoading: false,
@@ -89,7 +92,7 @@ export const sliceEntitiesForDashboard = {
datasource_type: DatasourceType.Query,
datasource_url: '',
datasource_name: '',
owners: [{ id: 0 }],
editors: getDefaultEditors(),
created_by: { id: 0 },
thumbnail_url: '',
},
@@ -109,7 +112,7 @@ export const sliceEntitiesForDashboard = {
datasource_type: DatasourceType.Query,
datasource_url: '',
datasource_name: '',
owners: [{ id: 0 }],
editors: getDefaultEditors(),
created_by: { id: 0 },
thumbnail_url: '',
},
@@ -130,7 +133,7 @@ export const sliceEntitiesForDashboard = {
datasource_type: DatasourceType.Query,
datasource_url: '',
datasource_name: '',
owners: [{ id: 0 }],
editors: getDefaultEditors(),
created_by: { id: 0 },
thumbnail_url: '',
},
@@ -151,7 +154,7 @@ export const sliceEntitiesForDashboard = {
datasource_type: DatasourceType.SlTable,
datasource_url: '',
datasource_name: '',
owners: [{ id: 0 }],
editors: getDefaultEditors(),
created_by: { id: 0 },
thumbnail_url: '',
},
@@ -172,7 +175,7 @@ export const sliceEntitiesForDashboard = {
datasource_type: DatasourceType.Table,
datasource_url: '',
datasource_name: '',
owners: [{ id: 0 }],
editors: getDefaultEditors(),
created_by: { id: 0 },
thumbnail_url: '',
},
@@ -193,7 +196,7 @@ export const sliceEntitiesForDashboard = {
datasource_type: DatasourceType.Query,
datasource_url: '',
datasource_name: '',
owners: [{ id: 0 }],
editors: getDefaultEditors(),
created_by: { id: 0 },
thumbnail_url: '',
},
@@ -214,7 +217,7 @@ export const sliceEntitiesForDashboard = {
datasource_type: DatasourceType.Query,
datasource_url: '',
datasource_name: '',
owners: [{ id: 0 }],
editors: getDefaultEditors(),
created_by: { id: 0 },
thumbnail_url: '',
},
@@ -235,7 +238,7 @@ export const sliceEntitiesForDashboard = {
datasource_type: DatasourceType.Dataset,
datasource_url: '',
datasource_name: '',
owners: [{ id: 0 }],
editors: getDefaultEditors(),
created_by: { id: 0 },
thumbnail_url: '',
},
@@ -256,7 +259,7 @@ export const sliceEntitiesForDashboard = {
datasource_type: DatasourceType.Table,
datasource_url: '',
datasource_name: '',
owners: [{ id: 0 }],
editors: getDefaultEditors(),
created_by: { id: 0 },
thumbnail_url: '',
},
@@ -277,7 +280,7 @@ export const sliceEntitiesForDashboard = {
datasource_type: DatasourceType.Table,
datasource_url: '',
datasource_name: '',
owners: [{ id: 0 }],
editors: getDefaultEditors(),
created_by: { id: 0 },
thumbnail_url: '',
},

View File

@@ -28,7 +28,7 @@ import {
} from '@superset-ui/core/components';
import ProgressBar from '@superset-ui/core/components/ProgressBar';
import { t } from '@apache-superset/core/translation';
import { QueryResponse, QueryState } from '@superset-ui/core';
import { QueryResponse } from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/theme';
import { shallowEqual, useSelector } from 'react-redux';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
@@ -406,24 +406,12 @@ const QueryTable = ({
modalBody={
selectedQuery ? (
<ModalResultSetWrapper>
{(() => {
const height =
reduxQueries[selectedQuery.id]?.state ===
QueryState.Success &&
reduxQueries[selectedQuery.id]?.results
? Math.floor(window.innerHeight * 0.5)
: undefined;
return (
<ResultSet
showSql
queryId={selectedQuery.id}
displayLimit={displayLimit}
defaultQueryLimit={1000}
useFixedHeight
height={height}
/>
);
})()}
<ResultSet
showSql
queryId={selectedQuery.id}
displayLimit={displayLimit}
defaultQueryLimit={1000}
/>
</ModalResultSetWrapper>
) : null
}

View File

@@ -44,4 +44,5 @@ export const ModalResultSetWrapper = styled.div`
display: flex;
flex-direction: column;
overflow: hidden;
height: 500px;
`;

View File

@@ -108,14 +108,12 @@ export interface ResultSetProps {
csv?: boolean;
database?: Record<string, any>;
displayLimit: number;
height?: number;
queryId: string;
search?: boolean;
showSql?: boolean;
showSqlInline?: boolean;
visualize?: boolean;
defaultQueryLimit: number;
useFixedHeight?: boolean;
}
const ResultContainer = styled.div`
@@ -166,14 +164,12 @@ const ResultSet = ({
csv = true,
database = {},
displayLimit,
height,
queryId,
search = true,
showSql = false,
showSqlInline = false,
visualize = true,
defaultQueryLimit,
useFixedHeight = false,
}: ResultSetProps) => {
const streamingThreshold = useSelector(
(state: SqlLabRootState) =>
@@ -774,22 +770,18 @@ const ResultSet = ({
/>
)}
</div>
{useFixedHeight && height !== undefined ? (
<ResultTable {...tableProps} height={height} />
) : (
<div
css={css`
flex: 1 1 auto;
padding-bottom: ${theme.sizeUnit * 3}px;
`}
>
<AutoSizer disableWidth>
{({ height: autoHeight }) => (
<ResultTable {...tableProps} height={autoHeight} />
)}
</AutoSizer>
</div>
)}
<div
css={css`
flex: 1 1 auto;
padding-bottom: ${theme.sizeUnit * 3}px;
`}
>
<AutoSizer disableWidth>
{({ height: autoHeight }) => (
<ResultTable {...tableProps} height={autoHeight} />
)}
</AutoSizer>
</div>
</ResultContainer>
<StreamingExportModal
visible={showStreamingModal}

View File

@@ -50,14 +50,16 @@ import { addDangerToast } from 'src/components/MessageToasts/actions';
import {
DatasetRadioState,
EXPLORE_CHART_DEFAULT,
DatasetOwner,
type DatasetOptionAutocomplete,
} from 'src/SqlLab/types';
import { mountExploreUrl } from 'src/explore/exploreUtils';
import { postFormData } from 'src/explore/exploreUtils/formData';
import { URL_PARAMS } from 'src/constants';
import { isEmpty } from 'lodash-es';
import { clearDatasetCache } from 'src/utils/cachedSupersetGet';
import type Subject from 'src/types/Subject';
import { openInNewTab, redirect } from 'src/utils/navigationUtils';
import { mapSubjectValuesToIds } from 'src/features/subjects/SubjectPicker';
interface QueryDatabase {
id?: number;
@@ -152,17 +154,21 @@ type UpdateDatasetPayload = {
datasetId: number;
sql: string;
columns: Array<Record<string, any>>;
owners: number[];
editors: number[];
overrideColumns: boolean;
templateParams?: string;
};
type DatasetOverwriteOption = DatasetOptionAutocomplete & {
label: string;
};
const updateDataset = async ({
dbId,
datasetId,
sql,
columns,
owners,
editors,
overrideColumns,
templateParams,
}: UpdateDatasetPayload) => {
@@ -171,7 +177,7 @@ const updateDataset = async ({
const body = JSON.stringify({
sql,
columns,
owners,
editors,
database_id: dbId,
...(templateParams !== undefined && { template_params: templateParams }),
});
@@ -231,14 +237,13 @@ export const SaveDatasetModal = ({
);
const [shouldOverwriteDataset, setShouldOverwriteDataset] = useState(false);
const [datasetToOverwrite, setDatasetToOverwrite] = useState<
Record<string, any>
Partial<DatasetOverwriteOption>
>({});
const [selectedDatasetToOverwrite, setSelectedDatasetToOverwrite] = useState<
SelectValue | undefined
>(undefined);
const [loading, setLoading] = useState<boolean>(false);
const user = useAppSelector(state => state.user);
const dispatch = useAppDispatch();
const [includeTemplateParameters, setIncludeTemplateParameters] =
useState(false);
@@ -247,10 +252,6 @@ export const SaveDatasetModal = ({
// `url` is from `mountExploreUrl(..., includeAppRoot=true)`; the
// navigationUtils helpers re-apply `ensureAppRoot` idempotently.
if (openWindow) {
// `openInNewTab` / `redirect` route the sink through navigationUtils'
// barriers (scheme allowlist, userinfo rejection, backslash
// rejection) — strictly stronger than master PR #40546's `sanitizeUrl`
// wrap, which only rejects `javascript:` / `data:` / `vbscript:`.
openInNewTab(url);
} else {
redirect(url);
@@ -276,7 +277,7 @@ export const SaveDatasetModal = ({
const [, key] = await Promise.all([
updateDataset({
dbId: datasource?.dbId,
datasetId: datasetToOverwrite?.datasetid,
datasetId: datasetToOverwrite.datasetId!,
sql: datasource?.sql,
columns: datasource?.columns?.map(
(d: { column_name: string; type: string; is_dttm: boolean }) => ({
@@ -285,13 +286,13 @@ export const SaveDatasetModal = ({
is_dttm: d.is_dttm,
}),
),
owners: datasetToOverwrite?.owners?.map((o: DatasetOwner) => o.id),
editors: mapSubjectValuesToIds(datasetToOverwrite.editors || []),
overrideColumns: true,
templateParams,
}),
postFormData(datasetToOverwrite.datasetid, 'table', {
postFormData(datasetToOverwrite.datasetId!, 'table', {
...formDataWithDefaults,
datasource: `${datasetToOverwrite.datasetid}__table`,
datasource: `${datasetToOverwrite.datasetId}__table`,
...(defaultVizType === VizType.Table && {
all_columns: datasource?.columns?.map(column => column.column_name),
}),
@@ -319,42 +320,38 @@ export const SaveDatasetModal = ({
}
};
const loadDatasetOverwriteOptions = useCallback(
async (input = '') => {
const { userId } = user;
const queryParams = rison.encode({
filters: [
{
col: 'table_name',
opr: 'ct',
value: input,
},
{
col: 'owners',
opr: 'rel_m_m',
value: userId,
},
],
order_column: 'changed_on_delta_humanized',
order_direction: 'desc',
});
const loadDatasetOverwriteOptions = useCallback(async (input = '') => {
const queryParams = rison.encode({
filters: [
{
col: 'table_name',
opr: 'ct',
value: input,
},
{
col: 'id',
opr: 'is_editable',
value: true,
},
],
order_column: 'changed_on_delta_humanized',
order_direction: 'desc',
});
return SupersetClient.get({
endpoint: `/api/v1/dataset/?q=${queryParams}`,
}).then(response => ({
data: response.json.result.map(
(r: { table_name: string; id: number; owners: [DatasetOwner] }) => ({
value: r.table_name,
label: r.table_name,
datasetid: r.id,
owners: r.owners,
}),
),
totalCount: response.json.count,
}));
},
[user],
);
return SupersetClient.get({
endpoint: `/api/v1/dataset/?q=${queryParams}`,
}).then(response => ({
data: response.json.result.map(
(r: { table_name: string; id: number; editors: Subject[] }) => ({
value: r.table_name,
label: r.table_name,
datasetId: r.id,
editors: r.editors,
}),
),
totalCount: response.json.count,
}));
}, []);
const handleSaveInDataset = () => {
setLoading(true);
@@ -400,7 +397,10 @@ export const SaveDatasetModal = ({
});
};
const handleOverwriteDatasetOption = (value: SelectValue, option: any) => {
const handleOverwriteDatasetOption = (
value: SelectValue,
option: DatasetOverwriteOption,
) => {
setDatasetToOverwrite(option);
setSelectedDatasetToOverwrite(value);
};
@@ -423,7 +423,7 @@ export const SaveDatasetModal = ({
const filterAutocompleteOption = (
inputValue: string,
option: { value: string; datasetid: number },
option: DatasetOverwriteOption,
) => option.value.toLowerCase().includes(inputValue.toLowerCase());
return (

View File

@@ -179,7 +179,7 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
<Icons.CloseOutlined
iconSize="l"
css={css`
verticalalign: middle;
vertical-align: middle;
`}
/>
</IconContainer>
@@ -196,7 +196,7 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
<IconContainer>
<Icons.EditOutlined
css={css`
verticalalign: middle;
vertical-align: middle;
`}
iconSize="l"
/>

View File

@@ -28,6 +28,7 @@ import {
import { GenericDataType } from '@apache-superset/core/common';
import { LatestQueryEditorVersion } from 'src/SqlLab/types';
import { ISaveableDatasource } from 'src/SqlLab/components/SaveDatasetModal';
import { SubjectType } from 'src/types/Subject';
export const mockedActions = Object.fromEntries(
Object.keys(actions).map(key => [key, jest.fn()]),
@@ -786,5 +787,5 @@ export const mockdatasets = Array.from({ length: 3 }, (_, i) => ({
catalog: null,
schema: `schema ${i}`,
table_name: `coolest table ${i}`,
owners: [{ username: 'admin', userId: 1 }],
editors: [{ id: i + 1, label: `Editor ${i + 1}`, type: SubjectType.User }],
}));

View File

@@ -27,6 +27,7 @@ import type {
DropdownButtonProps,
} from '@superset-ui/core/components';
import type { TableMetaData } from 'src/hooks/apiResources';
import type Subject from 'src/types/Subject';
export type QueryButtonProps = DropdownButtonProps | ButtonProps;
@@ -135,17 +136,10 @@ export const EXPLORE_CHART_DEFAULT = {
row_limit: 1000,
};
export interface DatasetOwner {
first_name: string;
id: number;
last_name: string;
username: string;
}
export interface DatasetOptionAutocomplete {
value: string;
datasetId: number;
owners: [DatasetOwner];
editors: Subject[];
}
export interface SchemaOption {

View File

@@ -55,7 +55,7 @@ const baseConfig = {
};
/**
* Scenario: Dashboard owner configures a time grain filter to show only Hour, Day, Week.
* Scenario: Dashboard editor configures a time grain filter to show only Hour, Day, Week.
* End-user opens the dashboard and can only select from those three options.
*/
test('time grain pre-filter restricts dashboard filter options', async () => {
@@ -106,7 +106,7 @@ test('time grain pre-filter restricts dashboard filter options', async () => {
});
/**
* Scenario: Dashboard owner disables pre-filter (unchecks the CollapsibleControl).
* Scenario: Dashboard editor disables pre-filter (unchecks the CollapsibleControl).
* No restrictions: all time grains appear in the runtime filter.
*/
test('all time grains appear when pre-filter is unchecked', async () => {
@@ -141,7 +141,7 @@ test('all time grains appear when pre-filter is unchecked', async () => {
});
/**
* Scenario: Dashboard owner narrowed the pre-filter after an end-user had
* Scenario: Dashboard editor narrowed the pre-filter after an end-user had
* already selected a value that now falls outside the allowlist.
* The current selection stays visible so the filter does not silently drop it.
*/

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import getOwnerName from 'src/utils/getOwnerName';
import getUserName from 'src/utils/getUserName';
import { t } from '@apache-superset/core/translation';
import { Tooltip } from '@superset-ui/core/components';
import type { AuditInfoProps } from './types';
@@ -29,7 +29,7 @@ export const ModifiedInfo = ({ user, date }: AuditInfoProps) => {
);
if (user) {
const userName = getOwnerName(user);
const userName = getUserName(user);
const title = t('Modified by: %s', userName);
return (
<Tooltip title={title} placement="bottom">
@@ -48,7 +48,7 @@ export const CreatedInfo = ({ user, date }: AuditInfoProps) => {
);
if (user) {
const userName = getOwnerName(user);
const userName = getUserName(user);
const title = t('Created by: %s', userName);
return (
<Tooltip title={title} placement="bottom">

View File

@@ -16,9 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import type Owner from 'src/types/Owner';
import type User from 'src/types/User';
export type AuditInfoProps = {
user?: Owner;
user?: User;
date: string;
};

View File

@@ -19,20 +19,20 @@
import { render, screen } from 'spec/helpers/testing-library';
import '@testing-library/jest-dom';
import { ChartSource } from 'src/types/ChartSource';
import { useChartOwnerNames } from 'src/hooks/apiResources';
import { useChartEditorNames } from 'src/hooks/apiResources';
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
import { ErrorType } from '@superset-ui/core';
import type { ErrorMessageComponentProps } from 'src/components/ErrorMessage/types';
import { getErrorMessageComponentRegistry } from 'src/components/ErrorMessage';
import { ChartErrorMessage } from './ChartErrorMessage';
// Mock the useChartOwnerNames hook
// Mock the useChartEditorNames hook
jest.mock('src/hooks/apiResources', () => ({
useChartOwnerNames: jest.fn(),
useChartEditorNames: jest.fn(),
}));
const mockUseChartOwnerNames = useChartOwnerNames as jest.MockedFunction<
typeof useChartOwnerNames
const mockUseChartEditorNames = useChartEditorNames as jest.MockedFunction<
typeof useChartEditorNames
>;
const ERROR_MESSAGE_COMPONENT = (props: ErrorMessageComponentProps) => (
@@ -51,7 +51,7 @@ describe('ChartErrorMessage', () => {
};
test('renders the default error message when error is null', () => {
mockUseChartOwnerNames.mockReturnValue({
mockUseChartEditorNames.mockReturnValue({
result: null,
status: ResourceStatus.Loading,
error: null,
@@ -84,7 +84,7 @@ describe('ChartErrorMessage', () => {
});
test('chart error banner is not dismissible', () => {
mockUseChartOwnerNames.mockReturnValue({
mockUseChartEditorNames.mockReturnValue({
result: null,
status: ResourceStatus.Loading,
error: null,

View File

@@ -20,7 +20,7 @@
import { t } from '@apache-superset/core/translation';
import { ClientErrorObject, SupersetError } from '@superset-ui/core';
import { FC } from 'react';
import { useChartOwnerNames } from 'src/hooks/apiResources';
import { useChartEditorNames } from 'src/hooks/apiResources';
import { ErrorMessageWithStackTrace } from 'src/components';
import { ChartSource } from 'src/types/ChartSource';
@@ -36,13 +36,13 @@ export type Props = {
const DEFAULT_CHART_ERROR = t('Data error');
export const ChartErrorMessage: FC<Props> = ({ chartId, error, ...props }) => {
// fetches the chart owners and adds them to the extra data of the error message
const { result: owners } = useChartOwnerNames(chartId);
// fetches the chart editors and adds them to the extra data of the error message
const { result: editors } = useChartEditorNames(chartId);
// don't mutate props
const ownedError = error && {
...error,
extra: { ...error.extra, owners },
extra: { ...error.extra, editors },
};
return (

View File

@@ -27,12 +27,7 @@ const dataset = {
created_on_humanized: '01-01-2001',
description: 'desc',
table_name: 'my_dataset',
owners: [
{
first_name: 'Sarah',
last_name: 'Connor',
},
],
editors: [{ id: 1, label: 'Sarah Connor', type: 1 }],
columns: [
{
column_name: 'gender',

View File

@@ -79,12 +79,7 @@ const dataset = {
created_on_humanized: '01-01-2001',
description: 'desc',
table_name: 'my_dataset',
owners: [
{
first_name: 'Sarah',
last_name: 'Connor',
},
],
editors: [{ id: 1, label: 'Sarah Connor', type: 1 }],
columns: [
{
column_name: 'gender',

View File

@@ -68,7 +68,7 @@ const mockDataset = {
changed_on_humanized: '1 day ago',
created_on_humanized: '2 days ago',
description: 'Test dataset',
owners: [],
editors: [{ id: 1, label: 'Test User', type: 1 }],
changed_by: { first_name: 'Test', last_name: 'User' },
created_by: { first_name: 'Test', last_name: 'User' },
};

View File

@@ -66,12 +66,7 @@ const MOCKED_DATASET = {
first_name: 'John',
last_name: 'Doe',
},
owners: [
{
first_name: 'John',
last_name: 'Doe',
},
],
editors: [{ id: 1, label: 'John Doe', type: 1 }],
};
const setupDatasetEndpoint = () => {

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import { Column, Metric } from '@superset-ui/core';
import type Subject from 'src/types/Subject';
export enum DrillByType {
Chart,
@@ -36,11 +37,8 @@ export type Dataset = {
created_on_humanized?: string;
description?: string;
table_name?: string;
owners?: {
first_name: string;
last_name: string;
}[];
columns?: Column[];
editors?: Subject[];
drillable_columns?: Column[];
metrics?: Metric[];
verbose_map?: Record<string, string>;

View File

@@ -42,7 +42,7 @@ import {
ServerPagination,
SortByType,
} from '@superset-ui/core/components/TableView/types';
import { FacePile } from 'src/components';
import { SubjectPile } from 'src/features/subjects/SubjectPile';
import { useListViewResource } from 'src/views/CRUD/hooks';
import Dataset from 'src/types/Dataset';
import { useDebouncedEffect } from 'src/explore/exploreUtils';
@@ -234,11 +234,11 @@ const ChangeDatasourceModal: FunctionComponent<ChangeDatasourceModalProps> = ({
{
Cell: ({
row: {
original: { owners = [] },
original: { editors = [] },
},
}: any) => <FacePile users={owners} />,
Header: t('Owners'),
id: 'owners',
}: any) => <SubjectPile subjects={editors} />,
Header: t('Editors'),
id: 'editors',
disableSortBy: true,
},
];

View File

@@ -125,6 +125,14 @@ describe('DatasourceModal', () => {
await waitFor(() => {
expect(onDatasourceSave).toHaveBeenCalled();
});
const putCall = fetchMock.callHistory
.calls()
.find(
call =>
call.url.includes('/api/v1/dataset/7') &&
call.options?.method === 'put',
);
expect(JSON.parse(putCall?.options?.body as string).editors).toEqual([1]);
});
test('should render error dialog', async () => {

View File

@@ -39,6 +39,7 @@ import {
import withToasts from 'src/components/MessageToasts/withToasts';
import { ErrorMessageWithStackTrace } from 'src/components';
import type { DatasetObject } from 'src/features/datasets/types';
import { mapSubjectValuesToIds } from 'src/features/subjects/SubjectPicker';
import type { DatasourceModalProps } from '../types';
const DatasourceEditor = AsyncEsmComponent(
@@ -173,9 +174,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
extra: buildExtraJsonObject(column),
}),
),
owners: datasource.owners.map(
(o: Record<string, number>) => o.value || o.id,
),
editors: mapSubjectValuesToIds(datasource.editors || []),
};
// Add folders if DATASET_FOLDERS feature is enabled
if (isFeatureEnabled(FeatureFlag.DatasetFolders) && datasource.folders) {
@@ -208,7 +207,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
json.result.type = 'table';
onDatasourceSave({
...json.result,
owners: currentDatasource.owners,
editors: currentDatasource.editors,
});
onHide();
} catch (response) {

View File

@@ -20,14 +20,13 @@ import { sanitizeUrl } from '@braintree/sanitize-url';
import rison from 'rison';
import {
useCallback,
ReactNode,
type ReactNode,
useState,
useEffect,
useRef,
useMemo,
} from 'react';
import { connect, ConnectedProps } from 'react-redux';
import type { JsonObject } from '@superset-ui/core';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import { Radio } from '@superset-ui/core/components/Radio';
@@ -56,7 +55,6 @@ import TextAreaControl from 'src/explore/components/controls/TextAreaControl';
import withToasts from 'src/components/MessageToasts/withToasts';
import CurrencyControl from 'src/explore/components/controls/CurrencyControl';
import {
AsyncSelect,
Badge,
Button,
Card,
@@ -85,12 +83,11 @@ import {
import Mousetrap from 'mousetrap';
import { clearDatasetCache } from 'src/utils/cachedSupersetGet';
import { makeUrl, openInNewTab } from 'src/utils/navigationUtils';
import {
OwnerSelectLabel,
OWNER_TEXT_LABEL_PROP,
OWNER_EMAIL_PROP,
OWNER_OPTION_FILTER_PROPS,
} from 'src/features/owners/OwnerSelectLabel';
import Subject from 'src/types/Subject';
import SubjectPicker, {
normalizeSubjectsToPickerValues,
type SubjectPickerValue,
} from 'src/features/subjects/SubjectPicker';
import { DatabaseSelector } from '../../../DatabaseSelector';
import SpatialControl from 'src/explore/components/controls/SpatialControl';
import CollectionTable from '../CollectionTable';
@@ -116,16 +113,6 @@ const extensionsRegistry = getExtensionsRegistry();
// Type definitions
interface Owner {
id?: number;
value?: number;
label?: ReactNode;
first_name?: string;
last_name?: string;
email?: string;
[key: string]: unknown;
}
interface Currency {
symbol?: string;
symbolPosition?: string;
@@ -189,7 +176,7 @@ interface DatasourceObject {
sql?: string;
columns: Column[];
metrics?: Metric[];
owners: Owner[];
editors: SubjectPickerValue[];
main_dttm_col?: string;
currency_code_column?: string;
filter_select_enabled?: boolean;
@@ -258,7 +245,7 @@ interface ChartUsageData {
certified_by?: string;
certification_details?: string;
description?: string;
owners?: Owner[];
editors?: Subject[];
changed_on_delta_humanized?: string;
changed_on?: string;
changed_by?: {
@@ -312,9 +299,9 @@ interface FormContainerProps {
children: ReactNode;
}
interface OwnersSelectorProps {
interface EditorsSelectorProps {
datasource: DatasourceObject;
onChange: (owners: Owner[]) => void;
onChange: (editors: SubjectPickerValue[]) => void;
}
const DatasourceContainer = styled.div`
@@ -778,48 +765,18 @@ function StackedField({ label, formElement }: StackedFieldProps): JSX.Element {
);
}
function OwnersSelector({
function EditorsSelector({
datasource,
onChange,
}: OwnersSelectorProps): JSX.Element {
const loadOptions = useCallback(
(
search = '',
page: number,
pageSize: number,
): Promise<{ data: Owner[]; totalCount: number }> => {
const query = rison.encode({ filter: search, page, page_size: pageSize });
return SupersetClient.get({
endpoint: `/api/v1/dataset/related/owners?q=${query}`,
}).then(response => ({
data: (response.json.result as Array<JsonObject>)
.filter(item => item.extra.active)
.map(item => ({
value: item.value as number,
label: OwnerSelectLabel({
name: item.text as string,
email: item.extra?.email as string | undefined,
}),
[OWNER_TEXT_LABEL_PROP]: item.text as string,
[OWNER_EMAIL_PROP]: (item.extra?.email as string) ?? '',
})),
totalCount: response.json.count,
}));
},
[],
);
}: EditorsSelectorProps): JSX.Element {
return (
<AsyncSelect
ariaLabel={t('Select owners')}
mode="multiple"
name="owners"
value={datasource.owners as { value: number; label: string }[]}
options={loadOptions}
onChange={value => onChange(value as Owner[])}
header={<FormLabel>{t('Owners')}</FormLabel>}
<SubjectPicker
relatedUrl="/api/v1/dataset/related/editors"
ariaLabel={t('Select editors')}
value={datasource.editors}
onChange={value => onChange(value)}
header={<FormLabel>{t('Editors')}</FormLabel>}
allowClear
optionFilterProps={OWNER_OPTION_FILTER_PROPS}
/>
);
}
@@ -895,21 +852,10 @@ function DatasourceEditor({
fetchUsageData: null,
});
// Initialize datasource state with transformed owners and metrics
// Initialize datasource state with transformed editors and metrics
const [datasource, setDatasource] = useState<DatasourceObject>(() => ({
...propsDatasource,
owners: propsDatasource.owners.map(owner => {
const ownerName = owner.label || `${owner.first_name} ${owner.last_name}`;
return {
value: owner.value || owner.id,
label: OwnerSelectLabel({
name: typeof ownerName === 'string' ? ownerName : '',
email: owner.email,
}),
[OWNER_TEXT_LABEL_PROP]: typeof ownerName === 'string' ? ownerName : '',
[OWNER_EMAIL_PROP]: owner.email ?? '',
};
}),
editors: normalizeSubjectsToPickerValues(propsDatasource.editors || []),
metrics: propsDatasource.metrics?.map(metric => {
const {
certified_by: certifiedByMetric,
@@ -1333,9 +1279,9 @@ function DatasourceEditor({
'certified_by',
'certification_details',
'description',
'owners.first_name',
'owners.last_name',
'owners.id',
'editors.id',
'editors.label',
'editors.type',
'changed_on_delta_humanized',
'changed_on',
'changed_by.first_name',
@@ -1740,10 +1686,10 @@ function DatasourceEditor({
}
/>
)}
<OwnersSelector
<EditorsSelector
datasource={datasource}
onChange={newOwners => {
onDatasourceChange({ ...datasource, owners: newOwners });
onChange={newEditors => {
onDatasourceChange({ ...datasource, editors: newEditors });
}}
/>
</Fieldset>

View File

@@ -24,6 +24,7 @@ import {
} from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import { SubjectType } from 'src/types/Subject';
import DatasetUsageTab from '.';
const mockChartsResponse = {
@@ -35,11 +36,11 @@ const mockChartsResponse = {
certified_by: 'John Doe',
certification_details: 'Certified for production use',
description: 'A test chart',
owners: [
editors: [
{
first_name: 'Jane',
last_name: 'Smith',
id: 1,
label: 'Jane Smith',
type: SubjectType.User,
},
],
changed_on_delta_humanized: '2 days ago',
@@ -62,11 +63,11 @@ const mockChartsResponse = {
id: 2,
slice_name: 'Test Chart 2',
url: '/explore/2/',
owners: [
editors: [
{
first_name: 'Bob',
last_name: 'Johnson',
id: 2,
label: 'Bob Johnson',
type: SubjectType.User,
},
],
changed_on_delta_humanized: '1 day ago',
@@ -190,8 +191,8 @@ test('renders correct column headers', async () => {
const chartHeader = screen
.getAllByText('Chart')
.find(el => el.closest('th'));
const ownersHeader = screen
.getAllByText('Chart owners')
const editorsHeader = screen
.getAllByText('Chart editors')
.find(el => el.closest('th'));
const lastModifiedHeader = screen
.getAllByText('Last modified')
@@ -201,7 +202,7 @@ test('renders correct column headers', async () => {
.find(el => el.closest('th'));
expect(chartHeader).toBeInTheDocument();
expect(ownersHeader).toBeInTheDocument();
expect(editorsHeader).toBeInTheDocument();
expect(lastModifiedHeader).toBeInTheDocument();
expect(dashboardHeader).toBeInTheDocument();
});
@@ -239,8 +240,8 @@ test('enables sorting for Chart and Last modified columns', async () => {
.find(el => el.closest('th'))
?.closest('th');
const ownersHeader = screen
.getAllByText('Chart owners')
const editorsHeader = screen
.getAllByText('Chart editors')
.find(el => el.closest('th'))
?.closest('th');
@@ -251,7 +252,7 @@ test('enables sorting for Chart and Last modified columns', async () => {
expect(chartHeader).toHaveClass('ant-table-column-has-sorters');
expect(lastModifiedHeader).toHaveClass('ant-table-column-has-sorters');
expect(ownersHeader).not.toHaveClass('ant-table-column-has-sorters');
expect(editorsHeader).not.toHaveClass('ant-table-column-has-sorters');
expect(dashboardHeader).not.toHaveClass('ant-table-column-has-sorters');
});
});
@@ -557,7 +558,7 @@ test('can search charts by chart name', async () => {
});
const searchInput = screen.getByPlaceholderText(
'Search charts by name, owner, or dashboard',
'Search charts by name, editor, or dashboard',
);
expect(searchInput).toBeInTheDocument();
@@ -576,7 +577,7 @@ test('can search charts by chart name', async () => {
});
});
test('can search charts by owner name', async () => {
test('can search charts by editor name', async () => {
setupTest();
await waitFor(() => {
@@ -584,7 +585,7 @@ test('can search charts by owner name', async () => {
});
const searchInput = screen.getByPlaceholderText(
'Search charts by name, owner, or dashboard',
'Search charts by name, editor, or dashboard',
);
await userEvent.type(searchInput, 'Bob');
@@ -603,7 +604,7 @@ test('can search charts by dashboard title', async () => {
});
const searchInput = screen.getByPlaceholderText(
'Search charts by name, owner, or dashboard',
'Search charts by name, editor, or dashboard',
);
await userEvent.type(searchInput, 'Test Dashboard');
@@ -622,7 +623,7 @@ test('chart search is case-insensitive', async () => {
});
const searchInput = screen.getByPlaceholderText(
'Search charts by name, owner, or dashboard',
'Search charts by name, editor, or dashboard',
);
await userEvent.type(searchInput, 'CHART 1');
@@ -641,7 +642,7 @@ test('shows No items when search has no results', async () => {
});
const searchInput = screen.getByPlaceholderText(
'Search charts by name, owner, or dashboard',
'Search charts by name, editor, or dashboard',
);
await userEvent.type(searchInput, 'nonexistent chart');
@@ -665,7 +666,7 @@ test('hides pagination when searching and restores it when cleared', async () =>
expect(screen.getByTitle('1')).toBeInTheDocument();
const searchInput = screen.getByPlaceholderText(
'Search charts by name, owner, or dashboard',
'Search charts by name, editor, or dashboard',
);
await userEvent.type(searchInput, 'Chart 1');

View File

@@ -30,7 +30,9 @@ import Table, {
type TablePaginationConfig,
type OnChangeFunction,
} from '@superset-ui/core/components/Table';
import { FacePile, ModifiedInfo, GenericLink } from 'src/components';
import { ModifiedInfo, GenericLink } from 'src/components';
import { SubjectPile } from 'src/features/subjects/SubjectPile';
import type Subject from 'src/types/Subject';
import { DashboardCrossLinks } from 'src/components/ListView/DashboardCrossLinks';
const FlexRowContainer = styled.div`
@@ -59,11 +61,7 @@ interface Chart {
certified_by?: string;
certification_details?: string;
description?: string;
owners: Array<{
first_name: string;
last_name: string;
id: number;
}>;
editors?: Subject[];
changed_on_delta_humanized: string;
changed_on?: string;
changed_by: {
@@ -244,11 +242,11 @@ const DatasetUsageTab = ({
width: 300,
},
{
title: t('Chart owners'),
dataIndex: 'owners',
key: 'owners',
title: t('Chart editors'),
dataIndex: 'editors',
key: 'editors',
render: (_: unknown, record: Chart) => (
<FacePile users={record.owners} maxCount={3} />
<SubjectPile subjects={record.editors || []} maxCount={3} />
),
sorter: false,
width: 150,
@@ -302,12 +300,10 @@ const DatasetUsageTab = ({
// Search in chart name
if (chart.slice_name?.toLowerCase().includes(lowerSearch)) return true;
// Search in owner names
// Search in editor names
if (
chart.owners?.some(
owner =>
owner.first_name?.toLowerCase().includes(lowerSearch) ||
owner.last_name?.toLowerCase().includes(lowerSearch),
chart.editors?.some(editor =>
editor.label?.toLowerCase().includes(lowerSearch),
)
)
return true;
@@ -327,7 +323,7 @@ const DatasetUsageTab = ({
return (
<div ref={tableContainerRef}>
<Input.Search
placeholder={t('Search charts by name, owner, or dashboard')}
placeholder={t('Search charts by name, editor, or dashboard')}
value={searchTerm}
onChange={e => {
setSearchTerm(e.target.value);

View File

@@ -119,7 +119,7 @@ export const setupDatasourceEditorMocks = () => {
count: 0,
ids: [],
});
fetchMock.get(call => call.url.includes('/api/v1/dataset/related/owners'), {
fetchMock.get(call => call.url.includes('/api/v1/dataset/related/editors'), {
result: [],
count: 0,
});

View File

@@ -44,7 +44,7 @@ const mockedProps = {
message: 'Issue code message B',
},
],
owners: ['Owner A', 'Owner B'],
editors: ['Owner A', 'Owner B'],
},
level: 'error' as ErrorLevel,
message: 'Error message',
@@ -118,19 +118,19 @@ test('should render the engine name', () => {
expect(screen.getByText(/Engine name/)).toBeInTheDocument();
});
test('should render the owners', () => {
test('should render the editors', () => {
render(<DatabaseErrorMessage {...mockedProps} />, { useRedux: true });
const button = screen.getByText('See more');
userEvent.click(button);
expect(
screen.getByText('Please reach out to the Chart Owners for assistance.'),
screen.getByText('Please reach out to the Chart Editors for assistance.'),
).toBeInTheDocument();
expect(
screen.getByText('Chart Owners: Owner A, Owner B'),
screen.getByText('Chart Editors: Owner A, Owner B'),
).toBeInTheDocument();
});
test('should NOT render the owners', () => {
test('should NOT render the editors', () => {
const noVisualizationProps = {
...mockedProps,
source: 'sqllab' as ErrorSource,
@@ -141,7 +141,7 @@ test('should NOT render the owners', () => {
const button = screen.getByText('See more');
userEvent.click(button);
expect(
screen.queryByText('Chart Owners: Owner A, Owner B'),
screen.queryByText('Chart Editors: Owner A, Owner B'),
).not.toBeInTheDocument();
});

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