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",
+    )