Compare commits

...

3 Commits

Author SHA1 Message Date
Ngoc Khuat
bac0b253f6 change our migration dates to a date far in the future to avoid it getting mixed with master's migrations 2025-07-09 23:05:40 +07:00
Ngoc Khuat
107d0ae201 Table schema and models for Data Apps (#60452) 2025-07-09 14:07:27 +02:00
Denis Berezin
4a2fae7c45 Add data apps basic pages and layout (#60514)
* Add data apps basic pages and layout

* Add page layout for edit mode

* Add published app foundation

* Fix CI
2025-07-09 13:15:44 +03:00
27 changed files with 1249 additions and 59 deletions

View File

@@ -408,6 +408,12 @@
warehouse-schema
xrays}}
data-apps
{:team "Workflows"
:api #{metabase.data-apps.core}
:uses #{models
util}}
database-routing
{:team "Admin Webapp"
:api #{metabase.database-routing.core}

1
.gitignore vendored
View File

@@ -147,6 +147,7 @@ llm-payloads
.aider*
/config.yml
CLAUDE.local.md
.claude/settings.local.json
.eslintcache

View File

@@ -19,6 +19,13 @@
"NativeQuerySnippet"
"Timeline"])
(def to-implement-models
"The list of exported models but are pending for implementation."
[;; see https://linear.app/metabase/issue/WRK-579
"DataApp"
"DataAppDefinition"
"DataAppRelease"])
(def exported-models
"The list of all models exported by serialization by default. Used for production code and by tests."
(concat data-model

View File

@@ -5,6 +5,7 @@
This file makes it impossible to forget to add entity_id to new entities. It tests that every entity is either
explicitly excluded, or has the :entity_id property."
(:require
[clojure.set :as set]
[clojure.test :refer :all]
[metabase-enterprise.serialization.v2.backfill-ids :as serdes.backfill]
[metabase-enterprise.serialization.v2.entity-ids :as v2.entity-ids]
@@ -30,62 +31,64 @@
- not exported in serialization; or
- exported as a child of something else (eg. timeline_event under timeline)
so they don't need a generated entity_id."
#{:model/ApiKey
:model/HTTPAction
:model/ImplicitAction
:model/QueryAction
:model/ApplicationPermissionsRevision
:model/AuditLog
:model/BookmarkOrdering
:model/CacheConfig
:model/CardBookmark
:model/ChannelTemplate
:model/CollectionBookmark
:model/ContentTranslation
:model/DashboardBookmark
:model/DataPermissions
:model/DatabaseRouter
:model/CollectionPermissionGraphRevision
:model/DashboardCardSeries
:model/LoginHistory
:model/FieldValues
:model/ModelIndex
:model/ModelIndexValue
:model/ModerationReview
:model/Notification
:model/NotificationCard
:model/NotificationSubscription
:model/NotificationHandler
:model/NotificationRecipient
:model/ParameterCard
:model/Permissions
:model/PermissionsGroup
:model/PermissionsGroupMembership
:model/PermissionsRevision
:model/PersistedInfo
:model/Pulse
:model/PulseCard
:model/PulseChannel
:model/PulseChannelRecipient
:model/Query
:model/QueryCache
:model/QueryExecution
:model/QueryField
:model/QueryTable
:model/RecentViews
:model/Revision
:model/SearchIndexMetadata
:model/Secret
:model/Session
:model/TaskHistory
:model/TimelineEvent
:model/User
:model/UserParameterValue
:model/UserKeyValue
:model/ViewLog
:model/GroupTableAccessPolicy
:model/ConnectionImpersonation
:model/CloudMigration})
(set/union
(set (map #(keyword "model" %) serdes.models/to-implement-models))
#{:model/ApiKey
:model/HTTPAction
:model/ImplicitAction
:model/QueryAction
:model/ApplicationPermissionsRevision
:model/AuditLog
:model/BookmarkOrdering
:model/CacheConfig
:model/CardBookmark
:model/ChannelTemplate
:model/CollectionBookmark
:model/ContentTranslation
:model/DashboardBookmark
:model/DataPermissions
:model/DatabaseRouter
:model/CollectionPermissionGraphRevision
:model/DashboardCardSeries
:model/LoginHistory
:model/FieldValues
:model/ModelIndex
:model/ModelIndexValue
:model/ModerationReview
:model/Notification
:model/NotificationCard
:model/NotificationSubscription
:model/NotificationHandler
:model/NotificationRecipient
:model/ParameterCard
:model/Permissions
:model/PermissionsGroup
:model/PermissionsGroupMembership
:model/PermissionsRevision
:model/PersistedInfo
:model/Pulse
:model/PulseCard
:model/PulseChannel
:model/PulseChannelRecipient
:model/Query
:model/QueryCache
:model/QueryExecution
:model/QueryField
:model/QueryTable
:model/RecentViews
:model/Revision
:model/SearchIndexMetadata
:model/Secret
:model/Session
:model/TaskHistory
:model/TimelineEvent
:model/User
:model/UserParameterValue
:model/UserKeyValue
:model/ViewLog
:model/GroupTableAccessPolicy
:model/ConnectionImpersonation
:model/CloudMigration}))
(deftest ^:parallel comprehensive-entity-id-test
(let [entity-id-models (->> (v2.entity-ids/toucan-models)

View File

@@ -25,7 +25,8 @@
(testing "We know about every model"
(let [known-models (set (concat serdes.models/exported-models
serdes.models/inlined-models
serdes.models/excluded-models))]
serdes.models/excluded-models
serdes.models/to-implement-models))]
(is (= (set known-models)
(set (map name (v2.entity-ids/toucan-models)))))))))

