Compare commits

..

21 Commits

Author SHA1 Message Date
Michael S. Molina
c85661f4fd feat: Chat prototype 2026-05-15 15:15:42 -03:00
Michael S. Molina
a06e6ea19b fix(extensions): add cache headers and strip Vary: Cookie for extension static assets (#40120)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 09:23:39 -03:00
Shaitan
ee9eec25f9 fix(dataset): validate datasource access during import (#39998)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 12:06:47 +01:00
Shaitan
ffa32414ef fix(query): restrict query cancellation to the query owner (#39996)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 12:05:38 +01:00
Shaitan
407321e394 fix(database): extend shillelagh URI pattern to cover all driver variants (#39995)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 12:04:34 +01:00
JUST.in DO IT
2b71d964cc fix(sqllab): missing estimate action button (#40101) 2026-05-14 14:43:07 -07:00
dependabot[bot]
f02e5b7e83 chore(deps-dev): bump babel-jest from 30.3.0 to 30.4.1 in /superset-frontend (#40090)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-14 13:52:53 -07:00
dependabot[bot]
5fa9657528 chore(deps): update @ant-design/icons requirement from ^6.2.2 to ^6.2.3 in /superset-frontend/packages/superset-ui-core (#40092)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: sadpandajoe <jcli38@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 13:52:37 -07:00
dependabot[bot]
d853930840 chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#40107)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-14 13:52:14 -07:00
Evan Rusackas
4e09889607 test(datasets): regression coverage for #16141 (export with same table name, different schemas) (#40123)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:08:23 -07:00
Evan Rusackas
672e9a1477 fix(docs): tighten onBrokenLinks to throw and fix surfaced broken links (#40102)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-14 11:07:18 -07:00
Richard Fogaca Nienkotter
8fa5a75c70 fix(mcp): apply cached adhoc filters to chart retrieval (#40099) 2026-05-14 14:21:54 -03:00
Mafi
144dae7c43 fix(dashboard): use datasetUuid instead of datasetId in display controls export/import (SC-104655) (#40008)
Co-authored-by: Matt Fitzgerald <matt.fitzgerald@preset.io>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:18:57 -07:00
Arpit Jain
62dc237014 chore(ci): add explicit permissions to additional workflows (#40067) 2026-05-14 23:24:46 +07:00
Sandesh Devaraju
823eb905d3 fix(mcp): JSON-serialize order_by_cols and support sort direction (#39952)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-05-14 11:19:37 -04:00
Alexandru Soare
966e97989b chore(mcp): Standardize error response shapes across chart tools (#39905) 2026-05-14 18:07:31 +03:00
Mehmet Salih Yavuz
8b0e63b58c fix(rls): prevent double-apply when converting physical dataset to virtual (#39725)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:05:48 +03:00
dependabot[bot]
64dae07675 chore(deps): bump markdown-to-jsx from 9.7.16 to 9.8.0 in /superset-frontend (#40111)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-14 21:39:48 +07:00
Evan Rusackas
e56883baef fix(ci): handle schedule event in change_detector and actually trigger all-changed (#40105)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-14 21:39:07 +07:00
Mehmet Salih Yavuz
a62bf2b0bb fix: chart rendering race condition and homepage connection reset (#40065)
Co-authored-by: Geidō <60598000+geido@users.noreply.github.com>
2026-05-14 17:10:11 +03:00
Mafi
01224007da fix(mixed-timeseries): preserve all-NaN metric columns after pivot when Jinja evaluates to NULL (#40005)
Co-authored-by: Matt Fitzgerald <matt.fitzgerald@preset.io>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 07:46:34 -03:00
131 changed files with 4134 additions and 2739 deletions

View File

@@ -8,6 +8,10 @@ on:
pull_request:
types: [synchronize, opened, reopened, ready_for_review]
permissions:
contents: read
pull-requests: read
# cancel previous workflow jobs for PRs
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}

View File

@@ -9,6 +9,10 @@ on:
branches:
- "master"
permissions:
contents: read
pull-requests: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true

View File

@@ -6,6 +6,9 @@ on:
- "master"
- "[0-9].[0-9]*"
permissions:
contents: read
jobs:
config:
runs-on: ubuntu-24.04

View File

@@ -6,6 +6,9 @@ on:
- "superset-embedded-sdk/**"
types: [synchronize, opened, reopened, ready_for_review]
permissions:
contents: read
# cancel previous workflow jobs for PRs
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}

View File

@@ -6,6 +6,9 @@ on:
- "master"
- "[0-9].[0-9]*"
permissions:
contents: read
jobs:
config:
runs-on: ubuntu-24.04

View File

@@ -8,6 +8,9 @@ on:
pull_request:
types: [synchronize, opened, reopened, ready_for_review]
permissions:
contents: read
jobs:
validate-all-ghas:

View File

@@ -4,6 +4,9 @@ on:
pull_request:
types: [synchronize, opened, reopened, ready_for_review]
permissions:
contents: read
# cancel previous workflow jobs for PRs
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}

View File

@@ -8,6 +8,10 @@ on:
pull_request:
types: [synchronize, opened, reopened, ready_for_review]
permissions:
contents: read
pull-requests: read
# cancel previous workflow jobs for PRs
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}

View File

@@ -78,6 +78,13 @@ jobs:
- name: yarn install
run: |
yarn install --check-cache
- name: Lint docs links
# Fast source-level check for bare relative internal links
# like `[Foo](../foo)` that Docusaurus's onBrokenLinks
# setting can't catch. Runs in seconds; fails fast before
# the expensive build step.
run: |
yarn lint:docs-links
- name: yarn typecheck
run: |
yarn typecheck

View File

@@ -8,6 +8,10 @@ on:
pull_request:
types: [synchronize, opened, reopened, ready_for_review]
permissions:
contents: read
pull-requests: read
# cancel previous workflow jobs for PRs
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}

View File

@@ -6,6 +6,9 @@ on:
paths:
- "helm/**"
permissions:
contents: read
# cancel previous workflow jobs for PRs
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}

View File

@@ -8,6 +8,10 @@ on:
pull_request:
types: [synchronize, opened, reopened, ready_for_review]
permissions:
contents: read
pull-requests: read
# cancel previous workflow jobs for PRs
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}

View File

@@ -11,6 +11,9 @@ on:
- "superset-websocket/**"
types: [synchronize, opened, reopened, ready_for_review]
permissions:
contents: read
# cancel previous workflow jobs for PRs
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}

298
SIP.md
View File

@@ -1,298 +0,0 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# SIP: Granular (Component-Level) Theming for Dashboard Components
**Status:** Draft — living doc, kept in lockstep with the work on `feat/granular-theming-v2`.
**Champion:** @rusackas
**Supersedes:** Closed PR [#36749](https://github.com/apache/superset/pull/36749) (became unrebasable after the .jsx → .tsx conversion, React 18 upgrade, and theme-controller churn since 2025-12).
## Motivation
Superset already supports themes at two levels:
- **Instance** — the global default theme configured by the deployment.
- **Dashboard** — a per-dashboard override (managed via `ThemeController.dashboardCrudTheme` + `createDashboardThemeProvider`).
Users have repeatedly asked to override theme tokens at a finer granularity — for example to make a single chart match a brand color in a sales dashboard, to highlight a tab with a different palette, or to give a Markdown callout a distinct background. Today the only options are to (a) override the entire dashboard or (b) inject custom CSS.
This SIP proposes a **third level**: per-component theme overrides on dashboard grid components (Charts, Markdown, Row, Column, Tabs), with an inheritance chain:
```
Instance Theme (deployment default)
└── Dashboard Theme (existing per-dashboard override)
└── Tab Theme ┐
└── Row/Col Theme │ (new, per-component)
└── Chart/Markdown Theme
```
Each level can override any subset of theme tokens; unspecified tokens are inherited from the parent.
## Non-goals
- **Custom CSS replacement.** This isn't trying to subsume CSS injection — only theme-token-level overrides (colors, font sizes, spacing, etc.).
- **New theme authoring UI.** Users pick from existing themes (the CRUD `theme` resource); creating themes still happens in the existing Themes section.
- **Backend schema changes.** All persistence lives on existing fields (`position_json` per-component `meta`, see Storage below).
- **Cross-dashboard reuse of component theme assignments.** A theme can be reused, but an *assignment* of a theme to a specific component lives with that component.
## Storage
Dashboard layout items are stored in `position_json` and surfaced in Redux as `LayoutItem`s with a `meta: LayoutItemMeta` field already typed as open-ended:
```ts
export type LayoutItemMeta = {
// ...known fields...
[key: string]: unknown;
};
```
We add an optional `themeId?: number | null` to `LayoutItemMeta`. No new tables, no migrations, no dashboard `json_metadata` changes.
A `themeId === null` means "explicitly no override — inherit from parent." A missing key means the same thing semantically; we treat them identically when reading.
Round-trip:
- **Read**: `LayoutItem.meta.themeId` is parsed straight from `position_json` like any other meta property.
- **Write**: dashboard save serializes the entire `position_json` already; storing `themeId` is free.
- **Backwards compatibility**: pre-feature dashboards have no `themeId` keys, so they fall through to the dashboard/instance theme as today.
## Architecture
### `ComponentThemeProvider`
A wrapper component placed inside each grid component, between the dashboard's existing `ThemeProvider` and the component's body:
```tsx
<ComponentThemeProvider layoutId={id}>
{/* component body */}
</ComponentThemeProvider>
```
Responsibilities:
1. Read `themeId` from the layout item via Redux selector.
2. Walk up the layout tree (`parents`) to compute the effective theme — first non-null `themeId` wins; fall back to the dashboard/instance theme.
3. Call `ThemeController.createDashboardThemeProvider(themeId)` (same code path used for dashboard CRUD themes — themes are themes, regardless of which scope picked them).
4. Wrap children in `AntdThemeProvider` with the resolved theme.
Caching: `ThemeController` already memoizes themes by id (`dashboardThemes: Map<string, Theme>`). We reuse that — same theme assigned to 100 charts costs one fetch.
### `ComponentHeaderControls` (Phase 2, lands first)
A shared vertical-dots menu for grid components. Each grid component type
plugs in its own list of menu items via a `useComponentMenuItems` hook;
the visual chrome (the dots icon button, the dropdown surface, the
edit-mode visibility gating) lives in `ComponentHeaderControls` itself.
Per-component menu surfaces (informational — the actual conversions of
the existing patterns happen as part of Phase 4 for each component, so
we don't change user-visible UX in Phase 2):
| Component | Current pattern | Converges to |
|---|---|---|
| Markdown | `MarkdownModeDropdown` (Edit/Preview popover) | dots menu w/ Edit + Preview items |
| Row / Col | Gear icon → `WithPopoverMenu` with `BackgroundStyleDropdown` | dots menu w/ Background item |
| Chart | `SliceHeaderControls` (already a dots menu — wraps `MenuDotsDropdown`) | reuses the same shared component |
| Tabs | none | dots menu (new affordance) |
Phase 2 itself only **builds the component** and converts **Markdown** as
the PoC. The other components remain on their existing patterns until
the per-component Phase-4 PRs wire them up together with their theme
provider — that lets reviewers evaluate the menu unification + theming
together rather than as separate churn passes.
### `ThemeSelectorModal` (Phase 3)
Modal triggered from "Apply theme" in `ComponentHeaderControls`. Shows:
- A theme picker populated from the CRUD `/api/v1/theme/` endpoint (same source the dashboard-level picker uses).
- A "Clear override (inherit)" button when `themeId` is already set.
- **Live preview**: as the user picks options the targeted component
re-renders with the candidate theme tokens immediately, *without*
marking the dashboard dirty. Cancel reverts; Apply commits to Redux.
Implemented via a tiny module-level subscribable `previewThemeStore`
+ `useSyncExternalStore` in `useEffectiveThemeId` (preview wins over
the Redux-resolved id when present).
On save it dispatches a Redux action that updates the component's `meta.themeId` and marks the dashboard dirty.
## Phases
| Phase | Scope | PR target |
|---|---|---|
| **1** | Storage shape (`LayoutItemMeta.themeId`) + `ComponentThemeProvider` skeleton wired to one component (Chart) for proof of concept. No UI. | One PR |
| **2** | Build `ComponentHeaderControls` (shared dots menu) + tests. **Component creation only** — per-component conversions of the existing menu patterns happen in Phase 4 alongside theme wiring, so reviewers can evaluate the menu unification + theming together rather than as separate churn passes. | One PR |
| **3** | `ThemeSelectorModal` + persistence + "Apply theme" menu item. End-to-end demo on Chart. | One PR |
| **4** | Per-component PRs (Markdown / Row / Column / Tabs): swap their existing menu pattern for `ComponentHeaderControls`, wire `ComponentThemeProvider` around the body, add the "Apply theme" item. One PR per component so each menu/UX change can be reviewed in isolation. | ~4 small PRs |
Each phase is independently revertable. Phase 2 has standalone value.
## Open questions / shortcomings
These get refined as the work progresses; do not merge any phase without revisiting this section.
- [ ] **Theme resolution caching at the component level.** `ThemeController` caches themes by id, but `ComponentThemeProvider` walks the parents tree every render to find the effective `themeId`. Need to confirm the walk is cheap enough at typical dashboard sizes (~50 components), or memoize via Redux reselect.
- [ ] **Export / screenshot behavior.** The screenshot service (Playwright / WebDriver) reads the same DOM, so theme overrides should "just work" — but we need a screenshot regression test.
- [ ] **Embedded SDK.** Embedded dashboards default to light mode (#38644). Need to confirm component-level themes still apply in embedded context, since embedded skips `ThemeController.setCrudTheme`.
- [ ] **Theme deletion** — what happens if a `themeId` references a theme that's been deleted from the CRUD store? Likely fall back silently to parent; need a `useEffect` cleanup path.
- [ ] **Permission model.** Should `theme_write` be required to assign a theme to a component? Currently any dashboard editor can do it. Probably fine, but worth confirming with @michael-s-molina.
- [ ] **i18n / a11y of the modal.** Standard checklist — needs labels, focus management, keyboard.
- [ ] **Mobile**`ComponentHeaderControls` hover-to-reveal pattern needs a tap-equivalent.
## Test plan
Each phase brings its own tests; the cumulative bar:
### Unit
- `ComponentThemeProvider`: resolves theme from own `themeId`; resolves from parent when own is null; falls back to dashboard/instance when no ancestor sets one; reacts to Redux meta changes.
- `useComponentThemeId` (selector / hook the provider uses): correctness on the parents-walk.
- `ComponentHeaderControls`: shows correct menu items per component type; routes `onClick` for each.
- `ThemeSelectorModal`: opens populated with available themes; "save" dispatches the right action; "clear override" sets `themeId: null`.
### Integration (RTL)
- Dashboard with one chart → assign a theme → chart re-renders with new tokens.
- Same dashboard → assign theme to the Row containing the chart → chart inherits Row theme (no own override).
- Set chart override + row override → chart wins (most-specific).
- Clear chart override → chart inherits row.
- Reload dashboard (re-render from `position_json`) → state preserved.
### E2E (Playwright)
- One scenario per component type: open dashboard → menu → apply theme → save → reload → verify.
- Permission scenario: editor can apply, viewer cannot see the menu.
### Manual / screenshot
- Light theme dashboard with one dark-themed chart and one default-themed chart — visual diff in CI.
- Embedded dashboard with a component theme — verify no host-CSS bleed.
## Out-of-scope (potential follow-ups)
- **Theme presets** — "apply this theme to all charts of viz type X" via a dashboard-level rule.
- **Theme inheritance debugger** — devtools view that shows which level set each token for a hovered component.
- **Bulk operations** — multi-select components and apply a theme to all.
## Implementation log
- _(Phase 1)_ — ✅ landed locally. `LayoutItemMeta.themeId`,
`ComponentThemeProvider` + `useEffectiveThemeId` hook, wired into
`ChartHolder`. 8 passing unit tests. No UI yet — `themeId` has to be
set via Redux devtools or position_json hand-edit to verify visually.
- _(Phase 2)_ — ✅ landed locally. `ComponentHeaderControls` shared dots
menu + 4 passing unit tests. Generic `items: ComponentMenuItem[]` API
so each grid component can plug in its own list (Edit/Preview for
Markdown, Background for Row/Col, Apply Theme/Delete for Chart, etc.).
Built on the existing `MenuDotsDropdown` so the trigger styling
matches Chart's `SliceHeaderControls` today (Phase 4 will converge
`SliceHeaderControls` onto this).
**Deferred to Phase 4**: actually swapping the existing per-component
menu UI (Markdown's `MarkdownModeDropdown` PopoverDropdown, Row/Col's
gear-icon-into-`WithPopoverMenu`, Tabs' nothing) for this component.
Those conversions are user-visible UX changes (e.g. Markdown loses
its toggle-style Edit/Preview switcher and gains a dots menu), so we
do them per-component alongside the theme wiring so each can be
reviewed in isolation.
- _(Phase 3)_ — ✅ landed locally. `ThemeSelectorModal` (fetches non-system
themes via the same `/api/v1/theme/?q=...` query that the dashboard
Properties modal uses; preselects the currently-resolved override;
"Apply" / "Cancel" / "Clear override (inherit)" buttons) and the
thin `setComponentThemeId(componentId, themeId | null)` action that
merges into `meta.themeId` via the existing `updateComponents` thunk.
No call site for the modal yet — Phase 4's per-component PRs add the
"Apply theme" item to each component's menu that opens this modal.
The modal is parent-controlled (`show`/`onHide`), parent-owned, so
there's no wiring needed beyond `<ThemeSelectorModal layoutId={id}
show={open} onHide={...} />` in each call site.
3 passing tests on `setComponentThemeId`: preserves other meta keys
+ sets numeric `themeId`; stores explicit `null` for the clear path;
no-op when the component id isn't in the layout.
- _(Phase 4)_ — ✅ landed locally for all five grid-component types.
Same three-step recipe applied to each:
(a) wrap body in `<ComponentThemeProvider layoutId={id}>`,
(b) add "Apply theme" item to the component's menu via
`ComponentHeaderControls`,
(c) mount `<ThemeSelectorModal>` gated on `editMode`.
- **Chart (4a)**: `SliceHeaderControls` gets the menu item; the
provider was already wrapping `ChartHolder` from Phase 1.
- **Tabs (4b)**: `TabsRenderer` wraps `<StyledTabsContainer>` in the
provider; adds the dots-menu trigger inside the existing left
`HoverMenu` next to the drag handle and delete button.
- **Row (4c)**: wraps the `<WithPopoverMenu>` body; adds the
dots-menu trigger to the left `HoverMenu` next to drag/delete/
setting-icon. The existing gear icon (which opens the
BackgroundStyleDropdown focus popover) is preserved as-is.
- **Column (4d)**: same recipe as Row, wrapping its
`<WithPopoverMenu>` body and adding the dots menu to the top
`HoverMenu` next to drag/delete/setting-icon.
- **Markdown (4e)**: class component, so theme-modal state goes
through `this.state.themeModalOpen`. Adds a second
`ComponentHeaderControls` to the existing `<WithPopoverMenu
menuItems>` array next to the `MarkdownModeDropdown`
(Edit/Preview toggle is preserved as-is — the full menu-pattern
convergence onto a single dots menu is intentionally deferred so
Markdown's Edit/Preview UX is not changed in this phase).
Functional outcome: every grid-component type now supports the full
Instance → Dashboard → Tab → Row/Col → Chart/Markdown inheritance
chain end-to-end. Setting a `themeId` at any level applies to that
subtree; clearing it falls through to the parent.
Note on the broader menu-pattern unification: the SIP originally
imagined Phase 4 PRs would also converge `MarkdownModeDropdown`
(Edit/Preview popover) and the Row/Column gear icon into the shared
dots menu. We deferred those user-visible UX displacements so each
Phase-4 PR adds the theming affordance *additively* — i.e. the
existing menu controls are untouched, the dots menu sits alongside.
A follow-up SIP (or single sweep PR) can take the menu unification
later without coupling it to the theming work.
### Phase 1 status
- [x] Add optional `themeId` field to `LayoutItemMeta`. (`src/dashboard/types.ts`)
- [x] Build `ComponentThemeProvider``pickEffectiveThemeId` resolver (pure
function, walks `parents` up the layout map until it finds a non-null
`themeId` or hits `DASHBOARD_ROOT_ID`) + `useEffectiveThemeId` Redux hook
+ `ComponentThemeProvider` that lazy-fetches the resolved theme via the
existing `ThemeController.createDashboardThemeProvider` (which caches by
id, so N components referencing the same theme = 1 fetch). Renders as a
pass-through when no ancestor sets a `themeId`.
- [x] Wire into `ChartHolder` — wrapped around the existing
`<AntdThemeProvider>` so per-component theme tokens apply to the chart
body while the existing popup-container `ConfigProvider` continues to
work in fullscreen mode.
- [x] Add unit tests — 8 cases for `pickEffectiveThemeId` covering own-id /
inheritance / null-skip / no-ancestor / root-stop / malformed-parents /
other-meta-keys / missing-id.
- [x] Update SIP with surprises uncovered during wiring (none significant —
the existing `createDashboardThemeProvider` did exactly what we needed,
including caching by id; the only structural decision was treating the
ChartHolder's `<AntdThemeProvider>` as a popup-container shim rather than
a token provider, and nesting our provider outside it).
#### Phase 1 surprises / notes
- `ThemeController.createDashboardThemeProvider` already does Theme.fromConfig
with the right dark/light base + font loading + caching. We did not need
to duplicate any of that logic in the component-level provider.
- The provider is `useState` + `useEffect` rather than `useMemo` because the
fetch is async. That means there's a one-frame flash of the parent theme
before the component theme kicks in. Probably acceptable; if not, we can
Suspense-ify in Phase 4.
- `useEffectiveThemeId` re-runs on every Redux state change because the
selector returns a primitive `number | null` — that's fine for now, but
if dashboards get bigger we may want a memoized selector via reselect
keyed on `(layoutId, layout)` — file in the open questions section.

View File

@@ -29,10 +29,10 @@ sidebar_position: 1
## Components
- [DropdownContainer](./dropdowncontainer)
- [Flex](./flex)
- [Grid](./grid)
- [Layout](./layout)
- [MetadataBar](./metadatabar)
- [Space](./space)
- [Table](./table)
- [DropdownContainer](./dropdowncontainer.mdx)
- [Flex](./flex.mdx)
- [Grid](./grid.mdx)
- [Layout](./layout.mdx)
- [MetadataBar](./metadatabar.mdx)
- [Space](./space.mdx)
- [Table](./table.mdx)

View File

@@ -62,7 +62,7 @@ This documentation is auto-generated from Storybook stories. To add or update co
4. Run `yarn generate:superset-components` in the `docs/` directory
:::info Work in Progress
This component library is actively being documented. See the [Components TODO](./TODO) page for a list of components awaiting documentation.
This component library is actively being documented. See the [Components TODO](./TODO.md) page for a list of components awaiting documentation.
:::
---

View File

@@ -29,49 +29,49 @@ sidebar_position: 1
## Components
- [AutoComplete](./autocomplete)
- [Avatar](./avatar)
- [Badge](./badge)
- [Breadcrumb](./breadcrumb)
- [Button](./button)
- [ButtonGroup](./buttongroup)
- [CachedLabel](./cachedlabel)
- [Card](./card)
- [Checkbox](./checkbox)
- [Collapse](./collapse)
- [DatePicker](./datepicker)
- [Divider](./divider)
- [EditableTitle](./editabletitle)
- [EmptyState](./emptystate)
- [FaveStar](./favestar)
- [IconButton](./iconbutton)
- [Icons](./icons)
- [IconTooltip](./icontooltip)
- [InfoTooltip](./infotooltip)
- [Input](./input)
- [Label](./label)
- [List](./list)
- [ListViewCard](./listviewcard)
- [Loading](./loading)
- [Menu](./menu)
- [Modal](./modal)
- [ModalTrigger](./modaltrigger)
- [Popover](./popover)
- [ProgressBar](./progressbar)
- [Radio](./radio)
- [SafeMarkdown](./safemarkdown)
- [Select](./select)
- [Skeleton](./skeleton)
- [Slider](./slider)
- [Steps](./steps)
- [Switch](./switch)
- [TableCollection](./tablecollection)
- [TableView](./tableview)
- [Tabs](./tabs)
- [Timer](./timer)
- [Tooltip](./tooltip)
- [Tree](./tree)
- [TreeSelect](./treeselect)
- [Typography](./typography)
- [UnsavedChangesModal](./unsavedchangesmodal)
- [Upload](./upload)
- [AutoComplete](./autocomplete.mdx)
- [Avatar](./avatar.mdx)
- [Badge](./badge.mdx)
- [Breadcrumb](./breadcrumb.mdx)
- [Button](./button.mdx)
- [ButtonGroup](./buttongroup.mdx)
- [CachedLabel](./cachedlabel.mdx)
- [Card](./card.mdx)
- [Checkbox](./checkbox.mdx)
- [Collapse](./collapse.mdx)
- [DatePicker](./datepicker.mdx)
- [Divider](./divider.mdx)
- [EditableTitle](./editabletitle.mdx)
- [EmptyState](./emptystate.mdx)
- [FaveStar](./favestar.mdx)
- [IconButton](./iconbutton.mdx)
- [Icons](./icons.mdx)
- [IconTooltip](./icontooltip.mdx)
- [InfoTooltip](./infotooltip.mdx)
- [Input](./input.mdx)
- [Label](./label.mdx)
- [List](./list.mdx)
- [ListViewCard](./listviewcard.mdx)
- [Loading](./loading.mdx)
- [Menu](./menu.mdx)
- [Modal](./modal.mdx)
- [ModalTrigger](./modaltrigger.mdx)
- [Popover](./popover.mdx)
- [ProgressBar](./progressbar.mdx)
- [Radio](./radio.mdx)
- [SafeMarkdown](./safemarkdown.mdx)
- [Select](./select.mdx)
- [Skeleton](./skeleton.mdx)
- [Slider](./slider.mdx)
- [Steps](./steps.mdx)
- [Switch](./switch.mdx)
- [TableCollection](./tablecollection.mdx)
- [TableView](./tableview.mdx)
- [Tabs](./tabs.mdx)
- [Timer](./timer.mdx)
- [Tooltip](./tooltip.mdx)
- [Tree](./tree.mdx)
- [TreeSelect](./treeselect.mdx)
- [Typography](./typography.mdx)
- [UnsavedChangesModal](./unsavedchangesmodal.mdx)
- [Upload](./upload.mdx)

View File

@@ -327,13 +327,13 @@ stats.sort_stats('cumulative').print_stats(10)
## Resources
### Internal
- [Coding Guidelines](../guidelines/design-guidelines)
- [Testing Guide](../testing/overview)
- [Extension Architecture](../extensions/architecture)
- [Coding Guidelines](../guidelines/design-guidelines.md)
- [Testing Guide](../testing/overview.md)
- [Extension Architecture](../extensions/architecture.md)
### External
- [Google's Code Review Guide](https://google.github.io/eng-practices/review/)
- [Best Practices for Code Review](https://smartbear.com/learn/code-review/best-practices-for-peer-code-review/)
- [The Art of Readable Code](https://www.oreilly.com/library/view/the-art-of/9781449318482/)
Next: [Reporting issues effectively](./issue-reporting)
Next: [Reporting issues effectively](./issue-reporting.md)

View File

@@ -668,7 +668,7 @@ A series of checks will now run when you make a git commit.
## Linting
See [how tos](./howtos#linting)
See [how tos](./howtos.md#linting)
## GitHub Actions and `act`

View File

@@ -77,7 +77,7 @@ Finally, never submit a PR that will put master branch in broken state. If the P
in `requirements.txt` pinned to a specific version which ensures that the application
build is deterministic.
- For TypeScript/JavaScript, include new libraries in `package.json`
- **Tests:** The pull request should include tests, either as doctests, unit tests, or both. Make sure to resolve all errors and test failures. See [Testing](./howtos#testing) for how to run tests.
- **Tests:** The pull request should include tests, either as doctests, unit tests, or both. Make sure to resolve all errors and test failures. See [Testing](./howtos.md#testing) for how to run tests.
- **Documentation:** If the pull request adds functionality, the docs should be updated as part of the same PR.
- **CI:** Reviewers will not review the code until all CI tests are passed. Sometimes there can be flaky tests. You can close and open PR to re-run CI test. Please report if the issue persists. After the CI fix has been deployed to `master`, please rebase your PR.
- **Code coverage:** Please ensure that code coverage does not decrease.

View File

@@ -282,7 +282,7 @@ You can now launch your VSCode debugger with the same config as above. VSCode wi
### Storybook
See the dedicated [Storybook documentation](../testing/storybook) for information on running Storybook locally and adding new stories.
See the dedicated [Storybook documentation](../testing/storybook.md) for information on running Storybook locally and adding new stories.
## Contributing Translations

View File

@@ -413,6 +413,6 @@ Consider:
- **Feature Request**: Use feature request template
- **Question**: Use GitHub Discussions
- **Configuration Help**: Ask in Slack
- **Development Help**: See [Contributing Guide](./overview)
- **Development Help**: See [Contributing Guide](./overview.md)
Next: [Understanding the release process](./release-process)
Next: [Understanding the release process](./release-process.md)

View File

@@ -94,7 +94,7 @@ Look through the GitHub issues. Issues tagged with
Superset could always use better documentation,
whether as part of the official Superset docs,
in docstrings, `docs/*.rst` or even on the web as blog posts or
articles. See [Documentation](./howtos#contributing-to-documentation) for more details.
articles. See [Documentation](./howtos.md#contributing-to-documentation) for more details.
### Add Translations
@@ -103,7 +103,7 @@ text strings from Superset's UI. You can jump into the existing
language dictionaries at
`superset/translations/<language_code>/LC_MESSAGES/messages.po`, or
even create a dictionary for a new language altogether.
See [Translating](./howtos#contributing-translations) for more details.
See [Translating](./howtos.md#contributing-translations) for more details.
### Ask Questions
@@ -158,9 +158,9 @@ Security team members should also follow these general expectations:
Ready to contribute? Here's how to get started:
1. **[Set up your environment](./development-setup)** - Get Superset running locally
1. **[Set up your environment](./development-setup.md)** - Get Superset running locally
2. **[Find something to work on](#types-of-contributions)** - Pick an issue or feature
3. **[Submit your contribution](./submitting-pr)** - Create a pull request
4. **[Follow guidelines](./guidelines)** - Ensure code quality
3. **[Submit your contribution](./submitting-pr.md)** - Create a pull request
4. **[Follow guidelines](./guidelines.md)** - Ensure code quality
Welcome to the Apache Superset community! 🚀

View File

@@ -466,4 +466,4 @@ Credit:
- [Release Scripts](https://github.com/apache/superset/tree/master/scripts/release)
- [Superset Repository Scripts](https://github.com/apache/superset/tree/master/scripts)
Next: Return to [Contributing Overview](./overview)
Next: Return to [Contributing Overview](./overview.md)

View File

@@ -31,11 +31,11 @@ Learn how to create and submit high-quality pull requests to Apache Superset.
### Prerequisites
- [ ] Development environment is set up
- [ ] You've forked and cloned the repository
- [ ] You've read the [contributing overview](./overview)
- [ ] You've read the [contributing overview](./overview.md)
- [ ] You've found or created an issue to work on
### PR Readiness Checklist
- [ ] Code follows [coding guidelines](../guidelines/design-guidelines)
- [ ] Code follows [coding guidelines](../guidelines/design-guidelines.md)
- [ ] Tests are passing locally
- [ ] Linting passes (`pre-commit run --all-files`)
- [ ] Documentation is updated if needed
@@ -318,4 +318,4 @@ git push origin master
- **GitHub**: Tag @apache/superset-committers for attention
- **Mailing List**: dev@superset.apache.org
Next: [Understanding code review process](./code-review)
Next: [Understanding code review process](./code-review.md)

View File

@@ -233,7 +233,7 @@ This architecture provides several key benefits:
Now that you understand the architecture, explore:
- **[Dependencies](./dependencies)** - Managing dependencies and understanding API stability
- **[Quick Start](./quick-start)** - Build your first extension
- **[Contribution Types](./contribution-types)** - What kinds of extensions you can build
- **[Development](./development)** - Project structure, APIs, and development workflow
- **[Dependencies](./dependencies.md)** - Managing dependencies and understanding API stability
- **[Quick Start](./quick-start.md)** - Build your first extension
- **[Contribution Types](./contribution-types.md)** - What kinds of extensions you can build
- **[Development](./development.md)** - Project structure, APIs, and development workflow

View File

@@ -29,7 +29,7 @@ These UI components are available to Superset extension developers through the `
## Available Components
- [Alert](./alert)
- [Alert](./alert.mdx)
## Usage
@@ -90,4 +90,4 @@ InteractiveMyComponent.argTypes = {
## Interactive Documentation
For interactive examples with controls, visit the [Storybook](/storybook/?path=/docs/extension-components--docs).
For interactive examples with controls, run Storybook locally — see the [Storybook documentation](/developer-docs/testing/storybook).

View File

@@ -110,7 +110,7 @@ editors.registerEditor(
);
```
See [Editors Extension Point](./extension-points/editors) for implementation details.
See [Editors Extension Point](./extension-points/editors.md) for implementation details.
## Backend
@@ -146,7 +146,7 @@ class MyExtensionAPI(RestApi):
from .api import MyExtensionAPI
```
**Note**: The [`@api`](superset-core/src/superset_core/rest_api/decorators.py) decorator automatically detects context and generates appropriate paths:
**Note**: The [`@api`](https://github.com/apache/superset/blob/master/superset-core/src/superset_core/rest_api/decorators.py) decorator automatically detects context and generates appropriate paths:
- **Extension context**: `/extensions/{publisher}/{name}/` with ID prefixed as `extensions.{publisher}.{name}.{id}`
- **Host context**: `/api/v1/` with original ID
@@ -193,7 +193,7 @@ def get_summary() -> dict:
return {"status": "success", "result": {"queries_today": 42}}
```
See [MCP Integration](./mcp) for implementation details.
See [MCP Integration](./mcp.md) for implementation details.
### MCP Prompts
@@ -223,7 +223,7 @@ async def analysis_guide(ctx: Context) -> str:
"""
```
See [MCP Integration](./mcp) for implementation details.
See [MCP Integration](./mcp.md) for implementation details.
### Semantic Layers

View File

@@ -161,6 +161,6 @@ Until then, monitor the Superset release notes and test your extensions with eac
## Next Steps
- **[Architecture](./architecture)** - Understand the extension system design
- **[Development](./development)** - Learn about APIs and development workflow
- **[Quick Start](./quick-start)** - Build your first extension
- **[Architecture](./architecture.md)** - Understand the extension system design
- **[Development](./development.md)** - Learn about APIs and development workflow
- **[Quick Start](./quick-start.md)** - Build your first extension

View File

@@ -252,7 +252,7 @@ class DatasetReferencesAPI(RestApi):
### Automatic Context Detection
The [`@api`](superset-core/src/superset_core/rest_api/decorators.py) decorator automatically detects whether it's being used in host or extension code:
The [`@api`](https://github.com/apache/superset/blob/master/superset-core/src/superset_core/rest_api/decorators.py) decorator automatically detects whether it's being used in host or extension code:
- **Extension APIs**: Registered under `/extensions/{publisher}/{name}/` with IDs prefixed as `extensions.{publisher}.{name}.{id}`
- **Host APIs**: Registered under `/api/v1/` with original IDs

View File

@@ -217,6 +217,6 @@ const disposable = handle.registerCompletionProvider(provider);
## Next Steps
- **[SQL Lab Extension Points](./sqllab)** - Learn about other SQL Lab customizations
- **[Contribution Types](../contribution-types)** - Explore other contribution types
- **[Development](../development)** - Set up your development environment
- **[SQL Lab Extension Points](./sqllab.md)** - Learn about other SQL Lab customizations
- **[Contribution Types](../contribution-types.md)** - Explore other contribution types
- **[Development](../development.md)** - Set up your development environment

View File

@@ -51,7 +51,7 @@ SQL Lab provides 4 extension points where extensions can contribute custom UI co
| **Right Sidebar** | `sqllab.rightSidebar` | ✓ | — | Custom panels (AI assistants, query analysis) |
| **Panels** | `sqllab.panels` | ✓ | ✓ | Custom tabs + toolbar actions (data profiling) |
\*Editor views are contributed via [Editor Contributions](./editors), not standard view contributions.
\*Editor views are contributed via [Editor Contributions](./editors.md), not standard view contributions.
## Customization Types
@@ -78,7 +78,7 @@ Extensions can add toolbar actions to **Left Sidebar**, **Editor**, and **Panels
### Custom Editors
Extensions can replace the default SQL editor with custom implementations (Monaco, CodeMirror, etc.). See [Editor Contributions](./editors) for details.
Extensions can replace the default SQL editor with custom implementations (Monaco, CodeMirror, etc.). See [Editor Contributions](./editors.md) for details.
## Examples
@@ -157,6 +157,6 @@ menus.registerMenuItem(
## Next Steps
- **[Contribution Types](../contribution-types)** - Learn about other contribution types (commands, menus)
- **[Development](../development)** - Set up your development environment
- **[Quick Start](../quick-start)** - Build a complete extension
- **[Contribution Types](../contribution-types.md)** - Learn about other contribution types (commands, menus)
- **[Development](../development.md)** - Set up your development environment
- **[Quick Start](../quick-start.md)** - Build a complete extension

View File

@@ -455,5 +455,5 @@ async def metrics_guide(ctx: Context) -> str:
## Next Steps
- **[Development](./development)** - Project structure, APIs, and dev workflow
- **[Security](./security)** - Security best practices for extensions
- **[Development](./development.md)** - Project structure, APIs, and dev workflow
- **[Security](./security.md)** - Security best practices for extensions

View File

@@ -47,13 +47,13 @@ Extension developers have access to pre-built UI components via `@apache-superse
## Next Steps
- **[Quick Start](./quick-start)** - Build your first extension with a complete walkthrough
- **[Architecture](./architecture)** - Design principles and system overview
- **[Dependencies](./dependencies)** - Managing dependencies and understanding API stability
- **[Contribution Types](./contribution-types)** - Available extension points
- **[Development](./development)** - Project structure, APIs, and development workflow
- **[Deployment](./deployment)** - Packaging and deploying extensions
- **[MCP Integration](./mcp)** - Adding AI agent capabilities using extensions
- **[Security](./security)** - Security considerations and best practices
- **[Tasks](./tasks)** - Framework for creating and managing long running tasks
- **[Community Extensions](./registry)** - Browse extensions shared by the community
- **[Quick Start](./quick-start.md)** - Build your first extension with a complete walkthrough
- **[Architecture](./architecture.md)** - Design principles and system overview
- **[Dependencies](./dependencies.md)** - Managing dependencies and understanding API stability
- **[Contribution Types](./contribution-types.md)** - Available extension points
- **[Development](./development.md)** - Project structure, APIs, and development workflow
- **[Deployment](./deployment.md)** - Packaging and deploying extensions
- **[MCP Integration](./mcp.md)** - Adding AI agent capabilities using extensions
- **[Security](./security.md)** - Security considerations and best practices
- **[Tasks](./tasks.md)** - Framework for creating and managing long running tasks
- **[Community Extensions](./registry.md)** - Browse extensions shared by the community

View File

@@ -168,7 +168,7 @@ class HelloWorldAPI(RestApi):
**Key points:**
- Uses [`@api`](superset-core/src/superset_core/rest_api/decorators.py) decorator with automatic context detection
- Uses [`@api`](https://github.com/apache/superset/blob/master/superset-core/src/superset_core/rest_api/decorators.py) decorator with automatic context detection
- Extends `RestApi` from `superset_core.rest_api.api`
- Uses Flask-AppBuilder decorators (`@expose`, `@protect`, `@safe`)
- Returns responses using `self.response(status_code, result=data)`
@@ -184,7 +184,7 @@ Replace the generated print statement with API import to trigger registration:
from .api import HelloWorldAPI # noqa: F401
```
The [`@api`](superset-core/src/superset_core/rest_api/decorators.py) decorator automatically detects extension context and registers your API with proper namespacing.
The [`@api`](https://github.com/apache/superset/blob/master/superset-core/src/superset_core/rest_api/decorators.py) decorator automatically detects extension context and registers your API with proper namespacing.
## Step 5: Create Frontend Component
@@ -225,7 +225,7 @@ The `@apache-superset/core` package must be listed in both `peerDependencies` (t
The webpack configuration requires specific settings for Module Federation. Key settings include `externalsType: "window"` and `externals` to map `@apache-superset/core` to `window.superset` at runtime, `import: false` for shared modules to use the host's React instead of bundling a separate copy, and `remoteEntry.[contenthash].js` for cache busting.
**Convention**: Superset always loads extensions by requesting the `./index` module from the Module Federation container. The `exposes` entry must be exactly `'./index': './src/index.tsx'` — do not rename or add additional entries. All API registrations must be reachable from that file. See [Architecture](./architecture#module-federation) for a full explanation.
**Convention**: Superset always loads extensions by requesting the `./index` module from the Module Federation container. The `exposes` entry must be exactly `'./index': './src/index.tsx'` — do not rename or add additional entries. All API registrations must be reachable from that file. See [Architecture](./architecture.md#module-federation) for a full explanation.
```javascript
const path = require('path');
@@ -496,7 +496,7 @@ Superset will extract and validate the extension metadata, load the assets, regi
Here's what happens when your extension loads:
1. **Superset starts**: Reads `manifest.json` from the `.supx` bundle and loads the backend entrypoint
2. **Backend registration**: `entrypoint.py` imports your API class, triggering the [`@api`](superset-core/src/superset_core/rest_api/decorators.py) decorator to register it automatically
2. **Backend registration**: `entrypoint.py` imports your API class, triggering the [`@api`](https://github.com/apache/superset/blob/master/superset-core/src/superset_core/rest_api/decorators.py) decorator to register it automatically
3. **Frontend loads**: When SQL Lab opens, Superset fetches the remote entry file
4. **Module Federation**: Webpack loads your extension module and resolves `@apache-superset/core` to `window.superset`
5. **Registration**: The module executes at load time, calling `views.registerView` to register your panel
@@ -509,9 +509,9 @@ Here's what happens when your extension loads:
Now that you have a working extension, explore:
- **[Development](./development)** - Project structure, APIs, and development workflow
- **[Contribution Types](./contribution-types)** - Other contribution points beyond panels
- **[Deployment](./deployment)** - Packaging and deploying your extension
- **[Security](./security)** - Security best practices for extensions
- **[Development](./development.md)** - Project structure, APIs, and development workflow
- **[Contribution Types](./contribution-types.md)** - Other contribution points beyond panels
- **[Deployment](./deployment.md)** - Packaging and deploying your extension
- **[Security](./security.md)** - Security best practices for extensions
For a complete real-world example, examine the query insights extension in the Superset codebase.

View File

@@ -28,7 +28,7 @@ By default, extensions are disabled and must be explicitly enabled by setting th
For external extensions, administrators are responsible for evaluating and verifying the security of any extensions they choose to install, just as they would when installing third-party NPM or PyPI packages. At this stage, all extensions run in the same context as the host application, without additional sandboxing. This means that external extensions can impact the security and performance of a Superset environment in the same way as any other installed dependency.
We plan to introduce an optional sandboxed execution model for extensions in the future (as part of an additional SIP). Until then, administrators should exercise caution and follow best practices when selecting and deploying third-party extensions. A directory of community extensions is available in the [Community Extensions](./registry) page. Note that these extensions are not vetted by the Apache Superset project—administrators must evaluate each extension before installation.
We plan to introduce an optional sandboxed execution model for extensions in the future (as part of an additional SIP). Until then, administrators should exercise caution and follow best practices when selecting and deploying third-party extensions. A directory of community extensions is available in the [Community Extensions](./registry.md) page. Note that these extensions are not vetted by the Apache Superset project—administrators must evaluate each extension before installation.
**Any performance or security vulnerabilities introduced by external extensions should be reported directly to the extension author, not as Superset vulnerabilities.**

View File

@@ -114,7 +114,7 @@ class CreateDashboardCommand(BaseCommand):
### Data Access Objects (DAOs)
See: [DAO Style Guidelines and Best Practices](./backend/dao-style-guidelines)
See: [DAO Style Guidelines and Best Practices](./backend/dao-style-guidelines.md)
## Testing

View File

@@ -29,16 +29,16 @@ This is a list of statements that describe how we do frontend development in Sup
- We develop using TypeScript.
- See: [SIP-36](https://github.com/apache/superset/issues/9101)
- We use React for building components, and Redux to manage app/global state.
- See: [Component Style Guidelines and Best Practices](./frontend/component-style-guidelines)
- See: [Component Style Guidelines and Best Practices](./frontend/component-style-guidelines.md)
- We prefer functional components to class components and use hooks for local component state.
- We use [Ant Design](https://ant.design/) components from our component library whenever possible, only building our own custom components when it's required.
- See: [SIP-48](https://github.com/apache/superset/issues/11283)
- We use [@emotion](https://emotion.sh/docs/introduction) to provide styling for our components, co-locating styling within component files.
- See: [SIP-37](https://github.com/apache/superset/issues/9145)
- See: [Emotion Styling Guidelines and Best Practices](./frontend/emotion-styling-guidelines)
- See: [Emotion Styling Guidelines and Best Practices](./frontend/emotion-styling-guidelines.md)
- We use Jest for unit tests, React Testing Library for component tests, and Cypress for end-to-end tests.
- See: [SIP-56](https://github.com/apache/superset/issues/11830)
- See: [Testing Guidelines and Best Practices](../testing/testing-guidelines)
- See: [Testing Guidelines and Best Practices](../testing/testing-guidelines.md)
- We add tests for every new component or file added to the frontend.
- We organize our repo so similar files live near each other, and tests are co-located with the files they test.
- See: [SIP-61](https://github.com/apache/superset/issues/12098)
@@ -46,6 +46,6 @@ This is a list of statements that describe how we do frontend development in Sup
- We use OXC (oxlint) and Prettier to automatically fix lint errors and format the code.
- We do not debate code formatting style in PRs, instead relying on automated tooling to enforce it.
- If there's not a linting rule, we don't have a rule!
- See: [Linting How-Tos](../contributing/howtos#typescript--javascript)
- See: [Linting How-Tos](../contributing/howtos.md#typescript--javascript)
- We use [React Storybook](https://storybook.js.org/) to help preview/test and stabilize our components
- A public Storybook with components from the `master` branch is available [here](https://apache-superset.github.io/superset-ui/?path=/story/*)

View File

@@ -31,7 +31,7 @@ This guide is intended primarily for reusable components. Whenever possible, all
## General Guidelines
- We use [Ant Design](https://ant.design/) as our component library. Do not build a new component if Ant Design provides one but rather instead extend or customize what the library provides
- Always style your component using Emotion and always prefer the theme variables whenever applicable. See: [Emotion Styling Guidelines and Best Practices](./emotion-styling-guidelines)
- Always style your component using Emotion and always prefer the theme variables whenever applicable. See: [Emotion Styling Guidelines and Best Practices](./emotion-styling-guidelines.md)
- All components should be made to be reusable whenever possible
- All components should follow the structure and best practices as detailed below
@@ -53,7 +53,7 @@ superset-frontend/src/components
**Storybook:** Components should come with a storybook file whenever applicable, with the following naming convention `\{ComponentName\}.stories.tsx`. More details about Storybook below
**Unit and end-to-end tests:** All components should come with unit tests using Jest and React Testing Library. The file name should follow this naming convention `\{ComponentName\}.test.tsx`. Read the [Testing Guidelines and Best Practices](../../testing/testing-guidelines) for more details
**Unit and end-to-end tests:** All components should come with unit tests using Jest and React Testing Library. The file name should follow this naming convention `\{ComponentName\}.test.tsx`. Read the [Testing Guidelines and Best Practices](../../testing/testing-guidelines.md) for more details
**Reference naming:** Use `PascalCase` for React components and `camelCase` for component instances

View File

@@ -37,16 +37,16 @@ Superset embraces a testing pyramid approach:
## Testing Documentation
### Frontend Testing
- **[Frontend Testing](./frontend-testing)** - Jest, React Testing Library, and component testing strategies
- **[Frontend Testing](./frontend-testing.md)** - Jest, React Testing Library, and component testing strategies
### Backend Testing
- **[Backend Testing](./backend-testing)** - pytest, database testing, and API testing patterns
- **[Backend Testing](./backend-testing.md)** - pytest, database testing, and API testing patterns
### End-to-End Testing
- **[E2E Testing](./e2e-testing)** - Playwright testing for complete user workflows
- **[E2E Testing](./e2e-testing.md)** - Playwright testing for complete user workflows
### CI/CD Integration
- **[CI/CD](./ci-cd)** - Continuous integration, automated testing, and deployment pipelines
- **[CI/CD](./ci-cd.md)** - Continuous integration, automated testing, and deployment pipelines
## Testing Tools & Frameworks

View File

@@ -254,7 +254,7 @@ const config: Config = {
'Apache Superset is a modern data exploration and visualization platform',
url: 'https://superset.apache.org',
baseUrl: '/',
onBrokenLinks: 'warn',
onBrokenLinks: 'throw',
markdown: {
mermaid: true,
hooks: {

View File

@@ -30,6 +30,7 @@
"lint:db-metadata:report": "python3 ../superset/db_engine_specs/lint_metadata.py --markdown -o ../superset/db_engine_specs/METADATA_STATUS.md",
"update:readme-db-logos": "node scripts/generate-database-docs.mjs --update-readme",
"eslint": "eslint .",
"lint:docs-links": "node scripts/lint-docs-links.mjs",
"version:add": "node scripts/manage-versions.mjs add",
"version:remove": "node scripts/manage-versions.mjs remove",
"version:add:docs": "node scripts/manage-versions.mjs add docs",

View File

@@ -1260,7 +1260,15 @@ function generateCategoryIndex(category, components) {
};
const componentList = components
.sort((a, b) => a.componentName.localeCompare(b.componentName))
.map(c => `- [${c.componentName}](./${c.componentName.toLowerCase()})`)
// `.mdx` suffix matches the actual component page files emitted
// by this generator (see the MDX wrappers below). The extension
// is required: Docusaurus only validates and rewrites *file-based*
// references (.md/.mdx). Bare relative paths bypass the file
// resolver and get emitted as raw HTML hrefs that the browser
// resolves against the current URL — which gives the wrong
// directory for trailing-slash routes and breaks SPA navigation.
// See docs/scripts/lint-docs-links.mjs.
.map(c => `- [${c.componentName}](./${c.componentName.toLowerCase()}.mdx)`)
.join('\n');
return `---
@@ -1366,7 +1374,7 @@ This documentation is auto-generated from Storybook stories. To add or update co
4. Run \`yarn generate:superset-components\` in the \`docs/\` directory
:::info Work in Progress
This component library is actively being documented. See the [Components TODO](./TODO) page for a list of components awaiting documentation.
This component library is actively being documented. See the [Components TODO](./TODO.md) page for a list of components awaiting documentation.
:::
---

View File

@@ -0,0 +1,230 @@
#!/usr/bin/env node
/**
* 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.
*/
/**
* lint-docs-links — source-level checks for internal markdown links.
*
* Catches three failure modes that combine to break SPA navigation in
* a Docusaurus build:
*
* 1. BARE — `[X](../foo)` with no extension. Skips
* Docusaurus's file resolver entirely. Emitted
* as a raw href and resolved by the browser
* against the current page URL — usually the
* wrong directory for trailing-slash routes.
* `onBrokenLinks: 'throw'` cannot catch this.
*
* 2. MISSING_TARGET — `[X](./gone.md)` with an extension, but no
* file at that path. The Docusaurus build
* catches this too (via
* `onBrokenMarkdownLinks: 'throw'`) but only
* after a multi-minute build. This script
* flags it in ~1s.
*
* 3. WRONG_EXTENSION — `[X](./foo.md)` where the file is actually
* `foo.mdx` (or vice versa). Same end result
* as MISSING_TARGET, but the fix is one
* character — so we report it as its own
* category with the actual extension on disk.
*
* Skips: fenced code blocks, asset-style targets (.png/.json/etc.),
* external URLs, in-page anchors, and the `versioned_docs/`
* snapshots (those are frozen historical content).
*
* Run from `docs/`:
* node scripts/lint-docs-links.mjs
*
* Exits 0 on clean, 1 on any finding.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const docsRoot = path.join(__dirname, '..');
const ROOTS = ['docs', 'admin_docs', 'developer_docs', 'components'];
const NON_DOC_EXTENSIONS = new Set([
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico',
'.json', '.yaml', '.yml', '.txt', '.csv',
'.zip', '.tar', '.gz',
'.pdf',
'.mp4', '.webm', '.mov',
]);
const LINK_RE = /\[[^\]\n]+?\]\((?<url>\.{1,2}\/[^)\s]+?)\)/g;
/**
* Classify a single markdown link from a source file.
* Returns one of: ok / bare / asset / missing-target / wrong-extension.
*/
function classifyLink(sourceFile, url) {
const stripped = url.split('#', 1)[0].split('?', 1)[0];
const ext = path.extname(stripped).toLowerCase();
// Non-doc assets — legit bare extensions, leave alone.
if (ext && NON_DOC_EXTENSIONS.has(ext)) {
return { kind: 'asset' };
}
// Anything that doesn't end in .md/.mdx is a bare relative URL.
if (ext !== '.md' && ext !== '.mdx') {
return { kind: 'bare' };
}
// Has a .md/.mdx extension — make sure the target exists.
const target = path.normalize(path.join(path.dirname(sourceFile), stripped));
if (fs.existsSync(target)) {
return { kind: 'ok' };
}
// Target doesn't exist — check if the OTHER extension does.
const otherExt = ext === '.md' ? '.mdx' : '.md';
const otherTarget = target.slice(0, -ext.length) + otherExt;
if (fs.existsSync(otherTarget)) {
return { kind: 'wrong-extension', actualExt: otherExt };
}
return { kind: 'missing-target' };
}
function* walk(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (
entry.name.startsWith('.') ||
entry.name === 'node_modules' ||
entry.name.endsWith('_versioned_docs') ||
entry.name === 'versioned_docs'
) {
continue;
}
yield* walk(full);
} else if (entry.isFile()) {
if (entry.name.endsWith('.md') || entry.name.endsWith('.mdx')) {
yield full;
}
}
}
}
function lintFile(file) {
const src = fs.readFileSync(file, 'utf8');
const findings = [];
let inFence = false;
const lines = src.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trimStart().startsWith('```')) {
inFence = !inFence;
continue;
}
if (inFence) continue;
for (const m of line.matchAll(LINK_RE)) {
const url = m.groups.url;
const result = classifyLink(file, url);
if (result.kind !== 'ok' && result.kind !== 'asset') {
findings.push({ line: i + 1, url, ...result });
}
}
}
return findings;
}
const findings = [];
for (const root of ROOTS) {
const abs = path.join(docsRoot, root);
if (!fs.existsSync(abs)) continue;
for (const file of walk(abs)) {
for (const f of lintFile(file)) {
findings.push({ file: path.relative(docsRoot, file), ...f });
}
}
}
if (findings.length === 0) {
console.log('✓ lint-docs-links: no broken internal links found');
process.exit(0);
}
// Group by kind for readable output.
const groups = {
bare: [],
'wrong-extension': [],
'missing-target': [],
};
for (const f of findings) {
groups[f.kind].push(f);
}
console.error(
`✗ lint-docs-links: found ${findings.length} broken internal link(s)`
);
console.error('');
if (groups.bare.length) {
console.error(
` ${groups.bare.length} bare relative link(s) (no .md/.mdx extension)`
);
console.error(
" Docusaurus's file resolver skips these; the browser resolves them"
);
console.error(
' against the current page URL — wrong directory for trailing-slash routes.'
);
console.error(' Add the extension so the file resolver picks them up.');
console.error('');
for (const f of groups.bare) {
console.error(` ${f.file}:${f.line} ${f.url}`);
}
console.error('');
}
if (groups['wrong-extension'].length) {
console.error(
` ${groups['wrong-extension'].length} wrong-extension link(s) (.md vs .mdx mismatch)`
);
console.error(' The target file exists with the other extension on disk.');
console.error('');
for (const f of groups['wrong-extension']) {
console.error(
` ${f.file}:${f.line} ${f.url} → use ${f.actualExt}`
);
}
console.error('');
}
if (groups['missing-target'].length) {
console.error(
` ${groups['missing-target'].length} missing-target link(s) (file doesn't exist)`
);
console.error('');
for (const f of groups['missing-target']) {
console.error(` ${f.file}:${f.line} ${f.url}`);
}
console.error('');
}
process.exit(1);

View File

@@ -20,12 +20,12 @@ Alerts and reports are disabled by default. To turn them on, you need to do some
#### In your `superset_config.py` or `superset_config_docker.py`
- `"ALERT_REPORTS"` [feature flag](/docs/6.0.0/configuration/configuring-superset#feature-flags) must be turned to True.
- `"ALERT_REPORTS"` [feature flag](/user-docs/6.0.0/configuration/configuring-superset#feature-flags) must be turned to True.
- `beat_schedule` in CeleryConfig must contain schedule for `reports.scheduler`.
- At least one of those must be configured, depending on what you want to use:
- emails: `SMTP_*` settings
- Slack messages: `SLACK_API_TOKEN`
- Users can customize the email subject by including date code placeholders, which will automatically be replaced with the corresponding UTC date when the email is sent. To enable this functionality, activate the `"DATE_FORMAT_IN_EMAIL_SUBJECT"` [feature flag](/docs/6.0.0/configuration/configuring-superset#feature-flags). This enables date formatting in email subjects, preventing all reporting emails from being grouped into the same thread (optional for the reporting feature).
- Users can customize the email subject by including date code placeholders, which will automatically be replaced with the corresponding UTC date when the email is sent. To enable this functionality, activate the `"DATE_FORMAT_IN_EMAIL_SUBJECT"` [feature flag](/user-docs/6.0.0/configuration/configuring-superset#feature-flags). This enables date formatting in email subjects, preventing all reporting emails from being grouped into the same thread (optional for the reporting feature).
- Use date codes from [strftime.org](https://strftime.org/) to create the email subject.
- If no date code is provided, the original string will be used as the email subject.
@@ -38,7 +38,7 @@ Screenshots will be taken but no messages actually sent as long as `ALERT_REPORT
- You must install a headless browser, for taking screenshots of the charts and dashboards. Only Firefox and Chrome are currently supported.
> If you choose Chrome, you must also change the value of `WEBDRIVER_TYPE` to `"chrome"` in your `superset_config.py`.
Note: All the components required (Firefox headless browser, Redis, Postgres db, celery worker and celery beat) are present in the *dev* docker image if you are following [Installing Superset Locally](/docs/6.0.0/installation/docker-compose/).
Note: All the components required (Firefox headless browser, Redis, Postgres db, celery worker and celery beat) are present in the *dev* docker image if you are following [Installing Superset Locally](/user-docs/6.0.0/installation/docker-compose/).
All you need to do is add the required config variables described in this guide (See `Detailed Config`).
If you are running a non-dev docker image, e.g., a stable release like `apache/superset:3.1.0`, that image does not include a headless browser. Only the `superset_worker` container needs this headless browser to browse to the target chart or dashboard.
@@ -70,7 +70,7 @@ Note: when you configure an alert or a report, the Slack channel list takes chan
### Kubernetes-specific
- You must have a `celery beat` pod running. If you're using the chart included in the GitHub repository under [helm/superset](https://github.com/apache/superset/tree/master/helm/superset), you need to put `supersetCeleryBeat.enabled = true` in your values override.
- You can see the dedicated docs about [Kubernetes installation](/docs/6.0.0/installation/kubernetes) for more details.
- You can see the dedicated docs about [Kubernetes installation](/user-docs/6.0.0/installation/kubernetes) for more details.
### Docker Compose specific

View File

@@ -78,11 +78,11 @@ Caching for SQL Lab query results is used when async queries are enabled and is
Note that this configuration does not use a flask-caching dictionary for its configuration, but
instead requires a cachelib object.
See [Async Queries via Celery](/docs/6.0.0/configuration/async-queries-celery) for details.
See [Async Queries via Celery](/user-docs/6.0.0/configuration/async-queries-celery) for details.
## Caching Thumbnails
This is an optional feature that can be turned on by activating its [feature flag](/docs/6.0.0/configuration/configuring-superset#feature-flags) on config:
This is an optional feature that can be turned on by activating its [feature flag](/user-docs/6.0.0/configuration/configuring-superset#feature-flags) on config:
```
FEATURE_FLAGS = {

View File

@@ -37,7 +37,7 @@ ENV SUPERSET_CONFIG_PATH /app/superset_config.py
```
Docker compose deployments handle application configuration differently using specific conventions.
Refer to the [docker compose tips & configuration](/docs/6.0.0/installation/docker-compose#docker-compose-tips--configuration)
Refer to the [docker compose tips & configuration](/user-docs/6.0.0/installation/docker-compose#docker-compose-tips--configuration)
for details.
The following is an example of just a few of the parameters you can set in your `superset_config.py` file:
@@ -254,7 +254,7 @@ flask --app "superset.app:create_app(superset_app_root='/analytics')"
### Docker builds
The [docker compose](/docs/6.0.0/installation/docker-compose#configuring-further) developer
The [docker compose](/user-docs/6.0.0/installation/docker-compose#configuring-further) developer
configuration includes an additional environmental variable,
[`SUPERSET_APP_ROOT`](https://github.com/apache/superset/blob/master/docker/.env),
to simplify the process of setting up a non-default root path across the services.
@@ -449,4 +449,4 @@ FEATURE_FLAGS = {
}
```
A current list of feature flags can be found in the [Feature Flags](/docs/6.0.0/configuration/feature-flags) documentation.
A current list of feature flags can be found in the [Feature Flags](/user-docs/6.0.0/configuration/configuring-superset#feature-flags) documentation.

View File

@@ -14,7 +14,7 @@ in your environment.
Youll need to install the required packages for the database you want to use as your metadata database
as well as the packages needed to connect to the databases you want to access through Superset.
For information about setting up Superset's metadata database, please refer to
installation documentations ([Docker Compose](/docs/6.0.0/installation/docker-compose), [Kubernetes](/docs/6.0.0/installation/kubernetes))
installation documentations ([Docker Compose](/user-docs/6.0.0/installation/docker-compose), [Kubernetes](/user-docs/6.0.0/installation/kubernetes))
:::
This documentation tries to keep pointer to the different drivers for commonly used database
@@ -26,7 +26,7 @@ Superset requires a Python [DB-API database driver](https://peps.python.org/pep-
and a [SQLAlchemy dialect](https://docs.sqlalchemy.org/en/20/dialects/) to be installed for
each database engine you want to connect to.
You can read more [here](/docs/6.0.0/configuration/databases#installing-drivers-in-docker-images) about how to
You can read more [here](/user-docs/6.0.0/configuration/databases#installing-drivers-in-docker-images) about how to
install new database drivers into your Superset configuration.
### Supported Databases and Dependencies
@@ -37,53 +37,53 @@ are compatible with Superset.
| <div style={{width: '150px'}}>Database</div> | PyPI package | Connection String |
| --------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [AWS Athena](/docs/6.0.0/configuration/databases#aws-athena) | `pip install pyathena[pandas]` , `pip install PyAthenaJDBC` | `awsathena+rest://{access_key_id}:{access_key}@athena.{region}.amazonaws.com/{schema}?s3_staging_dir={s3_staging_dir}&...` |
| [AWS DynamoDB](/docs/6.0.0/configuration/databases#aws-dynamodb) | `pip install pydynamodb` | `dynamodb://{access_key_id}:{secret_access_key}@dynamodb.{region_name}.amazonaws.com?connector=superset` |
| [AWS Redshift](/docs/6.0.0/configuration/databases#aws-redshift) | `pip install sqlalchemy-redshift` | `redshift+psycopg2://<userName>:<DBPassword>@<AWS End Point>:5439/<Database Name>` |
| [Apache Doris](/docs/6.0.0/configuration/databases#apache-doris) | `pip install pydoris` | `doris://<User>:<Password>@<Host>:<Port>/<Catalog>.<Database>` |
| [Apache Drill](/docs/6.0.0/configuration/databases#apache-drill) | `pip install sqlalchemy-drill` | `drill+sadrill://<username>:<password>@<host>:<port>/<storage_plugin>`, often useful: `?use_ssl=True/False` |
| [Apache Druid](/docs/6.0.0/configuration/databases#apache-druid) | `pip install pydruid` | `druid://<User>:<password>@<Host>:<Port-default-9088>/druid/v2/sql` |
| [Apache Hive](/docs/6.0.0/configuration/databases#hive) | `pip install pyhive` | `hive://hive@{hostname}:{port}/{database}` |
| [Apache Impala](/docs/6.0.0/configuration/databases#apache-impala) | `pip install impyla` | `impala://{hostname}:{port}/{database}` |
| [Apache Kylin](/docs/6.0.0/configuration/databases#apache-kylin) | `pip install kylinpy` | `kylin://<username>:<password>@<hostname>:<port>/<project>?<param1>=<value1>&<param2>=<value2>` |
| [Apache Pinot](/docs/6.0.0/configuration/databases#apache-pinot) | `pip install pinotdb` | `pinot://BROKER:5436/query?server=http://CONTROLLER:5983/` |
| [Apache Solr](/docs/6.0.0/configuration/databases#apache-solr) | `pip install sqlalchemy-solr` | `solr://{username}:{password}@{hostname}:{port}/{server_path}/{collection}` |
| [Apache Spark SQL](/docs/6.0.0/configuration/databases#apache-spark-sql) | `pip install pyhive` | `hive://hive@{hostname}:{port}/{database}` |
| [Ascend.io](/docs/6.0.0/configuration/databases#ascendio) | `pip install impyla` | `ascend://{username}:{password}@{hostname}:{port}/{database}?auth_mechanism=PLAIN;use_ssl=true` |
| [Azure MS SQL](/docs/6.0.0/configuration/databases#sql-server) | `pip install pymssql` | `mssql+pymssql://UserName@presetSQL:TestPassword@presetSQL.database.windows.net:1433/TestSchema` |
| [ClickHouse](/docs/6.0.0/configuration/databases#clickhouse) | `pip install clickhouse-connect` | `clickhousedb://{username}:{password}@{hostname}:{port}/{database}` |
| [CockroachDB](/docs/6.0.0/configuration/databases#cockroachdb) | `pip install cockroachdb` | `cockroachdb://root@{hostname}:{port}/{database}?sslmode=disable` |
| [Couchbase](/docs/6.0.0/configuration/databases#couchbase) | `pip install couchbase-sqlalchemy` | `couchbase://{username}:{password}@{hostname}:{port}?truststorepath={ssl certificate path}` |
| [CrateDB](/docs/6.0.0/configuration/databases#cratedb) | `pip install sqlalchemy-cratedb` | `crate://{username}:{password}@{hostname}:{port}`, often useful: `?ssl=true/false` or `?schema=testdrive`. |
| [Denodo](/docs/6.0.0/configuration/databases#denodo) | `pip install denodo-sqlalchemy` | `denodo://{username}:{password}@{hostname}:{port}/{database}` |
| [Dremio](/docs/6.0.0/configuration/databases#dremio) | `pip install sqlalchemy_dremio` |`dremio+flight://{username}:{password}@{host}:32010`, often useful: `?UseEncryption=true/false`. For Legacy ODBC: `dremio+pyodbc://{username}:{password}@{host}:31010` |
| [Elasticsearch](/docs/6.0.0/configuration/databases#elasticsearch) | `pip install elasticsearch-dbapi` | `elasticsearch+http://{user}:{password}@{host}:9200/` |
| [Exasol](/docs/6.0.0/configuration/databases#exasol) | `pip install sqlalchemy-exasol` | `exa+pyodbc://{username}:{password}@{hostname}:{port}/my_schema?CONNECTIONLCALL=en_US.UTF-8&driver=EXAODBC` |
| [Google BigQuery](/docs/6.0.0/configuration/databases#google-bigquery) | `pip install sqlalchemy-bigquery` | `bigquery://{project_id}` |
| [Google Sheets](/docs/6.0.0/configuration/databases#google-sheets) | `pip install shillelagh[gsheetsapi]` | `gsheets://` |
| [Firebolt](/docs/6.0.0/configuration/databases#firebolt) | `pip install firebolt-sqlalchemy` | `firebolt://{client_id}:{client_secret}@{database}/{engine_name}?account_name={name}` |
| [Hologres](/docs/6.0.0/configuration/databases#hologres) | `pip install psycopg2` | `postgresql+psycopg2://<UserName>:<DBPassword>@<Database Host>/<Database Name>` |
| [IBM Db2](/docs/6.0.0/configuration/databases#ibm-db2) | `pip install ibm_db_sa` | `db2+ibm_db://` |
| [IBM Netezza Performance Server](/docs/6.0.0/configuration/databases#ibm-netezza-performance-server) | `pip install nzalchemy` | `netezza+nzpy://<UserName>:<DBPassword>@<Database Host>/<Database Name>` |
| [MySQL](/docs/6.0.0/configuration/databases#mysql) | `pip install mysqlclient` | `mysql://<UserName>:<DBPassword>@<Database Host>/<Database Name>` |
| [OceanBase](/docs/6.0.0/configuration/databases#oceanbase) | `pip install oceanbase_py` | `oceanbase://<UserName>:<DBPassword>@<Database Host>/<Database Name>` |
| [Oracle](/docs/6.0.0/configuration/databases#oracle) | `pip install cx_Oracle` | `oracle://<username>:<password>@<hostname>:<port>` |
| [Parseable](/docs/6.0.0/configuration/databases#parseable) | `pip install sqlalchemy-parseable` | `parseable://<UserName>:<DBPassword>@<Database Host>/<Stream Name>` |
| [PostgreSQL](/docs/6.0.0/configuration/databases#postgres) | `pip install psycopg2` | `postgresql://<UserName>:<DBPassword>@<Database Host>/<Database Name>` |
| [Presto](/docs/6.0.0/configuration/databases#presto) | `pip install pyhive` | `presto://{username}:{password}@{hostname}:{port}/{database}` |
| [SAP Hana](/docs/6.0.0/configuration/databases#hana) | `pip install hdbcli sqlalchemy-hana` or `pip install apache_superset[hana]` | `hana://{username}:{password}@{host}:{port}` |
| [SingleStore](/docs/6.0.0/configuration/databases#singlestore) | `pip install sqlalchemy-singlestoredb` | `singlestoredb://{username}:{password}@{host}:{port}/{database}` |
| [StarRocks](/docs/6.0.0/configuration/databases#starrocks) | `pip install starrocks` | `starrocks://<User>:<Password>@<Host>:<Port>/<Catalog>.<Database>` |
| [Snowflake](/docs/6.0.0/configuration/databases#snowflake) | `pip install snowflake-sqlalchemy` | `snowflake://{user}:{password}@{account}.{region}/{database}?role={role}&warehouse={warehouse}` |
| [AWS Athena](/user-docs/6.0.0/configuration/databases#aws-athena) | `pip install pyathena[pandas]` , `pip install PyAthenaJDBC` | `awsathena+rest://{access_key_id}:{access_key}@athena.{region}.amazonaws.com/{schema}?s3_staging_dir={s3_staging_dir}&...` |
| [AWS DynamoDB](/user-docs/6.0.0/configuration/databases#aws-dynamodb) | `pip install pydynamodb` | `dynamodb://{access_key_id}:{secret_access_key}@dynamodb.{region_name}.amazonaws.com?connector=superset` |
| [AWS Redshift](/user-docs/6.0.0/configuration/databases#aws-redshift) | `pip install sqlalchemy-redshift` | `redshift+psycopg2://<userName>:<DBPassword>@<AWS End Point>:5439/<Database Name>` |
| [Apache Doris](/user-docs/6.0.0/configuration/databases#apache-doris) | `pip install pydoris` | `doris://<User>:<Password>@<Host>:<Port>/<Catalog>.<Database>` |
| [Apache Drill](/user-docs/6.0.0/configuration/databases#apache-drill) | `pip install sqlalchemy-drill` | `drill+sadrill://<username>:<password>@<host>:<port>/<storage_plugin>`, often useful: `?use_ssl=True/False` |
| [Apache Druid](/user-docs/6.0.0/configuration/databases#apache-druid) | `pip install pydruid` | `druid://<User>:<password>@<Host>:<Port-default-9088>/druid/v2/sql` |
| [Apache Hive](/user-docs/6.0.0/configuration/databases#hive) | `pip install pyhive` | `hive://hive@{hostname}:{port}/{database}` |
| [Apache Impala](/user-docs/6.0.0/configuration/databases#apache-impala) | `pip install impyla` | `impala://{hostname}:{port}/{database}` |
| [Apache Kylin](/user-docs/6.0.0/configuration/databases#apache-kylin) | `pip install kylinpy` | `kylin://<username>:<password>@<hostname>:<port>/<project>?<param1>=<value1>&<param2>=<value2>` |
| [Apache Pinot](/user-docs/6.0.0/configuration/databases#apache-pinot) | `pip install pinotdb` | `pinot://BROKER:5436/query?server=http://CONTROLLER:5983/` |
| [Apache Solr](/user-docs/6.0.0/configuration/databases#apache-solr) | `pip install sqlalchemy-solr` | `solr://{username}:{password}@{hostname}:{port}/{server_path}/{collection}` |
| [Apache Spark SQL](/user-docs/6.0.0/configuration/databases#apache-spark-sql) | `pip install pyhive` | `hive://hive@{hostname}:{port}/{database}` |
| [Ascend.io](/user-docs/6.0.0/configuration/databases#ascendio) | `pip install impyla` | `ascend://{username}:{password}@{hostname}:{port}/{database}?auth_mechanism=PLAIN;use_ssl=true` |
| [Azure MS SQL](/user-docs/6.0.0/configuration/databases#sql-server) | `pip install pymssql` | `mssql+pymssql://UserName@presetSQL:TestPassword@presetSQL.database.windows.net:1433/TestSchema` |
| [ClickHouse](/user-docs/6.0.0/configuration/databases#clickhouse) | `pip install clickhouse-connect` | `clickhousedb://{username}:{password}@{hostname}:{port}/{database}` |
| [CockroachDB](/user-docs/6.0.0/configuration/databases#cockroachdb) | `pip install cockroachdb` | `cockroachdb://root@{hostname}:{port}/{database}?sslmode=disable` |
| [Couchbase](/user-docs/6.0.0/configuration/databases#couchbase) | `pip install couchbase-sqlalchemy` | `couchbase://{username}:{password}@{hostname}:{port}?truststorepath={ssl certificate path}` |
| [CrateDB](/user-docs/6.0.0/configuration/databases#cratedb) | `pip install sqlalchemy-cratedb` | `crate://{username}:{password}@{hostname}:{port}`, often useful: `?ssl=true/false` or `?schema=testdrive`. |
| [Denodo](/user-docs/6.0.0/configuration/databases#denodo) | `pip install denodo-sqlalchemy` | `denodo://{username}:{password}@{hostname}:{port}/{database}` |
| [Dremio](/user-docs/6.0.0/configuration/databases#dremio) | `pip install sqlalchemy_dremio` |`dremio+flight://{username}:{password}@{host}:32010`, often useful: `?UseEncryption=true/false`. For Legacy ODBC: `dremio+pyodbc://{username}:{password}@{host}:31010` |
| [Elasticsearch](/user-docs/6.0.0/configuration/databases#elasticsearch) | `pip install elasticsearch-dbapi` | `elasticsearch+http://{user}:{password}@{host}:9200/` |
| [Exasol](/user-docs/6.0.0/configuration/databases#exasol) | `pip install sqlalchemy-exasol` | `exa+pyodbc://{username}:{password}@{hostname}:{port}/my_schema?CONNECTIONLCALL=en_US.UTF-8&driver=EXAODBC` |
| [Google BigQuery](/user-docs/6.0.0/configuration/databases#google-bigquery) | `pip install sqlalchemy-bigquery` | `bigquery://{project_id}` |
| [Google Sheets](/user-docs/6.0.0/configuration/databases#google-sheets) | `pip install shillelagh[gsheetsapi]` | `gsheets://` |
| [Firebolt](/user-docs/6.0.0/configuration/databases#firebolt) | `pip install firebolt-sqlalchemy` | `firebolt://{client_id}:{client_secret}@{database}/{engine_name}?account_name={name}` |
| [Hologres](/user-docs/6.0.0/configuration/databases#hologres) | `pip install psycopg2` | `postgresql+psycopg2://<UserName>:<DBPassword>@<Database Host>/<Database Name>` |
| [IBM Db2](/user-docs/6.0.0/configuration/databases#ibm-db2) | `pip install ibm_db_sa` | `db2+ibm_db://` |
| [IBM Netezza Performance Server](/user-docs/6.0.0/configuration/databases#ibm-netezza-performance-server) | `pip install nzalchemy` | `netezza+nzpy://<UserName>:<DBPassword>@<Database Host>/<Database Name>` |
| [MySQL](/user-docs/6.0.0/configuration/databases#mysql) | `pip install mysqlclient` | `mysql://<UserName>:<DBPassword>@<Database Host>/<Database Name>` |
| [OceanBase](/user-docs/6.0.0/configuration/databases#oceanbase) | `pip install oceanbase_py` | `oceanbase://<UserName>:<DBPassword>@<Database Host>/<Database Name>` |
| [Oracle](/user-docs/6.0.0/configuration/databases#oracle) | `pip install cx_Oracle` | `oracle://<username>:<password>@<hostname>:<port>` |
| [Parseable](/user-docs/6.0.0/configuration/databases#parseable) | `pip install sqlalchemy-parseable` | `parseable://<UserName>:<DBPassword>@<Database Host>/<Stream Name>` |
| [PostgreSQL](/user-docs/6.0.0/configuration/databases#postgres) | `pip install psycopg2` | `postgresql://<UserName>:<DBPassword>@<Database Host>/<Database Name>` |
| [Presto](/user-docs/6.0.0/configuration/databases#presto) | `pip install pyhive` | `presto://{username}:{password}@{hostname}:{port}/{database}` |
| [SAP Hana](/user-docs/6.0.0/configuration/databases#hana) | `pip install hdbcli sqlalchemy-hana` or `pip install apache_superset[hana]` | `hana://{username}:{password}@{host}:{port}` |
| [SingleStore](/user-docs/6.0.0/configuration/databases#singlestore) | `pip install sqlalchemy-singlestoredb` | `singlestoredb://{username}:{password}@{host}:{port}/{database}` |
| [StarRocks](/user-docs/6.0.0/configuration/databases#starrocks) | `pip install starrocks` | `starrocks://<User>:<Password>@<Host>:<Port>/<Catalog>.<Database>` |
| [Snowflake](/user-docs/6.0.0/configuration/databases#snowflake) | `pip install snowflake-sqlalchemy` | `snowflake://{user}:{password}@{account}.{region}/{database}?role={role}&warehouse={warehouse}` |
| SQLite | No additional library needed | `sqlite://path/to/file.db?check_same_thread=false` |
| [SQL Server](/docs/6.0.0/configuration/databases#sql-server) | `pip install pymssql` | `mssql+pymssql://<Username>:<Password>@<Host>:<Port-default:1433>/<Database Name>` |
| [TDengine](/docs/6.0.0/configuration/databases#tdengine) | `pip install taospy` `pip install taos-ws-py` | `taosws://<user>:<password>@<host>:<port>` |
| [Teradata](/docs/6.0.0/configuration/databases#teradata) | `pip install teradatasqlalchemy` | `teradatasql://{user}:{password}@{host}` |
| [TimescaleDB](/docs/6.0.0/configuration/databases#timescaledb) | `pip install psycopg2` | `postgresql://<UserName>:<DBPassword>@<Database Host>:<Port>/<Database Name>` |
| [Trino](/docs/6.0.0/configuration/databases#trino) | `pip install trino` | `trino://{username}:{password}@{hostname}:{port}/{catalog}` |
| [Vertica](/docs/6.0.0/configuration/databases#vertica) | `pip install sqlalchemy-vertica-python` | `vertica+vertica_python://<UserName>:<DBPassword>@<Database Host>/<Database Name>` |
| [YDB](/docs/6.0.0/configuration/databases#ydb) | `pip install ydb-sqlalchemy` | `ydb://{host}:{port}/{database_name}` |
| [YugabyteDB](/docs/6.0.0/configuration/databases#yugabytedb) | `pip install psycopg2` | `postgresql://<UserName>:<DBPassword>@<Database Host>/<Database Name>` |
| [SQL Server](/user-docs/6.0.0/configuration/databases#sql-server) | `pip install pymssql` | `mssql+pymssql://<Username>:<Password>@<Host>:<Port-default:1433>/<Database Name>` |
| [TDengine](/user-docs/6.0.0/configuration/databases#tdengine) | `pip install taospy` `pip install taos-ws-py` | `taosws://<user>:<password>@<host>:<port>` |
| [Teradata](/user-docs/6.0.0/configuration/databases#teradata) | `pip install teradatasqlalchemy` | `teradatasql://{user}:{password}@{host}` |
| [TimescaleDB](/user-docs/6.0.0/configuration/databases#timescaledb) | `pip install psycopg2` | `postgresql://<UserName>:<DBPassword>@<Database Host>:<Port>/<Database Name>` |
| [Trino](/user-docs/6.0.0/configuration/databases#trino) | `pip install trino` | `trino://{username}:{password}@{hostname}:{port}/{catalog}` |
| [Vertica](/user-docs/6.0.0/configuration/databases#vertica) | `pip install sqlalchemy-vertica-python` | `vertica+vertica_python://<UserName>:<DBPassword>@<Database Host>/<Database Name>` |
| [YDB](/user-docs/6.0.0/configuration/databases#ydb) | `pip install ydb-sqlalchemy` | `ydb://{host}:{port}/{database_name}` |
| [YugabyteDB](/user-docs/6.0.0/configuration/databases#yugabytedb) | `pip install psycopg2` | `postgresql://<UserName>:<DBPassword>@<Database Host>/<Database Name>` |
---
@@ -109,7 +109,7 @@ The connector library installation process is the same for all additional librar
#### 1. Determine the driver you need
Consult the [list of database drivers](/docs/6.0.0/configuration/databases)
Consult the [list of database drivers](/user-docs/6.0.0/configuration/databases)
and find the PyPI package needed to connect to your database. In this example, we're connecting
to a MySQL database, so we'll need the `mysqlclient` connector library.
@@ -165,11 +165,11 @@ to your database via the Superset web UI.
As an admin user, go to Settings -> Data: Database Connections and click the +DATABASE button.
From there, follow the steps on the
[Using Database Connection UI page](/docs/6.0.0/configuration/databases#connecting-through-the-ui).
[Using Database Connection UI page](/user-docs/6.0.0/configuration/databases#connecting-through-the-ui).
Consult the page for your specific database type in the Superset documentation to determine
the connection string and any other parameters you need to input. For instance,
on the [MySQL page](/docs/6.0.0/configuration/databases#mysql), we see that the connection string
on the [MySQL page](/user-docs/6.0.0/configuration/databases#mysql), we see that the connection string
to a local MySQL database differs depending on whether the setup is running on Linux or Mac.
Click the “Test Connection” button, which should result in a popup message saying,
@@ -407,7 +407,7 @@ this:
crate://<username>:<password>@<clustername>.cratedb.net:4200/?ssl=true
```
Follow the steps [here](/docs/6.0.0/configuration/databases#installing-database-drivers)
Follow the steps [here](/user-docs/6.0.0/configuration/databases#installing-database-drivers)
to install the CrateDB connector package when setting up Superset locally using
Docker Compose.
@@ -782,7 +782,7 @@ The recommended connector library for BigQuery is
##### Install BigQuery Driver
Follow the steps [here](/docs/6.0.0/configuration/databases#installing-drivers-in-docker-images) about how to
Follow the steps [here](/user-docs/6.0.0/configuration/databases#installing-drivers-in-docker-images) about how to
install new database drivers when setting up Superset locally via docker compose.
```bash
@@ -1177,7 +1177,7 @@ risingwave://root@{hostname}:{port}/{database}?sslmode=disable
##### Install Snowflake Driver
Follow the steps [here](/docs/6.0.0/configuration/databases#installing-database-drivers) about how to
Follow the steps [here](/user-docs/6.0.0/configuration/databases#installing-database-drivers) about how to
install new database drivers when setting up Superset locally via docker compose.
```bash

View File

@@ -51,7 +51,7 @@ Restart Superset for this configuration change to take effect.
#### Making a Dashboard Public
1. Add the `'DASHBOARD_RBAC': True` [Feature Flag](/docs/6.0.0/configuration/feature-flags) to `superset_config.py`
1. Add the `'DASHBOARD_RBAC': True` [Feature Flag](/user-docs/6.0.0/configuration/configuring-superset#feature-flags) to `superset_config.py`
2. Add the `Public` role to your dashboard as described [here](https://superset.apache.org/docs/using-superset/creating-your-first-dashboard/#manage-access-to-dashboards)
#### Embedding a Public Dashboard

View File

@@ -10,7 +10,7 @@ version: 1
## Jinja Templates
SQL Lab and Explore supports [Jinja templating](https://jinja.palletsprojects.com/en/2.11.x/) in queries.
To enable templating, the `ENABLE_TEMPLATE_PROCESSING` [feature flag](/docs/6.0.0/configuration/configuring-superset#feature-flags) needs to be enabled in
To enable templating, the `ENABLE_TEMPLATE_PROCESSING` [feature flag](/user-docs/6.0.0/configuration/configuring-superset#feature-flags) needs to be enabled in
`superset_config.py`. When templating is enabled, python code can be embedded in virtual datasets and
in Custom SQL in the filter and metric controls in Explore. By default, the following variables are
made available in the Jinja context:

View File

@@ -20,7 +20,7 @@ To help make the problem somewhat tractable—given that Apache Superset has no
To strive for data consistency (regardless of the timezone of the client) the Apache Superset backend tries to ensure that any timestamp sent to the client has an explicit (or semi-explicit as in the case with [Epoch time](https://en.wikipedia.org/wiki/Unix_time) which is always in reference to UTC) timezone encoded within.
The challenge however lies with the slew of [database engines](/docs/6.0.0/configuration/databases#installing-drivers-in-docker-images) which Apache Superset supports and various inconsistencies between their [Python Database API (DB-API)](https://www.python.org/dev/peps/pep-0249/) implementations combined with the fact that we use [Pandas](https://pandas.pydata.org/) to read SQL into a DataFrame prior to serializing to JSON. Regrettably Pandas ignores the DB-API [type_code](https://www.python.org/dev/peps/pep-0249/#type-objects) relying by default on the underlying Python type returned by the DB-API. Currently only a subset of the supported database engines work correctly with Pandas, i.e., ensuring timestamps without an explicit timestamp are serializd to JSON with the server timezone, thus guaranteeing the client will display timestamps in a consistent manner irrespective of the client's timezone.
The challenge however lies with the slew of [database engines](/user-docs/6.0.0/configuration/databases#installing-drivers-in-docker-images) which Apache Superset supports and various inconsistencies between their [Python Database API (DB-API)](https://www.python.org/dev/peps/pep-0249/) implementations combined with the fact that we use [Pandas](https://pandas.pydata.org/) to read SQL into a DataFrame prior to serializing to JSON. Regrettably Pandas ignores the DB-API [type_code](https://www.python.org/dev/peps/pep-0249/#type-objects) relying by default on the underlying Python type returned by the DB-API. Currently only a subset of the supported database engines work correctly with Pandas, i.e., ensuring timestamps without an explicit timestamp are serializd to JSON with the server timezone, thus guaranteeing the client will display timestamps in a consistent manner irrespective of the client's timezone.
For example the following is a comparison of MySQL and Presto,

View File

@@ -77,7 +77,7 @@ Look through the GitHub issues. Issues tagged with
Superset could always use better documentation,
whether as part of the official Superset docs,
in docstrings, `docs/*.rst` or even on the web as blog posts or
articles. See [Documentation](/docs/6.0.0/contributing/howtos#contributing-to-documentation) for more details.
articles. See [Documentation](/user-docs/6.0.0/contributing/howtos#contributing-to-documentation) for more details.
### Add Translations

View File

@@ -599,7 +599,7 @@ export enum FeatureFlag {
those specified under FEATURE_FLAGS in `superset_config.py`. For example, `DEFAULT_FEATURE_FLAGS = { 'FOO': True, 'BAR': False }` in `superset/config.py` and `FEATURE_FLAGS = { 'BAR': True, 'BAZ': True }` in `superset_config.py` will result
in combined feature flags of `{ 'FOO': True, 'BAR': True, 'BAZ': True }`.
The current status of the usability of each flag (stable vs testing, etc) can be found in the [Feature Flags](/docs/6.0.0/configuration/feature-flags) documentation.
The current status of the usability of each flag (stable vs testing, etc) can be found in the [Feature Flags](/user-docs/6.0.0/configuration/configuring-superset#feature-flags) documentation.
## Git Hooks
@@ -614,7 +614,7 @@ A series of checks will now run when you make a git commit.
## Linting
See [how tos](/docs/6.0.0/contributing/howtos#linting)
See [how tos](/user-docs/6.0.0/contributing/howtos#linting)
## GitHub Actions and `act`

View File

@@ -57,7 +57,7 @@ Finally, never submit a PR that will put master branch in broken state. If the P
in `requirements.txt` pinned to a specific version which ensures that the application
build is deterministic.
- For TypeScript/JavaScript, include new libraries in `package.json`
- **Tests:** The pull request should include tests, either as doctests, unit tests, or both. Make sure to resolve all errors and test failures. See [Testing](/docs/6.0.0/contributing/howtos#testing) for how to run tests.
- **Tests:** The pull request should include tests, either as doctests, unit tests, or both. Make sure to resolve all errors and test failures. See [Testing](/user-docs/6.0.0/contributing/howtos#testing) for how to run tests.
- **Documentation:** If the pull request adds functionality, the docs should be updated as part of the same PR.
- **CI:** Reviewers will not review the code until all CI tests are passed. Sometimes there can be flaky tests. You can close and open PR to re-run CI test. Please report if the issue persists. After the CI fix has been deployed to `master`, please rebase your PR.
- **Code coverage:** Please ensure that code coverage does not decrease.

View File

@@ -51,11 +51,11 @@ multiple tables as long as your database account has access to the tables.
## How do I create my own visualization?
We recommend reading the instructions in
[Creating Visualization Plugins](/docs/6.0.0/contributing/howtos#creating-visualization-plugins).
[Creating Visualization Plugins](/user-docs/6.0.0/contributing/howtos#creating-visualization-plugins).
## Can I upload and visualize CSV data?
Absolutely! Read the instructions [here](/docs/using-superset/exploring-data) to learn
Absolutely! Read the instructions [here](/user-docs/using-superset/exploring-data) to learn
how to enable and use CSV upload.
## Why are my queries timing out?
@@ -142,7 +142,7 @@ SQLALCHEMY_DATABASE_URI = 'sqlite:////new/location/superset.db?check_same_thread
```
You can read more about customizing Superset using the configuration file
[here](/docs/6.0.0/configuration/configuring-superset).
[here](/user-docs/6.0.0/configuration/configuring-superset).
## What if the table schema changed?
@@ -157,7 +157,7 @@ table afterwards to configure the Columns tab, check the appropriate boxes and s
To clarify, the database backend is an OLTP database used by Superset to store its internal
information like your list of users and dashboard definitions. While Superset supports a
[variety of databases as data _sources_](/docs/6.0.0/configuration/databases#installing-database-drivers),
[variety of databases as data _sources_](/user-docs/6.0.0/configuration/databases#installing-database-drivers),
only a few database engines are supported for use as the OLTP backend / metadata store.
Superset is tested using MySQL, PostgreSQL, and SQLite backends. Its recommended you install
@@ -190,7 +190,7 @@ second etc). Example:
## Does Superset work with [insert database engine here]?
The [Connecting to Databases section](/docs/6.0.0/configuration/databases) provides the best
The [Connecting to Databases section](/user-docs/6.0.0/configuration/databases) provides the best
overview for supported databases. Database engines not listed on that page may work too. We rely on
the community to contribute to this knowledge base.
@@ -226,7 +226,7 @@ are typical in basic SQL:
## Does Superset offer a public API?
Yes, a public REST API, and the surface of that API formal is expanding steadily. You can read more about this API and
interact with it using Swagger [here](/docs/api).
interact with it using Swagger [here](/developer-docs/api).
Some of the
original vision for the collection of endpoints under **/api/v1** was originally specified in
@@ -266,7 +266,7 @@ Superset uses [Scarf](https://about.scarf.sh/) by default to collect basic telem
We use the [Scarf Gateway](https://docs.scarf.sh/gateway/) to sit in front of container registries, the [scarf-js](https://about.scarf.sh/package-sdks) package to track `npm` installations, and a Scarf pixel to gather anonymous analytics on Superset page views.
Scarf purges PII and provides aggregated statistics. Superset users can easily opt out of analytics in various ways documented [here](https://docs.scarf.sh/gateway/#do-not-track) and [here](https://docs.scarf.sh/package-analytics/#as-a-user-of-a-package-using-scarf-js-how-can-i-opt-out-of-analytics).
Superset maintainers can also opt out of telemetry data collection by setting the `SCARF_ANALYTICS` environment variable to `false` in the Superset container (or anywhere Superset/webpack are run).
Additional opt-out instructions for Docker users are available on the [Docker Installation](/docs/6.0.0/installation/docker-compose) page.
Additional opt-out instructions for Docker users are available on the [Docker Installation](/user-docs/6.0.0/installation/docker-compose) page.
## Does Superset have an archive panel or trash bin from which a user can recover deleted assets?

View File

@@ -24,10 +24,10 @@ A Superset installation is made up of these components:
The optional components above are necessary to enable these features:
- [Alerts and Reports](/docs/6.0.0/configuration/alerts-reports)
- [Caching](/docs/6.0.0/configuration/cache)
- [Async Queries](/docs/6.0.0/configuration/async-queries-celery/)
- [Dashboard Thumbnails](/docs/6.0.0/configuration/cache/#caching-thumbnails)
- [Alerts and Reports](/user-docs/6.0.0/configuration/alerts-reports)
- [Caching](/user-docs/6.0.0/configuration/cache)
- [Async Queries](/user-docs/6.0.0/configuration/async-queries-celery/)
- [Dashboard Thumbnails](/user-docs/6.0.0/configuration/cache/#caching-thumbnails)
If you install with Kubernetes or Docker Compose, all of these components will be created.
@@ -59,7 +59,7 @@ The caching layer serves two main functions:
- Store the results of queries to your data warehouse so that when a chart is loaded twice, it pulls from the cache the second time, speeding up the application and reducing load on your data warehouse.
- Act as a message broker for the worker, enabling the Alerts & Reports, async queries, and thumbnail caching features.
Most people use Redis for their cache, but Superset supports other options too. See the [cache docs](/docs/6.0.0/configuration/cache/) for more.
Most people use Redis for their cache, but Superset supports other options too. See the [cache docs](/user-docs/6.0.0/configuration/cache/) for more.
### Worker and Beat
@@ -67,6 +67,6 @@ This is one or more workers who execute tasks like run async queries or take sna
## Other components
Other components can be incorporated into Superset. The best place to learn about additional configurations is the [Configuration page](/docs/6.0.0/configuration/configuring-superset). For instance, you could set up a load balancer or reverse proxy to implement HTTPS in front of your Superset application, or specify a Mapbox URL to enable geospatial charts, etc.
Other components can be incorporated into Superset. The best place to learn about additional configurations is the [Configuration page](/user-docs/6.0.0/configuration/configuring-superset). For instance, you could set up a load balancer or reverse proxy to implement HTTPS in front of your Superset application, or specify a Mapbox URL to enable geospatial charts, etc.
Superset won't even start without certain configuration settings established, so it's essential to review that page.

View File

@@ -21,7 +21,7 @@ with our [installing on k8s](https://superset.apache.org/docs/installation/runni
documentation.
:::
As mentioned in our [quickstart guide](/docs/quickstart), the fastest way to try
As mentioned in our [quickstart guide](/user-docs/quickstart), the fastest way to try
Superset locally is using Docker Compose on a Linux or Mac OSX
computer. Superset does not have official support for Windows. It's also the easiest
way to launch a fully functioning **development environment** quickly.

View File

@@ -9,11 +9,11 @@ import useBaseUrl from "@docusaurus/useBaseUrl";
# Installation Methods
How should you install Superset? Here's a comparison of the different options. It will help if you've first read the [Architecture](/docs/6.0.0/installation/architecture page to understand Superset's different components.
How should you install Superset? Here's a comparison of the different options. It will help if you've first read the [Architecture](/user-docs/6.0.0/installation/architecture) page to understand Superset's different components.
The fundamental trade-off is between you needing to do more of the detail work yourself vs. using a more complex deployment route that handles those details.
## [Docker Compose](/docs/6.0.0/installation/docker-compose
## [Docker Compose](/user-docs/6.0.0/installation/docker-compose)
**Summary:** This takes advantage of containerization while remaining simpler than Kubernetes. This is the best way to try out Superset; it's also useful for developing & contributing back to Superset.
@@ -27,9 +27,9 @@ You will need to back up your metadata DB. That could mean backing up the servic
You will also need to extend the Superset docker image. The default `lean` images do not contain drivers needed to access your metadata database (Postgres or MySQL), nor to access your data warehouse, nor the headless browser needed for Alerts & Reports. You could run a `-dev` image while demoing Superset, which has some of this, but you'll still need to install the driver for your data warehouse. The `-dev` images run as root, which is not recommended for production.
Ideally you will build your own image of Superset that extends `lean`, adding what your deployment needs. See [Building your own production Docker image](/docs/6.0.0/installation/docker-builds/#building-your-own-production-docker-image).
Ideally you will build your own image of Superset that extends `lean`, adding what your deployment needs. See [Building your own production Docker image](/user-docs/6.0.0/installation/docker-builds/#building-your-own-production-docker-image).
## [Kubernetes (K8s)](/docs/6.0.0/installation/kubernetes
## [Kubernetes (K8s)](/user-docs/6.0.0/installation/kubernetes)
**Summary:** This is the best-practice way to deploy a production instance of Superset, but has the steepest skill requirement - someone who knows Kubernetes.
@@ -41,7 +41,7 @@ A K8s deployment can scale up and down based on usage and deploy rolling updates
You will need to build your own Docker image, and back up your metadata DB, both as described in Docker Compose above. You'll also need to customize your Helm chart values and deploy and maintain your Kubernetes cluster.
## [PyPI (Python)](/docs/6.0.0/installation/pypi
## [PyPI (Python)](/user-docs/6.0.0/installation/pypi)
**Summary:** This is the only method that requires no knowledge of containers. It requires the most hands-on work to deploy, connect, and maintain each component.

View File

@@ -149,7 +149,7 @@ For production clusters it's recommended to build own image with this step done
Superset requires a Python DB-API database driver and a SQLAlchemy
dialect to be installed for each datastore you want to connect to.
See [Install Database Drivers](/docs/6.0.0/configuration/databases) for more information.
See [Install Database Drivers](/user-docs/6.0.0/configuration/databases) for more information.
It is recommended that you refer to versions listed in
[pyproject.toml](https://github.com/apache/superset/blob/master/pyproject.toml)
instead of hard-coding them in your bootstrap script, as seen below.
@@ -310,7 +310,7 @@ configOverrides:
### Enable Alerts and Reports
For this, as per the [Alerts and Reports doc](/docs/6.0.0/configuration/alerts-reports), you will need to:
For this, as per the [Alerts and Reports doc](/user-docs/6.0.0/configuration/alerts-reports), you will need to:
#### Install a supported webdriver in the Celery worker

View File

@@ -172,7 +172,7 @@ how to set up a development environment.
## Resources
- [Superset "In the Wild"](https://github.com/apache/superset/blob/master/RESOURCES/INTHEWILD.md) - open a PR to add your org to the list!
- [Feature Flags](/docs/6.0.0/configuration/feature-flags) - the status of Superset's Feature Flags.
- [Feature Flags](/user-docs/6.0.0/configuration/configuring-superset#feature-flags) - the status of Superset's Feature Flags.
- [Standard Roles](https://github.com/apache/superset/blob/master/RESOURCES/STANDARD_ROLES.md) - How RBAC permissions map to roles.
- [Superset Wiki](https://github.com/apache/superset/wiki) - Tons of additional community resources: best practices, community content and other information.
- [Superset SIPs](https://github.com/orgs/apache/projects/170) - The status of Superset's SIPs (Superset Improvement Proposals) for both consensus and implementation status.

View File

@@ -15,7 +15,7 @@ Although we recommend using `Docker Compose` for a quick start in a sandbox-type
environment and for other development-type use cases, **we
do not recommend this setup for production**. For this purpose please
refer to our
[Installing on Kubernetes](/docs/6.0.0/installation/kubernetes/)
[Installing on Kubernetes](/user-docs/6.0.0/installation/kubernetes/)
page.
:::
@@ -73,10 +73,10 @@ processes by running Docker Compose `stop` command. By doing so, you can avoid d
From this point on, you can head on to:
- [Create your first Dashboard](/docs/6.0.0/using-superset/creating-your-first-dashboard)
- [Connect to a Database](/docs/6.0.0/configuration/databases)
- [Using Docker Compose](/docs/6.0.0/installation/docker-compose)
- [Configure Superset](/docs/6.0.0/configuration/configuring-superset/)
- [Installing on Kubernetes](/docs/6.0.0/installation/kubernetes/)
- [Create your first Dashboard](/user-docs/6.0.0/using-superset/creating-your-first-dashboard)
- [Connect to a Database](/user-docs/6.0.0/configuration/databases)
- [Using Docker Compose](/user-docs/6.0.0/installation/docker-compose)
- [Configure Superset](/user-docs/6.0.0/configuration/configuring-superset/)
- [Installing on Kubernetes](/user-docs/6.0.0/installation/kubernetes/)
Or just explore our [Documentation](https://superset.apache.org/docs/intro)!

View File

@@ -31,7 +31,7 @@ your existing SQL-speaking database or data store.
First things first, we need to add the connection credentials to your database to be able
to query and visualize data from it. If you're using Superset locally via
[Docker compose](/docs/6.0.0/installation/docker-compose), you can
[Docker compose](/user-docs/6.0.0/installation/docker-compose), you can
skip this step because a Postgres database, named **examples**, is included and
pre-configured in Superset for you.
@@ -188,7 +188,7 @@ Access to dashboards is managed via owners (users that have edit permissions to
Non-owner users access can be managed in two different ways. The dashboard needs to be published to be visible to other users.
1. Dataset permissions - if you add to the relevant role permissions to datasets it automatically grants implicit access to all dashboards that uses those permitted datasets.
2. Dashboard roles - if you enable [**DASHBOARD_RBAC** feature flag](/docs/6.0.0/configuration/configuring-superset#feature-flags) then you will be able to manage which roles can access the dashboard
2. Dashboard roles - if you enable [**DASHBOARD_RBAC** feature flag](/user-docs/6.0.0/configuration/configuring-superset#feature-flags) then you will be able to manage which roles can access the dashboard
- Granting a role access to a dashboard will bypass dataset level checks. Having dashboard access implicitly grants read access to all the featured charts in the dashboard, and thereby also all the associated datasets.
- If no roles are specified for a dashboard, regular **Dataset permissions** will apply.

View File

View File

@@ -20,7 +20,7 @@ import json
import os
import re
import subprocess
from typing import List
from typing import List, Optional
from urllib.request import Request, urlopen
# Define patterns for each group of files you're interested in
@@ -111,7 +111,7 @@ def main(event_type: str, sha: str, repo: str) -> None:
"""Main function to check for file changes based on event context."""
print("SHA:", sha)
print("EVENT_TYPE", event_type)
files = []
files: Optional[List[str]] = []
if event_type == "pull_request":
pr_number = os.getenv("GITHUB_REF", "").split("/")[-2]
if is_int(pr_number):
@@ -124,11 +124,15 @@ def main(event_type: str, sha: str, repo: str) -> None:
print("Files touched since previous commit:")
print_files(files)
elif event_type == "workflow_dispatch":
print("Workflow dispatched, assuming all changed")
elif event_type in ("workflow_dispatch", "schedule"):
# Manual or cron-triggered runs aren't tied to a specific diff, so
# treat every group as changed. `files = None` makes the loop below
# short-circuit to True for every group via `files is None or ...`.
print(f"{event_type} run, assuming all changed")
files = None
else:
raise ValueError("Unsupported event type")
raise ValueError(f"Unsupported event type: {event_type}")
changes_detected = {}
for group, regex_patterns in PATTERNS.items():
@@ -144,7 +148,7 @@ def main(event_type: str, sha: str, repo: str) -> None:
# NOTE: as noted above, we assume that if 100 files are touched, we should
# trigger all checks. This is a workaround for the GitHub API limit of 100
# files. Using >= 99 because off-by-one errors are not uncommon
if changed or len(files) >= 99:
if changed or (files is not None and len(files) >= 99):
print(f"{check}=true", file=f)
print(f"Triggering group: {check}")

View File

@@ -110,7 +110,7 @@
"json-stringify-pretty-compact": "^2.0.0",
"lodash": "^4.18.1",
"mapbox-gl": "^3.23.1",
"markdown-to-jsx": "^9.7.16",
"markdown-to-jsx": "^9.8.0",
"match-sorter": "^8.3.0",
"memoize-one": "^5.2.1",
"mousetrap": "^1.6.5",
@@ -224,7 +224,7 @@
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.59.3",
"babel-jest": "^30.0.2",
"babel-jest": "^30.4.1",
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
@@ -17027,16 +17027,16 @@
"license": "Apache-2.0"
},
"node_modules/babel-jest": {
"version": "30.3.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz",
"integrity": "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==",
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz",
"integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/transform": "30.3.0",
"@jest/transform": "30.4.1",
"@types/babel__core": "^7.20.5",
"babel-plugin-istanbul": "^7.0.1",
"babel-preset-jest": "30.3.0",
"babel-preset-jest": "30.4.0",
"chalk": "^4.1.2",
"graceful-fs": "^4.2.11",
"slash": "^3.0.0"
@@ -17048,6 +17048,85 @@
"@babel/core": "^7.11.0 || ^8.0.0-0"
}
},
"node_modules/babel-jest/node_modules/@jest/pattern": {
"version": "30.4.0",
"resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz",
"integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"jest-regex-util": "30.4.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/babel-jest/node_modules/@jest/schemas": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz",
"integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sinclair/typebox": "^0.34.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/babel-jest/node_modules/@jest/transform": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz",
"integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.27.4",
"@jest/types": "30.4.1",
"@jridgewell/trace-mapping": "^0.3.25",
"babel-plugin-istanbul": "^7.0.1",
"chalk": "^4.1.2",
"convert-source-map": "^2.0.0",
"fast-json-stable-stringify": "^2.1.0",
"graceful-fs": "^4.2.11",
"jest-haste-map": "30.4.1",
"jest-regex-util": "30.4.0",
"jest-util": "30.4.1",
"pirates": "^4.0.7",
"slash": "^3.0.0",
"write-file-atomic": "^5.0.1"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/babel-jest/node_modules/@jest/types": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz",
"integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/pattern": "30.4.0",
"@jest/schemas": "30.4.1",
"@types/istanbul-lib-coverage": "^2.0.6",
"@types/istanbul-reports": "^3.0.4",
"@types/node": "*",
"@types/yargs": "^17.0.33",
"chalk": "^4.1.2"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/babel-jest/node_modules/@sinclair/typebox": {
"version": "0.34.49",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
"integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==",
"dev": true,
"license": "MIT"
},
"node_modules/babel-jest/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -17065,6 +17144,105 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/babel-jest/node_modules/jest-haste-map": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz",
"integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/types": "30.4.1",
"@types/node": "*",
"anymatch": "^3.1.3",
"fb-watchman": "^2.0.2",
"graceful-fs": "^4.2.11",
"jest-regex-util": "30.4.0",
"jest-util": "30.4.1",
"jest-worker": "30.4.1",
"picomatch": "^4.0.3",
"walker": "^1.0.8"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
},
"optionalDependencies": {
"fsevents": "^2.3.3"
}
},
"node_modules/babel-jest/node_modules/jest-regex-util": {
"version": "30.4.0",
"resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz",
"integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/babel-jest/node_modules/jest-util": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz",
"integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/types": "30.4.1",
"@types/node": "*",
"chalk": "^4.1.2",
"ci-info": "^4.2.0",
"graceful-fs": "^4.2.11",
"picomatch": "^4.0.3"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/babel-jest/node_modules/jest-worker": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz",
"integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@ungap/structured-clone": "^1.3.0",
"jest-util": "30.4.1",
"merge-stream": "^2.0.0",
"supports-color": "^8.1.1"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/babel-jest/node_modules/jest-worker/node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/babel-jest/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/babel-jest/node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -17132,9 +17310,9 @@
}
},
"node_modules/babel-plugin-jest-hoist": {
"version": "30.3.0",
"resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.3.0.tgz",
"integrity": "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==",
"version": "30.4.0",
"resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz",
"integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -17288,13 +17466,13 @@
}
},
"node_modules/babel-preset-jest": {
"version": "30.3.0",
"resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.3.0.tgz",
"integrity": "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==",
"version": "30.4.0",
"resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz",
"integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"babel-plugin-jest-hoist": "30.3.0",
"babel-plugin-jest-hoist": "30.4.0",
"babel-preset-current-node-syntax": "^1.2.0"
},
"engines": {
@@ -28917,6 +29095,58 @@
"dev": true,
"license": "MIT"
},
"node_modules/jest-config/node_modules/babel-jest": {
"version": "30.3.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz",
"integrity": "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/transform": "30.3.0",
"@types/babel__core": "^7.20.5",
"babel-plugin-istanbul": "^7.0.1",
"babel-preset-jest": "30.3.0",
"chalk": "^4.1.2",
"graceful-fs": "^4.2.11",
"slash": "^3.0.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
},
"peerDependencies": {
"@babel/core": "^7.11.0 || ^8.0.0-0"
}
},
"node_modules/jest-config/node_modules/babel-plugin-jest-hoist": {
"version": "30.3.0",
"resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.3.0.tgz",
"integrity": "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/babel__core": "^7.20.5"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/jest-config/node_modules/babel-preset-jest": {
"version": "30.3.0",
"resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.3.0.tgz",
"integrity": "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"babel-plugin-jest-hoist": "30.3.0",
"babel-preset-current-node-syntax": "^1.2.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
},
"peerDependencies": {
"@babel/core": "^7.11.0 || ^8.0.0-beta.1"
}
},
"node_modules/jest-config/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
@@ -33841,9 +34071,9 @@
}
},
"node_modules/markdown-to-jsx": {
"version": "9.7.16",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.7.16.tgz",
"integrity": "sha512-+LEgOlYfUEB9i2Oaxasec9H2HytB1+SQcgwFmQiNTKwe8cQ2E9bDNgePGq6ChIycMxtpcEY0g44aQ3uJoMw8eg==",
"version": "9.8.0",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.8.0.tgz",
"integrity": "sha512-NWL0vDt6BeQgCxc2kK6g2SEPJpQuacu+NJk5j4QT4wlNmbsHdudoAbTGLCIyoPhfMiEqD1OZVBsYK0hvCky/HQ==",
"license": "MIT",
"engines": {
"node": ">= 18"
@@ -50174,7 +50404,7 @@
"version": "0.20.4",
"license": "Apache-2.0",
"dependencies": {
"@ant-design/icons": "^6.2.2",
"@ant-design/icons": "^6.2.3",
"@apache-superset/core": "*",
"@babel/runtime": "^7.29.2",
"@types/json-bigint": "^1.0.4",
@@ -50206,7 +50436,7 @@
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.1",
"react-syntax-highlighter": "^16.1.0",
"react-ultimate-pagination": "^1.3.2",
"regenerator-runtime": "^0.14.1",
"rehype-raw": "^7.0.0",
@@ -50275,9 +50505,9 @@
}
},
"packages/superset-ui-core/node_modules/@ant-design/icons": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.2.2.tgz",
"integrity": "sha512-zlJtE7AMbG12TeYVPhtBXwNpFInNy8mjLzcIm+0BPw16/b8ODG87YJ1G37VIF5VFscdgfsf6EweAFPTobu/3iQ==",
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.2.3.tgz",
"integrity": "sha512-Pl3aoAtxQeKryYnt6VvDJtOxMOtA8wrRSACe/pTjOAIG3fdHrWm6Ivb4ku9tsFjYroSXBKirvuxG4QkwBXD9gg==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^8.0.1",

View File

@@ -191,7 +191,7 @@
"json-stringify-pretty-compact": "^2.0.0",
"lodash": "^4.18.1",
"mapbox-gl": "^3.23.1",
"markdown-to-jsx": "^9.7.16",
"markdown-to-jsx": "^9.8.0",
"match-sorter": "^8.3.0",
"memoize-one": "^5.2.1",
"mousetrap": "^1.6.5",
@@ -305,7 +305,7 @@
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.59.3",
"babel-jest": "^30.0.2",
"babel-jest": "^30.4.1",
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",

View File

@@ -24,7 +24,7 @@
"lib"
],
"dependencies": {
"@ant-design/icons": "^6.2.2",
"@ant-design/icons": "^6.2.3",
"@apache-superset/core": "*",
"@babel/runtime": "^7.29.2",
"@types/json-bigint": "^1.0.4",
@@ -56,7 +56,7 @@
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.1",
"react-syntax-highlighter": "^16.1.0",
"react-ultimate-pagination": "^1.3.2",
"regenerator-runtime": "^0.14.1",
"rehype-raw": "^7.0.0",

View File

@@ -17,59 +17,109 @@
* under the License.
*/
import Loadable from 'react-loadable';
import { ComponentClass } from 'react';
import { ReactElement, useEffect, useRef, useState } from 'react';
export type LoadableRendererProps = {
onRenderFailure?: Function;
onRenderSuccess?: Function;
onRenderFailure?: (error: Error) => void;
onRenderSuccess?: () => void;
};
const defaultProps = {
onRenderFailure() {},
onRenderSuccess() {},
type LoaderMap<Exports> = {
[K in keyof Exports]: () => Promise<Exports[K]> | Exports[K];
};
export interface LoadableRenderer<Props>
extends
ComponentClass<Props & LoadableRendererProps>,
Loadable.LoadableComponent {}
export default function createLoadableRenderer<
Props,
Exports extends { [key: string]: any },
>(options: Loadable.OptionsWithMap<Props, Exports>): LoadableRenderer<Props> {
const LoadableRenderer = Loadable.Map<Props, Exports>(
options,
) as LoadableRenderer<Props>;
// Extends the behavior of LoadableComponent to provide post-render listeners
class CustomLoadableRenderer extends LoadableRenderer {
static defaultProps: object;
componentDidMount() {
this.afterRender();
}
componentDidUpdate() {
this.afterRender();
}
afterRender() {
const { loaded, loading, error } = this.state;
const { onRenderFailure, onRenderSuccess } = this.props;
if (!loading) {
if (error) {
(onRenderFailure as Function)(error);
} else if (loaded && Object.keys(loaded).length > 0) {
(onRenderSuccess as Function)();
}
}
}
}
CustomLoadableRenderer.defaultProps = defaultProps;
CustomLoadableRenderer.preload = LoadableRenderer.preload;
return CustomLoadableRenderer;
export interface LoadingProps {
error?: { toString(): string };
}
export interface LoadableOptions<Props, Exports> {
loader: LoaderMap<Exports>;
loading: (loadingProps: LoadingProps) => ReactElement | null;
render: (loaded: Exports, props: Props) => ReactElement;
}
export interface LoadableRenderer<Props> {
(props: Props & LoadableRendererProps): ReactElement | null;
preload: () => Promise<unknown>;
displayName?: string;
}
export default function createLoadableRenderer<Props, Exports>(
options: LoadableOptions<Props, Exports>,
): LoadableRenderer<Props> {
let promise: Promise<Exports> | null = null;
let cachedResult: Exports | null = null;
let cachedError: Error | null = null;
const load = (): Promise<Exports> => {
if (promise) return promise;
const keys = Object.keys(options.loader) as (keyof Exports)[];
promise = Promise.all(
keys.map(key => Promise.resolve(options.loader[key]())),
).then(
values => {
const loaded = {} as Exports;
keys.forEach((key, i) => {
loaded[key] = values[i] as Exports[typeof key];
});
cachedResult = loaded;
return loaded;
},
err => {
cachedError = err instanceof Error ? err : new Error(String(err));
throw cachedError;
},
);
return promise;
};
const Renderer: LoadableRenderer<Props> = props => {
const [state, setState] = useState<{
loaded: Exports | null;
error: Error | null;
}>(() => ({ loaded: cachedResult, error: cachedError }));
useEffect(() => {
if (state.loaded || state.error) return undefined;
let cancelled = false;
load().then(
loaded => {
if (!cancelled) setState({ loaded, error: null });
},
err => {
if (!cancelled) setState({ loaded: null, error: err });
},
);
return () => {
cancelled = true;
};
}, [state.loaded, state.error]);
// Keep callback refs current without retriggering the post-load effect on
// every prop update.
const onRenderSuccessRef = useRef(props.onRenderSuccess);
const onRenderFailureRef = useRef(props.onRenderFailure);
onRenderSuccessRef.current = props.onRenderSuccess;
onRenderFailureRef.current = props.onRenderFailure;
useEffect(() => {
if (state.error) {
onRenderFailureRef.current?.(state.error);
} else if (state.loaded && Object.keys(state.loaded).length > 0) {
onRenderSuccessRef.current?.();
}
}, [state.loaded, state.error]);
if (state.error) {
return options.loading({ error: state.error });
}
if (!state.loaded) {
return options.loading({});
}
return options.render(state.loaded, props as Props);
};
Renderer.preload = load;
return Renderer;
}

View File

@@ -113,7 +113,7 @@ const EstimateQueryCostButton = ({
modalBody={renderModalBody()}
triggerNode={
<Button
color="primary"
color="default"
variant="text"
style={{ height: 32, padding: '4px 15px' }}
onClick={onClickHandler}

View File

@@ -780,6 +780,15 @@ export function exploreJSON(
handleChartDataResponse(response, json, useLegacyApi),
)
.then(queriesResponse => {
// Drop stale responses: if a newer query has started for this chart,
// its controller will have replaced ours in state, so ignore this
// response to avoid clobbering newer data with older results.
if (key != null) {
const currentController = getState().charts?.[key]?.queryController;
if (currentController && currentController !== controller) {
return undefined;
}
}
(queriesResponse as QueryData[]).forEach(
(resultItem: QueryData & { applied_filters?: JsonObject[] }) =>
dispatch(
@@ -825,6 +834,15 @@ export function exploreJSON(
);
}
// Drop stale failures the same way we drop stale successes,
// so a slow earlier request can't mark a newer one as failed.
if (key != null) {
const currentController = getState().charts?.[key]?.queryController;
if (currentController && currentController !== controller) {
return undefined;
}
}
if (isFeatureEnabled(FeatureFlag.GlobalAsyncQueries)) {
// In async mode we just pass the raw error response through
return dispatch(

View File

@@ -156,6 +156,78 @@ describe('chart actions', () => {
.mockImplementation((data: unknown) => Promise.resolve(data));
});
test('should drop stale success dispatches when a newer controller has replaced ours in state', async () => {
const chartKey = 'stale_success_test';
const formData: Partial<QueryFormData> = {
slice_id: 456,
datasource: 'table__1',
viz_type: 'table',
};
// A controller belonging to a *newer* in-flight request, already stored
// in state by the time this thunk's response resolves.
const newerController = new AbortController();
const state: MockState = {
charts: {
[chartKey]: {
queryController: newerController,
},
},
common: {
conf: {
SUPERSET_WEBSERVER_TIMEOUT: 60,
},
},
};
const getState = jest.fn(() => state);
const dispatchMock = jest.fn();
const getChartDataRequestSpy = jest
.spyOn(actions, 'getChartDataRequest')
.mockResolvedValue({
response: { status: 200 } as Response,
json: { result: [{ data: [{ stale: true }] }] },
});
const handleChartDataResponseSpy = jest
.spyOn(actions, 'handleChartDataResponse')
.mockResolvedValue([{ data: [{ stale: true }] }]);
const updateDataMaskSpy = jest
.spyOn(dataMaskActions, 'updateDataMask')
.mockReturnValue({ type: 'UPDATE_DATA_MASK' } as ReturnType<
typeof dataMaskActions.updateDataMask
>);
const getQuerySettingsStub = jest
.spyOn(exploreUtils, 'getQuerySettings')
.mockReturnValue([false, () => {}] as unknown as ReturnType<
typeof exploreUtils.getQuerySettings
>);
try {
const thunkAction = actions.exploreJSON(
formData as QueryFormData,
false,
undefined,
chartKey,
);
await thunkAction(
dispatchMock as unknown as actions.ChartThunkDispatch,
getState as unknown as () => actions.RootState,
undefined,
);
// CHART_UPDATE_STARTED is fine (it ran before the gate),
// but CHART_UPDATE_SUCCEEDED must NOT have fired with the stale data.
const dispatchedTypes = dispatchMock.mock.calls.map(
([action]) => action?.type,
);
expect(dispatchedTypes).toContain(actions.CHART_UPDATE_STARTED);
expect(dispatchedTypes).not.toContain(actions.CHART_UPDATE_SUCCEEDED);
} finally {
getChartDataRequestSpy.mockRestore();
handleChartDataResponseSpy.mockRestore();
updateDataMaskSpy.mockRestore();
getQuerySettingsStub.mockRestore();
}
});
test('should defer abort of previous controller to avoid Redux state mutation', async () => {
jest.useFakeTimers();
const chartKey = 'defer_abort_test';

View File

@@ -1,97 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { setComponentThemeId } from './setComponentThemeId';
import { UPDATE_COMPONENTS } from './dashboardLayout';
const componentFixture = {
id: 'CHART-abc',
type: 'CHART',
children: [],
parents: ['ROOT_ID', 'ROW-1'],
meta: {
chartId: 42,
sliceName: 'My Chart',
width: 4,
height: 30,
},
};
const buildState = () =>
({
dashboardLayout: {
present: {
'CHART-abc': componentFixture,
},
},
// The thunk wrapper (`setUnsavedChangesAfterAction`) reads this.
dashboardState: { hasUnsavedChanges: false },
}) as unknown as ReturnType<
Parameters<ReturnType<typeof setComponentThemeId>>[1]
>;
// `updateComponents` is wrapped by `setUnsavedChangesAfterAction`, which
// returns a thunk. The outer dispatch receives the thunk; we recursively
// execute it to capture the actual UPDATE_COMPONENTS action object.
const dispatchedActions = (
outer: (dispatch: any, getState: any) => void,
getState: any,
): any[] => {
const actions: any[] = [];
const dispatch = (action: any) => {
if (typeof action === 'function') {
action(dispatch, getState);
} else {
actions.push(action);
}
};
outer(dispatch, getState);
return actions;
};
test('dispatches an UPDATE_COMPONENTS that preserves existing meta and sets themeId', () => {
const actions = dispatchedActions(setComponentThemeId('CHART-abc', 7), () =>
buildState(),
);
const action = actions.find(a => a.type === UPDATE_COMPONENTS);
expect(action).toBeDefined();
expect(action.payload.nextComponents['CHART-abc'].meta).toEqual({
chartId: 42,
sliceName: 'My Chart',
width: 4,
height: 30,
themeId: 7,
});
});
test('clearing the override stores explicit null (not undefined)', () => {
const actions = dispatchedActions(
setComponentThemeId('CHART-abc', null),
() => buildState(),
);
const action = actions.find(a => a.type === UPDATE_COMPONENTS);
expect(action.payload.nextComponents['CHART-abc'].meta.themeId).toBeNull();
});
test('no-op when the component is missing from layout', () => {
const actions = dispatchedActions(
setComponentThemeId('CHART-missing', 7),
() => buildState(),
);
expect(actions).toEqual([]);
});

View File

@@ -1,61 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { GetState, RootState } from 'src/dashboard/types';
import { updateComponents } from './dashboardLayout';
// Match the local pattern used by `dashboardLayout.ts` and `hydrate.ts` —
// the project doesn't export a shared `AppDispatch` from
// `src/dashboard/types`; the closest exported one is
// `src/views/store`'s `typeof store.dispatch`, which we don't want to
// import here just to type a thunk.
type AppDispatch = ThunkDispatch<RootState, undefined, AnyAction>;
/**
* Sets (or clears) the per-component theme override on a dashboard
* grid component. `themeId === null` clears the override and falls back
* to the inherited theme.
*
* Thin wrapper around `updateComponents` that touches only the `themeId`
* key on the component's `meta`, preserving every other meta field. Used
* by `ThemeSelectorModal` (and any future call site) so the meta-merge
* logic lives in one place.
*/
export function setComponentThemeId(
componentId: string,
themeId: number | null,
) {
return (dispatch: AppDispatch, getState: GetState) => {
const { dashboardLayout } = getState();
const component = dashboardLayout.present[componentId];
if (!component) return;
dispatch(
updateComponents({
[componentId]: {
...component,
meta: {
...component.meta,
themeId,
},
},
}),
);
};
}

View File

@@ -1,90 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { type ReactNode, useContext, useEffect, useState } from 'react';
import type { Theme } from '@apache-superset/core/theme';
import { ThemeContext } from 'src/theme/ThemeProvider';
import { useEffectiveThemeId } from './useEffectiveThemeId';
interface ComponentThemeProviderProps {
/**
* Layout item id (the key into `dashboardLayout.present`). The provider
* walks up the parents tree from this node to compute the effective
* theme override.
*/
layoutId: string | undefined;
children: ReactNode;
}
/**
* Per-component theme override wrapper. When the component (or any
* ancestor up to but not including the dashboard root) sets a `themeId`
* in its `LayoutItemMeta`, this provider loads that CRUD theme and
* applies it as a `SupersetThemeProvider` around the children, overriding
* the dashboard-level (and, transitively, the instance-level) theme for
* this subtree.
*
* When no ancestor sets a `themeId` — the default — the component renders
* as a pass-through. The outer `CrudThemeProvider` (mounted by
* `DashboardPage`) continues to provide the dashboard-level theme.
*/
export default function ComponentThemeProvider({
layoutId,
children,
}: ComponentThemeProviderProps) {
const effectiveThemeId = useEffectiveThemeId(layoutId);
// Read ThemeContext directly (not via `useThemeContext`, which throws
// when no ThemeProvider is mounted). When rendered outside a dashboard
// — e.g. isolated component tests, storybook, embedded contexts that
// skip the dashboard ThemeProvider — we behave as a pass-through.
const themeContext = useContext(ThemeContext);
const [componentTheme, setComponentTheme] = useState<Theme | null>(null);
useEffect(() => {
if (effectiveThemeId == null || !themeContext) {
setComponentTheme(null);
return undefined;
}
let cancelled = false;
// `createDashboardThemeProvider` caches by id internally, so per-component
// calls for the same theme are deduplicated to a single fetch.
themeContext
.createDashboardThemeProvider(String(effectiveThemeId))
.then(theme => {
if (!cancelled) setComponentTheme(theme);
});
return () => {
cancelled = true;
};
}, [effectiveThemeId, themeContext]);
if (!componentTheme) {
return <>{children}</>;
}
return (
<componentTheme.SupersetThemeProvider>
{children}
</componentTheme.SupersetThemeProvider>
);
}
export {
useEffectiveThemeId,
pickEffectiveThemeId,
} from './useEffectiveThemeId';

View File

@@ -1,70 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { previewThemeStore } from './previewThemeStore';
afterEach(() => {
// Defensive — module-level state would leak between tests otherwise.
previewThemeStore.clear('CHART-a');
previewThemeStore.clear('CHART-b');
});
test('get returns undefined for unknown layoutId', () => {
expect(previewThemeStore.get('CHART-a')).toBeUndefined();
});
test('set stores a numeric preview readable by get', () => {
previewThemeStore.set('CHART-a', 7);
expect(previewThemeStore.get('CHART-a')).toBe(7);
});
test('set stores explicit null (distinct from "no preview")', () => {
previewThemeStore.set('CHART-a', null);
expect(previewThemeStore.get('CHART-a')).toBeNull();
// Distinct from the unknown-key case
expect(previewThemeStore.get('CHART-b')).toBeUndefined();
});
test('clear removes the entry; subsequent get returns undefined', () => {
previewThemeStore.set('CHART-a', 7);
previewThemeStore.clear('CHART-a');
expect(previewThemeStore.get('CHART-a')).toBeUndefined();
});
test('subscribers fire on set and clear, do not fire on no-op set', () => {
const listener = jest.fn();
const unsubscribe = previewThemeStore.subscribe(listener);
previewThemeStore.set('CHART-a', 7);
previewThemeStore.set('CHART-a', 7); // no-op (same value)
previewThemeStore.set('CHART-a', 9);
previewThemeStore.clear('CHART-a');
previewThemeStore.clear('CHART-a'); // no-op
unsubscribe();
previewThemeStore.set('CHART-a', 1); // not observed (unsubscribed)
expect(listener).toHaveBeenCalledTimes(3);
});
test('multiple layoutIds are tracked independently', () => {
previewThemeStore.set('CHART-a', 1);
previewThemeStore.set('CHART-b', 2);
expect(previewThemeStore.get('CHART-a')).toBe(1);
expect(previewThemeStore.get('CHART-b')).toBe(2);
previewThemeStore.clear('CHART-a');
expect(previewThemeStore.get('CHART-a')).toBeUndefined();
expect(previewThemeStore.get('CHART-b')).toBe(2);
});

View File

@@ -1,74 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Module-level subscribable store for transient per-component theme
* previews. Used by `ThemeSelectorModal` to make a draft selection
* visually applied without committing to Redux (which would mark the
* dashboard dirty). `ComponentThemeProvider` subscribes via
* `useSyncExternalStore` and prefers a present preview over the
* resolved-from-Redux `themeId`.
*
* `null` means "explicitly clear the override during preview" — the
* provider treats it the same way it treats a Redux `null`. Absence
* (key not in the map) means "no preview active; use Redux".
*/
type PreviewValue = number | null;
type Listener = () => void;
const previewMap = new Map<string, PreviewValue>();
const listeners = new Set<Listener>();
const emit = (): void => {
listeners.forEach(l => l());
};
export const previewThemeStore = {
/** Sets a transient preview for `layoutId`. Replaces any prior preview. */
set(layoutId: string, themeId: PreviewValue): void {
if (previewMap.get(layoutId) === themeId) return;
previewMap.set(layoutId, themeId);
emit();
},
/** Clears any preview for `layoutId`. No-op when none is active. */
clear(layoutId: string): void {
if (!previewMap.has(layoutId)) return;
previewMap.delete(layoutId);
emit();
},
/**
* Returns the previewed value for `layoutId`, or `undefined` when no
* preview is active. Used by `ComponentThemeProvider` via
* `useSyncExternalStore`. Returning `undefined` (vs `null`) lets
* callers distinguish "no preview" from "preview the cleared state".
*/
get(layoutId: string): PreviewValue | undefined {
return previewMap.has(layoutId) ? previewMap.get(layoutId) : undefined;
},
subscribe(listener: Listener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};

View File

@@ -1,121 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { DashboardLayout } from 'src/dashboard/types';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import { pickEffectiveThemeId } from './useEffectiveThemeId';
/**
* Helper to build a minimally-shaped `DashboardLayout` for these tests.
* The real reducer carries many more fields per node; only `parents` and
* `meta` are read by the resolver.
*/
const buildLayout = (
nodes: Record<
string,
{
parents?: string[];
themeId?: number | null;
extraMeta?: Record<string, unknown>;
}
>,
): DashboardLayout =>
Object.fromEntries(
Object.entries(nodes).map(([id, { parents, themeId, extraMeta }]) => [
id,
{
id,
type: 'CHART',
children: [],
parents,
meta: {
...extraMeta,
...(themeId !== undefined ? { themeId } : {}),
},
},
]),
) as unknown as DashboardLayout;
test('returns null for missing layoutId', () => {
expect(pickEffectiveThemeId(undefined, buildLayout({}))).toBeNull();
expect(pickEffectiveThemeId('CHART-1', buildLayout({}))).toBeNull();
});
test("returns the node's own themeId when set", () => {
const layout = buildLayout({
'CHART-1': {
parents: [DASHBOARD_ROOT_ID, 'ROW-1'],
themeId: 42,
},
'ROW-1': { parents: [DASHBOARD_ROOT_ID], themeId: 7 },
});
expect(pickEffectiveThemeId('CHART-1', layout)).toBe(42);
});
test("inherits the closest ancestor's themeId when own is unset", () => {
const layout = buildLayout({
'CHART-1': { parents: [DASHBOARD_ROOT_ID, 'TAB-1', 'ROW-1'] },
'ROW-1': { parents: [DASHBOARD_ROOT_ID, 'TAB-1'], themeId: 7 },
'TAB-1': { parents: [DASHBOARD_ROOT_ID], themeId: 99 },
});
expect(pickEffectiveThemeId('CHART-1', layout)).toBe(7);
});
test('skips ancestors whose themeId is null and continues walking', () => {
// A literal `null` on a node means "I don't override" — keep walking.
const layout = buildLayout({
'CHART-1': { parents: [DASHBOARD_ROOT_ID, 'TAB-1', 'ROW-1'] },
'ROW-1': { parents: [DASHBOARD_ROOT_ID, 'TAB-1'], themeId: null },
'TAB-1': { parents: [DASHBOARD_ROOT_ID], themeId: 99 },
});
expect(pickEffectiveThemeId('CHART-1', layout)).toBe(99);
});
test('returns null when no ancestor sets a themeId', () => {
const layout = buildLayout({
'CHART-1': { parents: [DASHBOARD_ROOT_ID, 'ROW-1'] },
'ROW-1': { parents: [DASHBOARD_ROOT_ID] },
});
expect(pickEffectiveThemeId('CHART-1', layout)).toBeNull();
});
test('stops at DASHBOARD_ROOT_ID — root-level theme is the dashboard CRUD theme, handled separately', () => {
const layout = buildLayout({
'CHART-1': { parents: [DASHBOARD_ROOT_ID] },
[DASHBOARD_ROOT_ID]: { parents: [], themeId: 999 },
});
expect(pickEffectiveThemeId('CHART-1', layout)).toBeNull();
});
test('does not loop on a malformed parents chain', () => {
// Self-referential parent shouldn't hang the resolver.
const layout = buildLayout({
'CHART-1': { parents: ['CHART-1'] },
});
expect(pickEffectiveThemeId('CHART-1', layout)).toBeNull();
});
test('ignores other meta keys', () => {
const layout = buildLayout({
'CHART-1': {
parents: [DASHBOARD_ROOT_ID],
extraMeta: { sliceName: 'Foo', width: 4, background: 'gray' },
},
});
expect(pickEffectiveThemeId('CHART-1', layout)).toBeNull();
});

View File

@@ -1,85 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useSyncExternalStore } from 'react';
import { useSelector } from 'react-redux';
import type {
DashboardLayout,
LayoutItem,
RootState,
} from 'src/dashboard/types';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import { previewThemeStore } from './previewThemeStore';
/**
* Walks up the dashboard layout tree from `layoutId` and returns the first
* `themeId` it finds, or `null` if no ancestor sets one.
*
* Inheritance order (closest wins):
* Chart/Markdown -> Row/Column -> Tab -> Dashboard root -> (null = inherit
* from dashboard CRUD theme or instance theme, applied by CrudThemeProvider
* higher in the tree).
*
* `themeId: null` on a node means "explicitly clear my override" — we treat
* the property as absent (and continue walking) iff it is undefined; a literal
* `null` is also treated as "no override" since the dashboard-level theme is
* applied by a different provider.
*/
export function pickEffectiveThemeId(
layoutId: string | undefined,
layout: DashboardLayout,
): number | null {
if (!layoutId || !layout) return null;
let cursorId: string | undefined = layoutId;
// Defensive cap — dashboards shouldn't nest deeper than this, and the cap
// protects against malformed `parents` arrays causing infinite loops.
let hops = 0;
while (cursorId && cursorId !== DASHBOARD_ROOT_ID && hops < 32) {
const node: LayoutItem | undefined = layout[cursorId];
if (!node) return null;
const themeId = node.meta?.themeId;
if (typeof themeId === 'number') return themeId;
cursorId = node.parents?.[node.parents.length - 1];
hops += 1;
}
return null;
}
/**
* Redux hook variant of `pickEffectiveThemeId`. Memoizes on the layout
* reference; consumers that only care about the resolved id (not the layout
* map itself) won't re-render when sibling components change their meta.
*
* If `ThemeSelectorModal` has registered a preview override for this
* `layoutId` via `previewThemeStore`, the preview wins — that's how the
* modal applies a draft selection visually without committing to Redux.
*/
export function useEffectiveThemeId(
layoutId: string | undefined,
): number | null {
const reduxResolved = useSelector<RootState, number | null>(state =>
pickEffectiveThemeId(layoutId, state.dashboardLayout?.present),
);
const preview = useSyncExternalStore(
previewThemeStore.subscribe,
() =>
layoutId === undefined ? undefined : previewThemeStore.get(layoutId),
() => undefined,
);
return preview === undefined ? reduxResolved : preview;
}

View File

@@ -57,7 +57,6 @@ import { useDrillDetailMenuItems } from 'src/components/Chart/useDrillDetailMenu
import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
import ThemeSelectorModal from 'src/dashboard/components/ThemeSelectorModal';
import { usePermissions } from 'src/hooks/usePermissions';
import { useDatasetDrillInfo } from 'src/hooks/apiResources/datasets';
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
@@ -167,13 +166,6 @@ const SliceHeaderControls = (
const [drillModalIsOpen, setDrillModalIsOpen] = useState(false);
// setting openKeys undefined falls back to uncontrolled behaviour
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [themeModalOpen, setThemeModalOpen] = useState(false);
// Per-component theming is an edit-mode affordance only — viewers see the
// applied theme but can't change it.
const editMode = useSelector<RootState, boolean>(
state => !!state.dashboardState.editMode,
);
const [openScopingModal, scopingModal] = useCrossFiltersScopingModal(
props.slice.slice_id,
);
@@ -266,9 +258,6 @@ const SliceHeaderControls = (
// eslint-disable-next-line no-unused-expressions
props.toggleExpandSlice?.(props.slice.slice_id);
break;
case MenuKeys.ApplyTheme:
setThemeModalOpen(true);
break;
case MenuKeys.ExploreChart:
// eslint-disable-next-line no-unused-expressions
props.logExploreChart?.(props.slice.slice_id);
@@ -461,13 +450,6 @@ const SliceHeaderControls = (
});
}
if (editMode) {
newMenuItems.push({
key: MenuKeys.ApplyTheme,
label: t('Apply theme'),
});
}
if (canExplore) {
newMenuItems.push({
key: MenuKeys.ExploreChart,
@@ -699,13 +681,6 @@ const SliceHeaderControls = (
dataset={datasetWithVerboseMap}
/>
{canEditCrossFilters && scopingModal}
{editMode && (
<ThemeSelectorModal
layoutId={componentId}
show={themeModalOpen}
onHide={() => setThemeModalOpen(false)}
/>
)}
{isFullSize && <Global styles={fullscreenStyles(theme)} />}
</>
);

View File

@@ -1,176 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import rison from 'rison';
import { useDispatch } from 'react-redux';
import { SupersetClient } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { Button, Modal, Select } from '@superset-ui/core/components';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { useEffectiveThemeId } from 'src/dashboard/components/ComponentThemeProvider';
import { previewThemeStore } from 'src/dashboard/components/ComponentThemeProvider/previewThemeStore';
import { setComponentThemeId } from 'src/dashboard/actions/setComponentThemeId';
interface ThemeOption {
id: number;
theme_name: string;
}
interface ThemeSelectorModalProps {
/** The layout component receiving the theme override. */
layoutId: string;
/** Controls visibility. Parent owns this — toggled via menu click. */
show: boolean;
onHide: () => void;
}
/**
* Modal for picking a CRUD theme to apply to a single dashboard component
* (or clearing the existing override). On save, dispatches
* `setComponentThemeId`, which updates `component.meta.themeId` and marks
* the dashboard dirty. The actual visual application is handled by
* `ComponentThemeProvider`, which reads the meta change via its Redux
* selector and re-renders the component with the new theme tokens.
*/
export default function ThemeSelectorModal({
layoutId,
show,
onHide,
}: ThemeSelectorModalProps) {
const dispatch = useDispatch();
const { addDangerToast } = useToasts();
const currentThemeId = useEffectiveThemeId(layoutId);
// Modal-local draft of the selection. Synced from the resolved id when
// the modal opens; live-previewed via the previewThemeStore as the user
// picks options; only committed to Redux on Apply.
const [selectedId, setSelectedId] = useState<number | null>(currentThemeId);
const [themes, setThemes] = useState<ThemeOption[]>([]);
const [loading, setLoading] = useState(false);
// Snapshot the resolved id at open-time so we can revert correctly when
// the user cancels — `currentThemeId` itself is reactive (and would
// already reflect the in-flight preview), so we can't use it directly.
const initialIdRef = useRef<number | null>(currentThemeId);
useEffect(() => {
if (show) {
initialIdRef.current = currentThemeId;
setSelectedId(currentThemeId);
}
// No `show` cleanup here — the close handlers below clear the preview
// explicitly so we don't fight with the Apply path (which keeps the
// theme applied).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [show]);
// Push the user's draft selection through the preview store. The
// ComponentThemeProvider prefers preview > Redux, so the targeted
// component re-renders with the candidate theme as soon as this updates.
useEffect(() => {
if (!show) return undefined;
previewThemeStore.set(layoutId, selectedId);
return () => {
// Cleanup runs on close + on every selectedId change; the next
// effect call re-sets it. On unmount/close we want the preview
// gone so the provider re-resolves from Redux. Safe because Apply
// commits to Redux *before* hiding the modal, so the post-clear
// resolution lands on the saved value, not the original.
previewThemeStore.clear(layoutId);
};
}, [show, layoutId, selectedId]);
useEffect(() => {
if (!show) return;
setLoading(true);
// Same query the dashboard-properties modal uses — non-system themes only.
const q = rison.encode({
columns: ['id', 'theme_name'],
filters: [{ col: 'is_system', opr: 'eq', value: false }],
});
SupersetClient.get({ endpoint: `/api/v1/theme/?q=${q}` })
.then(({ json }) => {
setThemes((json.result as ThemeOption[]) ?? []);
})
.catch(() => {
addDangerToast(t('An error occurred while fetching available themes'));
})
.finally(() => setLoading(false));
}, [show, addDangerToast]);
const options = useMemo(
() => themes.map(t => ({ value: t.id, label: t.theme_name })),
[themes],
);
const handleSave = () => {
dispatch(setComponentThemeId(layoutId, selectedId));
onHide();
};
const handleClear = () => {
// Clearing the override means "inherit from parent" — store explicit
// null so the resolver knows it was intentional (vs absent / never set).
dispatch(setComponentThemeId(layoutId, null));
onHide();
};
return (
<Modal
show={show}
onHide={onHide}
title={t('Apply theme')}
footer={
<>
{currentThemeId !== null && (
<Button
data-test="component-theme-clear"
buttonStyle="secondary"
onClick={handleClear}
>
{t('Clear override (inherit)')}
</Button>
)}
<Button data-test="component-theme-cancel" onClick={onHide}>
{t('Cancel')}
</Button>
<Button
data-test="component-theme-apply"
buttonStyle="primary"
onClick={handleSave}
disabled={selectedId === null}
>
{t('Apply')}
</Button>
</>
}
>
<Select
ariaLabel={t('Theme')}
loading={loading}
options={options}
value={selectedId ?? undefined}
onChange={value => setSelectedId(value as number)}
placeholder={t('Select a theme')}
allowClear
onClear={() => setSelectedId(null)}
/>
</Modal>
);
}

View File

@@ -33,7 +33,6 @@ import ResizableContainer from 'src/dashboard/components/resizable/ResizableCont
import getChartAndLabelComponentIdFromPath from 'src/dashboard/util/getChartAndLabelComponentIdFromPath';
import useFilterFocusHighlightStyles from 'src/dashboard/util/useFilterFocusHighlightStyles';
import { AntdThemeProvider } from '@superset-ui/core/components';
import ComponentThemeProvider from 'src/dashboard/components/ComponentThemeProvider';
import { COLUMN_TYPE, ROW_TYPE } from 'src/dashboard/util/componentTypes';
import {
GRID_BASE_UNIT,
@@ -285,61 +284,59 @@ const ChartHolder = ({
outlinedComponentId ? 'fade-in' : 'fade-out',
)}
>
<ComponentThemeProvider layoutId={component.id}>
<AntdThemeProvider
getPopupContainer={(triggerNode: HTMLElement) =>
document.fullscreenElement
? (triggerNode?.closest?.(
'[data-test="dashboard-component-chart-holder"]',
) as HTMLElement) || document.body
: document.body
}
>
{!editMode && (
<AnchorLink
id={component.id}
scrollIntoView={outlinedComponentId === component.id}
/>
)}
{!!outlinedComponentId && (
<style>
{`label[for=${outlinedColumnName}] + .Select .Select__control {
<AntdThemeProvider
getPopupContainer={(triggerNode: HTMLElement) =>
document.fullscreenElement
? (triggerNode?.closest?.(
'[data-test="dashboard-component-chart-holder"]',
) as HTMLElement) || document.body
: document.body
}
>
{!editMode && (
<AnchorLink
id={component.id}
scrollIntoView={outlinedComponentId === component.id}
/>
)}
{!!outlinedComponentId && (
<style>
{`label[for=${outlinedColumnName}] + .Select .Select__control {
border-color: ${theme.colorPrimary};
transition: border-color 1s ease-in-out;
}`}
</style>
)}
<Chart
componentId={component.id}
id={component.meta.chartId ?? 0}
dashboardId={dashboardId}
width={chartWidth}
height={chartHeight}
sliceName={
component.meta.sliceNameOverride ||
component.meta.sliceName ||
''
}
updateSliceName={(_sliceId: number, name: string) =>
handleUpdateSliceName(name)
}
isComponentVisible={isComponentVisible}
handleToggleFullSize={handleToggleFullSize}
isFullSize={isFullSize}
setControlValue={handleExtraControl}
extraControls={extraControls}
isInView={isInView}
chartHolderRef={chartHolderRef}
/>
{editMode && (
<HoverMenu position="top">
<div data-test="dashboard-delete-component-button">
<DeleteComponentButton onDelete={handleDeleteComponent} />
</div>
</HoverMenu>
)}
</AntdThemeProvider>
</ComponentThemeProvider>
</style>
)}
<Chart
componentId={component.id}
id={component.meta.chartId ?? 0}
dashboardId={dashboardId}
width={chartWidth}
height={chartHeight}
sliceName={
component.meta.sliceNameOverride ||
component.meta.sliceName ||
''
}
updateSliceName={(_sliceId: number, name: string) =>
handleUpdateSliceName(name)
}
isComponentVisible={isComponentVisible}
handleToggleFullSize={handleToggleFullSize}
isFullSize={isFullSize}
setControlValue={handleExtraControl}
extraControls={extraControls}
isInView={isInView}
chartHolderRef={chartHolderRef}
/>
{editMode && (
<HoverMenu position="top">
<div data-test="dashboard-delete-component-button">
<DeleteComponentButton onDelete={handleDeleteComponent} />
</div>
</HoverMenu>
)}
</AntdThemeProvider>
</div>
</ResizableContainer>
),

View File

@@ -35,9 +35,6 @@ import IconButton from 'src/dashboard/components/IconButton';
import ResizableContainer from 'src/dashboard/components/resizable/ResizableContainer';
import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown';
import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
import ComponentThemeProvider from 'src/dashboard/components/ComponentThemeProvider';
import ComponentHeaderControls from 'src/dashboard/components/menu/ComponentHeaderControls';
import ThemeSelectorModal from 'src/dashboard/components/ThemeSelectorModal';
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
import { EMPTY_CONTAINER_Z_INDEX } from 'src/dashboard/constants';
@@ -164,7 +161,6 @@ const Column = (props: ColumnProps) => {
} = props;
const [isFocused, setIsFocused] = useState(false);
const [themeModalOpen, setThemeModalOpen] = useState(false);
const handleDeleteComponent = useCallback(() => {
deleteComponent(id, parentId);
@@ -220,154 +216,135 @@ const Column = (props: ColumnProps) => {
onResizeStop={onResizeStop}
editMode={editMode}
>
<ComponentThemeProvider layoutId={columnComponent.id}>
<WithPopoverMenu
isFocused={isFocused}
onChangeFocus={handleChangeFocus}
disableClick
menuItems={[
<BackgroundStyleDropdown
key={`${columnComponent.id}-background`}
id={`${columnComponent.id}-background`}
value={
(columnComponent.meta.background as string) ||
BACKGROUND_TRANSPARENT
}
onChange={handleChangeBackground}
/>,
]}
<WithPopoverMenu
isFocused={isFocused}
onChangeFocus={handleChangeFocus}
disableClick
menuItems={[
<BackgroundStyleDropdown
key={`${columnComponent.id}-background`}
id={`${columnComponent.id}-background`}
value={
(columnComponent.meta.background as string) ||
BACKGROUND_TRANSPARENT
}
onChange={handleChangeBackground}
/>,
]}
editMode={editMode}
>
{editMode && (
<HoverMenu
innerRef={
dragSourceRef as unknown as React.RefObject<HTMLDivElement>
}
position="top"
>
<DragHandle position="top" />
<DeleteComponentButton
iconSize="m"
onDelete={handleDeleteComponent}
/>
<IconButton
onClick={() => handleChangeFocus(true)}
icon={<Icons.SettingOutlined iconSize="m" />}
/>
</HoverMenu>
)}
<ColumnStyles
className={cx('grid-column', backgroundStyle?.className)}
editMode={editMode}
>
{editMode && (
<HoverMenu
innerRef={
dragSourceRef as unknown as React.RefObject<HTMLDivElement>
}
position="top"
<Droppable
component={columnComponent}
parentComponent={columnComponent}
{...(columnItems.length === 0
? {
dropToChild: true,
}
: {
component: columnItems[0],
})}
depth={depth}
index={0}
orientation="column"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
columnItems.length > 0 && 'droptarget-edge',
)}
editMode
>
<DragHandle position="top" />
<DeleteComponentButton
iconSize="m"
onDelete={handleDeleteComponent}
/>
<IconButton
onClick={() => handleChangeFocus(true)}
icon={<Icons.SettingOutlined iconSize="m" />}
/>
<ComponentHeaderControls
items={[
{
key: 'apply-theme',
label: t('Apply theme'),
onClick: () => setThemeModalOpen(true),
},
]}
ariaLabel={t('Column options')}
/>
</HoverMenu>
{({ dropIndicatorProps }: DropIndicatorChildProps) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
{editMode && (
<ThemeSelectorModal
layoutId={columnComponent.id}
show={themeModalOpen}
onHide={() => setThemeModalOpen(false)}
/>
)}
<ColumnStyles
className={cx('grid-column', backgroundStyle?.className)}
editMode={editMode}
>
{editMode && (
<Droppable
component={columnComponent}
parentComponent={columnComponent}
{...(columnItems.length === 0
? {
dropToChild: true,
{columnItems.length === 0 ? (
<div css={emptyColumnContentStyles}>{t('Empty column')}</div>
) : (
columnItems.map((componentId: string, itemIndex: number) => (
<Fragment key={componentId}>
<DashboardComponent
id={componentId}
parentId={columnComponent.id}
depth={depth + 1}
index={itemIndex}
availableColumnCount={columnComponent.meta.width ?? 0}
columnWidth={columnWidth}
onResizeStart={
onResizeStart as unknown as (
event: MouseEvent | TouchEvent,
direction: string,
elementRef: HTMLElement,
) => void
}
onResize={
onResize as unknown as (
event: MouseEvent | TouchEvent,
direction: string,
elementRef: HTMLElement,
delta: { width: number; height: number },
) => void
}
onResizeStop={
onResizeStop as unknown as (
event: MouseEvent | TouchEvent,
direction: string,
elementRef: HTMLElement,
delta: { width: number; height: number },
id: string,
) => void
}
isComponentVisible={isComponentVisible}
onChangeTab={onChangeTab}
/>
{editMode && (
<Droppable
component={columnItems}
parentComponent={columnComponent}
depth={depth}
index={itemIndex + 1}
orientation="column"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
itemIndex === columnItems.length - 1 &&
'droptarget-edge',
)}
editMode
>
{({ dropIndicatorProps }: DropIndicatorChildProps) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
: {
component: columnItems[0],
})}
depth={depth}
index={0}
orientation="column"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
columnItems.length > 0 && 'droptarget-edge',
</Droppable>
)}
editMode
>
{({ dropIndicatorProps }: DropIndicatorChildProps) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
{columnItems.length === 0 ? (
<div css={emptyColumnContentStyles}>{t('Empty column')}</div>
) : (
columnItems.map((componentId: string, itemIndex: number) => (
<Fragment key={componentId}>
<DashboardComponent
id={componentId}
parentId={columnComponent.id}
depth={depth + 1}
index={itemIndex}
availableColumnCount={columnComponent.meta.width ?? 0}
columnWidth={columnWidth}
onResizeStart={
onResizeStart as unknown as (
event: MouseEvent | TouchEvent,
direction: string,
elementRef: HTMLElement,
) => void
}
onResize={
onResize as unknown as (
event: MouseEvent | TouchEvent,
direction: string,
elementRef: HTMLElement,
delta: { width: number; height: number },
) => void
}
onResizeStop={
onResizeStop as unknown as (
event: MouseEvent | TouchEvent,
direction: string,
elementRef: HTMLElement,
delta: { width: number; height: number },
id: string,
) => void
}
isComponentVisible={isComponentVisible}
onChangeTab={onChangeTab}
/>
{editMode && (
<Droppable
component={columnItems}
parentComponent={columnComponent}
depth={depth}
index={itemIndex + 1}
orientation="column"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
itemIndex === columnItems.length - 1 &&
'droptarget-edge',
)}
editMode
>
{({ dropIndicatorProps }: DropIndicatorChildProps) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
</Fragment>
))
)}
</ColumnStyles>
</WithPopoverMenu>
</ComponentThemeProvider>
</Fragment>
))
)}
</ColumnStyles>
</WithPopoverMenu>
</ResizableContainer>
),
[

View File

@@ -34,9 +34,6 @@ import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
import ResizableContainer from 'src/dashboard/components/resizable/ResizableContainer';
import MarkdownModeDropdown from 'src/dashboard/components/menu/MarkdownModeDropdown';
import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
import ComponentThemeProvider from 'src/dashboard/components/ComponentThemeProvider';
import ComponentHeaderControls from 'src/dashboard/components/menu/ComponentHeaderControls';
import ThemeSelectorModal from 'src/dashboard/components/ThemeSelectorModal';
import type { LayoutItem } from 'src/dashboard/types';
import type { DropResult } from 'src/dashboard/components/dnd/dragDroppableConfig';
import { ROW_TYPE, COLUMN_TYPE } from 'src/dashboard/util/componentTypes';
@@ -93,7 +90,6 @@ export interface MarkdownState {
undoLength: number;
redoLength: number;
hasError?: boolean;
themeModalOpen: boolean;
}
// TODO: localize
@@ -156,7 +152,6 @@ class Markdown extends PureComponent<MarkdownProps, MarkdownState> {
editorMode: 'preview',
undoLength: props.undoLength,
redoLength: props.redoLength,
themeModalOpen: false,
};
this.renderStartTime = Logger.getTimestamp();
@@ -401,82 +396,62 @@ class Markdown extends PureComponent<MarkdownProps, MarkdownState> {
editMode={editMode}
>
{({ dragSourceRef }: DragChildProps) => (
<ComponentThemeProvider layoutId={component.id}>
<WithPopoverMenu
onChangeFocus={this.handleChangeFocus}
shouldFocus={this.shouldFocusMarkdown}
menuItems={[
<MarkdownModeDropdown
key={`${component.id}-mode`}
id={`${component.id}-mode`}
value={this.state.editorMode}
onChange={this.handleChangeEditorMode}
/>,
<ComponentHeaderControls
key={`${component.id}-options`}
items={[
{
key: 'apply-theme',
label: t('Apply theme'),
onClick: () => this.setState({ themeModalOpen: true }),
},
]}
ariaLabel={t('Markdown options')}
/>,
]}
editMode={editMode}
>
{editMode && (
<ThemeSelectorModal
layoutId={component.id}
show={this.state.themeModalOpen}
onHide={() => this.setState({ themeModalOpen: false })}
/>
<WithPopoverMenu
onChangeFocus={this.handleChangeFocus}
shouldFocus={this.shouldFocusMarkdown}
menuItems={[
<MarkdownModeDropdown
key={`${component.id}-mode`}
id={`${component.id}-mode`}
value={this.state.editorMode}
onChange={this.handleChangeEditorMode}
/>,
]}
editMode={editMode}
>
<MarkdownStyles
data-test="dashboard-markdown-editor"
className={cx(
'dashboard-markdown',
isEditing && 'dashboard-markdown--editing',
)}
<MarkdownStyles
data-test="dashboard-markdown-editor"
className={cx(
'dashboard-markdown',
isEditing && 'dashboard-markdown--editing',
)}
id={component.id}
>
<ResizableContainer
id={component.id}
adjustableWidth={parentComponent.type === ROW_TYPE}
adjustableHeight
widthStep={columnWidth}
widthMultiple={widthMultiple}
heightStep={GRID_BASE_UNIT}
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
minHeightMultiple={GRID_MIN_ROW_UNITS}
maxWidthMultiple={availableColumnCount + widthMultiple}
onResizeStart={this.handleResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
editMode={isFocused ? false : editMode}
>
<ResizableContainer
id={component.id}
adjustableWidth={parentComponent.type === ROW_TYPE}
adjustableHeight
widthStep={columnWidth}
widthMultiple={widthMultiple}
heightStep={GRID_BASE_UNIT}
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
minHeightMultiple={GRID_MIN_ROW_UNITS}
maxWidthMultiple={availableColumnCount + widthMultiple}
onResizeStart={this.handleResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
editMode={isFocused ? false : editMode}
<div
ref={dragSourceRef}
className="dashboard-component dashboard-component-chart-holder"
data-test="dashboard-component-chart-holder"
>
<div
ref={dragSourceRef}
className="dashboard-component dashboard-component-chart-holder"
data-test="dashboard-component-chart-holder"
>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton
onDelete={this.handleDeleteComponent}
/>
</HoverMenu>
)}
{editMode && isEditing
? this.renderEditMode()
: this.renderPreviewMode()}
</div>
</ResizableContainer>
</MarkdownStyles>
</WithPopoverMenu>
</ComponentThemeProvider>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton
onDelete={this.handleDeleteComponent}
/>
</HoverMenu>
)}
{editMode && isEditing
? this.renderEditMode()
: this.renderPreviewMode()}
</div>
</ResizableContainer>
</MarkdownStyles>
</WithPopoverMenu>
)}
</Draggable>
);

View File

@@ -43,9 +43,6 @@ import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
import IconButton from 'src/dashboard/components/IconButton';
import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown';
import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
import ComponentThemeProvider from 'src/dashboard/components/ComponentThemeProvider';
import ComponentHeaderControls from 'src/dashboard/components/menu/ComponentHeaderControls';
import ThemeSelectorModal from 'src/dashboard/components/ThemeSelectorModal';
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
@@ -161,7 +158,6 @@ const Row = memo((props: RowProps) => {
const [isInView, setIsInView] = useState(false);
const [hoverMenuHovered, setHoverMenuHovered] = useState(false);
const [containerHeight, setContainerHeight] = useState<number | null>(null);
const [themeModalOpen, setThemeModalOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const isComponentVisibleRef = useRef(isComponentVisible);
@@ -274,151 +270,130 @@ const Row = memo((props: RowProps) => {
const remainColumnCount = availableColumnCount - occupiedColumnCount;
const renderChild = useCallback(
({ dragSourceRef }: { dragSourceRef: RefObject<HTMLDivElement> }) => (
<ComponentThemeProvider layoutId={rowComponent.id}>
<WithPopoverMenu
isFocused={isFocused}
onChangeFocus={handleChangeFocus}
disableClick
menuItems={[
<BackgroundStyleDropdown
id={`${rowComponent.id}-background`}
value={backgroundStyle.value}
onChange={handleChangeBackground}
/>,
]}
<WithPopoverMenu
isFocused={isFocused}
onChangeFocus={handleChangeFocus}
disableClick
menuItems={[
<BackgroundStyleDropdown
id={`${rowComponent.id}-background`}
value={backgroundStyle.value}
onChange={handleChangeBackground}
/>,
]}
editMode={editMode}
>
{editMode && (
<HoverMenu
onHover={handleMenuHover}
innerRef={dragSourceRef}
position="left"
>
<DragHandle position="left" />
<DeleteComponentButton onDelete={handleDeleteComponent} />
<IconButton
onClick={() => handleChangeFocus(true)}
icon={<Icons.SettingOutlined iconSize="l" />}
/>
</HoverMenu>
)}
<GridRow
className={cx(
'grid-row',
rowItems.length === 0 && 'grid-row--empty',
hoverMenuHovered && 'grid-row--hovered',
backgroundStyle.className,
)}
data-test={`grid-row-${backgroundStyle.className}`}
ref={containerRef}
editMode={editMode}
>
{editMode && (
<HoverMenu
onHover={handleMenuHover}
innerRef={dragSourceRef}
position="left"
<Droppable
{...(rowItems.length === 0
? {
component: rowComponent,
parentComponent: rowComponent,
dropToChild: true,
}
: {
component: rowItems[0],
parentComponent: rowComponent,
})}
depth={depth}
index={0}
orientation="row"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
'empty-droptarget--vertical',
rowItems.length > 0 && 'droptarget-side',
)}
editMode
style={{
height: rowItems.length > 0 ? containerHeight : '100%',
...(rowItems.length > 0 && { width: 16 }),
}}
>
<DragHandle position="left" />
<DeleteComponentButton onDelete={handleDeleteComponent} />
<IconButton
onClick={() => handleChangeFocus(true)}
icon={<Icons.SettingOutlined iconSize="l" />}
/>
<ComponentHeaderControls
items={[
{
key: 'apply-theme',
label: t('Apply theme'),
onClick: () => setThemeModalOpen(true),
},
]}
ariaLabel={t('Row options')}
/>
</HoverMenu>
{({ dropIndicatorProps }: { dropIndicatorProps: JsonObject }) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
{editMode && (
<ThemeSelectorModal
layoutId={rowComponent.id}
show={themeModalOpen}
onHide={() => setThemeModalOpen(false)}
/>
{rowItems.length === 0 && (
<div css={emptyRowContentStyles as any}>{t('Empty row')}</div>
)}
<GridRow
className={cx(
'grid-row',
rowItems.length === 0 && 'grid-row--empty',
hoverMenuHovered && 'grid-row--hovered',
backgroundStyle.className,
)}
data-test={`grid-row-${backgroundStyle.className}`}
ref={containerRef}
editMode={editMode}
>
{editMode && (
<Droppable
{...(rowItems.length === 0
? {
component: rowComponent,
parentComponent: rowComponent,
dropToChild: true,
}
: {
component: rowItems[0],
parentComponent: rowComponent,
})}
depth={depth}
index={0}
orientation="row"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
'empty-droptarget--vertical',
rowItems.length > 0 && 'droptarget-side',
{rowItems.length > 0 &&
rowItems.map((componentId, itemIndex) => (
<Fragment key={componentId}>
<DashboardComponent
key={componentId}
id={componentId}
parentId={rowComponent.id as string}
depth={depth + 1}
index={itemIndex}
availableColumnCount={remainColumnCount}
columnWidth={columnWidth}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
isComponentVisible={isComponentVisible}
onChangeTab={onChangeTab}
isInView={isInView}
/>
{editMode && (
<Droppable
component={rowItems}
parentComponent={rowComponent}
depth={depth}
index={itemIndex + 1}
orientation="row"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
'empty-droptarget--vertical',
remainColumnCount === 0 &&
itemIndex === rowItems.length - 1 &&
'droptarget-side',
)}
editMode
style={{
height: containerHeight,
...(remainColumnCount === 0 &&
itemIndex === rowItems.length - 1 && { width: 16 }),
}}
>
{({
dropIndicatorProps,
}: {
dropIndicatorProps: JsonObject;
}) => dropIndicatorProps && <div {...dropIndicatorProps} />}
</Droppable>
)}
editMode
style={{
height: rowItems.length > 0 ? containerHeight : '100%',
...(rowItems.length > 0 && { width: 16 }),
}}
>
{({ dropIndicatorProps }: { dropIndicatorProps: JsonObject }) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
{rowItems.length === 0 && (
<div css={emptyRowContentStyles as any}>{t('Empty row')}</div>
)}
{rowItems.length > 0 &&
rowItems.map((componentId, itemIndex) => (
<Fragment key={componentId}>
<DashboardComponent
key={componentId}
id={componentId}
parentId={rowComponent.id as string}
depth={depth + 1}
index={itemIndex}
availableColumnCount={remainColumnCount}
columnWidth={columnWidth}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
isComponentVisible={isComponentVisible}
onChangeTab={onChangeTab}
isInView={isInView}
/>
{editMode && (
<Droppable
component={rowItems}
parentComponent={rowComponent}
depth={depth}
index={itemIndex + 1}
orientation="row"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
'empty-droptarget--vertical',
remainColumnCount === 0 &&
itemIndex === rowItems.length - 1 &&
'droptarget-side',
)}
editMode
style={{
height: containerHeight,
...(remainColumnCount === 0 &&
itemIndex === rowItems.length - 1 && { width: 16 }),
}}
>
{({
dropIndicatorProps,
}: {
dropIndicatorProps: JsonObject;
}) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
</Fragment>
))}
</GridRow>
</WithPopoverMenu>
</ComponentThemeProvider>
</Fragment>
))}
</GridRow>
</WithPopoverMenu>
),
[
backgroundStyle.className,

View File

@@ -55,7 +55,7 @@ describe('TabsRenderer', () => {
});
test('renders tabs container with correct test attributes', () => {
render(<TabsRenderer {...mockProps} />, { useRedux: true });
render(<TabsRenderer {...mockProps} />);
const tabsContainer = screen.getByTestId('dashboard-component-tabs');
@@ -64,14 +64,14 @@ describe('TabsRenderer', () => {
});
test('renders LineEditableTabs with correct props', () => {
render(<TabsRenderer {...mockProps} />, { useRedux: true });
render(<TabsRenderer {...mockProps} />);
const editableTabs = screen.getByTestId('nav-list');
expect(editableTabs).toBeInTheDocument();
});
test('applies correct tab bar padding', () => {
const { rerender } = render(<TabsRenderer {...mockProps} />, { useRedux: true });
const { rerender } = render(<TabsRenderer {...mockProps} />);
let editableTabs = screen.getByTestId('nav-list');
expect(editableTabs).toBeInTheDocument();
@@ -89,7 +89,7 @@ describe('TabsRenderer', () => {
activeKey: 'tab-2',
handleClickTab: handleClickTabMock,
};
render(<TabsRenderer {...propsWithTab2Active} />, { useRedux: true });
render(<TabsRenderer {...propsWithTab2Active} />);
const tabElement = screen.getByText('Tab 1').closest('[role="tab"]');
expect(tabElement).not.toBeNull();
@@ -109,7 +109,7 @@ describe('TabsRenderer', () => {
tabsDragSourceRef: mockRef,
};
render(<TabsRenderer {...editModeProps} />, { useRedux: true });
render(<TabsRenderer {...editModeProps} />);
const hoverMenu = document.querySelector('.hover-menu');
@@ -123,7 +123,7 @@ describe('TabsRenderer', () => {
renderHoverMenu: true,
};
render(<TabsRenderer {...viewModeProps} />, { useRedux: true });
render(<TabsRenderer {...viewModeProps} />);
const hoverMenu = document.querySelector('.hover-menu');
@@ -139,7 +139,7 @@ describe('TabsRenderer', () => {
tabsDragSourceRef: mockRef,
};
render(<TabsRenderer {...noHoverMenuProps} />, { useRedux: true });
render(<TabsRenderer {...noHoverMenuProps} />);
const hoverMenu = document.querySelector('.hover-menu');
@@ -149,7 +149,6 @@ describe('TabsRenderer', () => {
test('renders with correct tab type based on edit mode', () => {
const { rerender } = render(
<TabsRenderer {...mockProps} editMode={false} />,
{ useRedux: true },
);
let editableTabs = screen.getByTestId('nav-list');
@@ -174,7 +173,7 @@ describe('TabsRenderer', () => {
handleEdit: mockProps.handleEdit,
};
render(<TabsRenderer {...minimalProps} />, { useRedux: true });
render(<TabsRenderer {...minimalProps} />);
const tabsContainer = screen.getByTestId('dashboard-component-tabs');
@@ -189,13 +188,13 @@ describe('TabsRenderer', () => {
handleEdit: handleEditMock,
};
render(<TabsRenderer {...editableProps} />, { useRedux: true });
render(<TabsRenderer {...editableProps} />);
expect(screen.getByTestId('nav-list')).toBeInTheDocument();
});
test('renders tab content correctly', () => {
render(<TabsRenderer {...mockProps} />, { useRedux: true });
render(<TabsRenderer {...mockProps} />);
expect(screen.getByText('Tab 1 Content')).toBeInTheDocument();
expect(screen.queryByText('Tab 2 Content')).not.toBeInTheDocument(); // Not active

View File

@@ -45,10 +45,6 @@ import {
import HoverMenu from '../../menu/HoverMenu';
import DragHandle from '../../dnd/DragHandle';
import DeleteComponentButton from '../../DeleteComponentButton';
import ComponentThemeProvider from '../../ComponentThemeProvider';
import ComponentHeaderControls from '../../menu/ComponentHeaderControls';
import ThemeSelectorModal from '../../ThemeSelectorModal';
import { t } from '@apache-superset/core/translation';
const StyledTabsContainer = styled.div<{ isDragging?: boolean }>`
width: 100%;
@@ -177,7 +173,6 @@ const TabsRenderer = memo<TabsRendererProps>(
onTabTitleEditingChange,
}) => {
const [activeId, setActiveId] = useState<string | null>(null);
const [themeModalOpen, setThemeModalOpen] = useState(false);
// Use ref to always have access to the current tabIds in callbacks
const tabIdsRef = useRef(tabIds);
@@ -214,86 +209,66 @@ const TabsRenderer = memo<TabsRendererProps>(
const isDragging = activeId !== null;
return (
<ComponentThemeProvider layoutId={tabsComponent.id}>
<StyledTabsContainer
className="dashboard-component dashboard-component-tabs"
data-test="dashboard-component-tabs"
isDragging={isDragging}
>
{editMode && renderHoverMenu && tabsDragSourceRef && (
<HoverMenu innerRef={tabsDragSourceRef} position="left">
<DragHandle position="left" />
<DeleteComponentButton onDelete={handleDeleteComponent} />
<ComponentHeaderControls
items={[
{
key: 'apply-theme',
label: t('Apply theme'),
onClick: () => setThemeModalOpen(true),
},
]}
ariaLabel={t('Tabs options')}
/>
</HoverMenu>
)}
{editMode && (
<ThemeSelectorModal
layoutId={tabsComponent.id}
show={themeModalOpen}
onHide={() => setThemeModalOpen(false)}
/>
)}
<StyledTabsContainer
className="dashboard-component dashboard-component-tabs"
data-test="dashboard-component-tabs"
isDragging={isDragging}
>
{editMode && renderHoverMenu && tabsDragSourceRef && (
<HoverMenu innerRef={tabsDragSourceRef} position="left">
<DragHandle position="left" />
<DeleteComponentButton onDelete={handleDeleteComponent} />
</HoverMenu>
)}
<LineEditableTabs
id={tabsComponent.id}
activeKey={activeKey}
onChange={key => {
if (typeof key === 'string') {
const tabIndex = tabIds.indexOf(key);
if (tabIndex !== -1) handleClickTab(tabIndex);
}
}}
onEdit={handleEdit}
data-test="nav-list"
type={editMode ? 'editable-card' : 'card'}
items={tabItems}
tabBarStyle={{ paddingLeft: tabBarPaddingLeft }}
fullHeight
{...(editMode && {
renderTabBar: (tabBarProps, DefaultTabBar) => (
<DndContext
key={tabIds.join('-')}
sensors={[sensor]}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={onDragCancel}
collisionDetection={closestCenter}
<LineEditableTabs
id={tabsComponent.id}
activeKey={activeKey}
onChange={key => {
if (typeof key === 'string') {
const tabIndex = tabIds.indexOf(key);
if (tabIndex !== -1) handleClickTab(tabIndex);
}
}}
onEdit={handleEdit}
data-test="nav-list"
type={editMode ? 'editable-card' : 'card'}
items={tabItems}
tabBarStyle={{ paddingLeft: tabBarPaddingLeft }}
fullHeight
{...(editMode && {
renderTabBar: (tabBarProps, DefaultTabBar) => (
<DndContext
key={tabIds.join('-')}
sensors={[sensor]}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={onDragCancel}
collisionDetection={closestCenter}
>
<SortableContext
items={tabIds}
strategy={horizontalListSortingStrategy}
>
<SortableContext
items={tabIds}
strategy={horizontalListSortingStrategy}
>
<DefaultTabBar {...tabBarProps}>
{(node: React.ReactElement) => (
<DraggableTabNode
{...(
node as React.ReactElement<DraggableTabNodeProps>
).props}
key={node.key}
data-node-key={node.key as string}
disabled={isEditingTabTitle}
>
{node}
</DraggableTabNode>
)}
</DefaultTabBar>
</SortableContext>
</DndContext>
),
})}
/>
</StyledTabsContainer>
</ComponentThemeProvider>
<DefaultTabBar {...tabBarProps}>
{(node: React.ReactElement) => (
<DraggableTabNode
{...(node as React.ReactElement<DraggableTabNodeProps>)
.props}
key={node.key}
data-node-key={node.key as string}
disabled={isEditingTabTitle}
>
{node}
</DraggableTabNode>
)}
</DefaultTabBar>
</SortableContext>
</DndContext>
),
})}
/>
</StyledTabsContainer>
);
},
);

View File

@@ -1,64 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import ComponentHeaderControls from '.';
test('renders nothing when items is empty', () => {
const { container } = render(<ComponentHeaderControls items={[]} />);
expect(container).toBeEmptyDOMElement();
});
test('renders the trigger when items are provided', () => {
render(
<ComponentHeaderControls
items={[{ key: 'edit', label: 'Edit', onClick: () => {} }]}
/>,
);
expect(screen.getByTestId('dropdown-trigger')).toBeInTheDocument();
});
test('fires onClick when a menu item is selected', async () => {
const onEdit = jest.fn();
const onPreview = jest.fn();
render(
<ComponentHeaderControls
items={[
{ key: 'edit', label: 'Edit', onClick: onEdit },
{ key: 'preview', label: 'Preview', onClick: onPreview },
]}
/>,
);
await userEvent.click(screen.getByTestId('dropdown-trigger'));
await userEvent.click(await screen.findByRole('menuitem', { name: 'Edit' }));
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onPreview).not.toHaveBeenCalled();
});
test('disabled items are still rendered but do not fire onClick', async () => {
const onClick = jest.fn();
render(
<ComponentHeaderControls
items={[{ key: 'gone', label: 'Gone', onClick, disabled: true }]}
/>,
);
await userEvent.click(screen.getByTestId('dropdown-trigger'));
const item = await screen.findByRole('menuitem', { name: 'Gone' });
await userEvent.click(item);
expect(onClick).not.toHaveBeenCalled();
});

View File

@@ -1,94 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { ReactNode } from 'react';
import { MenuDotsDropdown, Menu } from '@superset-ui/core/components';
export interface ComponentMenuItem {
/** Stable key for React + telemetry. */
key: string;
/** Label rendered in the menu row. */
label: ReactNode;
/** Optional icon rendered to the left of the label. */
icon?: ReactNode;
/** Click handler. Provider closes the menu after firing. */
onClick?: () => void;
/** When true, dims and disables the row. */
disabled?: boolean;
/** Renders a horizontal rule above this item. */
divider?: boolean;
/** Marks the row as destructive (red tone). */
danger?: boolean;
}
interface ComponentHeaderControlsProps {
items: ComponentMenuItem[];
/** Data-test attribute hook for the trigger button. */
dataTest?: string;
/**
* Optional `aria-label` override for the trigger button. Default is the
* generic "Component options".
*/
ariaLabel?: string;
}
/**
* Shared vertical-dots menu for dashboard grid components. Each component
* (Chart, Markdown, Row, Column, Tabs) plugs in its own `items` and the
* visual chrome — the dots icon, dropdown surface, accessible labelling —
* lives here.
*
* Built on `MenuDotsDropdown` from `@superset-ui/core/components` so we get
* the same trigger styling as Chart's `SliceHeaderControls` does today;
* Phase 4 will converge `SliceHeaderControls` onto this same component.
*
* The component is intentionally render-only: it does not read Redux, does
* not gate on `editMode`, and does not know about theming. Callers decide
* when to render it. This keeps it reusable across edit vs view, hover
* menus, embedded contexts, etc.
*/
export default function ComponentHeaderControls({
items,
dataTest = 'component-header-controls',
ariaLabel,
}: ComponentHeaderControlsProps) {
if (items.length === 0) return null;
// antd Menu items: split divider markers into their own item entries.
const menuItems = items.flatMap(item => {
const row = {
key: item.key,
label: item.label,
icon: item.icon,
onClick: item.onClick,
disabled: item.disabled,
danger: item.danger,
};
return item.divider
? [{ type: 'divider' as const, key: `${item.key}-divider` }, row]
: [row];
});
return (
<MenuDotsDropdown
data-test={dataTest}
aria-label={ariaLabel}
overlay={<Menu items={menuItems} />}
/>
);
}

View File

@@ -278,12 +278,6 @@ export type LayoutItemMeta = {
code?: string;
/** Background style value for columns and rows */
background?: string;
/**
* Optional per-component theme override (CRUD `theme` resource id).
* `null` means "explicitly inherit"; a missing key means the same thing
* semantically. See ComponentThemeProvider for the inheritance walk.
*/
themeId?: number | null;
/** Allow additional meta properties used by different component types */
[key: string]: unknown;
};
@@ -379,7 +373,6 @@ export enum MenuKeys {
ExportFullXlsx = 'export_full_xlsx',
ForceRefresh = 'force_refresh',
Fullscreen = 'fullscreen',
ApplyTheme = 'apply_theme',
ToggleChartDescription = 'toggle_chart_description',
ViewQuery = 'view_query',
ViewResults = 'view_results',

View File

@@ -18,7 +18,7 @@
*/
import 'src/public-path';
import { lazy, StrictMode, Suspense, useEffect } from 'react';
import { lazy, Suspense, useEffect } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import { Global } from '@emotion/react';
@@ -197,11 +197,7 @@ function start() {
if (!root) {
root = createRoot(appMountPoint);
}
root.render(
<StrictMode>
<EmbeddedApp />
</StrictMode>,
);
root.render(<EmbeddedApp />);
},
err => {
// something is most likely wrong with the guest token; reset the guard

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { SupersetClient } from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import type { common as core } from '@apache-superset/core';
import ExtensionsLoader from './ExtensionsLoader';
@@ -111,3 +112,33 @@ test('handles initialization errors gracefully', async () => {
errorSpy.mockRestore();
appendChildSpy.mockRestore();
});
test('logs success after initializeExtensions completes', async () => {
const loader = ExtensionsLoader.getInstance();
const infoSpy = jest.spyOn(logging, 'info').mockImplementation();
jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [] },
} as any);
await loader.initializeExtensions();
expect(infoSpy).toHaveBeenCalledWith('Extensions initialized successfully.');
infoSpy.mockRestore();
});
test('logs error when initializeExtensions fails', async () => {
const loader = ExtensionsLoader.getInstance();
const errorSpy = jest.spyOn(logging, 'error').mockImplementation();
const fetchError = new Error('Network error');
jest.spyOn(SupersetClient, 'get').mockRejectedValue(fetchError);
await loader.initializeExtensions();
expect(errorSpy).toHaveBeenCalledWith(
'Error setting up extensions:',
fetchError,
);
errorSpy.mockRestore();
});

View File

@@ -34,6 +34,8 @@ class ExtensionsLoader {
private extensionIndex: Map<string, Extension> = new Map();
private initializationPromise: Promise<void> | null = null;
// eslint-disable-next-line no-useless-constructor
private constructor() {
// Private constructor for singleton pattern
@@ -54,16 +56,27 @@ class ExtensionsLoader {
* Initializes extensions by fetching the list from the API and loading each one.
* @throws Error if initialization fails.
*/
public async initializeExtensions(): Promise<void> {
const response = await SupersetClient.get({
endpoint: '/api/v1/extensions/',
});
const extensions: Extension[] = response.json.result;
await Promise.all(
extensions.map(async extension => {
await this.initializeExtension(extension);
}),
);
public initializeExtensions(): Promise<void> {
if (this.initializationPromise) {
return this.initializationPromise;
}
this.initializationPromise = (async () => {
try {
const response = await SupersetClient.get({
endpoint: '/api/v1/extensions/',
});
const extensions: Extension[] = response.json.result;
await Promise.all(
extensions.map(async extension => {
await this.initializeExtension(extension);
}),
);
logging.info('Extensions initialized successfully.');
} catch (error) {
logging.error('Error setting up extensions:', error);
}
})();
return this.initializationPromise;
}
/**

View File

@@ -18,7 +18,6 @@
*/
import { render, waitFor } from 'spec/helpers/testing-library';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import fetchMock from 'fetch-mock';
import ExtensionsStartup from './ExtensionsStartup';
import ExtensionsLoader from './ExtensionsLoader';
@@ -192,14 +191,12 @@ test('only initializes once even with multiple renders', async () => {
loader.initializeExtensions = originalInitialize;
});
test('initializes ExtensionsLoader and logs success when EnableExtensions feature flag is enabled', async () => {
test('initializes ExtensionsLoader when EnableExtensions feature flag is enabled', async () => {
// Ensure feature flag is enabled
mockIsFeatureEnabled.mockImplementation(
(flag: FeatureFlag) => flag === FeatureFlag.EnableExtensions,
);
const infoSpy = jest.spyOn(logging, 'info').mockImplementation();
// Mock the initializeExtensions method to succeed
const originalInitialize = ExtensionsLoader.prototype.initializeExtensions;
ExtensionsLoader.prototype.initializeExtensions = jest
@@ -220,15 +217,10 @@ test('initializes ExtensionsLoader and logs success when EnableExtensions featur
expect(
ExtensionsLoader.prototype.initializeExtensions,
).toHaveBeenCalledTimes(1);
// Verify success message was logged
expect(infoSpy).toHaveBeenCalledWith(
'Extensions initialized successfully.',
);
});
// Restore original method
ExtensionsLoader.prototype.initializeExtensions = originalInitialize;
infoSpy.mockRestore();
});
test('does not initialize ExtensionsLoader when EnableExtensions feature flag is disabled', async () => {
@@ -259,38 +251,36 @@ test('does not initialize ExtensionsLoader when EnableExtensions feature flag is
initializeSpy.mockRestore();
});
test('logs error when ExtensionsLoader initialization fails', async () => {
test('continues rendering children even when ExtensionsLoader initialization fails', async () => {
// Ensure feature flag is enabled
mockIsFeatureEnabled.mockReturnValue(true);
const errorSpy = jest.spyOn(logging, 'error').mockImplementation();
// Mock the initializeExtensions method to throw an error
// Mock the initializeExtensions method to reject — ExtensionsLoader handles
// its own error logging internally
const originalInitialize = ExtensionsLoader.prototype.initializeExtensions;
ExtensionsLoader.prototype.initializeExtensions = jest
.fn()
.mockImplementation(() => {
throw new Error('Test initialization error');
});
.mockImplementation(() => Promise.resolve());
render(<ExtensionsStartup />, {
useRedux: true,
initialState: mockInitialState,
});
const { container } = render(
<ExtensionsStartup>
<div data-testid="child" />
</ExtensionsStartup>,
{
useRedux: true,
initialState: mockInitialState,
},
);
await waitFor(() => {
// Verify feature flag was checked
expect(mockIsFeatureEnabled).toHaveBeenCalledWith(
FeatureFlag.EnableExtensions,
);
// Verify error was logged
expect(errorSpy).toHaveBeenCalledWith(
'Error setting up extensions:',
expect.any(Error),
);
expect(
container.querySelector('[data-testid="child"]'),
).toBeInTheDocument();
});
// Restore original method
ExtensionsLoader.prototype.initializeExtensions = originalInitialize;
errorSpy.mockRestore();
});

View File

@@ -82,17 +82,7 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
const setup = async () => {
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
try {
await ExtensionsLoader.getInstance().initializeExtensions();
supersetCore.utils.logging.info(
'Extensions initialized successfully.',
);
} catch (error) {
supersetCore.utils.logging.error(
'Error setting up extensions:',
error,
);
}
await ExtensionsLoader.getInstance().initializeExtensions();
}
setInitialized(true);
};

View File

@@ -33,13 +33,7 @@ import {
} from '@apache-superset/core/theme';
import { ThemeController } from './ThemeController';
// Exported so callers that want to opt out of `useThemeContext`'s
// "must be used within a ThemeProvider" throw can read the context
// directly and handle the null case themselves (e.g. components like
// `ComponentThemeProvider` that need to behave as a pass-through when
// rendered outside the dashboard's ThemeProvider tree, such as in
// isolated component tests).
export const ThemeContext = createContext<ThemeContextType | null>(null);
const ThemeContext = createContext<ThemeContextType | null>(null);
interface ThemeProviderProps {
children: React.ReactNode;

View File

@@ -18,7 +18,6 @@
*/
import 'src/public-path';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { logging } from '@apache-superset/core/utils';
import initPreamble from 'src/preamble';
@@ -32,11 +31,7 @@ if (appMountPoint) {
await initPreamble();
} finally {
const { default: App } = await import(/* webpackMode: "eager" */ './App');
root.render(
<StrictMode>
<App />
</StrictMode>,
);
root.render(<App />);
}
})().catch(err => {
logging.error('Unhandled error during app initialization', err);

View File

@@ -20,7 +20,6 @@ import 'src/public-path';
// Menu App. Used in views that do not already include the Menu component in the layout.
// eg, backend rendered views
import { StrictMode } from 'react';
import { Provider } from 'react-redux';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
@@ -46,26 +45,24 @@ const emotionCache = createCache({
});
const app = (
<StrictMode>
<CacheProvider value={emotionCache}>
<ThemeProvider theme={theme}>
<Provider store={store}>
<BrowserRouter>
<QueryParamProvider
adapter={ReactRouter5Adapter}
options={{
searchStringToObject: querystring.parse,
objectToSearchString: (object: Record<string, any>) =>
querystring.stringify(object, { encode: false }),
}}
>
<Menu data={menu} />
</QueryParamProvider>
</BrowserRouter>
</Provider>
</ThemeProvider>
</CacheProvider>
</StrictMode>
<CacheProvider value={emotionCache}>
<ThemeProvider theme={theme}>
<Provider store={store}>
<BrowserRouter>
<QueryParamProvider
adapter={ReactRouter5Adapter}
options={{
searchStringToObject: querystring.parse,
objectToSearchString: (object: Record<string, any>) =>
querystring.stringify(object, { encode: false }),
}}
>
<Menu data={menu} />
</QueryParamProvider>
</BrowserRouter>
</Provider>
</ThemeProvider>
</CacheProvider>
);
const menuMountPoint = document.getElementById('app-menu');

View File

@@ -36,6 +36,7 @@ else:
from flask import Flask, Response
from werkzeug.exceptions import NotFound
from superset.extensions.cache_middleware import ExtensionCacheMiddleware
from superset.extensions.local_extensions_watcher import (
start_local_extensions_watcher_thread,
)
@@ -66,7 +67,6 @@ def create_app(
or app.config["APPLICATION_ROOT"],
)
if app_root != "/":
app.wsgi_app = AppRootMiddleware(app.wsgi_app, app_root)
# If not set, manually configure options that depend on the
# value of app_root so things work out of the box
if not app.config["STATIC_ASSETS_PREFIX"]:
@@ -77,6 +77,13 @@ def create_app(
app_initializer = app.config.get("APP_INITIALIZER", SupersetAppInitializer)(app)
app_initializer.init_app()
# Must be applied before AppRootMiddleware so the path prefix
# is stripped before the extension asset path regex runs.
app.wsgi_app = ExtensionCacheMiddleware(app.wsgi_app)
if app_root != "/":
app.wsgi_app = AppRootMiddleware(app.wsgi_app, app_root)
# Set up LOCAL_EXTENSIONS file watcher when in debug mode
if app.debug:
start_local_extensions_watcher_thread(app)

View File

@@ -146,6 +146,27 @@ class ExportDashboardsCommand(ExportModelsCommand):
if dataset:
target["datasetUuid"] = str(dataset.uuid)
# Replace display control dataset references with uuid.
# datasetId is intentionally preserved alongside datasetUuid so that
# bundles remain importable by older versions that do not yet understand
# datasetUuid for display-control targets.
for customization in (
payload.get("metadata", {}).get("chart_customization_config") or []
):
for target in customization.get("targets") or []:
dataset_id = target.get("datasetId")
if dataset_id is not None:
dataset = DatasetDAO.find_by_id(dataset_id)
if dataset:
target["datasetUuid"] = str(dataset.uuid)
else:
logger.warning(
"Dashboard '%s': display control target references "
"missing dataset %s; datasetUuid will not be set",
model.dashboard_title,
dataset_id,
)
# the mapping between dashboard -> charts is inferred from the position
# attribute, so if it's not present we need to add a default config
if not payload.get("position"):
@@ -230,3 +251,14 @@ class ExportDashboardsCommand(ExportModelsCommand):
dataset = DatasetDAO.find_by_id(dataset_id)
if dataset:
yield from ExportDatasetsCommand([dataset_id]).run()
# Export datasets referenced by display controls
for customization in (
payload.get("metadata", {}).get("chart_customization_config") or []
):
for target in customization.get("targets") or []:
dataset_id = target.get("datasetId")
if dataset_id is not None:
dataset = DatasetDAO.find_by_id(dataset_id)
if dataset:
yield from ExportDatasetsCommand([dataset_id]).run()

View File

@@ -42,6 +42,11 @@ def find_native_filter_datasets(metadata: dict[str, Any]) -> set[str]:
dataset_uuid = target.get("datasetUuid")
if dataset_uuid:
uuids.add(dataset_uuid)
for customization in metadata.get("chart_customization_config") or []:
for target in customization.get("targets") or []:
dataset_uuid = target.get("datasetUuid")
if dataset_uuid:
uuids.add(dataset_uuid)
return uuids
@@ -139,6 +144,28 @@ def update_id_refs( # pylint: disable=too-many-locals # noqa: C901
native_filter["scope"]["excluded"] = [
id_map[old_id] for old_id in scope_excluded if old_id in id_map
]
# fix display control dataset references
for customization in (
fixed.get("metadata", {}).get("chart_customization_config") or []
):
for target in customization.get("targets") or []:
dataset_uuid = target.pop("datasetUuid", None)
if dataset_uuid:
info = dataset_info.get(dataset_uuid)
if info:
target["datasetId"] = info["datasource_id"]
else:
# UUID present but unresolvable — remove stale integer ID
# so the control fails visibly rather than binding to
# whatever dataset happens to own that ID in this environment
target.pop("datasetId", None)
logger.warning(
"Display control target references unknown dataset UUID %s; "
"datasetId will not be restored",
dataset_uuid,
)
fixed = update_cross_filter_scoping(fixed, id_map)
return fixed

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