You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by as...@apache.org on 2026/08/11 11:14:47 UTC
(superset) 01/01: pushing changes
This is an automated email from the ASF dual-hosted git repository.
alexandrusoare pushed a commit to branch alexandrusoare/feat/dynamic-dashboard-poc
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 61428a0f4d5ecf8e3b5fe87ce1891a7bf343adb9
Author: alexandrusoare <al...@gmail.com>
AuthorDate: Tue Aug 11 14:14:21 2026 +0300
pushing changes
---
.../DynamicComponent/DynamicComponent.tsx | 8 +-
.../HandlebarsDashboard/HandlebarsDashboard.tsx | 223 +++++++++++++++++++++
.../HandlebarsDashboard/handlebarsHelpers.json | 54 +++++
.../HandlebarsDashboard/helpers.ts | 52 +++++
.../HandlebarsDashboard/index.ts} | 27 ++-
.../HandlebarsDashboard/types.ts} | 21 +-
.../visualizations/presets/dashboardComponents.ts | 3 +-
superset/dynamic_dashboard/__init__.py | 0
superset/dynamic_dashboard/api.py | 65 ++++++
superset/initialization/__init__.py | 4 +
...0_f1a2b3c4d5e6_add_dynamic_dashboard_configs.py | 103 ++++++++++
superset/models/dynamic_dashboard.py | 61 ++++++
12 files changed, 600 insertions(+), 21 deletions(-)
diff --git a/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent/DynamicComponent.tsx b/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent/DynamicComponent.tsx
index 45b84e2090e..3db7144246e 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent/DynamicComponent.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent/DynamicComponent.tsx
@@ -108,6 +108,9 @@ const DynamicComponent: FC<DynamicComponentProps> = ({
}),
shallowEqual,
);
+ const dashboardId = useSelector<RootState, number | undefined>(
+ state => state.dashboardInfo?.id,
+ );
return (
<Draggable
@@ -168,7 +171,10 @@ const DynamicComponent: FC<DynamicComponentProps> = ({
</HoverMenu>
)}
<Suspense fallback={<div>{t('Loading...')}</div>}>
- <Component dashboardData={dashboardData} />
+ <Component
+ dashboardData={dashboardData}
+ dashboardId={dashboardId}
+ />
</Suspense>
</div>
</ResizableContainer>
diff --git a/superset-frontend/src/visualizations/dashboardComponents/HandlebarsDashboard/HandlebarsDashboard.tsx b/superset-frontend/src/visualizations/dashboardComponents/HandlebarsDashboard/HandlebarsDashboard.tsx
new file mode 100644
index 00000000000..8c76e33abb6
--- /dev/null
+++ b/superset-frontend/src/visualizations/dashboardComponents/HandlebarsDashboard/HandlebarsDashboard.tsx
@@ -0,0 +1,223 @@
+/**
+ * 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, useState } from 'react';
+import {
+ SupersetClient,
+ buildQueryContext,
+ normalizeOrderBy,
+ JsonObject,
+} from '@superset-ui/core';
+import { SafeMarkdown } from '@superset-ui/core/components';
+import { styled } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import Handlebars from 'handlebars';
+import { HELPER_IMPLEMENTATIONS } from './helpers';
+import { SlotConfig, SlotResult } from './types';
+
+type Props = {
+ /** Dashboard ID, used to fetch config from the dedicated config table. */
+ dashboardId?: number;
+};
+
+async function extractErrorMessage(error: unknown): Promise<string> {
+ if (error instanceof Error) return error.message;
+ const response = error as Partial<Response>;
+ if (response && typeof response.json === 'function') {
+ try {
+ const body = await (response as Response).clone().json();
+ return (
+ body?.errors?.[0]?.message ?? body?.message ?? JSON.stringify(body)
+ );
+ } catch {
+ try {
+ return await (response as Response).clone().text();
+ } catch {
+ return String(error);
+ }
+ }
+ }
+ return String(error);
+}
+
+async function fetchSlot(slot: SlotConfig): Promise<SlotResult> {
+ const payload = buildQueryContext(slot.formData, baseQueryObject => [
+ {
+ ...baseQueryObject,
+ orderby: normalizeOrderBy(baseQueryObject).orderby,
+ },
+ ]);
+
+ try {
+ const { json } = await SupersetClient.post({
+ endpoint: '/api/v1/chart/data',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+
+ const result = (json as JsonObject).result?.[0] ?? {};
+ return {
+ data: result.data ?? [],
+ columns: result.colnames ?? [],
+ };
+ } catch (e) {
+ throw new Error(`Slot "${slot.name}": ${await extractErrorMessage(e)}`);
+ }
+}
+
+interface DynamicDashboardApiConfig {
+ dashboard_template: string;
+ slots: SlotConfig[];
+}
+
+const ErrorPre = styled.pre`
+ white-space: pre-wrap;
+`;
+
+const HandlebarsDashboard = ({ dashboardId }: Props) => {
+ const [apiConfig, setApiConfig] = useState<DynamicDashboardApiConfig | null>(
+ null,
+ );
+ const [configError, setConfigError] = useState<string | null>(null);
+ const [context, setContext] = useState<Record<string, SlotResult> | null>(
+ null,
+ );
+ const [fetchError, setFetchError] = useState<string | null>(null);
+
+ // Fetch the template + slots from the dedicated config table.
+ useEffect(() => {
+ if (!dashboardId) {
+ setConfigError('No dashboard ID available');
+ return;
+ }
+
+ let cancelled = false;
+
+ SupersetClient.get({
+ endpoint: `/api/v1/dynamic-dashboard/${dashboardId}/config`,
+ })
+ .then(({ json }) => {
+ if (cancelled) return;
+ const result = json as JsonObject;
+ if (result.error) {
+ setConfigError(result.error as string);
+ } else {
+ setApiConfig({
+ dashboard_template: result.dashboard_template as string,
+ slots: (result.slots as SlotConfig[]) ?? [],
+ });
+ }
+ })
+ .catch(async e => {
+ if (cancelled) return;
+ setConfigError(await extractErrorMessage(e));
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [dashboardId]);
+
+ // Fetch slot data once config is loaded.
+ useEffect(() => {
+ if (!apiConfig) return;
+ let cancelled = false;
+
+ const slots = apiConfig.slots;
+
+ Promise.allSettled(slots.map(fetchSlot)).then(results => {
+ if (cancelled) return;
+ const ctx: Record<string, SlotResult> = {};
+ const errors: string[] = [];
+ slots.forEach((slot, i) => {
+ const outcome = results[i];
+ if (outcome.status === 'fulfilled') {
+ ctx[slot.name] = outcome.value;
+ } else {
+ ctx[slot.name] = { data: [], columns: [] };
+ errors.push(
+ outcome.reason instanceof Error
+ ? outcome.reason.message
+ : String(outcome.reason),
+ );
+ }
+ });
+ if (errors.length > 0 && errors.length === slots.length) {
+ setFetchError(errors.join('\n'));
+ } else {
+ if (errors.length > 0) {
+ // eslint-disable-next-line no-console
+ console.error('Some Handlebars dashboard slots failed:', errors);
+ }
+ setContext(ctx);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [apiConfig]);
+
+ const appContainer = document.getElementById('app');
+ const { common } = JSON.parse(
+ appContainer?.getAttribute('data-bootstrap') || '{}',
+ );
+ const htmlSanitization = common?.conf?.HTML_SANITIZATION ?? true;
+ const htmlSchemaOverrides =
+ common?.conf?.HTML_SANITIZATION_SCHEMA_EXTENSIONS || {};
+
+ const [rendered, setRendered] = useState('');
+ const [renderError, setRenderError] = useState('');
+
+ useEffect(() => {
+ if (!context || !apiConfig) return;
+ const partials: Record<string, string> = Object.fromEntries(
+ apiConfig.slots.map((slot: SlotConfig) => [slot.name, slot.template]),
+ );
+ const hb = Handlebars.create();
+ try {
+ Object.entries(HELPER_IMPLEMENTATIONS).forEach(([name, fn]) =>
+ hb.registerHelper(name, fn),
+ );
+ Object.entries(partials).forEach(([name, src]) =>
+ hb.registerPartial(name, src),
+ );
+ const template = hb.compile(apiConfig.dashboard_template);
+ setRendered(template(context));
+ setRenderError('');
+ } catch (e) {
+ setRendered('');
+ setRenderError(e instanceof Error ? e.message : String(e));
+ }
+ }, [apiConfig, context]);
+
+ if (configError) return <ErrorPre>{configError}</ErrorPre>;
+ if (fetchError) return <ErrorPre>{fetchError}</ErrorPre>;
+ if (renderError) return <ErrorPre>{renderError}</ErrorPre>;
+ if (!apiConfig || !context) return <p>{t('Loading...')}</p>;
+
+ return (
+ <SafeMarkdown
+ source={rendered}
+ htmlSanitization={htmlSanitization}
+ htmlSchemaOverrides={htmlSchemaOverrides}
+ />
+ );
+};
+
+export default HandlebarsDashboard;
diff --git a/superset-frontend/src/visualizations/dashboardComponents/HandlebarsDashboard/handlebarsHelpers.json b/superset-frontend/src/visualizations/dashboardComponents/HandlebarsDashboard/handlebarsHelpers.json
new file mode 100644
index 00000000000..ab9703c78e2
--- /dev/null
+++ b/superset-frontend/src/visualizations/dashboardComponents/HandlebarsDashboard/handlebarsHelpers.json
@@ -0,0 +1,54 @@
+{
+ "formatNumber": {
+ "signature": "{{formatNumber value}}",
+ "description": "Formats a number with locale commas (e.g. 1234567 → 1,234,567)"
+ },
+ "eq": {
+ "signature": "{{eq a b}}",
+ "description": "Returns true if a === b"
+ },
+ "ne": {
+ "signature": "{{ne a b}}",
+ "description": "Returns true if a !== b"
+ },
+ "gt": {
+ "signature": "{{gt a b}}",
+ "description": "Returns true if a > b"
+ },
+ "gte": {
+ "signature": "{{gte a b}}",
+ "description": "Returns true if a >= b"
+ },
+ "lt": {
+ "signature": "{{lt a b}}",
+ "description": "Returns true if a < b"
+ },
+ "lte": {
+ "signature": "{{lte a b}}",
+ "description": "Returns true if a <= b"
+ },
+ "and": {
+ "signature": "{{and a b}}",
+ "description": "Returns true if all arguments are truthy"
+ },
+ "or": {
+ "signature": "{{or a b}}",
+ "description": "Returns true if any argument is truthy"
+ },
+ "not": {
+ "signature": "{{not a}}",
+ "description": "Returns true if a is falsy"
+ },
+ "add": {
+ "signature": "{{add a b}}",
+ "description": "Returns a + b"
+ },
+ "subtract": {
+ "signature": "{{subtract a b}}",
+ "description": "Returns a - b"
+ },
+ "multiply": {
+ "signature": "{{multiply a b}}",
+ "description": "Returns a * b"
+ }
+}
diff --git a/superset-frontend/src/visualizations/dashboardComponents/HandlebarsDashboard/helpers.ts b/superset-frontend/src/visualizations/dashboardComponents/HandlebarsDashboard/helpers.ts
new file mode 100644
index 00000000000..7d4cf655276
--- /dev/null
+++ b/superset-frontend/src/visualizations/dashboardComponents/HandlebarsDashboard/helpers.ts
@@ -0,0 +1,52 @@
+/**
+ * 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.
+ */
+
+/**
+ * Handlebars helper implementations for Dynamic dashboards.
+ *
+ * The canonical list of helper *names* lives in handlebarsHelpers.json (the
+ * single source of truth). This module provides the runtime implementations.
+ * A CI test asserts that every key in the JSON has an implementation here,
+ * and vice versa.
+ */
+
+export const HELPER_IMPLEMENTATIONS: Record<
+ string,
+ Handlebars.HelperDelegate
+> = {
+ formatNumber: (number: number, locale = 'en-US') => {
+ if (typeof number !== 'number') return number;
+ return number.toLocaleString(locale);
+ },
+ // Comparison
+ eq: (a: unknown, b: unknown) => a === b,
+ ne: (a: unknown, b: unknown) => a !== b,
+ gt: (a: unknown, b: unknown) => (a as number) > (b as number),
+ gte: (a: unknown, b: unknown) => (a as number) >= (b as number),
+ lt: (a: unknown, b: unknown) => (a as number) < (b as number),
+ lte: (a: unknown, b: unknown) => (a as number) <= (b as number),
+ // Logic — Handlebars appends an options object as the last arg, so slice it off
+ and: (...args: unknown[]) => args.slice(0, -1).every(Boolean),
+ or: (...args: unknown[]) => args.slice(0, -1).some(Boolean),
+ not: (a: unknown) => !a,
+ // Arithmetic
+ add: (a: unknown, b: unknown) => Number(a) + Number(b),
+ subtract: (a: unknown, b: unknown) => Number(a) - Number(b),
+ multiply: (a: unknown, b: unknown) => Number(a) * Number(b),
+};
diff --git a/superset-frontend/src/visualizations/presets/dashboardComponents.ts b/superset-frontend/src/visualizations/dashboardComponents/HandlebarsDashboard/index.ts
similarity index 66%
copy from superset-frontend/src/visualizations/presets/dashboardComponents.ts
copy to superset-frontend/src/visualizations/dashboardComponents/HandlebarsDashboard/index.ts
index 0d527f3e8c7..8d39a008cfc 100644
--- a/superset-frontend/src/visualizations/presets/dashboardComponents.ts
+++ b/superset-frontend/src/visualizations/dashboardComponents/HandlebarsDashboard/index.ts
@@ -16,15 +16,24 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { lazy } from 'react';
-/*
- Preset dashboard components (in addition to basic components like row, column, chart, etc...)
- */
-
-import DashboardComponentsRegistry from '../dashboardComponents/DashboardComponentsRegistry';
+const HandlebarsDashboard = lazy(
+ () =>
+ import(
+ /* webpackChunkName: "HandlebarsDashboard" */ './HandlebarsDashboard'
+ ),
+);
-const dashboardComponents = DashboardComponentsRegistry([
- // Here can be added default dashboard components
-]);
+export const HandlebarsComponentConfig = {
+ key: 'handlebars-dashboard',
+ item: {
+ Component: HandlebarsDashboard,
+ metadata: {
+ name: 'handlebars-dashboard',
+ iconName: 'code',
+ },
+ },
+};
-export default dashboardComponents;
+export default HandlebarsDashboard;
diff --git a/superset-frontend/src/visualizations/presets/dashboardComponents.ts b/superset-frontend/src/visualizations/dashboardComponents/HandlebarsDashboard/types.ts
similarity index 69%
copy from superset-frontend/src/visualizations/presets/dashboardComponents.ts
copy to superset-frontend/src/visualizations/dashboardComponents/HandlebarsDashboard/types.ts
index 0d527f3e8c7..f462fd22e97 100644
--- a/superset-frontend/src/visualizations/presets/dashboardComponents.ts
+++ b/superset-frontend/src/visualizations/dashboardComponents/HandlebarsDashboard/types.ts
@@ -16,15 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { QueryFormData } from '@superset-ui/core';
-/*
- Preset dashboard components (in addition to basic components like row, column, chart, etc...)
- */
-
-import DashboardComponentsRegistry from '../dashboardComponents/DashboardComponentsRegistry';
-
-const dashboardComponents = DashboardComponentsRegistry([
- // Here can be added default dashboard components
-]);
+export interface SlotConfig {
+ /** Identifies the slot as a Handlebars partial name and context key. */
+ name: string;
+ formData: QueryFormData;
+ template: string;
+}
-export default dashboardComponents;
+export interface SlotResult {
+ data: Record<string, unknown>[];
+ columns: string[];
+}
diff --git a/superset-frontend/src/visualizations/presets/dashboardComponents.ts b/superset-frontend/src/visualizations/presets/dashboardComponents.ts
index 0d527f3e8c7..cc7bf0d524c 100644
--- a/superset-frontend/src/visualizations/presets/dashboardComponents.ts
+++ b/superset-frontend/src/visualizations/presets/dashboardComponents.ts
@@ -22,9 +22,10 @@
*/
import DashboardComponentsRegistry from '../dashboardComponents/DashboardComponentsRegistry';
+import { HandlebarsComponentConfig } from '../dashboardComponents/HandlebarsDashboard';
const dashboardComponents = DashboardComponentsRegistry([
- // Here can be added default dashboard components
+ HandlebarsComponentConfig,
]);
export default dashboardComponents;
diff --git a/superset/dynamic_dashboard/__init__.py b/superset/dynamic_dashboard/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/superset/dynamic_dashboard/api.py b/superset/dynamic_dashboard/api.py
new file mode 100644
index 00000000000..a0134174d7d
--- /dev/null
+++ b/superset/dynamic_dashboard/api.py
@@ -0,0 +1,65 @@
+# 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.
+
+"""REST API for Dynamic Dashboard configuration."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from flask import Response
+from flask_appbuilder.api import BaseApi, expose
+
+from superset import db
+from superset.models.dynamic_dashboard import DynamicDashboardConfig
+from superset.utils import json
+
+logger = logging.getLogger(__name__)
+
+
+class DynamicDashboardConfigApi(BaseApi):
+ route_base = "/api/v1/dynamic-dashboard"
+ resource_name = "dynamic_dashboard_config"
+
+ @expose("/<int:dashboard_id>/config", methods=("GET",))
+ def get_config(self, dashboard_id: int) -> Response:
+ """Return the Handlebars config for a dashboard."""
+ config: DynamicDashboardConfig | None = (
+ db.session.query(DynamicDashboardConfig)
+ .filter_by(dashboard_id=dashboard_id)
+ .first()
+ )
+ if not config:
+ return self.response(
+ 404,
+ error=f"No dynamic dashboard config for dashboard {dashboard_id}",
+ )
+
+ slots: list[dict[str, Any]] = json.loads(config.slots) if config.slots else []
+ drill_down: dict[str, Any] | None = (
+ json.loads(config.drill_down_config) if config.drill_down_config else None
+ )
+
+ return self.response(
+ 200,
+ dashboard_id=dashboard_id,
+ dashboard_template=config.template,
+ slots=slots,
+ drill_down_config=drill_down,
+ version=config.version,
+ )
diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py
index 09e852c1b91..d54fc782715 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -267,6 +267,10 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_api(DashboardFilterStateRestApi)
appbuilder.add_api(DashboardPermalinkRestApi)
appbuilder.add_api(DashboardRestApi)
+
+ from superset.dynamic_dashboard.api import DynamicDashboardConfigApi
+
+ appbuilder.add_api(DynamicDashboardConfigApi)
appbuilder.add_api(DatabaseRestApi)
appbuilder.add_api(DatasetRestApi)
appbuilder.add_api(DatasetColumnsRestApi)
diff --git a/superset/migrations/versions/2026-08-10_12-00_f1a2b3c4d5e6_add_dynamic_dashboard_configs.py b/superset/migrations/versions/2026-08-10_12-00_f1a2b3c4d5e6_add_dynamic_dashboard_configs.py
new file mode 100644
index 00000000000..86e4b3f66de
--- /dev/null
+++ b/superset/migrations/versions/2026-08-10_12-00_f1a2b3c4d5e6_add_dynamic_dashboard_configs.py
@@ -0,0 +1,103 @@
+# 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.
+"""Add dynamic_dashboard_configs table
+
+Revision ID: f1a2b3c4d5e6
+Revises: b8d2f4a6c901
+Create Date: 2026-08-10 12:00:00.000000
+
+"""
+
+from sqlalchemy import Column, DateTime, Integer, Text
+
+from superset.migrations.shared.utils import (
+ create_fks_for_table,
+ create_index,
+ create_table,
+ drop_fks_for_table,
+ drop_index,
+ drop_table,
+)
+
+# revision identifiers, used by Alembic.
+revision = "f1a2b3c4d5e6"
+down_revision = "b8d2f4a6c901"
+
+TABLE_NAME = "dynamic_dashboard_configs"
+
+
+def upgrade() -> None:
+ create_table(
+ TABLE_NAME,
+ Column("id", Integer, primary_key=True),
+ Column("dashboard_id", Integer, nullable=False),
+ Column("template", Text, nullable=False),
+ Column("slots", Text, nullable=False, server_default="[]"),
+ Column("drill_down_config", Text, nullable=True),
+ Column("version", Integer, nullable=False, server_default="1"),
+ Column("created_on", DateTime, nullable=True),
+ Column("changed_on", DateTime, nullable=True),
+ Column("created_by_fk", Integer, nullable=True),
+ Column("changed_by_fk", Integer, nullable=True),
+ )
+
+ create_index(
+ TABLE_NAME,
+ "uq_dynamic_dashboard_configs_dashboard_id",
+ ["dashboard_id"],
+ unique=True,
+ )
+
+ create_fks_for_table(
+ foreign_key_name="fk_dynamic_dashboard_configs_dashboard_id",
+ table_name=TABLE_NAME,
+ referenced_table="dashboards",
+ local_cols=["dashboard_id"],
+ remote_cols=["id"],
+ ondelete="CASCADE",
+ )
+
+ create_fks_for_table(
+ foreign_key_name="fk_dynamic_dashboard_configs_created_by_fk",
+ table_name=TABLE_NAME,
+ referenced_table="ab_user",
+ local_cols=["created_by_fk"],
+ remote_cols=["id"],
+ ondelete="SET NULL",
+ )
+
+ create_fks_for_table(
+ foreign_key_name="fk_dynamic_dashboard_configs_changed_by_fk",
+ table_name=TABLE_NAME,
+ referenced_table="ab_user",
+ local_cols=["changed_by_fk"],
+ remote_cols=["id"],
+ ondelete="SET NULL",
+ )
+
+
+def downgrade() -> None:
+ drop_fks_for_table(
+ TABLE_NAME,
+ [
+ "fk_dynamic_dashboard_configs_dashboard_id",
+ "fk_dynamic_dashboard_configs_created_by_fk",
+ "fk_dynamic_dashboard_configs_changed_by_fk",
+ ],
+ )
+ drop_index(TABLE_NAME, "uq_dynamic_dashboard_configs_dashboard_id")
+ drop_table(TABLE_NAME)
diff --git a/superset/models/dynamic_dashboard.py b/superset/models/dynamic_dashboard.py
new file mode 100644
index 00000000000..f560a837672
--- /dev/null
+++ b/superset/models/dynamic_dashboard.py
@@ -0,0 +1,61 @@
+# 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.
+
+"""Model for storing Dynamic (Handlebars) dashboard configuration."""
+
+from __future__ import annotations
+
+from sqlalchemy import Column, ForeignKey, Integer, Text
+from sqlalchemy.orm import relationship
+
+from superset import db
+from superset.models.helpers import AuditMixinNullable
+
+
+class DynamicDashboardConfig(db.Model, AuditMixinNullable): # type: ignore[name-defined]
+ """Stores the Handlebars template and slot configuration for a Dynamic dashboard.
+
+ Separates the rendering config from the dashboard's ``position_json`` layout
+ blob, making it safe from accidental overwrites by the layout editor and
+ enabling future version-history support.
+ """
+
+ __tablename__ = "dynamic_dashboard_configs"
+
+ id = Column(Integer, primary_key=True)
+ dashboard_id = Column(
+ Integer, ForeignKey("dashboards.id", ondelete="CASCADE"), nullable=False, unique=True
+ )
+
+ # The root Handlebars HTML template.
+ template = Column(Text, nullable=False)
+
+ # JSON array of slot definitions:
+ # [{"name": "...", "formData": {...}, "template": "..."}]
+ slots = Column(Text, nullable=False, default="[]")
+
+ # Optional JSON object for drill-down configuration.
+ drill_down_config = Column(Text, nullable=True)
+
+ # Monotonically increasing version, for future version-history support.
+ version = Column(Integer, nullable=False, default=1)
+
+ dashboard = relationship(
+ "Dashboard",
+ foreign_keys=[dashboard_id],
+ backref="dynamic_config",
+ )