This is an automated email from the ASF dual-hosted git repository. EnxDev pushed a commit to branch enxdev/feat/chatbot-p3-namespaces in repository https://gitbox.apache.org/repos/asf/superset.git
commit bf71eb67122326770cae9fb32ab02337ef224692 Author: Enzo Martellucci <[email protected]> AuthorDate: Tue May 26 13:52:25 2026 +0200 feat(extensions): P2 admin guards + P3 context namespaces (dashboard, explore, dataset, navigation) --- .../packages/superset-core/package.json | 16 ++++ .../packages/superset-core/src/dashboard/index.ts | 84 +++++++++++++++++ .../packages/superset-core/src/dataset/index.ts | 73 +++++++++++++++ .../packages/superset-core/src/explore/index.ts | 75 +++++++++++++++ .../packages/superset-core/src/index.ts | 4 + .../packages/superset-core/src/navigation/index.ts | 91 ++++++++++++++++++ .../src/components/ChatbotMount/index.tsx | 11 ++- superset-frontend/src/core/chatbot/index.ts | 19 +++- superset-frontend/src/core/dashboard/index.ts | 86 +++++++++++++++++ superset-frontend/src/core/dataset/index.ts | 62 +++++++++++++ superset-frontend/src/core/explore/index.ts | 82 +++++++++++++++++ superset-frontend/src/core/index.ts | 4 + superset-frontend/src/core/navigation/index.ts | 102 +++++++++++++++++++++ .../src/extensions/ExtensionsStartup.tsx | 36 +++++++- superset/extensions/api.py | 5 + superset/extensions/settings.py | 3 +- 16 files changed, 741 insertions(+), 12 deletions(-) diff --git a/superset-frontend/packages/superset-core/package.json b/superset-frontend/packages/superset-core/package.json index cf9d4a02665..0fd06f79a62 100644 --- a/superset-frontend/packages/superset-core/package.json +++ b/superset-frontend/packages/superset-core/package.json @@ -18,6 +18,22 @@ "types": "./lib/authentication/index.d.ts", "default": "./lib/authentication/index.js" }, + "./dashboard": { + "types": "./lib/dashboard/index.d.ts", + "default": "./lib/dashboard/index.js" + }, + "./dataset": { + "types": "./lib/dataset/index.d.ts", + "default": "./lib/dataset/index.js" + }, + "./explore": { + "types": "./lib/explore/index.d.ts", + "default": "./lib/explore/index.js" + }, + "./navigation": { + "types": "./lib/navigation/index.d.ts", + "default": "./lib/navigation/index.js" + }, "./commands": { "types": "./lib/commands/index.d.ts", "default": "./lib/commands/index.js" diff --git a/superset-frontend/packages/superset-core/src/dashboard/index.ts b/superset-frontend/packages/superset-core/src/dashboard/index.ts new file mode 100644 index 00000000000..4e78c7eef6c --- /dev/null +++ b/superset-frontend/packages/superset-core/src/dashboard/index.ts @@ -0,0 +1,84 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * @fileoverview Dashboard namespace for Superset extensions (P3). + * + * Exposes dashboard identity and filter state as a stable semantic API. + * Extensions must not depend on the Redux dashboard slice structure directly. + */ + +import { Event } from '../common'; + +/** + * A single native filter's current selected value(s). + * The value type is intentionally kept as `unknown` because filter values + * are heterogeneous (date ranges, string lists, numbers, etc.). + */ +export interface FilterValue { + /** The filter's stable id. */ + filterId: string; + /** Display label of the filter. */ + label: string; + /** Currently applied value, or `null` when the filter is cleared. */ + value: unknown; +} + +/** + * Normalized dashboard context exposed to extensions on the Dashboard page. + */ +export interface DashboardContext { + /** Numeric dashboard id. */ + dashboardId: number; + /** Display title of the dashboard. */ + title: string; + /** + * Active native filter values keyed by filter id. + * Only includes filters that have a value applied. + */ + filters: FilterValue[]; +} + +/** + * Returns the normalized dashboard context for the page currently being viewed, + * or `undefined` when the user is not on a Dashboard page. + * + * @example + * ```typescript + * const dash = dashboard.getCurrentDashboard(); + * if (dash) { + * console.log(dash.title, dash.filters); + * } + * ``` + */ +export declare function getCurrentDashboard(): DashboardContext | undefined; + +/** + * Event fired when the dashboard identity or its active filter values change. + * Fired on native filter value changes and on navigation to a different dashboard. + * + * @example + * ```typescript + * const sub = dashboard.onDidChangeDashboard(dash => { + * chatbot.updateContext({ dashboard: dash }); + * }); + * sub.dispose(); + * ``` + */ +export declare const onDidChangeDashboard: Event<DashboardContext>; diff --git a/superset-frontend/packages/superset-core/src/dataset/index.ts b/superset-frontend/packages/superset-core/src/dataset/index.ts new file mode 100644 index 00000000000..ea3fafa4fdb --- /dev/null +++ b/superset-frontend/packages/superset-core/src/dataset/index.ts @@ -0,0 +1,73 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * @fileoverview Dataset namespace for Superset extensions (P3). + * + * Exposes the dataset currently being viewed as a stable semantic API. + * Aligned with backend-enforced dataset visibility and column-access semantics. + */ + +import { Event } from '../common'; + +/** + * Normalized dataset context exposed to extensions on the Dataset page. + */ +export interface DatasetContext { + /** Numeric dataset id. */ + datasetId: number; + /** Display name (table name or virtual dataset name). */ + datasetName: string; + /** Schema the dataset belongs to, if applicable. */ + schema: string | null; + /** Catalog the dataset belongs to, if applicable. */ + catalog: string | null; + /** Database name backing this dataset. */ + databaseName: string | null; + /** Whether this is a virtual (SQL-defined) dataset. */ + isVirtual: boolean; +} + +/** + * Returns the normalized dataset context for the page currently being viewed, + * or `undefined` when the user is not on a Dataset page. + * + * @example + * ```typescript + * const ds = dataset.getCurrentDataset(); + * if (ds) { + * console.log(ds.datasetName, ds.schema); + * } + * ``` + */ +export declare function getCurrentDataset(): DatasetContext | undefined; + +/** + * Event fired when the focused dataset changes (e.g. the user navigates to a + * different dataset detail page). + * + * @example + * ```typescript + * const sub = dataset.onDidChangeDataset(ds => { + * chatbot.updateContext({ dataset: ds }); + * }); + * sub.dispose(); + * ``` + */ +export declare const onDidChangeDataset: Event<DatasetContext>; diff --git a/superset-frontend/packages/superset-core/src/explore/index.ts b/superset-frontend/packages/superset-core/src/explore/index.ts new file mode 100644 index 00000000000..162d1b2e6f7 --- /dev/null +++ b/superset-frontend/packages/superset-core/src/explore/index.ts @@ -0,0 +1,75 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * @fileoverview Explore namespace for Superset extensions (P3). + * + * Exposes the current chart/explore context as a stable semantic API. + * Normalized over Explore Redux state — extensions must not depend on + * the Redux slice structure directly. + */ + +import { Event } from '../common'; + +/** + * Normalized chart context exposed to extensions during an Explore session. + * Covers saved chart identity and transient editing context; excludes raw + * form-data internals and datasource-implementation details. + */ +export interface ChartContext { + /** The saved chart id, or `null` when the chart has not been persisted. */ + chartId: number | null; + /** Display name of the saved chart, or `null` for a new/unsaved chart. */ + chartName: string | null; + /** The visualization type currently selected in the editor. */ + vizType: string; + /** Id of the datasource backing the chart (physical or virtual dataset). */ + datasourceId: number | null; + /** Human-readable datasource name. */ + datasourceName: string | null; +} + +/** + * Returns the normalized chart context for the active Explore session, or + * `undefined` when the user is not on the Explore page. + * + * @example + * ```typescript + * const chart = explore.getCurrentChart(); + * if (chart) { + * console.log(chart.vizType, chart.chartName); + * } + * ``` + */ +export declare function getCurrentChart(): ChartContext | undefined; + +/** + * Event fired when the chart context changes within the active Explore session + * (e.g. when the viz type, datasource, or saved name changes). + * Not fired during route changes — subscribe to `navigation.onDidChangePage` for those. + * + * @example + * ```typescript + * const sub = explore.onDidChangeChart(chart => { + * chatbot.updateContext({ chart }); + * }); + * sub.dispose(); + * ``` + */ +export declare const onDidChangeChart: Event<ChartContext>; diff --git a/superset-frontend/packages/superset-core/src/index.ts b/superset-frontend/packages/superset-core/src/index.ts index 75863372409..79c699caff4 100644 --- a/superset-frontend/packages/superset-core/src/index.ts +++ b/superset-frontend/packages/superset-core/src/index.ts @@ -19,9 +19,13 @@ export * as common from './common'; export * as authentication from './authentication'; export * as commands from './commands'; +export * as dashboard from './dashboard'; +export * as dataset from './dataset'; export * as editors from './editors'; +export * as explore from './explore'; export * as extensions from './extensions'; export * as menus from './menus'; +export * as navigation from './navigation'; export * as sqlLab from './sqlLab'; export * as views from './views'; export * as contributions from './contributions'; diff --git a/superset-frontend/packages/superset-core/src/navigation/index.ts b/superset-frontend/packages/superset-core/src/navigation/index.ts new file mode 100644 index 00000000000..a26766db1f3 --- /dev/null +++ b/superset-frontend/packages/superset-core/src/navigation/index.ts @@ -0,0 +1,91 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * @fileoverview Navigation namespace for Superset extensions (P3). + * + * Exposes stable routing and page-surface context to extensions. + * Extensions use this namespace to react to page changes without polling. + */ + +import { Event } from '../common'; + +/** + * The set of top-level application surfaces the chatbot is aware of. + * `'other'` covers any route not explicitly enumerated. + */ +export type PageType = + | 'dashboard' + | 'explore' + | 'sqllab' + | 'dataset' + | 'home' + | 'other'; + +/** + * Lightweight page descriptor: the current surface and the focused entity id, + * if the surface has one (dashboard id, chart id, dataset id). Does not embed + * full entity payloads — use the surface-specific namespace for those. + */ +export interface PageContext { + pageType: PageType; + /** + * The numeric id of the primary entity on this page, if applicable. + * `null` when the surface has no focused entity, or when the entity is + * addressed by a non-numeric slug (e.g. dashboard accessed via slug URL). + */ + entityId: number | null; +} + +/** + * Returns the current page surface type. + * + * @example + * ```typescript + * const pageType = navigation.getPageType(); + * if (pageType === 'dashboard') { ... } + * ``` + */ +export declare function getPageType(): PageType; + +/** + * Returns lightweight context about the current page: surface type and focused + * entity id. Does not embed entity payloads; use the surface namespace for those. + * + * @example + * ```typescript + * const { pageType, entityId } = navigation.getCurrentPage(); + * ``` + */ +export declare function getCurrentPage(): PageContext; + +/** + * Event fired whenever the user navigates to a different page or entity. + * Provides the new `PageContext` as the event payload. + * + * @example + * ```typescript + * const sub = navigation.onDidChangePage(({ pageType, entityId }) => { + * chatbot.updateContext({ pageType, entityId }); + * }); + * // later: + * sub.dispose(); + * ``` + */ +export declare const onDidChangePage: Event<PageContext>; diff --git a/superset-frontend/src/components/ChatbotMount/index.tsx b/superset-frontend/src/components/ChatbotMount/index.tsx index a88291c56fc..5dd8f9f01cd 100644 --- a/superset-frontend/src/components/ChatbotMount/index.tsx +++ b/superset-frontend/src/components/ChatbotMount/index.tsx @@ -29,16 +29,19 @@ const CHATBOT_EDGE_MARGIN = 24; const ChatbotMount = () => { const theme = useTheme(); const [adminSelectedId, setAdminSelectedId] = useState<string | null>(null); + const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({}); const [activeChatbot, setActiveChatbot] = useState(() => - getActiveChatbot(null), + getActiveChatbot(null, {}), ); useEffect(() => { SupersetClient.get({ endpoint: '/api/v1/extensions/settings' }) .then(({ json }) => { const id = json.result?.active_chatbot_id ?? null; + const enabled: Record<string, boolean> = json.result?.enabled ?? {}; setAdminSelectedId(id); - setActiveChatbot(getActiveChatbot(id)); + setEnabledMap(enabled); + setActiveChatbot(getActiveChatbot(id, enabled)); }) .catch(() => { // Settings fetch failure is non-fatal — fall back to first-to-register. @@ -48,9 +51,9 @@ const ChatbotMount = () => { useEffect( () => subscribeToLocation(CHATBOT_LOCATION, () => - setActiveChatbot(getActiveChatbot(adminSelectedId)), + setActiveChatbot(getActiveChatbot(adminSelectedId, enabledMap)), ), - [adminSelectedId], + [adminSelectedId, enabledMap], ); if (!activeChatbot) { diff --git a/superset-frontend/src/core/chatbot/index.ts b/superset-frontend/src/core/chatbot/index.ts index 4b1d1bb00a7..4cb92b4bc9a 100644 --- a/superset-frontend/src/core/chatbot/index.ts +++ b/superset-frontend/src/core/chatbot/index.ts @@ -48,24 +48,35 @@ export interface ActiveChatbot { * * Selection policy: * - If no chatbot is registered, returns `undefined` — the corner stays empty. - * - If `adminSelectedId` is provided and matches a registered chatbot, that one wins. - * - Otherwise the first-to-register chatbot is used as a fallback. + * - Disabled chatbots (per `enabledMap`) are excluded before selection. + * - If `adminSelectedId` matches an enabled registered chatbot, that one wins. + * - Otherwise the first enabled chatbot in registration order is used as a fallback. * * @param adminSelectedId The id stored in the admin "Default chatbot" setting, if any. + * @param enabledMap Per-extension enabled flags from the admin settings API. * @returns The active chatbot's id and provider, or `undefined` if none. */ export const getActiveChatbot = ( adminSelectedId?: string | null, + enabledMap?: Record<string, boolean>, ): ActiveChatbot | undefined => { const registeredIds = getRegisteredViewIds(CHATBOT_LOCATION); if (registeredIds.length === 0) { return undefined; } + const candidates = enabledMap + ? registeredIds.filter(id => enabledMap[id] !== false) + : registeredIds; + + if (candidates.length === 0) { + return undefined; + } + const selectedId = - adminSelectedId && registeredIds.includes(adminSelectedId) + adminSelectedId && candidates.includes(adminSelectedId) ? adminSelectedId - : registeredIds[0]; + : candidates[0]; const provider = getViewProvider(CHATBOT_LOCATION, selectedId); if (!provider) { diff --git a/superset-frontend/src/core/dashboard/index.ts b/superset-frontend/src/core/dashboard/index.ts new file mode 100644 index 00000000000..1ae57066683 --- /dev/null +++ b/superset-frontend/src/core/dashboard/index.ts @@ -0,0 +1,86 @@ +/** + * 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. + */ + +/** + * Host-internal implementation of the `dashboard` namespace. + * + * Wraps Redux dashboardInfo and dataMask state and normalizes them into the + * stable `DashboardContext` contract. Extensions must not depend on the Redux + * slice structure directly. + */ + +import type { dashboard as dashboardApi } from '@apache-superset/core'; +import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate'; +import { + UPDATE_DATA_MASK, + SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE, +} from 'src/dataMask/actions'; +import { store, RootState } from 'src/views/store'; +import { AnyListenerPredicate } from '@reduxjs/toolkit'; +import { createActionListener } from '../utils'; + +type DashboardContext = dashboardApi.DashboardContext; +type FilterValue = dashboardApi.FilterValue; + +function buildDashboardContext(): DashboardContext | undefined { + const state = store.getState(); + const info = (state as any).dashboardInfo; + if (!info?.id) return undefined; + + const nativeFilters = (state as any).nativeFilters?.filters ?? {}; + const dataMask = (state as any).dataMask ?? {}; + + const filters: FilterValue[] = Object.entries(dataMask) + .filter(([id]) => id in nativeFilters) + .map(([id, mask]: [string, any]) => ({ + filterId: id, + label: nativeFilters[id]?.name ?? id, + value: mask?.filterState?.value ?? null, + })); + + return { + dashboardId: info.id as number, + title: info.dashboard_title ?? info.slug ?? String(info.id), + filters, + }; +} + +const dashboardChangePredicate: AnyListenerPredicate<RootState> = action => + action.type === HYDRATE_DASHBOARD || + action.type === UPDATE_DATA_MASK || + action.type === SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE; + +const getCurrentDashboard: typeof dashboardApi.getCurrentDashboard = () => + buildDashboardContext(); + +const onDidChangeDashboard: typeof dashboardApi.onDidChangeDashboard = ( + listener: (ctx: DashboardContext) => void, + thisArgs?: any, +) => + createActionListener<DashboardContext>( + dashboardChangePredicate, + listener, + () => buildDashboardContext() ?? null, + thisArgs, + ); + +export const dashboard: typeof dashboardApi = { + getCurrentDashboard, + onDidChangeDashboard, +}; diff --git a/superset-frontend/src/core/dataset/index.ts b/superset-frontend/src/core/dataset/index.ts new file mode 100644 index 00000000000..3c98d79454b --- /dev/null +++ b/superset-frontend/src/core/dataset/index.ts @@ -0,0 +1,62 @@ +/** + * 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. + */ + +/** + * Host-internal implementation of the `dataset` namespace. + * + * Dataset page components call `setCurrentDataset` to publish context as they + * load. Extensions consume the stable `DatasetContext` contract; they are + * isolated from the page's internal data-fetching implementation. + */ + +import type { dataset as datasetApi } from '@apache-superset/core'; +import { Disposable } from '../models'; + +type DatasetContext = datasetApi.DatasetContext; + +let currentDataset: DatasetContext | undefined; +const listeners = new Set<(ctx: DatasetContext) => void>(); + +/** + * Host-internal: called by the Dataset page when its entity loads or changes. + * Not part of the public `@apache-superset/core` API. + */ +export const setCurrentDataset = (ctx: DatasetContext | undefined): void => { + currentDataset = ctx; + if (ctx) { + listeners.forEach(fn => fn(ctx)); + } +}; + +const getCurrentDataset: typeof datasetApi.getCurrentDataset = () => + currentDataset ? { ...currentDataset } : undefined; + +const onDidChangeDataset: typeof datasetApi.onDidChangeDataset = ( + listener: (ctx: DatasetContext) => void, + thisArgs?: any, +): Disposable => { + const bound = thisArgs ? listener.bind(thisArgs) : listener; + listeners.add(bound); + return new Disposable(() => listeners.delete(bound)); +}; + +export const dataset: typeof datasetApi = { + getCurrentDataset, + onDidChangeDataset, +}; diff --git a/superset-frontend/src/core/explore/index.ts b/superset-frontend/src/core/explore/index.ts new file mode 100644 index 00000000000..4dc3fdb8276 --- /dev/null +++ b/superset-frontend/src/core/explore/index.ts @@ -0,0 +1,82 @@ +/** + * 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. + */ + +/** + * Host-internal implementation of the `explore` namespace. + * + * Wraps Redux explore state and normalizes it into the stable `ChartContext` + * contract. Extensions must not depend on the Redux slice structure directly. + */ + +import type { explore as exploreApi } from '@apache-superset/core'; +import { HYDRATE_EXPLORE } from 'src/explore/actions/hydrateExplore'; +import { + SET_FORM_DATA, + UPDATE_CHART_TITLE, +} from 'src/explore/actions/exploreActions'; +import { store, RootState } from 'src/views/store'; +import { AnyListenerPredicate } from '@reduxjs/toolkit'; +import { createActionListener } from '../utils'; + +type ChartContext = exploreApi.ChartContext; + +function buildChartContext(): ChartContext | undefined { + const state = store.getState(); + const exploreState = (state as any).explore; + if (!exploreState) return undefined; + + const { slice, datasource, controls } = exploreState; + const vizType: string = + (controls?.viz_type?.value as string) ?? + exploreState.form_data?.viz_type ?? + ''; + + return { + chartId: slice?.slice_id ?? null, + chartName: slice?.slice_name ?? null, + vizType, + datasourceId: datasource?.id ?? null, + datasourceName: + datasource?.table_name ?? datasource?.datasource_name ?? null, + }; +} + +const exploreChangePredicate: AnyListenerPredicate<RootState> = action => + action.type === HYDRATE_EXPLORE || + action.type === SET_FORM_DATA || + action.type === UPDATE_CHART_TITLE; + +const getCurrentChart: typeof exploreApi.getCurrentChart = () => + buildChartContext(); + +const onDidChangeChart: typeof exploreApi.onDidChangeChart = ( + listener: (ctx: ChartContext) => void, + thisArgs?: any, +) => + createActionListener<ChartContext>( + exploreChangePredicate, + listener, + () => buildChartContext() ?? null, + thisArgs, + ); + +export const explore: typeof exploreApi = { + getCurrentChart, + onDidChangeChart, +}; diff --git a/superset-frontend/src/core/index.ts b/superset-frontend/src/core/index.ts index 6a106ebe87a..d259597457c 100644 --- a/superset-frontend/src/core/index.ts +++ b/superset-frontend/src/core/index.ts @@ -28,10 +28,14 @@ export const core: typeof coreType = { export * from './authentication'; export * from './commands'; +export * from './dashboard'; +export * from './dataset'; export * from './editors'; +export * from './explore'; export * from './extensions'; export * from './menus'; export * from './models'; +export * from './navigation'; export * from './sqlLab'; export * from './utils'; export * from './views'; diff --git a/superset-frontend/src/core/navigation/index.ts b/superset-frontend/src/core/navigation/index.ts new file mode 100644 index 00000000000..12799900280 --- /dev/null +++ b/superset-frontend/src/core/navigation/index.ts @@ -0,0 +1,102 @@ +/** + * 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. + */ + +/** + * Host-internal implementation of the `navigation` namespace. + * + * Backed by browser location — no Redux dependency. + * The app shell calls `notifyPageChange(pathname)` whenever the route changes. + */ + +import type { navigation as navigationApi } from '@apache-superset/core'; +import { Disposable } from '../models'; + +type PageType = navigationApi.PageType; +type PageContext = navigationApi.PageContext; + +const listeners = new Set<(ctx: PageContext) => void>(); + +function derivePageType(pathname: string): PageType { + if (pathname.startsWith('/superset/dashboard/')) return 'dashboard'; + if (pathname.startsWith('/explore/')) return 'explore'; + if (pathname.startsWith('/superset/explore/')) return 'explore'; + if (pathname.startsWith('/chart/add')) return 'explore'; + if (pathname.startsWith('/sqllab/')) return 'sqllab'; + if (pathname.startsWith('/dataset/')) return 'dataset'; + if (pathname.startsWith('/superset/welcome/')) return 'home'; + return 'other'; +} + +function extractEntityId(pathname: string, pageType: PageType): number | null { + if (pageType === 'dashboard') { + const m = pathname.match(/\/superset\/dashboard\/(\d+)/); + return m ? parseInt(m[1], 10) : null; + } + if (pageType === 'dataset') { + const m = pathname.match(/\/dataset\/(\d+)/); + return m ? parseInt(m[1], 10) : null; + } + return null; +} + +let currentContext: PageContext = { + pageType: derivePageType(window.location.pathname), + entityId: null, +}; +currentContext.entityId = extractEntityId( + window.location.pathname, + currentContext.pageType, +); + +/** Called by ExtensionsStartup whenever the React Router location changes. */ +export const notifyPageChange = (pathname: string): void => { + const pageType = derivePageType(pathname); + const entityId = extractEntityId(pathname, pageType); + const next: PageContext = { pageType, entityId }; + if ( + next.pageType === currentContext.pageType && + next.entityId === currentContext.entityId + ) { + return; + } + currentContext = next; + listeners.forEach(fn => fn(next)); +}; + +const getPageType: typeof navigationApi.getPageType = () => + currentContext.pageType; + +const getCurrentPage: typeof navigationApi.getCurrentPage = () => ({ + ...currentContext, +}); + +const onDidChangePage: typeof navigationApi.onDidChangePage = ( + listener: (ctx: PageContext) => void, + thisArgs?: any, +): Disposable => { + const bound = thisArgs ? listener.bind(thisArgs) : listener; + listeners.add(bound); + return new Disposable(() => listeners.delete(bound)); +}; + +export const navigation: typeof navigationApi = { + getPageType, + getCurrentPage, + onDidChangePage, +}; diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx b/superset-frontend/src/extensions/ExtensionsStartup.tsx index 5d523be8a4a..e72478d3229 100644 --- a/superset-frontend/src/extensions/ExtensionsStartup.tsx +++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx @@ -16,7 +16,8 @@ * specific language governing permissions and limitations * under the License. */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { useLocation } from 'react-router-dom'; // eslint-disable-next-line no-restricted-syntax import * as supersetCore from '@apache-superset/core'; import { logging } from '@apache-superset/core/utils'; @@ -25,12 +26,17 @@ import { authentication, core, commands, + dashboard, + dataset, editors, + explore, extensions, menus, + navigation, sqlLab, views, } from 'src/core'; +import { notifyPageChange } from 'src/core/navigation'; import { useSelector } from 'react-redux'; import { RootState } from 'src/views/store'; import ExtensionsLoader from './ExtensionsLoader'; @@ -41,9 +47,13 @@ declare global { authentication: typeof authentication; core: typeof core; commands: typeof commands; + dashboard: typeof dashboard; + dataset: typeof dataset; editors: typeof editors; + explore: typeof explore; extensions: typeof extensions; menus: typeof menus; + navigation: typeof navigation; sqlLab: typeof sqlLab; views: typeof views; }; @@ -54,11 +64,21 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({ children, }) => { const [initialized, setInitialized] = useState(false); + const location = useLocation(); + const prevPathname = useRef<string | null>(null); const userId = useSelector<RootState, number | undefined>( ({ user }) => user.userId, ); + // Notify the navigation namespace on every route change. + useEffect(() => { + if (prevPathname.current !== location.pathname) { + prevPathname.current = location.pathname; + notifyPageChange(location.pathname); + } + }, [location.pathname]); + useEffect(() => { if (initialized) return; @@ -74,9 +94,13 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({ authentication, core, commands, + dashboard, + dataset, editors, + explore, extensions, menus, + navigation, sqlLab, views, }; @@ -87,7 +111,10 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({ // chains) would otherwise surface as uncaught rejections in the host. const handleUnhandledRejection = (event: PromiseRejectionEvent) => { // Always log so extension authors can diagnose failures. - logging.error('[extensions] Unhandled rejection from extension:', event.reason); + logging.error( + '[extensions] Unhandled rejection from extension:', + event.reason, + ); event.preventDefault(); }; window.addEventListener('unhandledrejection', handleUnhandledRejection); @@ -102,7 +129,10 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({ } return () => { - window.removeEventListener('unhandledrejection', handleUnhandledRejection); + window.removeEventListener( + 'unhandledrejection', + handleUnhandledRejection, + ); }; }, [initialized, userId]); diff --git a/superset/extensions/api.py b/superset/extensions/api.py index f39db3686ed..d116c6ca70e 100644 --- a/superset/extensions/api.py +++ b/superset/extensions/api.py @@ -22,6 +22,7 @@ from flask import request, send_file from flask.wrappers import Response from flask_appbuilder.api import BaseApi, expose, protect, safe +from superset.extensions import security_manager from superset.extensions.settings import ( get_extension_settings, update_extension_settings, @@ -209,7 +210,11 @@ class ExtensionsRestApi(BaseApi): responses: 200: description: Updated settings + 403: + $ref: '#/components/responses/403' """ + if not security_manager.is_admin(): + return self.response(403, message="Admin access required.") body = request.get_json(silent=True) or {} result = update_extension_settings(body) return self.response(200, result=result) diff --git a/superset/extensions/settings.py b/superset/extensions/settings.py index 30189df8e28..f3a9739a85d 100644 --- a/superset/extensions/settings.py +++ b/superset/extensions/settings.py @@ -21,6 +21,7 @@ from typing import Any from superset import db from superset.models.core import ExtensionEnabled, ExtensionSettings +from superset.utils.decorators import transaction _SETTINGS_ROW_ID = 1 @@ -34,6 +35,7 @@ def get_extension_settings() -> dict[str, Any]: } +@transaction() def update_extension_settings(body: dict[str, Any]) -> dict[str, Any]: row = db.session.get(ExtensionSettings, _SETTINGS_ROW_ID) if row is None: @@ -51,5 +53,4 @@ def update_extension_settings(body: dict[str, Any]) -> dict[str, Any]: db.session.add(flag) flag.enabled = bool(enabled) - db.session.commit() return get_extension_settings()
