feat: database extension registry (#23174)

Co-authored-by: Elizabeth Thompson <eschutho@gmail.com>
Co-authored-by: Lily Kuang <lily@preset.io>
This commit is contained in:
Beto Dealmeida 2023-05-18 17:00:06 -07:00 committed by GitHub
parent e856e35c53
commit 6b5459121f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 185 additions and 18 deletions

View File

@ -29,7 +29,7 @@ type ReturningDisplayable<P = void> = (props: P) => string | React.ReactElement;
/**
* This type defines all available extensions of Superset's default UI.
* Namespace the keys here to follow the form of 'some_domain.functonality.item'.
* Namespace the keys here to follow the form of 'some_domain.functionality.item'.
* Take care to name your keys well, as the name describes what this extension point's role is in Superset.
*
* When defining a new option here, take care to keep any parameters to functions (or components) minimal.
@ -66,8 +66,48 @@ type RightMenuItemIconProps = {
menuChild: MenuObjectChildProps;
};
type DatabaseDeleteRelatedExtensionProps = {
databaseId: number;
database: object;
};
type DatasetDeleteRelatedExtensionProps = {
dataset: object;
};
/**
* Interface for extensions to database connections
*/
export interface DatabaseConnectionExtension {
/**
* Display title text for the extension show when creating a database connection
*/
title: string;
/**
* url or dataURI (recommended) of a logo to use in place of a title. title is fallback display if no logo is provided
*/
logo?: React.ComponentType<any>;
/**
* Descriptive text displayed under the logo or title to provide user with more context about the configuration section
*/
description: React.ComponentType<any>;
/**
* React component to render for display in the database connection configuration
*/
component: React.ComponentType<any>;
/**
* Is the database extension enabled?
*/
enabled: () => boolean;
/**
* Callback for onsave
*/
// TODO: we need to move the db types to superset-ui/core in order to import them correctly
onSave: (componentState: any, db: any) => any;
/**
* Used for parent to store data
*/
onEdit?: (componentState: any) => void;
}
export type Extensions = Partial<{
'alertsreports.header.icon': React.ComponentType;
@ -83,7 +123,9 @@ export type Extensions = Partial<{
'welcome.banner': React.ComponentType;
'welcome.main.replacement': React.ComponentType;
'ssh_tunnel.form.switch': React.ComponentType<SwitchProps>;
'databaseconnection.extraOption': DatabaseConnectionExtension;
'database.delete.related': React.ComponentType<DatabaseDeleteRelatedExtensionProps>;
'dataset.delete.related': React.ComponentType<DatasetDeleteRelatedExtensionProps>;
}>;
/**

View File

@ -31,11 +31,14 @@ const propTypes = {
disabled: PropTypes.bool,
freeForm: PropTypes.bool,
isLoading: PropTypes.bool,
mode: PropTypes.string,
multi: PropTypes.bool,
isMulti: PropTypes.bool,
name: PropTypes.string.isRequired,
onChange: PropTypes.func,
onFocus: PropTypes.func,
onSelect: PropTypes.func,
onDeselect: PropTypes.func,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
@ -174,12 +177,14 @@ export default class SelectControl extends React.PureComponent {
label,
multi,
name,
placeholder,
onFocus,
showHeader,
value,
tokenSeparators,
notFoundContent,
onFocus,
onSelect,
onDeselect,
placeholder,
showHeader,
tokenSeparators,
value,
// ControlHeader props
description,
renderTrigger,
@ -236,10 +241,12 @@ export default class SelectControl extends React.PureComponent {
: true,
header: showHeader && <ControlHeader {...headerProps} />,
loading: isLoading,
mode: isMulti || multi ? 'multiple' : 'single',
mode: this.props.mode || (isMulti || multi ? 'multiple' : 'single'),
name: `select-${name}`,
onChange: this.onChange,
onFocus,
onSelect,
onDeselect,
options: this.state.options,
placeholder,
sortComparator: this.props.sortComparator,

View File

@ -18,7 +18,11 @@
*/
import React, { ChangeEvent, EventHandler } from 'react';
import cx from 'classnames';
import { t, SupersetTheme } from '@superset-ui/core';
import {
t,
SupersetTheme,
DatabaseConnectionExtension,
} from '@superset-ui/core';
import InfoTooltip from 'src/components/InfoTooltip';
import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
import Collapse from 'src/components/Collapse';
@ -38,6 +42,7 @@ const ExtraOptions = ({
onEditorChange,
onExtraInputChange,
onExtraEditorChange,
extraExtension,
}: {
db: DatabaseObject | null;
onInputChange: EventHandler<ChangeEvent<HTMLInputElement>>;
@ -45,6 +50,7 @@ const ExtraOptions = ({
onEditorChange: Function;
onExtraInputChange: EventHandler<ChangeEvent<HTMLInputElement>>;
onExtraEditorChange: Function;
extraExtension: DatabaseConnectionExtension | undefined;
}) => {
const expandableModalIsOpen = !!db?.expose_in_sqllab;
const createAsOpen = !!(db?.allow_ctas || db?.allow_cvas);
@ -61,6 +67,10 @@ const ExtraOptions = ({
return value;
});
const ExtraExtensionComponent = extraExtension?.component;
const ExtraExtensionLogo = extraExtension?.logo;
const ExtensionDescription = extraExtension?.description;
return (
<Collapse
expandIconPosition="right"
@ -437,6 +447,32 @@ const ExtraOptions = ({
</StyledInputContainer>
)}
</Collapse.Panel>
{extraExtension && ExtraExtensionComponent && ExtensionDescription && (
<Collapse.Panel
header={
<div>
{ExtraExtensionLogo && <ExtraExtensionLogo />}
<span
css={(theme: SupersetTheme) => ({
fontSize: theme.typography.sizes.l,
fontWeight: theme.typography.weights.bold,
})}
>
{extraExtension?.title}
</span>
<p className="helper">
<ExtensionDescription />
</p>
</div>
}
key={extraExtension?.title}
collapsible={extraExtension.enabled?.() ? 'header' : 'disabled'}
>
<StyledInputContainer css={no_margin_bottom}>
<ExtraExtensionComponent db={db} onEdit={extraExtension.onEdit} />
</StyledInputContainer>
</Collapse.Panel>
)}
<Collapse.Panel
header={
<div>

View File

@ -578,12 +578,31 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
sshTunnelPrivateKeyPasswordFields,
setSSHTunnelPrivateKeyPasswordFields,
] = useState<string[]>([]);
const [extraExtensionComponentState, setExtraExtensionComponentState] =
useState<object>({});
const SSHTunnelSwitchComponent =
extensionsRegistry.get('ssh_tunnel.form.switch') ?? SSHTunnelSwitch;
const [useSSHTunneling, setUseSSHTunneling] = useState<boolean>(false);
let dbConfigExtraExtension = extensionsRegistry.get(
'databaseconnection.extraOption',
);
if (dbConfigExtraExtension) {
// add method for db modal to store data
dbConfigExtraExtension = {
...dbConfigExtraExtension,
onEdit: componentState => {
setExtraExtensionComponentState({
...extraExtensionComponentState,
...componentState,
});
},
};
}
const conf = useCommonConf();
const dbImages = getDatabaseImages();
const connectionAlert = getConnectionAlert();
@ -715,6 +734,19 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
};
const onSave = async () => {
let dbConfigExtraExtensionOnSaveError;
dbConfigExtraExtension
?.onSave(extraExtensionComponentState, db)
.then(({ error }: { error: any }) => {
if (error) {
dbConfigExtraExtensionOnSaveError = error;
addDangerToast(error);
}
});
if (dbConfigExtraExtensionOnSaveError) {
setLoading(false);
return;
}
// Clone DB object
const dbToUpdate = { ...(db || {}) };
@ -803,6 +835,18 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
);
if (result) {
if (onDatabaseAdd) onDatabaseAdd();
dbConfigExtraExtension
?.onSave(extraExtensionComponentState, db)
.then(({ error }: { error: any }) => {
if (error) {
dbConfigExtraExtensionOnSaveError = error;
addDangerToast(error);
}
});
if (dbConfigExtraExtensionOnSaveError) {
setLoading(false);
return;
}
if (!editNewDb) {
onClose();
addSuccessToast(t('Database settings updated'));
@ -817,6 +861,19 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
if (dbId) {
setHasConnectedDb(true);
if (onDatabaseAdd) onDatabaseAdd();
dbConfigExtraExtension
?.onSave(extraExtensionComponentState, db)
.then(({ error }: { error: any }) => {
if (error) {
dbConfigExtraExtensionOnSaveError = error;
addDangerToast(error);
}
});
if (dbConfigExtraExtensionOnSaveError) {
setLoading(false);
return;
}
if (useTabLayout) {
// tab layout only has one step
// so it should close immediately on save
@ -1596,6 +1653,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
if (!editNewDb) {
return (
<ExtraOptions
extraExtension={dbConfigExtraExtension}
db={db as DatabaseObject}
onInputChange={({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.inputChange, {
@ -1807,6 +1865,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
</Tabs.TabPane>
<Tabs.TabPane tab={<span>{t('Advanced')}</span>} key="2">
<ExtraOptions
extraExtension={dbConfigExtraExtension}
db={db as DatabaseObject}
onInputChange={({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.inputChange, {

View File

@ -573,6 +573,7 @@ export const StyledStickyHeader = styled.div`
top: 0;
z-index: ${({ theme }) => theme.zIndex.max};
background: ${({ theme }) => theme.colors.grayscale.light5};
height: auto;
`;
export const StyledCatalogTable = styled.div`

View File

@ -534,9 +534,9 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
databaseCurrentlyDeleting.sqllab_tab_count,
)}
</p>
{DatabaseDeleteRelatedExtension && currentDatabase?.id && (
{DatabaseDeleteRelatedExtension && (
<DatabaseDeleteRelatedExtension
databaseId={currentDatabase.id}
database={databaseCurrentlyDeleting}
/>
)}
</>

View File

@ -16,7 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { FeatureFlag, styled, SupersetClient, t } from '@superset-ui/core';
import {
FeatureFlag,
getExtensionsRegistry,
styled,
SupersetClient,
t,
} from '@superset-ui/core';
import React, {
FunctionComponent,
useState,
@ -64,6 +70,11 @@ import {
} from 'src/features/datasets/constants';
import DuplicateDatasetModal from 'src/features/datasets/DuplicateDatasetModal';
const extensionsRegistry = getExtensionsRegistry();
const DatasetDeleteRelatedExtension = extensionsRegistry.get(
'dataset.delete.related',
);
const FlexRowContainer = styled.div`
align-items: center;
display: flex;
@ -707,12 +718,23 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
<SubMenu {...menuData} />
{datasetCurrentlyDeleting && (
<DeleteModal
description={t(
'The dataset %s is linked to %s charts that appear on %s dashboards. Are you sure you want to continue? Deleting the dataset will break those objects.',
datasetCurrentlyDeleting.table_name,
datasetCurrentlyDeleting.chart_count,
datasetCurrentlyDeleting.dashboard_count,
)}
description={
<>
<p>
{t(
'The dataset %s is linked to %s charts that appear on %s dashboards. Are you sure you want to continue? Deleting the dataset will break those objects.',
datasetCurrentlyDeleting.table_name,
datasetCurrentlyDeleting.chart_count,
datasetCurrentlyDeleting.dashboard_count,
)}
</p>
{DatasetDeleteRelatedExtension && (
<DatasetDeleteRelatedExtension
dataset={datasetCurrentlyDeleting}
/>
)}
</>
}
onConfirm={() => {
if (datasetCurrentlyDeleting) {
handleDatasetDelete(datasetCurrentlyDeleting);