View File

@@ -0,0 +1,205 @@
import { useDisclosure } from "@mantine/hooks";
import { useCallback, useState } from "react";
import { useMount } from "react-use";
import { t } from "ttag";
import { ToolbarButton } from "metabase/common/components/ToolbarButton";
import { useDispatch } from "metabase/lib/redux";
import { closeNavbar } from "metabase/redux/app";
import {
ActionIcon,
Box,
Button,
Group,
Icon,
Stack,
Title,
Tooltip,
} from "metabase/ui";
import { DataAppsComponentsList } from "./DataAppsComponentsList";
import { DataAppEditSettingsModal } from "./modals/DataAppEditSettingsModal";
import { DataAppPublishModal } from "./modals/DataAppPublishModal";
import type { DataApp, DataAppEditSettings } from "./types";
import { createMockDataApp, getDataAppById } from "./utils";
type SettingsSectionKey = "components";
type DataAppContainerProps = {
params: {
appId: string;
};
};
export const DataAppContainer = ({
params: { appId },
}: DataAppContainerProps) => {
const dispatch = useDispatch();
const isNewApp = !appId;
const [activeSettingsSection, setActiveSettingsSection] = useState<
SettingsSectionKey | undefined
>(isNewApp ? "components" : undefined); // show components list by default for new empty data app
const [dataApp, setDataApp] = useState<DataApp | undefined>(() => {
if (appId) {
return getDataAppById(appId);
}
return createMockDataApp();
});
const hasPublishButton = dataApp?.status !== "published";
const [
isOpenEditTitleModal,
{ open: openEditTitleModal, close: closeEditTitleModal },
] = useDisclosure(false);
const [
isOpenPublishModal,
{ open: openPublishModal, close: closePublishModal },
] = useDisclosure(false);
useMount(() => {
dispatch(closeNavbar());
});
const handleEditSettingsSubmit = useCallback(
(newSettings: DataAppEditSettings) => {
if (!dataApp) {
return;
}
setDataApp({
...dataApp,
...newSettings,
});
closeEditTitleModal();
},
[closeEditTitleModal, dataApp],
);
if (!dataApp) {
return <div>{t`Not found`}</div>;
}
return (
<>
<Stack mih="100%" h="0" gap={0}>
<Group
bg="white"
gap={0}
py="0.5rem"
px="2rem 1rem"
justify="space-between"
style={{
borderBottom: "2px solid var(--mb-color-border)",
}}
>
<Group gap="xs" align="center">
<Title order={4}>{dataApp.name}</Title>
<Tooltip label={t`Edit Data App Settings`}>
<ActionIcon onClick={openEditTitleModal}>
<Icon name="pencil" />
</ActionIcon>
</Tooltip>
</Group>
<Group>
<ToolbarButton
icon="add"
aria-label={t`Add components`}
tooltipLabel={t`Add components`}
isActive={activeSettingsSection === "components"}
onClick={() => setActiveSettingsSection("components")}
/>
{hasPublishButton && (
<Button
data-testid="data-app-publish-button"
px="md"
py="sm"
variant="subtle"
onClick={openPublishModal}
>
{t`Share`}
</Button>
)}
</Group>
</Group>
<Group
bg="var(--mb-color-bg-light)"
align="stretch"
gap={0}
style={{
flexGrow: 1,
}}
>
<Box
style={{
flexGrow: 1,
backgroundImage:
"radial-gradient(circle, var(--mb-color-border) 1px, transparent 0)",
backgroundSize: "16px 16px",
backgroundRepeat: "repeat",
}}
>
{`Canvas here`}
</Box>
{activeSettingsSection === "components" && (
<ComponentsSidebar
onClose={() => setActiveSettingsSection(undefined)}
/>
)}
</Group>
</Stack>
<DataAppEditSettingsModal
opened={isOpenEditTitleModal}
dataApp={dataApp}
onSubmit={handleEditSettingsSubmit}
onClose={closeEditTitleModal}
/>
<DataAppPublishModal
opened={isOpenPublishModal}
dataApp={dataApp}
onClose={closePublishModal}
/>
</>
);
};
type ComponentsSidebarProps = {
onClose: () => void;
};
export const ComponentsSidebar = ({ onClose }: ComponentsSidebarProps) => {
// TODO: use Sidebar component ?
return (
<Box
style={{
borderLeft: "1px solid var(--mb-color-border)",
width: "20rem",
}}
>
<Group
bg="white"
p="0.5rem 1rem"
align="center"
justify="space-between"
mb="1rem"
>
<Title order={4}>{t`Components`}</Title>
<ActionIcon onClick={onClose}>
<Icon name="close" />
</ActionIcon>
</Group>
<DataAppsComponentsList />
</Box>
);
};

