msyavuz opened a new issue, #45248:
URL: https://github.com/apache/superset/issues/45248

   *Please make sure you are familiar with the SIP process documented*
   [here](https://github.com/apache/superset/issues/5602). The SIP will be 
numbered by a committer upon acceptance.
   
   > **Status: draft.** Opened to consolidate #44101 (Settings-menu extension 
point + `/extensions/view/` host page, a POC for SIP-96) with a proposal for 
main-navbar entries, so both land under one design instead of two overlapping 
ones.
   
   ## [SIP] Proposal for navbar extension points and extension-owned pages
   
   ### Motivation
   
   An extension that owns a surface of its own (an admin console, a catalog 
browser, a governance report, SIP-96's notification manager) has nowhere to 
live and no way to be reached. The only way to add a navigation entry today is 
`FLASK_APP_MUTATOR` + `appbuilder.add_link()` in `superset_config.py`: 
deployment config, not something an extension can install, enable, disable or 
version with itself.
   
   The `views` / `menus` registries in `src/core` already accept arbitrary 
locations, but SQL Lab is the only host surface that reads from them. Nothing 
in the app shell outside SQL Lab renders extension contributions, and there is 
no route an extension-registered view can be mounted on.
   
   #44101 adds the first two pieces of this (an "Extensions" group in the 
Settings dropdown and a generic `/extensions/view/:viewId` host page). This SIP 
keeps that work and extends it with the pieces an extension needs to behave 
like a first-class page: an entry in the main navbar, SPA navigation, 
active-tab highlighting and a stable item model.
   
   ### Proposed Change
   
   Two new host surfaces read from the existing registries, plus one route and 
two small SDK additions.
   
   **1. Navbar locations** (`main` and `settings`), rendered by `Menu.tsx` and 
`RightMenu.tsx`:
   
   - Items are appended after the built-ins, sorted by `order` then 
registration order. No extension can displace Dashboards / Charts / Datasets / 
SQL Lab.
   - `standalone` and `uiConfig.hideNav` early-returns apply as today.
   - Active-tab highlighting gets a case keyed on the item id when the current 
path is that item's page.
   - An item whose `view` / `command` target is not registered is skipped 
rather than rendered dead.
   - Items pop in via `useSyncExternalStore` once the extension bundle lands 
(bundles load in a `useEffect` after `userId` resolves). That late paint is 
accepted; see Rejected Alternatives.
   
   **2. Navbar item model.** An item carries its own label and icon and does 
exactly one of three things on click:
   
   - `view`: SPA-navigates to `/extensions/view/<viewId>` where the host 
renders the registered view.
   - `command`: runs a registered command without navigating (open a modal, 
kick off a job).
   - `href`: plain link to a URL Superset doesn't own (docs, an external 
