console: better handling for unavailable GDC sources on connect and form pages

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9139
Co-authored-by: Vijay Prasanna <11921040+vijayprasanna13@users.noreply.github.com>
GitOrigin-RevId: 7fb0aeb07d13298bd420e25ca6b0408bdad9a029
This commit is contained in:
Matthew Goodwin 2023-05-15 01:14:11 -05:00 committed by hasura-bot
parent 06276b0055
commit 36b739c57f
15 changed files with 388 additions and 101 deletions

View File

@ -10,7 +10,7 @@ export default {
component: ConnectDatabaseV2,
decorators: [ReactQueryDecorator()],
parameters: {
msw: handlers({ dcAgentsAdded: true }),
msw: handlers({ agentTestType: 'super_connector_agents_added' }),
},
} as ComponentMeta<typeof ConnectDatabaseV2>;
@ -51,7 +51,7 @@ export const FromEnvironment: ComponentStory<typeof ConnectDatabaseV2> = () => {
</div>
);
};
FromEnvironment.storyName = '💠 Using Environment (DC Agents Available)';
FromEnvironment.storyName = '💠 Using Environment (DC Agents Added)';
/**
*
@ -65,9 +65,9 @@ FromEnvironment.storyName = '💠 Using Environment (DC Agents Available)';
*/
export const FromEnvironment2 = FromEnvironment.bind({});
FromEnvironment2.storyName = '💠 Using Environment (DC Agents NOT Available)';
FromEnvironment2.storyName = '💠 Using Environment (DC Agents NOT Added)';
FromEnvironment2.parameters = {
msw: handlers({ dcAgentsAdded: false }),
msw: handlers({ agentTestType: 'super_connector_agents_not_added' }),
};
/**
*
@ -77,9 +77,9 @@ FromEnvironment2.parameters = {
*
*/
export const Playground = Template.bind({});
Playground.storyName = '💠 Playground (DC Agents NOT Available)';
Playground.storyName = '💠 Playground (DC Agents NOT Added)';
Playground.parameters = {
msw: handlers({ dcAgentsAdded: false }),
msw: handlers({ agentTestType: 'super_connector_agents_not_added' }),
};
Playground.args = Template.args;
@ -94,9 +94,28 @@ Playground.args = Template.args;
*/
export const Playground2 = Template.bind({});
Playground2.storyName = '💠 Playground (DC Agents Available)';
Playground2.storyName = '💠 Playground (DC Agents Added)';
Playground2.args = Template.args;
/**
*
* Playground 3
*
*
* Mock DC Agents are added in this version
*
*
*/
export const Playground3 = Template.bind({});
Playground3.storyName = '💠 Playground (DC Agents Added but not available)';
Playground3.parameters = {
msw: handlers({
agentTestType: 'super_connector_agents_added_but_unavailable',
}),
};
Playground3.args = Template.args;
/**
* TODO:
*

View File

@ -1,8 +1,7 @@
import React from 'react';
import { DriverInfo } from '../DataSource';
import { EELiteAccess } from '../EETrial';
import { ConnectDatabaseWrapper, FancyRadioCards } from './components';
import { ConnectDbBody } from './ConnectDbBody';
import { ConnectDatabaseWrapper, FancyRadioCards } from './components';
import { DEFAULT_DRIVER } from './constants';
import { useDatabaseConnectDrivers } from './hooks/useConnectDatabaseDrivers';
import { DbConnectConsoleType } from './types';
@ -32,22 +31,33 @@ export type ConnectDatabaseProps = {
export const ConnectDatabaseV2 = (props: ConnectDatabaseProps) => {
const { initialDriverName, eeLicenseInfo, consoleType } = props;
const [selectedDriver, setSelectedDriver] =
React.useState<DriverInfo>(DEFAULT_DRIVER);
// const [selectedDriver, setSelectedDriver] =
// React.useState<DriverInfo>(DEFAULT_DRIVER);
const [selectedDriverName, setSelectedDriverName] = React.useState(
DEFAULT_DRIVER.name
);
const { cardData, allDrivers, availableDrivers } = useDatabaseConnectDrivers({
showEnterpriseDrivers: consoleType !== 'oss',
onFirstSuccess: () =>
setSelectedDriver(
currentDriver =>
setSelectedDriverName(
current =>
allDrivers.find(
d =>
d.name === initialDriverName &&
(d.enterprise === false || consoleType !== 'oss')
) || currentDriver
)?.name || current
),
});
// this needs to be a reactive value hence the useMemo usage.
// when "allDrivers" changes due to a react query invalidation/metadata reload, the properties of the driver may change
// in order for this to reflect automatically, we make this value dependant on both the state of "allDrivers" array and the "selectedDriverName" string
const selectedDriver = React.useMemo(
() => allDrivers.find(d => d.name === selectedDriverName) || DEFAULT_DRIVER,
[allDrivers, selectedDriverName]
);
const isDriverAvailable = (availableDrivers ?? []).some(
d => d.name === selectedDriver.name
);
@ -58,9 +68,7 @@ export const ConnectDatabaseV2 = (props: ConnectDatabaseProps) => {
items={cardData}
value={selectedDriver?.name}
onChange={val => {
setSelectedDriver(
prev => allDrivers?.find(d => d.name === val) || prev
);
setSelectedDriverName(val);
}}
/>
<ConnectDbBody

View File

@ -4,7 +4,6 @@ import { useAppDispatch } from '../../../../storeHooks';
import { DriverInfo } from '../../../DataSource';
import { useMetadata } from '../../../hasura-metadata-api';
import { ConnectButton } from '../../components/ConnectButton';
import { DEFAULT_DRIVER } from '../../constants';
export const Cloud = ({
selectedDriver,
@ -19,8 +18,6 @@ export const Cloud = ({
const dispatch = useAppDispatch();
const selectedDriverName = selectedDriver?.name ?? DEFAULT_DRIVER.name;
return (
<>
{selectedDriver?.name === 'postgres' && (
@ -45,7 +42,7 @@ export const Cloud = ({
</IndicatorCard>
</div>
) : (
<ConnectButton driverName={selectedDriverName} />
<ConnectButton selectedDriver={selectedDriver} />
)}
</>
);

View File

@ -2,5 +2,5 @@ import { DriverInfo } from '../../../DataSource';
import { ConnectButton } from '../../components/ConnectButton';
export const Oss = ({ selectedDriver }: { selectedDriver: DriverInfo }) => (
<ConnectButton driverName={selectedDriver?.name} />
<ConnectButton selectedDriver={selectedDriver} />
);

View File

@ -12,7 +12,7 @@ export const Pro = ({
}) => {
const pushRoute = usePushRoute();
return isDriverAvailable ? (
<ConnectButton driverName={selectedDriver?.name} />
<ConnectButton selectedDriver={selectedDriver} />
) : (
<div className="mt-3" data-testid="setup-connector">
<SetupConnector

View File

@ -78,7 +78,7 @@ export const ProLite = ({
{(!selectedDriver?.enterprise ||
(eeLicenseInfo === 'active' && isDriverAvailable)) && (
<ConnectButton driverName={selectedDriver?.name} />
<ConnectButton selectedDriver={selectedDriver} />
)}
</>
);

View File

@ -1,17 +1,81 @@
import React from 'react';
import { useQueryClient } from 'react-query';
import { useHasuraAlert } from '../../../new-components/Alert';
import { Button } from '../../../new-components/Button';
import { hasuraToast } from '../../../new-components/Toasts';
import { useReloadMetadata } from '../../hasura-metadata-api/useReloadMetadata';
import { usePushRoute } from '../hooks';
import { DriverInfo } from '../../DataSource';
import to from 'await-to-js';
export const ConnectButton = ({ driverName }: { driverName: string }) => {
export const ConnectButton = ({
selectedDriver,
}: {
selectedDriver: DriverInfo;
}) => {
const pushRoute = usePushRoute();
const { hasuraConfirm } = useHasuraAlert();
const { reloadMetadata } = useReloadMetadata();
const client = useQueryClient();
const connectionIssue = !selectedDriver.available;
const handleClick = () => {
if (connectionIssue) {
hasuraConfirm({
message: (
<>
<p>The selected driver cannot be reached at the moment.</p>
<p>
This is usually due to a connection issue and can be resolved by
reloading metadata.
</p>
<p>If this issue persists, please contact support.</p>
</>
),
title: 'Driver Error',
confirmText: 'Reload Metadata',
onCloseAsync: async ({ confirmed }) => {
if (!confirmed) return;
const [err, result] = await to(
reloadMetadata({
shouldReloadAllSources: false,
shouldReloadRemoteSchemas: false,
})
);
if (err) {
hasuraToast({
message: 'There was an error reloading your metadata.',
title: 'Error',
type: 'error',
});
return;
}
const { success } = result;
if (success) {
client.invalidateQueries();
return { withSuccess: true, successText: 'Metadata Reloaded' };
} else {
hasuraToast({
message: 'There was an error reloading your metadata.',
title: 'Error',
type: 'error',
});
return;
}
},
});
} else {
pushRoute(`/data/v2/manage/database/add?driver=${selectedDriver.name}`);
}
};
return (
<Button
className="mt-6 self-end"
data-testid="connect-existing-button"
onClick={() =>
pushRoute(`/data/v2/manage/database/add?driver=${driverName}`)
}
onClick={handleClick}
>
Connect Existing Database
</Button>

View File

@ -7,7 +7,7 @@ export default {
component: ConnectGDCSourceWidget,
decorators: [ReactQueryDecorator()],
parameters: {
msw: handlers(),
msw: handlers({ agentTestType: 'super_connector_agents_not_added' }),
},
} as ComponentMeta<typeof ConnectGDCSourceWidget>;

View File

@ -1,25 +1,29 @@
import { DataSource, Feature } from '../../../DataSource';
import { useHttpClient } from '../../../Network';
import { OpenApi3Form } from '../../../OpenApi3Form';
import { Button } from '../../../../new-components/Button';
import { transformSchemaToZodObject } from '../../../OpenApi3Form/utils';
import { InputField, useConsoleForm } from '../../../../new-components/Form';
import { Tabs } from '../../../../new-components/Tabs';
import to from 'await-to-js';
import { AxiosError } from 'axios';
import get from 'lodash/get';
import { useEffect, useState } from 'react';
import { FaExclamationTriangle } from 'react-icons/fa';
import Skeleton from 'react-loading-skeleton';
import { useQuery } from 'react-query';
import { z, ZodSchema } from 'zod';
import { graphQLCustomizationSchema } from '../GraphQLCustomization/schema';
import { GraphQLCustomization } from '../GraphQLCustomization/GraphQLCustomization';
import { ZodSchema, z } from 'zod';
import { Button } from '../../../../new-components/Button';
import { Collapsible } from '../../../../new-components/Collapsible';
import { InputField, useConsoleForm } from '../../../../new-components/Form';
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
import { Tabs } from '../../../../new-components/Tabs';
import { hasuraToast } from '../../../../new-components/Toasts';
import { useAvailableDrivers } from '../../../ConnectDB/hooks';
import { DataSource, Feature } from '../../../DataSource';
import { useHttpClient } from '../../../Network';
import { OpenApi3Form } from '../../../OpenApi3Form';
import { transformSchemaToZodObject } from '../../../OpenApi3Form/utils';
import { useMetadata } from '../../../hasura-metadata-api';
import { useManageDatabaseConnection } from '../../hooks/useManageDatabaseConnection';
import { DisplayToastErrorMessage } from '../Common/DisplayToastErrorMessage';
import { GraphQLCustomization } from '../GraphQLCustomization/GraphQLCustomization';
import { graphQLCustomizationSchema } from '../GraphQLCustomization/schema';
import { adaptGraphQLCustomization } from '../GraphQLCustomization/utils/adaptResponse';
import { generateGDCRequestPayload } from './utils/generateRequest';
import { hasuraToast } from '../../../../new-components/Toasts';
import { useManageDatabaseConnection } from '../../hooks/useManageDatabaseConnection';
import { Collapsible } from '../../../../new-components/Collapsible';
import { DisplayToastErrorMessage } from '../Common/DisplayToastErrorMessage';
import { useAvailableDrivers } from '../../../ConnectDB/hooks';
interface ConnectGDCSourceWidgetProps {
driver: string;
@ -31,9 +35,13 @@ const useFormValidationSchema = (driver: string) => {
return useQuery({
queryKey: ['form-schema', driver],
queryFn: async () => {
const configSchemas = await DataSource(
httpClient
).connectDB.getConfigSchema(driver);
const [err, configSchemas] = await to(
DataSource(httpClient).connectDB.getConfigSchema(driver)
);
if (err) {
throw err;
}
if (!configSchemas || configSchemas === Feature.NotImplemented)
throw Error('Could not retrive config schema info for driver');
@ -57,36 +65,57 @@ export const ConnectGDCSourceWidget = (props: ConnectGDCSourceWidgetProps) => {
const { driver, dataSourceName } = props;
const [tab, setTab] = useState('connection_details');
const { data: drivers } = useAvailableDrivers();
const {
data: drivers,
isLoading: isLoadingAvailableDrivers,
isError: isAvailableDriversError,
error: availableDriversError,
} = useAvailableDrivers();
const driverDisplayName =
drivers?.find(d => d.name === driver)?.displayName ?? driver;
const { data: metadataSource } = useMetadata(m =>
const {
data: metadataSource,
isLoading: isLoadingMetadata,
isError: isMetadataError,
error: metadataError,
} = useMetadata(m =>
m.metadata.sources.find(source => source.name === dataSourceName)
);
const { createConnection, editConnection, isLoading } =
useManageDatabaseConnection({
onSuccess: () => {
hasuraToast({
type: 'success',
title: isEditMode
? 'Database updated successfully!'
: 'Database added successfully!',
});
},
onError: err => {
hasuraToast({
type: 'error',
title: err.name,
children: <DisplayToastErrorMessage message={err.message} />,
});
},
});
const isEditMode = !!dataSourceName;
const {
createConnection,
editConnection,
isLoading: isLoadingCreateConnection,
} = useManageDatabaseConnection({
onSuccess: () => {
hasuraToast({
type: 'success',
title: isEditMode
? 'Database updated successfully!'
: 'Database added successfully!',
});
},
onError: err => {
hasuraToast({
type: 'error',
title: err.name,
children: <DisplayToastErrorMessage message={err.message} />,
});
},
});
const { data } = useFormValidationSchema(driver);
const {
data,
isLoading: isLoadingValidationSchema,
isError: isValidationSchemaError,
error: validationSchemaError,
} = useFormValidationSchema(driver);
const isLoading =
(isLoadingMetadata && !isMetadataError) ||
(isLoadingValidationSchema && !isValidationSchemaError) ||
(isLoadingAvailableDrivers && !isAvailableDriversError);
const [schema, setSchema] = useState<ZodSchema>(z.any());
@ -113,7 +142,52 @@ export const ConnectGDCSourceWidget = (props: ConnectGDCSourceWidgetProps) => {
});
}, [metadataSource, reset]);
if (!data?.configSchemas) return null;
if (isLoading) {
return (
<div>
<Skeleton count={10} height={30} />
</div>
);
}
if (validationSchemaError) {
const err = validationSchemaError as AxiosError<{ error?: string }>;
return (
<IndicatorCard status="negative">
{err?.response?.data?.error ||
err.toString() ||
'An error occurred loading the connection configuration.'}
</IndicatorCard>
);
}
if (metadataError) {
const err = metadataError as AxiosError<{ error?: string }>;
return (
<IndicatorCard status="negative">
{err?.response?.data?.error ||
err.toString() ||
'An error occurred loading metadata.'}
</IndicatorCard>
);
}
if (availableDriversError) {
const err = availableDriversError as AxiosError<{ error?: string }>;
return (
<IndicatorCard status="negative">
{err?.response?.data?.error ||
err.toString() ||
'An error occurred loading the available drivers.'}
</IndicatorCard>
);
}
if (!data?.configSchemas) {
return (
<IndicatorCard status="negative">
An error occurred loading the connection configuration.
</IndicatorCard>
);
}
const handleSubmit = (formValues: any) => {
const payload = generateGDCRequestPayload({
@ -133,8 +207,6 @@ export const ConnectGDCSourceWidget = (props: ConnectGDCSourceWidgetProps) => {
get(formState.errors, 'configuration.connectionInfo'),
].filter(Boolean);
console.log(formState.errors);
return (
<div>
<div className="text-xl text-gray-600 font-semibold">
@ -186,7 +258,7 @@ export const ConnectGDCSourceWidget = (props: ConnectGDCSourceWidgetProps) => {
<Button
type="submit"
mode="primary"
isLoading={isLoading}
isLoading={isLoadingCreateConnection}
loadingText="Saving"
>
{isEditMode ? 'Update Connection' : 'Connect Database'}

View File

@ -5,7 +5,7 @@ import { ConnectGDCSourceWidget } from '../ConnectGDCSourceWidget/ConnectGDCSour
import { ConnectMssqlWidget } from '../ConnectMssqlWidget/ConnectMssqlWidget';
import { ConnectPostgresWidget } from '../ConnectPostgresWidget/ConnectPostgresWidget';
const getEditDatasourceName = (): string | undefined => {
const getDataSourceNameFromUrlParams = (): string | undefined => {
const urlParams = new URLSearchParams(window.location.search);
const database = urlParams.get('database');
@ -13,7 +13,7 @@ const getEditDatasourceName = (): string | undefined => {
return database ?? undefined;
};
const getDriverName = (): string | undefined => {
const getDriverNameFromUrlParams = (): string | undefined => {
const urlParams = new URLSearchParams(window.location.search);
const driver = urlParams.get('driver');
@ -22,8 +22,8 @@ const getDriverName = (): string | undefined => {
};
const ConnectDatabaseWrapper = () => {
const dataSourceName = getEditDatasourceName();
const driver = getDriverName();
const dataSourceName = getDataSourceNameFromUrlParams();
const driver = getDriverNameFromUrlParams();
if (!driver) return <div>Error. No driver found.</div>;
@ -68,7 +68,7 @@ const ConnectDatabaseWrapper = () => {
};
export const ConnectUIContainer = () => {
const driver = getDriverName();
const driver = getDriverNameFromUrlParams();
return (
<div className="p-4">
<BreadCrumb

View File

@ -1,11 +1,14 @@
import React from 'react';
// import { MdSignalWifiStatusbarConnectedNoInternet1 } from 'react-icons/md';
import { Badge } from '../../../new-components/Badge';
import { IoCloudOfflineOutline } from 'react-icons/io5';
export const DatabaseLogo: React.FC<{
title: string;
image: string;
releaseName?: string;
}> = ({ title, image, releaseName }) => {
noConnection: boolean;
}> = ({ title, image, releaseName, noConnection }) => {
return (
// adding pointer evens none just to make sure none of this captures clicks since that's handled in the parent for the radio buttons
<div className="flex flex-col mt-2 items-center justify-center absolute h-full w-full pointer-events-none">
@ -15,10 +18,18 @@ export const DatabaseLogo: React.FC<{
alt={`${title} logo`}
/>
<div className="text-black text-base">{title}</div>
{releaseName && releaseName !== 'GA' && (
<div className="absolute top-0 right-0 m-1 scale-75">
<Badge color="indigo">{releaseName}</Badge>
{noConnection ? (
<div className="absolute top-0 right-0 m-3 ">
<IoCloudOfflineOutline size={20} className="text-red-500" />
</div>
) : (
releaseName &&
releaseName !== 'GA' && (
<div className="absolute top-0 right-0 m-1 scale-75">
<Badge color="indigo">{releaseName}</Badge>
</div>
)
)}
</div>
);

View File

@ -3,12 +3,22 @@ import { useAvailableDrivers } from '../../ConnectDB/hooks';
import { DriverInfo } from '../../DataSource';
import { DatabaseLogo } from '../components';
import dbLogos from '../graphics/db-logos';
import { SuperConnectorDrivers as SuperDrivers } from '../../hasura-metadata-types';
type useDatabaseConnectDriversProps = {
onFirstSuccess?: (data: DriverInfo[]) => void;
showEnterpriseDrivers?: boolean;
};
export const kindNameMap: Record<SuperDrivers, string> = {
sqlite: 'Hasura SQLite',
athena: 'Amazon Athena',
snowflake: 'Snowflake',
mysql8: 'MySql',
mariadb: 'MariaDB',
oracle: 'Oracle',
};
// a GDC driver is only "available" once an agent is added for it
// these are drivers are a special case bc we may want to display them in the UI before their agent's are added in certain cases
const SuperConnectorDrivers: readonly DriverInfo[] = [
@ -61,10 +71,15 @@ export const useDatabaseConnectDrivers = ({
showEnterpriseDrivers = true,
onFirstSuccess,
}: useDatabaseConnectDriversProps = {}) => {
const { data: availableDrivers } = useAvailableDrivers({
const { data } = useAvailableDrivers({
onFirstSuccess,
});
const availableDrivers = data?.map(d => ({
...d,
displayName: d.displayName || kindNameMap[d.name] || d.name,
}));
const allDrivers = sortBy(
uniqBy(
[...(availableDrivers ?? []), ...SuperConnectorDrivers],
@ -80,6 +95,7 @@ export const useDatabaseConnectDrivers = ({
content: (
<DatabaseLogo
title={d.displayName}
noConnection={d.available === false}
image={dbLogos[d.name] || dbLogos.default}
releaseName={d.release}
/>

View File

@ -255,6 +255,67 @@ export const mockSourceKinds = {
},
],
},
agentsAddedSuperConnectorNotAvailable: {
sources: [
{
available: true,
builtin: true,
display_name: 'pg',
kind: 'pg',
},
{
available: true,
builtin: true,
display_name: 'citus',
kind: 'citus',
},
{
available: true,
builtin: true,
display_name: 'cockroach',
kind: 'cockroach',
},
{
available: true,
builtin: true,
display_name: 'mssql',
kind: 'mssql',
},
{
available: true,
builtin: true,
display_name: 'bigquery',
kind: 'bigquery',
},
{
available: false,
builtin: false,
//display_name: 'Hasura SQLite',
kind: 'sqlite',
},
{
available: false,
builtin: false,
//display_name: 'Amazon Athena',
kind: 'athena',
release_name: 'Beta',
},
{
available: false,
builtin: false,
//display_name: 'Snowflake',
kind: 'snowflake',
release_name: 'Beta',
},
{
available: false,
builtin: false,
//display_name: 'MySQL',
kind: 'mysql8',
release_name: 'Alpha',
},
],
},
agentsNotAdded: {
sources: [
{
@ -287,12 +348,6 @@ export const mockSourceKinds = {
display_name: 'bigquery',
kind: 'bigquery',
},
{
available: true,
builtin: true,
display_name: 'MySQL',
kind: 'mysql8',
},
{
available: true,
builtin: false,

View File

@ -4,20 +4,27 @@ import {
mockMetadata,
mockSourceKinds,
} from './data.mock';
type AgentTestType =
| 'super_connector_agents_added'
| 'super_connector_agents_not_added'
| 'super_connector_agents_added_but_unavailable';
export const handlers = ({
dcAgentsAdded,
}: { dcAgentsAdded?: boolean } = {}) => [
export const handlers = (props?: { agentTestType: AgentTestType }) => [
rest.post('http://localhost:8080/v1/metadata', (req, res, ctx) => {
const requestBody = req.body as Record<string, any>;
if (requestBody.type === 'list_source_kinds') {
return res(
ctx.json(
dcAgentsAdded
? mockSourceKinds.agentsAdded
: mockSourceKinds.agentsNotAdded
)
);
switch (props?.agentTestType) {
case 'super_connector_agents_added':
return res(ctx.json(mockSourceKinds.agentsAdded));
case 'super_connector_agents_not_added':
return res(ctx.json(mockSourceKinds.agentsNotAdded));
case 'super_connector_agents_added_but_unavailable':
return res(
ctx.json(mockSourceKinds.agentsAddedSuperConnectorNotAvailable)
);
default:
return res(ctx.json(mockSourceKinds.agentsNotAdded));
}
}
if (requestBody.type === 'export_metadata')
return res(ctx.json(mockMetadata));

View File

@ -0,0 +1,38 @@
import { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { reloadMetadata } from '../../metadata/actions';
export const useReloadMetadata = () => {
const dispatch = useDispatch();
const reload = useCallback(
({
shouldReloadAllSources = false,
shouldReloadRemoteSchemas = false,
}: {
shouldReloadRemoteSchemas: boolean;
shouldReloadAllSources: boolean;
}) => {
return new Promise<{ success: boolean }>((resolve, reject) => {
try {
dispatch(
reloadMetadata(
shouldReloadRemoteSchemas,
shouldReloadAllSources,
() => {
resolve({ success: true });
},
() => {
resolve({ success: false });
}
)
);
} catch (err) {
reject(err);
}
});
},
[dispatch]
);
return { reloadMetadata: reload };
};