View File

@@ -0,0 +1,40 @@
import { Card, Stack, Text } from "metabase/ui";
const COMPONENTS = [
{
title: "Main Heading",
slug: "h1",
},
{
title: "Paragraph",
slug: "p",
},
{
title: "List",
slug: "list",
},
{
title: "Card",
slug: "card",
},
{
title: "Table",
slug: "table",
},
{
title: "Form",
slug: "form",
},
];
export const DataAppsComponentsList = () => {
return (
<Stack px="1rem">
{COMPONENTS.map(({ title, slug }) => (
<Card key={slug}>
<Text fw={500}>{title}</Text>
</Card>
))}
</Stack>
);
};

View File

@@ -0,0 +1,100 @@
import { Link } from "react-router";
import { t } from "ttag";
import NoResults from "assets/img/no_results.svg";
import { BrowseCard } from "metabase/browse/components/BrowseCard";
import {
BrowseSection,
CenteredEmptyState,
} from "metabase/browse/components/BrowseContainer.styled";
import { BrowseGrid } from "metabase/browse/components/BrowseGrid";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { useSelector } from "metabase/lib/redux";
import * as Urls from "metabase/lib/urls";
import { getUserIsAdmin } from "metabase/selectors/user";
import { Box, Button, Flex, Group, Icon, Stack, Title } from "metabase/ui";
import type { DataApp } from "./types";
import { DataAppsListMock } from "./utils";
export const DataAppsList = () => {
const isAdmin = useSelector(getUserIsAdmin);
const dataApps: DataApp[] | undefined = DataAppsListMock;
const isLoading = false;
if (!dataApps && isLoading) {
return <LoadingAndErrorWrapper loading />;
}
if (!dataApps?.length && !isAdmin) {
return (
<CenteredEmptyState
title={<Box mb=".5rem">{t`No data apps here yet`}</Box>}
illustrationElement={
<Box mb=".5rem">
<img src={NoResults} />
</Box>
}
/>
);
}
return (
<Stack mt="1rem">
<Box>
<BrowseSection>
<Flex
w="100%"
h="2.25rem"
direction="row"
justify="space-between"
align="center"
>
<Title order={2} c="text-dark">
<Group gap="sm">
<Icon
size={24}
color="var(--mb-color-brand)"
name="format_code"
/>
{t`Data Apps`}
</Group>
</Title>
</Flex>
</BrowseSection>
</Box>
<Box p="0 2.5rem">
<BrowseSection direction="column">
<BrowseGrid data-testid="database-browser">
{dataApps &&
dataApps.length > 0 &&
dataApps.map((app) => (
<BrowseCard
to={Urls.dataAppEdit(app.id)}
key={app.id}
title={app.name}
icon="format_code"
size="lg"
iconColor="var(--mb-color-brand)"
/>
))}
</BrowseGrid>
{isAdmin && <AddAppCard />}
</BrowseSection>
</Box>
</Stack>
);
};
const AddAppCard = () => (
<Link to={Urls.dataAppCreate()}>
<Button
variant="outline"
style={{
alignSelf: "flex-start",
marginTop: "1rem",
}}
>{t`Create Data App`}</Button>
</Link>
);

View File

@@ -0,0 +1,9 @@
/* eslint-disable i18next/no-literal-string */
export const PublicDataApp = ({
params: { appUrl },
}: {
params: { appUrl: string };
}) => {
return <div>DATA APP {appUrl}</div>;
};

View File

@@ -0,0 +1,14 @@
import { PLUGIN_DATA_APPS } from "metabase/plugins";
import { DataAppContainer } from "metabase-enterprise/data-apps/DataAppContainer";
import { DataAppsList } from "metabase-enterprise/data-apps/DataAppsList";
import { PublicDataApp } from "metabase-enterprise/data-apps/PublicDataApp";
import { hasPremiumFeature } from "metabase-enterprise/settings";
import type { TokenFeature } from "metabase-types/api";
// eslint-disable-next-line no-constant-condition
if (true || hasPremiumFeature("data-apps" as TokenFeature)) {
PLUGIN_DATA_APPS.isEnabled = () => true;
PLUGIN_DATA_APPS.LIST_APPS_PAGE_COMPONENT = DataAppsList;
PLUGIN_DATA_APPS.APP_PAGE_COMPONENT = DataAppContainer;
PLUGIN_DATA_APPS.PUBLIC_APP_PAGE_COMPONENT = PublicDataApp;
}

View File