console).
   
   Declaring more than one is a registration error.
   
   **3. Host page** `/extensions/view/:viewId+` (as in #44101). Resolves the 
view reactively through `useResolveView`, so a bookmark or refresh shows a 
placeholder until the extension finishes loading and then re-renders instead of 
permanently committing to "could not be loaded". An `extensions ready` signal 
exposed from `ExtensionsLoader` lets the page distinguish "still loading" from 
"not found" once initialization has settled.
   
   **4. Backend shell route.** Every SPA route needs an explicit Flask view 
rendering `spa.html`; a direct hit on an unregistered path 404s. 
`/extensions/view/<path:view_id>` is a login-gated shell page (same pattern as 
`welcome`). It returns no data, so it is not a security boundary: the 
extension's own REST endpoints are, and those already go through `@protect()`.
   
   **5. `navigation.navigateTo(path)`** in the public SDK, backed by SPA 
history, so a command handler (or the host's own navbar click) can move the 
user to a page without a full reload. Today the `navigation` namespace is 
read-only and #44101's menu items have to fall back to `window.location.assign`.
   
   **Extension author flow** is unchanged in scaffolding, build and install. 
The whole delta is two registration calls as module side effects in 
`frontend/src/index.tsx`:
   
   ```ts
   import { navigation, views } from '@apache-superset/core';
   import CatalogPage from './CatalogPage';
   
   views.registerView(
     { id: 'acme.catalog.main', name: 'Data catalog' },
     'global.page',
     CatalogPage,
   );
   navigation.registerNavbarItem(
     {
       id: 'acme.catalog',
       label: 'Data catalog',
       icon: 'DatabaseOutlined',
       view: 'acme.catalog.main',
     },
     'main',
   );
   ```
   
   The page is then reachable at `/extensions/view/acme.catalog.main`, 
deep-linkable and refresh-safe, and the navbar entry highlights while it is 
open. Data comes from the extension's own `@api` endpoints mounted at 
`/extensions/{publisher}/{name}/{resource}/`; nothing host-specific sits in the 
middle.
   
   ### New or Changed Public Interfaces
   
   `@apache-superset/core`, `navigation` namespace:
   
   ```ts
   export type NavbarLocation = 'main' | 'settings';
   
   export interface NavbarItem {
     /** Unique, publisher-namespaced. Doubles as React key and active-tab key. 
*/
     id: string;
     label: string;
     /** Icon name resolved against the host icon set. */
     icon?: string;
     /** Tooltip / a11y description. */
     description?: string;
     /** Sort weight among contributed items. Default Infinity. */
     order?: number;
     /** Exactly one of: */
     view?: string;
     command?: string;
     href?: string;
   }
   
   export function registerNavbarItem(item: NavbarItem, location: 
NavbarLocation): Disposable;
   export function getNavbarItems(location: NavbarLocation): NavbarItem[];
   export function navigateTo(path: string): void;
   ```
   
   - `Page` union gains `'extension'`, and the navigation tracker's path table 
gains the extension route, so `getPage()` stops reporting `'home'` on extension 
pages.
   - A `global.page` view location (name open, see below) exported from the SDK 
so extensions don't hardcode the string. #44101 introduces 
`global.settingsPanel` / `global.settingsMenu` as host-internal literals; this 
SIP proposes exporting the location constants.
   - `ExtensionContext.window` toast surface from #44101 is kept as-is.
   - Routes: `EXTENSION_VIEW: '/extensions/view/:viewId+'` behind 
`FeatureFlag.EnableExtensions`, plus a prefix check in `isFrontendRoute` so 
navbar links stay SPA navigations.
   - Backend: `extension_view` shell route on the core view, login-gated.
   - `extension.json`: a `navbar` contribution block is documented alongside 
the existing declarative types. As with the existing `views` / `menus` blocks, 
the host learns about contributions from the runtime registration calls; the 
manifest block is documentation and tooling input only.
   
   ### New dependencies
   
   None.
   
   ### Migration Plan and Compatibility
   
   No database migration. No change for deployments with `ENABLE_EXTENSIONS` 
off. Existing `FLASK_APP_MUTATOR` + `add_link()` menu entries keep working and 
keep rendering before contributed items. Extensions written against #44101's 
`global.settingsMenu` + command pattern continue to work; the navbar item model 
is additive.
   
   Known limitations, accepted for the first cut:
   
   - Late paint of contributed items (see above).
   - Contributed items exist only in the React shell; server-rendered FAB menu 
pages won't show them.
   - One route depth. Extension-internal sub-routing is out of scope; 
extensions can use query params or a hash.
   - No per-item RBAC. An item is visible iff its extension loaded, and the 
extensions API already filters by user. Per-item gating would need the user's 
permission set on the client, which bootstrap data does not expose.
   - The loader has no unload path, so disabling an extension removes its entry 
only after a refresh.
   
   ### Rejected Alternatives
   
   - **Dynamic route table that `RouteSwitch` subscribes to.** `routes.tsx` is 
evaluated at module load and extensions register asynchronously, so a hard 
refresh on an extension URL would render with no matching route and paint blank 
until the bundle lands. One static parameterised route plus a reactive resolver 
is simpler and refresh-safe.
   - **A separate `navigation.registerPage` API.** The `views` registry already 
provides location keying, `useSyncExternalStore` reactivity, `ErrorBoundary` 
wrapping and placeholder handling. Pages are views at a reserved location; no 
second registry.
   - **Separate `/extension/:extensionId/:pageId/` prefix.** Considered to 
avoid clashing with `/extensions/list/` and the extension REST prefix. #44101's 
`/extensions/view/:viewId+` works because the list route is registered first 
and view ids are already publisher-namespaced, so the extra segment is 
redundant.
   - **Serving contributions from the extensions API to avoid late paint.** The 
Python `Manifest` model has no `contributions` field and neither the CLI nor 
the API reads one from `extension.json`. Worth fixing, but independently of 
this SIP.
   - **`@has_access` with a dedicated `ExtensionView` permission on the shell 
route.** Gives operators a knob to withhold extension pages from a role, but 
the FAB-generated permission isn't backfilled onto existing custom roles by 
role sync, which breaks existing deployments on upgrade. Login-only gating on a 
data-less shell page matches `welcome`.
   - **Command-only navbar items (labelled by the command title).** Most navbar 
entries are navigational and have no command behind them; putting the label on 
the item keeps `command` for the modal / action case only.
   
   ### Open questions
   
   1. Ship `main` and `settings` together, or `settings` first (as #44101 does) 
and `main` as a follow-up?
   2. Flat items only, or allow children (submenu) in the first cut?
   3. Name of the exported page location: `global.page`, 
`global.settingsPanel`, or one location per navbar location?
   4. Do we want `href` in the first cut, or are `view` + `command` enough?
   5. 404 at the Flask layer on an unknown `view_id` (leaks which extensions 
are installed to any authenticated user), or always render the shell and let 
the client show not-found?
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to