App widgets oss (#22743)

* update lineage provider

* add plugin registry for applications

* fix tests

* add documentation for plugin
This commit is contained in:
Karan Hotchandani 2025-08-11 11:55:06 +05:30 committed by GitHub
parent 85aa2a8c2b
commit 1564b308be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 224 additions and 47 deletions

View File

@ -28,6 +28,7 @@ import ForbiddenPage from '../../pages/ForbiddenPage/ForbiddenPage';
import PlatformLineage from '../../pages/PlatformLineage/PlatformLineage';
import TagPage from '../../pages/TagPage/TagPage';
import { checkPermission, userPermissions } from '../../utils/PermissionsUtils';
import { useApplicationsProvider } from '../Settings/Applications/ApplicationsProvider/ApplicationsProvider';
import AdminProtectedRoute from './AdminProtectedRoute';
import withSuspenseFallback from './withSuspenseFallback';
@ -278,7 +279,9 @@ const AddMetricPage = withSuspenseFallback(
const AuthenticatedAppRouter: FunctionComponent = () => {
const { permissions } = usePermissionProvider();
const { t } = useTranslation();
const { plugins } = useApplicationsProvider();
const pluginRoutes = plugins.flatMap((plugin) => plugin.getRoutes?.() || []);
const createBotPermission = useMemo(
() =>
checkPermission(Operation.Create, ResourceEntity.USER, permissions) &&
@ -686,6 +689,11 @@ const AuthenticatedAppRouter: FunctionComponent = () => {
path={ROUTES.EDIT_KPI}
/>
{/* Plugin routes */}
{pluginRoutes.map((route, idx) => {
return <Route key={idx} {...route} />;
})}
<Route element={<Navigate to={ROUTES.MY_DATA} />} path={ROUTES.HOME} />
<Route
element={

View File

@ -30,8 +30,8 @@ import { useCustomPages } from '../../../hooks/useCustomPages';
import { filterHiddenNavigationItems } from '../../../utils/CustomizaNavigation/CustomizeNavigation';
import { useAuthProvider } from '../../Auth/AuthProviders/AuthProvider';
import BrandImage from '../../common/BrandImage/BrandImage';
import { useApplicationsProvider } from '../../Settings/Applications/ApplicationsProvider/ApplicationsProvider';
import './left-sidebar.less';
import { LeftSidebarItem as LeftSidebarItemType } from './LeftSidebar.interface';
import LeftSidebarItem from './LeftSidebarItem.component';
const { Sider } = Layout;
@ -83,24 +83,45 @@ const LeftSidebar = () => {
[handleLogoutClick]
);
const { plugins } = useApplicationsProvider();
const pluginSidebarActions = useMemo(() => {
return plugins
.flatMap((plugin) => plugin.getSidebarActions?.() ?? [])
.sort((a, b) => (a.index ?? 999) - (b.index ?? 999));
}, [plugins]);
const menuItems = useMemo(() => {
return [
...sideBarItems.map((item) => {
return {
key: item.key,
icon: <Icon component={item.icon} />,
label: <LeftSidebarItem data={item} />,
children: item.children?.map((item: LeftSidebarItemType) => {
return {
key: item.key,
icon: <Icon component={item.icon} />,
label: <LeftSidebarItem data={item} />,
};
}),
};
}),
];
}, [sideBarItems]);
const mergedItems = (() => {
const baseItems = [...sideBarItems];
pluginSidebarActions.forEach((pluginItem) => {
if (typeof pluginItem.index === 'number' && pluginItem.index >= 0) {
baseItems.splice(
Math.min(pluginItem.index, baseItems.length),
0,
pluginItem
);
} else {
baseItems.push(pluginItem);
}
});
return baseItems;
})();
// Map to menu structure
return mergedItems.map((item) => ({
key: item.key,
icon: <Icon component={item.icon} />,
label: <LeftSidebarItem data={item} />,
children: item.children?.map((child) => ({
key: child.key,
icon: <Icon component={child.icon} />,
label: <LeftSidebarItem data={child} />,
})),
}));
}, [sideBarItems, pluginSidebarActions]);
const handleMenuClick: MenuProps['onClick'] = useCallback(() => {
setOpenKeys([]);

View File

@ -14,6 +14,13 @@ import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import LeftSidebar from './LeftSidebar.component';
jest.mock(
'../../Settings/Applications/ApplicationsProvider/ApplicationsProvider',
() => ({
useApplicationsProvider: () => ({ applications: [], plugins: [] }),
})
);
describe('LeftSidebar', () => {
it('renders sidebar links correctly', () => {
render(

View File

@ -33,7 +33,7 @@ import { ItemType } from 'antd/lib/menu/hooks/useItems';
import { AxiosError } from 'axios';
import { compare } from 'fast-json-patch';
import { isEmpty } from 'lodash';
import { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { ReactComponent as IconExternalLink } from '../../../../assets/svg/external-links.svg';
@ -71,6 +71,7 @@ import TabsLabel from '../../../common/TabsLabel/TabsLabel.component';
import ConfirmationModal from '../../../Modals/ConfirmationModal/ConfirmationModal';
import PageLayoutV1 from '../../../PageLayoutV1/PageLayoutV1';
import ApplicationConfiguration from '../ApplicationConfiguration/ApplicationConfiguration';
import { useApplicationsProvider } from '../ApplicationsProvider/ApplicationsProvider';
import AppLogo from '../AppLogo/AppLogo.component';
import AppRunsHistory from '../AppRunsHistory/AppRunsHistory.component';
import AppSchedule from '../AppSchedule/AppSchedule.component';
@ -95,6 +96,7 @@ const AppDetails = () => {
isSaveLoading: false,
});
const { getResourceLimit } = useLimitStore();
const { plugins } = useApplicationsProvider();
const fetchAppDetails = useCallback(async () => {
setLoadingState((prev) => ({ ...prev, isFetchLoading: true }));
@ -323,6 +325,17 @@ const AppDetails = () => {
}
};
// Check if there's a plugin configuration component for this app
const pluginConfigComponent = useMemo(() => {
if (!appData?.name || !plugins.length) {
return null;
}
const plugin = plugins.find((p) => p.name === appData.name);
return plugin?.getConfigComponent?.(appData) || null;
}, [appData?.name, plugins]);
const tabs = useMemo(() => {
const tabConfiguration =
appData?.appConfiguration && appData.allowConfiguration && jsonSchema
@ -335,7 +348,11 @@ const AppDetails = () => {
/>
),
key: ApplicationTabs.CONFIGURATION,
children: (
children: pluginConfigComponent ? (
// Use plugin configuration component if available
React.createElement(pluginConfigComponent)
) : (
// Fall back to default ApplicationConfiguration
<ApplicationConfiguration
appData={appData}
isLoading={loadingState.isSaveLoading}

View File

@ -41,6 +41,10 @@ jest.mock('../../../../hooks/useFqn', () => ({
useFqn: jest.fn().mockReturnValue({ fqn: 'mockFQN' }),
}));
jest.mock('../ApplicationsProvider/ApplicationsProvider', () => ({
useApplicationsProvider: () => ({ applications: [], plugins: [] }),
}));
const mockConfigureApp = jest.fn();
const mockDeployApp = jest.fn();
const mockRestoreApp = jest.fn();

View File

@ -14,6 +14,7 @@
import { FC } from 'react';
import { AppType } from '../../../../generated/entity/applications/app';
import { getScheduleOptionsFromSchedules } from '../../../../utils/SchedularUtils';
import { AppPlugin } from '../plugins/AppPlugin';
class ApplicationsClassBase {
public importSchema(fqn: string) {
@ -56,6 +57,11 @@ class ApplicationsClassBase {
return import(`../../../../assets/img/appScreenshots/${screenshotName}`);
}
public appPluginRegistry: Record<
string,
new (name: string, isInstalled: boolean) => AppPlugin
> = {};
public getScheduleOptionsForApp(
app: string,
appType: AppType,

View File

@ -11,7 +11,9 @@
* limitations under the License.
*/
import { EntityReference } from '../../../../generated/entity/type';
import type { AppPlugin } from '../plugins/AppPlugin';
export type ApplicationsContextType = {
applications: EntityReference[];
plugins: AppPlugin[];
};

View File

@ -25,6 +25,8 @@ import { EntityReference } from '../../../../generated/entity/type';
import { useApplicationStore } from '../../../../hooks/useApplicationStore';
import { getInstalledApplicationList } from '../../../../rest/applicationAPI';
import Loader from '../../../common/Loader/Loader';
import applicationsClassBase from '../AppDetails/ApplicationsClassBase';
import type { AppPlugin } from '../plugins/AppPlugin';
import { ApplicationsContextType } from './ApplicationsProvider.interface';
export const ApplicationsContext = createContext({} as ApplicationsContextType);
@ -45,7 +47,7 @@ export const ApplicationsProvider = ({ children }: { children: ReactNode }) => {
(app) => app.name ?? app.fullyQualifiedName ?? ''
);
setApplicationsName(applicationsNameList);
} catch (err) {
} catch {
// do not handle error
} finally {
setLoading(false);
@ -60,10 +62,24 @@ export const ApplicationsProvider = ({ children }: { children: ReactNode }) => {
}
}, []);
const appContext = useMemo(() => {
return { applications };
const installedPluginInstances: AppPlugin[] = useMemo(() => {
return applications
.map((app) => {
if (!app.name) {
return null;
}
const PluginClass = applicationsClassBase.appPluginRegistry[app.name];
return PluginClass ? new PluginClass(app.name, true) : null;
})
.filter(Boolean) as AppPlugin[];
}, [applications]);
const appContext = useMemo(() => {
return { applications, plugins: installedPluginInstances };
}, [applications, installedPluginInstances]);
return (
<ApplicationsContext.Provider value={appContext}>
{loading ? <Loader /> : children}

View File

@ -0,0 +1,66 @@
/*
* Copyright 2025 Collate.
* Licensed 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 { FC } from 'react';
import { RouteProps } from 'react-router-dom';
import { App } from '../../../../generated/entity/applications/app';
import { LeftSidebarItem } from '../../../MyData/LeftSidebar/LeftSidebar.interface';
export interface LeftSidebarItemExample extends LeftSidebarItem {
index: number;
}
/**
* Interface defining the structure and capabilities of an application plugin.
*
* This interface allows plugins to extend the OpenMetadata application with
* custom components, routes, and sidebar actions. Plugins can be installed
* or uninstalled dynamically and provide modular functionality.
*/
export interface AppPlugin {
/**
* The unique name of the app received from the /apps endpoint.
*/
name: string;
/**
* Indicates whether the app is currently installed and active.
* Used to determine plugin availability and UI state.
*/
isInstalled: boolean;
/**
* Optional method that returns a React component for plugin configuration.
* It is the responsibility of this component to update application data using patchApplication API
*
* @param app - The App entity containing application details and configuration
* @returns A React functional component for plugin settings/configuration,
* or null if no configuration is needed.
*/
getConfigComponent?(app: App): FC | null;
/**
* Optional method that provides custom routes for the plugin.
*
* @returns An array of route properties that define the plugin's
* navigation structure and page routing.
*/
getRoutes?(): Array<RouteProps>;
/**
* Optional method that provides custom sidebar actions for the plugin.
*
* @returns An array of sidebar items that will be displayed in the
* left sidebar when the plugin is active.
*/
getSidebarActions?(): Array<LeftSidebarItemExample>;
}

View File

@ -61,6 +61,7 @@ export interface LineageContextType {
platformView: LineagePlatformView;
expandAllColumns: boolean;
isPlatformLineage: boolean;
entityFqn: string;
toggleColumnView: () => void;
onInitReactFlow: (reactFlowInstance: ReactFlowInstance) => void;
onPaneClick: () => void;
@ -98,5 +99,6 @@ export interface LineageContextType {
) => void;
onUpdateLayerView: (layers: LineageLayer[]) => void;
redraw: () => Promise<void>;
updateEntityFqn: (entityFqn: string) => void;
dqHighlightedEdges?: Set<string>;
}

View File

@ -217,6 +217,21 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
const [isPlatformLineage, setIsPlatformLineage] = useState(false);
const [dqHighlightedEdges, setDqHighlightedEdges] = useState<Set<string>>();
// Add state for entityFqn that can be updated independently of URL params
const [entityFqn, setEntityFqn] = useState<string>(decodedFqn);
// Update entityFqn when decodedFqn changes (for backward compatibility)
useEffect(() => {
if (decodedFqn) {
setEntityFqn(decodedFqn);
}
}, [decodedFqn]);
// Function to update entityFqn
const updateEntityFqn = useCallback((fqn: string) => {
setEntityFqn(fqn);
}, []);
const lineageLayer = useMemo(() => {
const param = location.search;
const searchData = QueryString.parse(
@ -276,7 +291,7 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
} = createEdgesAndEdgeMaps(
allNodes,
lineageData.edges ?? [],
decodedFqn,
entityFqn,
activeLayer.includes(LineageLayer.ColumnLevelLineage),
isFirstTime ? true : undefined
);
@ -284,7 +299,7 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
const initialNodes = createNodes(
allNodes,
lineageData.edges ?? [],
decodedFqn,
entityFqn,
incomingMap,
outgoingMap,
activeLayer.includes(LineageLayer.ColumnLevelLineage),
@ -334,7 +349,7 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
setColumnsHavingLineage(columnsHavingLineage);
},
[
decodedFqn,
entityFqn,
activeLayer,
isEditMode,
reactFlowInstance,
@ -379,7 +394,7 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
setLineageData(res);
const { nodes, edges, entity } = parseLineageData(res, '', decodedFqn);
const { nodes, edges, entity } = parseLineageData(res, '', entityFqn);
const updatedEntityLineage = {
nodes,
edges,
@ -399,7 +414,7 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
setLoading(false);
}
},
[]
[entityFqn]
);
const fetchLineageData = useCallback(
@ -423,7 +438,7 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
});
setLineageData(res);
const { nodes, edges, entity } = parseLineageData(res, fqn, decodedFqn);
const { nodes, edges, entity } = parseLineageData(res, fqn, entityFqn);
const updatedEntityLineage = {
nodes,
edges,
@ -443,7 +458,7 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
setLoading(false);
}
},
[queryFilter, decodedFqn]
[queryFilter, entityFqn]
);
const onPlatformViewChange = useCallback(
@ -473,17 +488,17 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
const exportLineageData = useCallback(
async (_: string) => {
return exportLineageAsync(
decodedFqn,
entityFqn,
entityType,
lineageConfig,
queryFilter
);
},
[entityType, decodedFqn, lineageConfig, queryFilter]
[entityType, entityFqn, lineageConfig, queryFilter]
);
const onExportClick = useCallback(() => {
if (decodedFqn || isPlatformLineagePage) {
if (entityFqn || isPlatformLineagePage) {
showModal({
...(isPlatformLineagePage
? {
@ -491,7 +506,7 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
exportTypes: [ExportTypes.PNG],
}
: {
name: decodedFqn,
name: entityFqn,
exportTypes: [ExportTypes.CSV, ExportTypes.PNG],
}),
title: t('label.lineage'),
@ -502,7 +517,7 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
}
}, [
entityType,
decodedFqn,
entityFqn,
lineageConfig,
queryFilter,
nodes,
@ -552,7 +567,7 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
const { nodes: newNodes, edges: newEdges } = parseLineageData(
concatenatedLineageData,
node.fullyQualifiedName ?? '',
decodedFqn
entityFqn
);
const uniqueNodes = [...(entityLineage.nodes ?? [])];
@ -625,7 +640,15 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
);
}
},
[nodes, edges, lineageConfig, entityLineage, setEntityLineage, queryFilter]
[
nodes,
edges,
lineageConfig,
entityLineage,
setEntityLineage,
queryFilter,
entityFqn,
]
);
const handleLineageTracing = useCallback(
@ -686,6 +709,7 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
isPlatformLineage?: boolean
) => {
setEntity(entity);
setEntityFqn(entity?.fullyQualifiedName ?? '');
setEntityType(entityType);
setIsPlatformLineage(isPlatformLineage ?? false);
if (isPlatformLineage && !entity) {
@ -958,7 +982,7 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
const { nodes: newNodes, edges: newEdges } = parseLineageData(
concatenatedLineageData,
parentNode.data.node.fullyQualifiedName,
decodedFqn
entityFqn
);
updateLineageData(
@ -1174,7 +1198,7 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
createEdgesAndEdgeMaps(
allNodes,
allEdges,
decodedFqn,
entityFqn,
activeLayer.includes(LineageLayer.ColumnLevelLineage)
);
setEdges(createdEdges);
@ -1191,7 +1215,7 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
});
}
},
[entityLineage, nodes, decodedFqn]
[entityLineage, nodes, entityFqn]
);
const onAddPipelineClick = useCallback(() => {
@ -1499,7 +1523,7 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
}, [entityLineage, redrawLineage]);
const onPlatformViewUpdate = useCallback(() => {
if (entity && decodedFqn && entityType) {
if (entity && entityFqn && entityType) {
if (platformView === LineagePlatformView.Service && entity?.service) {
fetchLineageData(
entity?.service.fullyQualifiedName ?? '',
@ -1525,7 +1549,7 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
lineageConfig
);
} else if (platformView === LineagePlatformView.None) {
fetchLineageData(decodedFqn, entityType, lineageConfig);
fetchLineageData(entityFqn, entityType, lineageConfig);
} else if (isPlatformLineage) {
fetchPlatformLineage(
getEntityTypeFromPlatformView(platformView),
@ -1541,7 +1565,7 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
}, [
entity,
entityType,
decodedFqn,
entityFqn,
lineageConfig,
platformView,
queryFilter,
@ -1571,7 +1595,7 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
getUpstreamDownstreamNodesEdges(
updatedEntityLineage.edges ?? [],
updatedEntityLineage.nodes ?? [],
decodedFqn
entityFqn
);
const updatedNodes =
@ -1588,7 +1612,7 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
nodes: updatedNodes as EntityReference[],
});
}
}, [isEditMode, updatedEntityLineage, decodedFqn]);
}, [isEditMode, updatedEntityLineage, entityFqn]);
useEffect(() => {
if (isEditMode) {
@ -1658,6 +1682,8 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
expandAllColumns,
platformView,
isPlatformLineage,
entityFqn,
updateEntityFqn,
toggleColumnView,
onInitReactFlow,
onPaneClick,
@ -1708,6 +1734,8 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
columnsHavingLineage,
expandAllColumns,
isPlatformLineage,
entityFqn,
updateEntityFqn,
toggleColumnView,
onInitReactFlow,
onPaneClick,
@ -1755,9 +1783,9 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
useEffect(() => {
if (activeLayer.includes(LineageLayer.DataObservability)) {
fetchDataQualityLineage(decodedFqn, lineageConfig);
fetchDataQualityLineage(entityFqn, lineageConfig);
}
}, [activeLayer, decodedFqn, lineageConfig]);
}, [activeLayer, entityFqn, lineageConfig]);
useEffect(() => {
if (