@@ -0,0 +1,46 @@
import { useMemo } from "react";
import { t } from "ttag";
import { Form, FormProvider, FormTextInput } from "metabase/forms";
import { Box, Button, Modal, Stack } from "metabase/ui";
import type { DataApp, DataAppEditSettings } from "../types";
interface DataAppEditSettingsModalProps {
dataApp: DataApp;
opened: boolean;
onSubmit: (newSettings: DataAppEditSettings) => void;
onClose: () => void;
}
export const DataAppEditSettingsModal = ({
dataApp,
opened,
onSubmit,
onClose,
}: DataAppEditSettingsModalProps) => {
const initialValues = useMemo(() => {
const settings: DataAppEditSettings = {
name: dataApp.name,
url: dataApp.url,
};
return settings;
}, [dataApp]);
return (
<Modal opened={opened} title={t`Data App Settings`} onClose={onClose}>
<FormProvider initialValues={initialValues} onSubmit={onSubmit}>
<Form as={Stack}>
<FormTextInput label={t`Name`} name="name" required />
<FormTextInput label={t`Url slug`} name="url" required />
<Box>
<Button variant="filled" type="submit">{t`Save`}</Button>
</Box>
</Form>
</FormProvider>
</Modal>
);
};

View File

@@ -0,0 +1,38 @@
import { t } from "ttag";
import { PublicLinkCopyPanel } from "metabase/embedding/components/PublicLinkPopover/PublicLinkCopyPanel";
import * as Urls from "metabase/lib/urls";
import { Anchor, Group, Icon, Modal, Stack, Text } from "metabase/ui";
import type { DataApp } from "../types";
interface DataAppPublishModalProps {
dataApp: DataApp;
opened: boolean;
onClose: () => void;
}
export const DataAppPublishModal = ({
dataApp,
opened,
onClose,
}: DataAppPublishModalProps) => {
const fullAppUrl = Urls.publishedDataApp(dataApp.url);
return (
<Modal opened={opened} onClose={onClose}>
<Stack>
<Text>{t`Share a link to use your app.`}</Text>
<PublicLinkCopyPanel url={fullAppUrl} />
<Anchor fw="bold" target="_blank" href={fullAppUrl}>
<Group display="inline-flex" align="center" gap="xs">
{t`View app`}
<Icon name="share" />
</Group>
</Anchor>
</Stack>
</Modal>
);
};

View File

@@ -0,0 +1,14 @@
export type DataApp = {
id: string;
name: string;
url: string;
status: string; // private, published, archived
entity_id?: string;
created_at?: string;
updated_at?: string;
};
export type DataAppEditSettings = {
name: DataApp["name"];
url: DataApp["url"];
};

View File

@@ -0,0 +1,25 @@
import { slugify } from "metabase/lib/formatting/url";
import type { DataApp } from "./types";
export function createMockDataApp(opts: Partial<DataApp> = {}): DataApp {
return {
id: "",
name: "Untitled",
url: slugify(opts.name || "Untitled"),
status: "private", // private, published, archived
...opts,
};
}
export const DataAppsListMock = [
createMockDataApp({
id: "aaaaa",
name: "Super App",
url: "super-app",
}),
];
export function getDataAppById(idToFind: string | undefined) {
return DataAppsListMock.find(({ id }) => id === idToFind);
}

View File

@@ -36,3 +36,4 @@ import "./resource_downloads";
import "./user_provisioning";
import "./clean_up";
import "./metabot";
import "./data-apps";

View File

@@ -0,0 +1,14 @@
import MetabaseSettings from "metabase/lib/settings";
export function dataAppEdit(appId: string) {
return `/data-apps/edit/${appId}`;
}
export function dataAppCreate() {
return `/data-apps/new`;
}
export function publishedDataApp(appUrl: string) {
const siteUrl = MetabaseSettings.get("site-url");
return `${siteUrl}/public/data-app/${appUrl}`;
}

View File

@@ -5,6 +5,7 @@ export * from "./bookmarks";
export * from "./browse";
export * from "./collections";
export * from "./dashboards";
export * from "./data-apps";
export * from "./indexed-entities";
export * from "./modelToUrl";
export * from "./misc";

View File

@@ -4,6 +4,7 @@ import { c, t } from "ttag";
import { useUserSetting } from "metabase/common/hooks";
import CS from "metabase/css/core/index.css";
import { useSelector } from "metabase/lib/redux";
import { PLUGIN_DATA_APPS } from "metabase/plugins";
import { getIsEmbeddingIframe } from "metabase/selectors/embed";
import { getEntityTypes } from "metabase/selectors/embedding-data-picker";
import { Collapse, Group, Icon, UnstyledButton } from "metabase/ui";
@@ -23,6 +24,7 @@ export const BrowseNavSection = ({
const BROWSE_MODELS_URL = "/browse/models";
const BROWSE_DATA_URL = "/browse/databases";
const BROWSE_METRICS_URL = "/browse/metrics";
const BROWSE_DATA_APPS_URL = "/data-apps";
const [expandBrowse = true, setExpandBrowse] = useUserSetting(
"expand-browse-in-nav",
@@ -91,6 +93,18 @@ export const BrowseNavSection = ({
{t`Metrics`}
</PaddedSidebarLink>
)}
{PLUGIN_DATA_APPS.isEnabled() && (
<PaddedSidebarLink
icon="format_code"
url={BROWSE_DATA_APPS_URL}
isSelected={nonEntityItem?.url?.startsWith(BROWSE_DATA_APPS_URL)}
onClick={onItemSelect}
aria-label={t`Browse data apps`}
>
{t`Data Apps`}
</PaddedSidebarLink>
)}
</Collapse>
</div>
);

View File

@@ -720,6 +720,21 @@ export const PLUGIN_DASHCARD_MENU: PluginDashcardMenu = {
dashcardMenuItemGetters: [],
};
export const PLUGIN_DATA_APPS = {
isEnabled: () => false,
LIST_APPS_PAGE_COMPONENT: PluginPlaceholder,
APP_PAGE_COMPONENT: PluginPlaceholder as ComponentType<{
params: {
appId: string;
};
}>,
PUBLIC_APP_PAGE_COMPONENT: PluginPlaceholder as ComponentType<{
params: {
appUrl: string;
};
}>,
};
export const PLUGIN_CONTENT_TRANSLATION = {
isEnabled: false,
setEndpointsForStaticEmbedding: (_encodedToken: string) => {},

View File

@@ -1,4 +1,5 @@
import { Route } from "metabase/hoc/Title";
import { PLUGIN_DATA_APPS } from "metabase/plugins";
import { PublicNotFound } from "metabase/public/components/PublicNotFound";
import PublicAction from "metabase/public/containers/PublicAction";
import PublicApp from "metabase/public/containers/PublicApp";
@@ -18,6 +19,12 @@ export const getRoutes = (store) => {
path="dashboard/:uuid(/:tabSlug)"
component={PublicOrEmbeddedDashboardPage}
/>
{PLUGIN_DATA_APPS.isEnabled() && (
<Route
path="data-app/:appUrl"
component={PLUGIN_DATA_APPS.PUBLIC_APP_PAGE_COMPONENT}
/>
)}
<Route path="*" component={PublicNotFound} />
</Route>
<Route path="*" component={PublicNotFound} />

View File

@@ -39,6 +39,7 @@ import NewModelOptions from "metabase/models/containers/NewModelOptions";
import { getRoutes as getModelRoutes } from "metabase/models/routes";
import {
PLUGIN_COLLECTIONS,
PLUGIN_DATA_APPS,
PLUGIN_LANDING_PAGE,
PLUGIN_METABOT,
} from "metabase/plugins";
@@ -238,6 +239,22 @@ export const getRoutes = (store) => {
<ModalRoute path="archive" modal={ArchiveDashboardModalConnected} />
</Route>
{PLUGIN_DATA_APPS.isEnabled() && (
<Route path="data-apps">
<IndexRoute
component={PLUGIN_DATA_APPS.LIST_APPS_PAGE_COMPONENT}
/>
<Route
path="new"
component={PLUGIN_DATA_APPS.APP_PAGE_COMPONENT}
/>
<Route
path="edit/:appId"
component={PLUGIN_DATA_APPS.APP_PAGE_COMPONENT}
/>
</Route>
)}
<Route path="/question">
<Route
path="/question/entity/:entity_id(**)"

View File

@@ -169,7 +169,379 @@ databaseChangeLog:
type: ${text.type}
constraints:
nullable: false
# >>>>>>>>>> DO NOT ADD NEW MIGRATIONS BELOW THIS LINE! ADD THEM ABOVE <<<<<<<<<<
- changeSet:
id: v56.2025-09-01T16:00:00
author: qnkhuat
comment: Create data_app table for data apps functionality
preConditions:
- onFail: MARK_RAN
- not:
- tableExists:
tableName: data_app
changes:
- createTable:
tableName: data_app
remarks: Applications
columns:
- column:
name: id
remarks: Unique ID
type: int
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: name
remarks: App name
type: ${text.type}
constraints:
nullable: false
- column:
name: description
remarks: App description
type: ${text.type}
constraints:
nullable: true
- column:
name: slug
remarks: unique slug of an app
type: varchar(254)
constraints:
nullable: false
unique: true
- column:
name: creator_id
remarks: the creator
type: int
constraints:
nullable: false
references: core_user(id)
foreignKeyName: fk_data_app_creator_id
deleteCascade: true
- column:
name: status
remarks: Status of the app (private, published, archived)
type: varchar(32)
constraints:
nullable: false
- column:
name: entity_id
remarks: Entity id
type: char(21)
constraints:
nullable: true
- column:
remarks: The timestamp of when the app was created
name: created_at
type: ${timestamp_type}
defaultValueComputed: current_timestamp
constraints:
nullable: false
- column:
remarks: The timestamp of when the app was updated
name: updated_at
type: ${timestamp_type}
defaultValueComputed: current_timestamp
constraints:
nullable: false
rollback:
- dropTable:
tableName: data_app
- changeSet:
id: v56.2025-09-01T16:00:10
author: qnkhuat
comment: Add index on data_app.creator_id
preConditions:
- onFail: MARK_RAN
- tableExists:
tableName: data_app
- not:
- indexExists:
tableName: data_app
indexName: idx_data_app_creator_id
changes:
- createIndex:
tableName: data_app
indexName: idx_data_app_creator_id
columns:
- column:
name: creator_id
rollback: #no op, will be deleted with the table
- changeSet:
id: v56.2025-09-01T16:01:00
author: qnkhuat
comment: Create data_app_definition table for data apps functionality
preConditions:
- onFail: MARK_RAN
- not:
- tableExists:
tableName: data_app_definition
changes:
- createTable:
tableName: data_app_definition
remarks: App definitions (append only)
columns:
- column:
name: id
remarks: Unique ID
type: int
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: app_id
remarks: Referenced app
type: int
constraints:
nullable: false
referencedTableName: data_app
referencedColumnNames: id
foreignKeyName: fk_data_app_definition_app_id
deleteCascade: true
- column:
name: creator_id
remarks: the creator
type: int
constraints:
nullable: false
references: core_user(id)
foreignKeyName: fk_data_app_definition_creator_id
deleteCascade: true
- column:
name: revision_number
remarks: Revision number for ordering
type: int
constraints:
nullable: false
- column:
name: config
remarks: App configuration JSON
type: ${text.type}
constraints:
nullable: false
- column:
remarks: The timestamp of when the definition was created
name: created_at
type: ${timestamp_type}
defaultValueComputed: current_timestamp
constraints:
nullable: false
rollback:
- dropTable:
tableName: data_app_definition
- changeSet:
id: v56.2025-09-01T16:02:00
author: qnkhuat
comment: Index for getting the latest definition
preConditions:
- onFail: MARK_RAN
- tableExists:
tableName: data_app_definition
- not:
- indexExists:
tableName: data_app_definition
indexName: idx_data_app_definition_app_id_revision_numer
changes:
- createIndex:
tableName: data_app_definition
indexName: idx_data_app_definition_app_id_revision_numer
columns:
- column:
name: app_id
- column:
name: revision_number
descending: true
rollback: #no op, will be deleted with the table
- changeSet:
id: v56.2025-09-01T16:02:10
author: qnkhuat
comment: Add index on data_app_definition.creator_id
preConditions:
- onFail: MARK_RAN
- tableExists:
tableName: data_app_definition
- not:
- indexExists:
tableName: data_app_definition
indexName: idx_data_app_definition_creator_id
changes:
- createIndex:
tableName: data_app_definition
indexName: idx_data_app_definition_creator_id
columns:
- column:
name: creator_id
rollback: #no op, will be deleted with the table
- changeSet:
id: v56.2025-09-01T16:02:20
author: qnkhuat
comment: Add unique constraint on data_app_definition (app_id, revision_number)
preConditions:
- onFail: MARK_RAN
- tableExists:
tableName: data_app_definition
- not:
- uniqueConstraintExists:
tableName: data_app_definition
constraintName: uniq_idx_data_app_definition_app_id_revision_number
changes:
- addUniqueConstraint:
tableName: data_app_definition
constraintName: uniq_idx_data_app_definition_app_id_revision_number
columnNames: app_id, revision_number
rollback:
- dropUniqueConstraint:
tableName: data_app_definition
constraintName: uniq_idx_data_app_definition_app_id_revision_number
- changeSet:
id: v56.2025-09-01T16:03:00
author: qnkhuat
comment: Create data_app_release table for data apps functionality
preConditions:
- onFail: MARK_RAN
- not:
- tableExists:
tableName: data_app_release
changes:
- createTable:
tableName: data_app_release
remarks: App publishing records
columns:
- column:
name: id
remarks: Unique ID
type: int
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: app_id
remarks: Referenced app
type: int
constraints:
nullable: false
referencedTableName: data_app
referencedColumnNames: id
foreignKeyName: fk_data_app_release_app_id
deleteCascade: true
- column:
name: app_definition_id
remarks: Referenced app definition
type: int
constraints:
nullable: false
referencedTableName: data_app_definition
referencedColumnNames: id
foreignKeyName: fk_data_app_release_app_definition_id
- column:
name: creator_id
remarks: the creator
type: int
constraints:
nullable: false
references: core_user(id)
foreignKeyName: fk_data_app_release_creator_id
deleteCascade: true
- column:
name: retracted
remarks: Whether this version is retracted
type: ${boolean.type}
defaultValueBoolean: false
constraints:
nullable: false
- column:
remarks: The timestamp of when the app was first released
name: created_at
type: ${timestamp_type}
defaultValueComputed: current_timestamp
constraints:
nullable: false
- column:
remarks: The timestamp of when the release was last updated
name: updated_at
type: ${timestamp_type}
defaultValueComputed: current_timestamp
constraints:
nullable: false
rollback:
- dropTable:
tableName: data_app_release
- changeSet:
id: v56.2025-09-01T16:04:00
author: qnkhuat
comment: index data_app_release.app_definition_id
preConditions:
- onFail: MARK_RAN
- not:
- indexExists:
tableName: data_app_release
indexName: idx_data_app_release_app_definition_id
changes:
- createIndex:
tableName: data_app_release
indexName: idx_data_app_release_app_definition_id
columns:
- column:
name: app_definition_id
rollback: #no op, will be deleted with the table
- changeSet:
id: v56.2025-09-01T16:07:00
author: qnkhuat
comment: Index to get the latest active release
preConditions:
- onFail: MARK_RAN
- not:
- indexExists:
tableName: data_app_release
indexName: idx_data_app_release_app_id_retracted_id
changes:
- createIndex:
tableName: data_app_release
indexName: idx_data_app_release_app_id_retracted_id
columns:
- column:
name: app_id
- column:
name: retracted
- column:
name: id
descending: true
rollback: #no op, will be deleted with the table
- changeSet:
id: v56.2025-09-01T16:08:00
author: qnkhuat
comment: Add index on data_app_release.creator_id
preConditions:
- onFail: MARK_RAN
- tableExists:
tableName: data_app_release
- not:
- indexExists:
tableName: data_app_release
indexName: idx_data_app_release_creator_id
changes:
- createIndex:
tableName: data_app_release
indexName: idx_data_app_release_creator_id
columns:
- column:
name: creator_id
rollback: #no op, will be deleted with the table
# >>>>>>>>>> DO NOT ADD NEW MIGRATIONS BELOW THIS LINE! ADD THEM ABOVE <<<<<<<<<<
########################################################################################################################
#

View File

@@ -53,7 +53,10 @@
"Entities in the order they should be serialized/deserialized. This is done so we make sure that we load
instances of entities before others that might depend on them, e.g. `Databases` before `Tables` before `Fields`."
(concat
[:model/Channel
[:model/DataApp
:model/DataAppDefinition
:model/DataAppRelease
:model/Channel
:model/ChannelTemplate
:model/Database
:model/User

View File

@@ -0,0 +1,115 @@
# Data Apps Development Guide
## Overview
This directory contains the core functionality for Metabase Data Apps - a feature that allows users to create and publish custom applications within Metabase.
## Key Components
- **core.clj** - Public API for the data apps module (all external interactions should go through this namespace)
- **models.clj** - Internal database models and schema definitions for Apps, AppDefinitions, and AppRelease
## Data Model
### Tables and Relationships
```
data_app
├── id (PK)
├── name
├── slug (unique)
├── status (private, published, archived)
├── entity_id
├── created_at
└── updated_at
data_app_definition
├── id (PK)
├── app_id (FK -> data_app.id, cascade delete)
├── revision_number (incrementing number, calculated atomically)
├── config (JSON)
├── entity_id (char(21))
└── created_at
data_app_release
├── id (PK)
├── app_id (FK -> data_app.id, cascade delete)
├── app_definition_id (FK -> data_app_definition.id, cascade delete)
├── created_at
├── updated_at
└── retracted (boolean, default false)
```
### Key Relationships
1. **DataApp → DataAppDefinition** (1:N)
- Each app can have multiple sequential snapshots (revisions) of its definition
- Definitions are append-only (immutable once created)
- Indexed on `app_id` for efficient queries
- `revision_number` is calculated atomically to prevent race conditions
2. **DataApp → DataAppRelease** (1:N)
- Each app can have multiple publishing records
- Multiple releases can exist without being retracted
- Retracted status allows for simpler publishing and rollback
3. **DataAppDefinition → DataAppRelease** (1:N)
- Each definition can be published multiple times
- Release records track when a definition was published
## Development Guidelines
### Model Structure
The data apps feature uses three main models:
- `:model/DataApp` - The main app entity with unique slug (table: `data_app`)
- `:model/DataAppDefinition` - Immutable versioned app configurations (table: `data_app_definition`)
- `:model/DataAppRelease` - Release records tracking active versions (table: `data_app_release`)
### Public API (core.clj)
These functions form the public interface for the data apps module. All external code should use these functions rather than directly accessing the models:
- `create-app!` - Creates a new app with its initial definition
- `set-latest-definition!` - Creates a new version of an app definition (revision_number calculated atomically)
- `publish!` - Publishes a specific app definition version
**Important:** Do not import or use functions from `models.clj` directly outside of this module. Always use the public API in `core.clj`.
### Database Considerations
- App definitions use `revision_number` (not version) that increments atomically
- Multiple definitions can be published without needing to deactivate others
- Validation occurs before insert/update operations
- Retracted flag allows marking releases as unavailable when needed
### Common Patterns
1. Always validate app definition configs before saving
2. Use transactions when updating multiple related records
3. Revision numbers are calculated atomically using SQL (no race conditions)
4. Handle publication state transitions explicitly in transactions
5. Use `data_app` prefix for all table names for clarity
## Maintenance
### When to Update This File
Please update this CLAUDE.md file when:
1. **Adding new models or tables** - Update the data model section
2. **Modifying database schema** - Update table structures and relationships
3. **Adding new core functions** - Add to the Key Functions section
4. **Changing validation rules** - Document in the appropriate section
5. **Introducing new patterns** - Add to Common Patterns
6. **Adding new files** - Update the Key Components section
### Files to Check for Updates
When updating this documentation, review these files for changes:
- `models.clj` - Model definitions and business logic
- `core.clj` - Core functionality
- `/resources/migrations/*_update_migrations.yaml` - Database schema changes
- Any new `.clj` files added to this directory
This ensures the documentation stays in sync with the actual implementation.

View File

@@ -0,0 +1 @@
(ns metabase.data-apps.core)

View File

@@ -0,0 +1,118 @@
(ns metabase.data-apps.models
(:require
[metabase.models.interface :as mi]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]
[methodical.core :as methodical]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(methodical/defmethod t2/table-name :model/DataApp [_model] :data_app)
(methodical/defmethod t2/table-name :model/DataAppDefinition [_model] :data_app_definition)
(methodical/defmethod t2/table-name :model/DataAppRelease [_model] :data_app_release)
(doseq [model [:model/DataApp
:model/DataAppDefinition
:model/DataAppRelease]]
(derive model :metabase/model))
(derive :model/DataApp :hook/timestamped?)
(derive :model/DataApp :hook/entity-id)
(derive :model/DataAppRelease :hook/timestamped?)
(derive :model/DataAppDefinition :hook/created-at-timestamped?)
;;------------------------------------------------------------------------------------------------;;
;; :model/DataApp ;;
;;------------------------------------------------------------------------------------------------;;
(def ^:private data-app-statuses #{:private :published :archived})
(t2/deftransforms :model/DataApp
{:status (mi/transform-validator mi/transform-keyword (partial mi/assert-enum data-app-statuses))})
;;------------------------------------------------------------------------------------------------;;
;; :model/DataAppDefinition ;;
;;------------------------------------------------------------------------------------------------;;
(t2/deftransforms :model/DataAppDefinition
{:config mi/transform-json})
(mr/def ::AppDefinitionConfig
[:maybe [:map {:closed true}
[:actions [:sequential :map]]
[:parameters [:sequential :map]]
[:pages [:sequential :map]]]])
(defn- validate-app-definition
"Validate an AppDefinition."
[app-definition]
(mu/validate-throw ::AppDefinitionConfig (:config app-definition)))
(t2/define-before-insert :model/DataAppDefinition
[instance]
(validate-app-definition instance)
instance)
(t2/define-before-update :model/DataAppDefinition
[instance]
(throw (ex-info "AppDefinition is append-only and cannot be updated"
{:status-code 400
:changes (t2/changes instance)})))
(defn- next-revision-number-hsql
[app-id]
[:coalesce [:+ [:inline 1]
{:select [:%max.revision_number]
:from [:data_app_definition]
:where [:= :app_id app-id]}]
[:inline 1]])
;;------------------------------------------------------------------------------------------------;;
;; :model/DataAppRelease ;;
;;------------------------------------------------------------------------------------------------;;
(t2/define-before-update :model/DataAppRelease
[instance]
(let [changed-keys (-> instance t2/changes keys set)]
(when-not (= #{:retracted} changed-keys)
(throw (ex-info (format "AppRelease is append-only. Only 'retracted' field can be updated, but got: %s"
changed-keys)
{:status-code 400}))))
instance)
;;------------------------------------------------------------------------------------------------;;
;; Serialization ;;
;;------------------------------------------------------------------------------------------------;;
(comment
#_todo)
;;------------------------------------------------------------------------------------------------;;
;; Public APIs ;;
;;------------------------------------------------------------------------------------------------;;
(defn set-latest-definition!
"Create a new definition for an existing app."
[app-id definition]
(t2/insert-returning-instance! :model/DataAppDefinition
(merge definition
{:app_id app-id
:revision_number (next-revision-number-hsql app-id)})))
(defn create-app!
"Create a new App with an initial definition."
[app-data]
(t2/with-transaction [_conn]
(let [app (t2/insert-returning-instance! :model/DataApp (dissoc app-data :definition))
app-definition (when-let [definition (:definition app-data)]
(set-latest-definition! (:id app) definition))]
(assoc app :definition app-definition))))
(defn publish!
"Publish a new definition of an app."
[app-id app-definition-id]
(t2/with-transaction [_conn]
(t2/insert-returning-instance! :model/DataAppRelease
{:app_id app-id
:app_definition_id app-definition-id})))

View File

@@ -14,6 +14,9 @@
Tests will check to make sure new models get included in this map."
'{:model/Action metabase.actions.models
:model/ApiKey metabase.api-keys.models.api-key
:model/DataApp metabase.data-apps.models
:model/DataAppDefinition metabase.data-apps.models
:model/DataAppRelease metabase.data-apps.models
:model/ApplicationPermissionsRevision metabase.permissions.models.application-permissions-revision
:model/AuditLog metabase.audit-app.models.audit-log
:model/BookmarkOrdering metabase.bookmarks.models.bookmark