console: Gt 652 email and slack alerts for schema registry

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9764
GitOrigin-RevId: 4b42eb2066c5903b114ddcee3dadaa7ca7afc244
This commit is contained in:
nevermore 2023-07-20 13:51:15 +05:30 committed by hasura-bot
parent 7120ae7a44
commit 7a3b3aa59b
22 changed files with 1327 additions and 150 deletions

View File

@ -88,6 +88,8 @@ type CloudServerEnv = {
userRole: CloudUserRole; userRole: CloudUserRole;
neonOAuthClientId?: string; neonOAuthClientId?: string;
neonRootDomain?: string; neonRootDomain?: string;
slackOAuthClientId?: string;
slackRootDomain?: string;
allowedLuxFeatures?: LuxFeature[]; allowedLuxFeatures?: LuxFeature[];
userId?: string; userId?: string;
consoleSentryDsn?: string; // Corresponds to the HASURA_CONSOLE_SENTRY_DSN environment variable consoleSentryDsn?: string; // Corresponds to the HASURA_CONSOLE_SENTRY_DSN environment variable
@ -160,6 +162,8 @@ export type EnvVars = {
userRole?: string; userRole?: string;
neonOAuthClientId?: string; neonOAuthClientId?: string;
neonRootDomain?: string; neonRootDomain?: string;
slackOAuthClientId?: string;
slackRootDomain?: string;
allowedLuxFeatures?: LuxFeature[]; allowedLuxFeatures?: LuxFeature[];
userId?: string; userId?: string;
userEmail?: string; userEmail?: string;
@ -216,6 +220,8 @@ const globals = {
hasuraCloudProjectName: window.__env?.projectName, hasuraCloudProjectName: window.__env?.projectName,
neonOAuthClientId: window.__env?.neonOAuthClientId, neonOAuthClientId: window.__env?.neonOAuthClientId,
neonRootDomain: window.__env?.neonRootDomain, neonRootDomain: window.__env?.neonRootDomain,
slackOAuthClientId: window.__env?.slackOAuthClientId,
slackRootDomain: window.__env?.slackRootDomain,
allowedLuxFeatures: window.__env?.allowedLuxFeatures || [], allowedLuxFeatures: window.__env?.allowedLuxFeatures || [],
luxDataHost: window.__env?.luxDataHost luxDataHost: window.__env?.luxDataHost
? stripTrailingSlash(window.__env.luxDataHost) ? stripTrailingSlash(window.__env.luxDataHost)

View File

@ -1,109 +1,37 @@
import React from 'react'; import React from 'react';
import { Tabs } from '../../../new-components/Tabs';
import { EmailAlerts } from './EmailAlerts';
import { SlackAlerts } from './SlackAlerts';
import { Dialog } from '../../../new-components/Dialog'; import { Dialog } from '../../../new-components/Dialog';
import { Checkbox } from '../../../new-components/Form';
import { FaClock, FaBan, FaBell } from 'react-icons/fa';
import { useGetAlertConfig } from '../hooks/useGetAlertConfig';
import { CapitalizeFirstLetter } from '../utils';
import globals from '../../../Globals';
import { AlertHeader } from './AlertsHeader';
import { CustomDialogFooter } from './CustomDialogFooter';
import { ConfigKey } from '../types';
type DialogProps = { type DialogProps = {
onClose: () => void; onClose: () => void;
}; };
const configKeys: ConfigKey[] = ['breaking', 'dangerous', 'safe'];
const defaultAlertConfig: Record<string, boolean> = {
safe: true,
breaking: true,
dangerous: true,
};
export const AlertsDialog: React.FC<DialogProps> = ({ onClose }) => { export const AlertsDialog: React.FC<DialogProps> = ({ onClose }) => {
const projectID = globals.hasuraCloudProjectId || ''; const [tabState, setTabState] = React.useState('email');
const fetchAlertConfigResponse = useGetAlertConfig(projectID);
const [config, setConfig] =
React.useState<Record<ConfigKey, boolean>>(defaultAlertConfig);
const { kind } = fetchAlertConfigResponse;
const alertConfig =
kind === 'success' &&
fetchAlertConfigResponse.response.schema_registry_alerts.length
? fetchAlertConfigResponse.response.schema_registry_alerts[0].config
: defaultAlertConfig;
// initialise checkboxes
React.useEffect(() => {
if (kind === 'success') {
setConfig(alertConfig);
}
}, [alertConfig]);
return ( return (
<Dialog <Dialog hasBackdrop onClose={tabState === 'slack' ? onClose : undefined}>
hasBackdrop <div className="h-full ml-4">
onClose={kind === 'error' ? onClose : undefined} <Tabs
footer={ value={tabState}
kind === 'success' ? ( onValueChange={state => setTabState(state)}
<CustomDialogFooter onClose={onClose} alertConfig={config} /> headerTabBackgroundColor="white"
) : undefined items={[
} {
> value: 'email',
{kind === 'loading' ? ( label: 'Email',
<div className="flex items-top p-md"> content: <EmailAlerts onClose={onClose} />,
<AlertHeader },
icon={<FaClock className="w-9 h-9 mt-sm mr-md fill-current" />} {
title="Loading..." value: 'slack',
/> label: 'Slack',
</div> content: <SlackAlerts onClose={onClose} />,
) : kind === 'error' ? ( },
<div className="flex items-top p-md"> ]}
<AlertHeader />
icon={<FaBan className="w-9 h-9 mt-sm mr-md fill-red-500" />} </div>
title="Error"
description={fetchAlertConfigResponse.message}
/>
</div>
) : (
<>
<div className="flex items-top p-md">
<div className="text-yellow-500">
<FaBell className="w-9 h-9 mt-sm mr-md fill-current" />
</div>
<div>
<p className="font-semibold">Email Alerts</p>
<div className="overflow-y-auto max-h-[calc(100vh-14rem)]">
<p className="m-0">
Select the change categories for which an email should be
sent!
</p>
</div>
</div>
</div>
<div className="flex flex-col ml-8">
{configKeys.map(c => {
return (
<div
className="flex items-center mb-xs cursor-pointer w-auto"
role="checkbox"
onClick={() => {
setConfig(prevConfig => ({
...prevConfig,
[c]: !prevConfig[c],
}));
}}
>
<Checkbox checked={config[c]} />
<p>{CapitalizeFirstLetter(c)}&nbsp;changes</p>
</div>
);
})}
</div>
</>
)}
</Dialog> </Dialog>
); );
}; };

View File

@ -12,16 +12,16 @@ export const AlertHeader: React.FC<AlertHeaderProps> = ({
description, description,
}) => { }) => {
return ( return (
<> <div className="flex items-top p-md">
<div className="text-yellow-500">{icon}</div> <div className="text-yellow-500">{icon}</div>
<div> <div>
<p className="font-semibold">{title}</p> <p className="text-lg font-semibold">{title}</p>
{description && ( {description && (
<div className="overflow-y-auto max-h-[calc(100vh-14rem)]"> <div className="overflow-y-auto max-h-[calc(100vh-14rem)]">
<p className="m-0">{description}</p> <p className="m-0">{description}</p>
</div> </div>
)} )}
</div> </div>
</> </div>
); );
}; };

View File

@ -1,26 +1,18 @@
import React from 'react'; import React from 'react';
import globals from '../../../Globals';
import { Button } from '../../../new-components/Button'; import { Button } from '../../../new-components/Button';
import { useSetEmailAlertConfig } from '../hooks/useSetAlertConfig';
import { ConfigKey } from '../types';
import { Analytics } from '../../Analytics'; import { Analytics } from '../../Analytics';
type CustomDialogFooterProps = { type CustomDialogFooterProps = {
onSet: () => void;
onClose: () => void; onClose: () => void;
alertConfig: Record<ConfigKey, boolean>; isLoading: boolean;
}; };
export const CustomDialogFooter: React.FC<CustomDialogFooterProps> = ({ export const CustomDialogFooter: React.FC<CustomDialogFooterProps> = ({
onSet,
onClose, onClose,
alertConfig, isLoading,
}) => { }) => {
const projectID = globals.hasuraCloudProjectId || '';
const { setEmailAlertMutation } = useSetEmailAlertConfig(onClose);
const onSet = React.useCallback(() => {
setEmailAlertMutation.mutate({ projectId: projectID, config: alertConfig });
}, [alertConfig]);
return ( return (
<div className="flex justify-between border-t border-gray-300 bg-white p-sm"> <div className="flex justify-between border-t border-gray-300 bg-white p-sm">
<div> <div>
@ -38,8 +30,8 @@ export const CustomDialogFooter: React.FC<CustomDialogFooterProps> = ({
e.preventDefault(); e.preventDefault();
onSet(); onSet();
}} }}
isLoading={setEmailAlertMutation.isLoading} isLoading={isLoading}
disabled={setEmailAlertMutation.isLoading} disabled={isLoading}
> >
Set Set
</Button> </Button>

View File

@ -0,0 +1,101 @@
import React from 'react';
import { Checkbox } from '../../../new-components/Form';
import { FaClock, FaBan, FaBell } from 'react-icons/fa';
import { useGetAlertConfig } from '../hooks/useGetAlertConfig';
import { CapitalizeFirstLetter } from '../utils';
import globals from '../../../Globals';
import { AlertHeader } from './AlertsHeader';
import { useSetEmailAlertConfig } from '../hooks/useSetAlertConfig';
import { CustomDialogFooter } from './CustomDialogFooter';
import { ConfigKey } from '../types';
type EmailAlertsProps = {
onClose: () => void;
};
const configKeys: ConfigKey[] = ['breaking', 'dangerous', 'safe'];
const defaultAlertConfig: Record<string, boolean> = {
safe: false,
breaking: false,
dangerous: false,
};
export const EmailAlerts: React.FC<EmailAlertsProps> = ({ onClose }) => {
const projectID = globals.hasuraCloudProjectId || '';
const fetchAlertConfigResponse = useGetAlertConfig(projectID, 'mail');
const [config, setConfig] =
React.useState<Record<ConfigKey, boolean>>(defaultAlertConfig);
const { kind } = fetchAlertConfigResponse;
const alertConfig =
kind === 'success' &&
fetchAlertConfigResponse.response.alert_config_service.length
? fetchAlertConfigResponse.response.alert_config_service[0].rules
: defaultAlertConfig;
const { setEmailAlertMutation } = useSetEmailAlertConfig(onClose);
const onSet = React.useCallback(() => {
setEmailAlertMutation.mutate({ projectId: projectID, rules: config });
}, [config]);
// initialise checkboxes
React.useEffect(() => {
if (kind === 'success') {
setConfig(alertConfig);
}
}, [alertConfig]);
return (
<div className="ml-[-14px]">
{kind === 'loading' ? (
<AlertHeader
icon={<FaClock className="w-9 h-9 mt-sm mr-md fill-current" />}
title="Loading..."
/>
) : kind === 'error' ? (
<AlertHeader
icon={<FaBan className="w-9 h-9 mt-sm mr-md fill-red-500" />}
title="Error"
description={fetchAlertConfigResponse.message}
/>
) : (
<>
<AlertHeader
icon={<FaBell className="w-9 h-9 mt-sm mr-md fill-current" />}
title="Email Alerts"
description="Select the change categories for which an email should be
sent!"
/>
<div className="flex flex-col ml-8">
{configKeys.map(c => {
return (
<div
className="flex items-center mb-xs cursor-pointer w-auto"
role="checkbox"
onClick={() => {
setConfig(prevConfig => ({
...prevConfig,
[c]: !prevConfig[c],
}));
}}
>
<Checkbox checked={config[c]} />
<p>{CapitalizeFirstLetter(c)}&nbsp;changes</p>
</div>
);
})}
</div>
</>
)}
<CustomDialogFooter
onSet={onSet}
onClose={onClose}
isLoading={setEmailAlertMutation.isLoading}
/>
</div>
);
};

View File

@ -0,0 +1,252 @@
import React from 'react';
import { Button } from './../../../new-components/Button';
import { AlertHeader } from './AlertsHeader';
import { FaBell, FaTimes } from 'react-icons/fa';
import Tooltip from '../../../components/Common/Tooltip/Tooltip';
import {
useSlackIntegration,
SlackOauthStatus,
} from '../hooks/useSlackIntegration';
import globals from '../../../Globals';
import { useGetSlackState } from '../hooks/useGetSlackState';
import { SlackButtonSVG } from './SlackButtonSvg';
import { FaExclamationTriangle, FaCheck, FaTrash } from 'react-icons/fa';
import { SlackDeleteConfirmationDialog } from './SlackDeleteConfirmationDialog';
import { Analytics } from '../../Analytics';
type TileProp = {
statusIcon: React.ReactNode;
actionName: string;
actionIcon: React.ReactNode;
message: React.ReactNode;
};
const getSlackIntegrationStatusTileProps = (
slackStatus: SlackOauthStatus,
onAction: () => void
): TileProp | null => {
switch (slackStatus.status) {
case 'error': {
return {
statusIcon: (
<FaExclamationTriangle className="fill-current h-4 w-4 mr-8 shrink-0 text-gray-500" />
),
actionName: 'dismiss-slack-integration-error',
actionIcon: (
<Tooltip message="Dismiss">
<FaTimes
className="fill-current cursor-pointer text-muted hover:text-gray-800 ml-2"
onClick={onAction}
/>
</Tooltip>
),
message: <p>{slackStatus.error.message}</p>,
};
}
case 'authenticated': {
return {
statusIcon: (
<FaCheck className="fill-current mt-[3px] h-4 w-4 mr-8 shrink-0 text-emerald-500" />
),
actionName: 'delete-slack-integration',
actionIcon: (
<Tooltip message="Remove Slack Integration">
<FaTrash
className="fill-current cursor-pointer text-muted hover:text-gray-800 ml-2"
onClick={onAction}
/>
</Tooltip>
),
message: (
<div>
Slack Integration for alerts on this project has been configured for
the channel <b>{slackStatus.channelName}</b> for{' '}
<b>{slackStatus.teamName}</b> workspace.
</div>
),
};
}
default: {
return null;
}
}
};
type SlackIntegrationStatusTileProps = {
status: SlackOauthStatus;
onAction: () => void;
};
export const SlackIntegrationStatusTile: React.FC<
SlackIntegrationStatusTileProps
> = props => {
const { status, onAction } = props;
const slackIntegrationTileProps = getSlackIntegrationStatusTileProps(
status,
onAction
);
if (!slackIntegrationTileProps) return null;
const { statusIcon, actionName, actionIcon, message } =
slackIntegrationTileProps;
return (
<div className="flex justify-between border rounded mx-6 p-2 mb-4 text-gray-500">
<div className="flex justify-start items-center">
{statusIcon}
{message}
</div>
<Analytics name={`settings-schema-registry-slack-alerts-${actionName}`}>
<div className="flex">{actionIcon}</div>
</Analytics>
</div>
);
};
type SlackIntegrationStateProps = {
slackIntegrationStatus: SlackOauthStatus;
onIntegrationSlack: () => void;
onDelete: () => void;
onDismiss: () => void;
};
export const SlackIntegrationState = (props: SlackIntegrationStateProps) => {
const { slackIntegrationStatus, onIntegrationSlack, onDelete, onDismiss } =
props;
const isButtonDisabled = slackIntegrationStatus.status === 'authenticating';
switch (slackIntegrationStatus.status) {
case 'idle':
case 'authenticating':
return (
<div className="flex justify-center mb-4">
<Analytics name="settings-schema-registry-add-to-slack-btn">
<Button
onClick={onIntegrationSlack}
mode="default"
data-testid="onboarding-wizard-neon-connect-db-button"
isLoading={isButtonDisabled}
disabled={isButtonDisabled}
>
<div className="flex justify-center">
<SlackButtonSVG />
<p className="ml-2">Add to Slack</p>
</div>
</Button>
</Analytics>
</div>
);
case 'authenticated': {
return (
<SlackIntegrationStatusTile
status={slackIntegrationStatus}
onAction={onDelete}
/>
);
}
case 'error':
return (
<SlackIntegrationStatusTile
status={slackIntegrationStatus}
onAction={onDismiss}
/>
);
}
};
type SlackAlertsProps = {
onClose: () => void;
};
export const SlackAlerts: React.FC<SlackAlertsProps> = ({ onClose }) => {
const projectID = globals.hasuraCloudProjectId || '';
const slackState = useGetSlackState(projectID);
const doesSlackIntegrationExist =
slackState.kind === 'success' && slackState.response.slack_config.length;
const slackChannel = doesSlackIntegrationExist
? slackState.response.slack_config[0].channel_name
: '';
const slackTeam = doesSlackIntegrationExist
? slackState.response.slack_config[0].team_name
: '';
const [
isSlackDeleteConfirmationModalOpen,
setIsSlackDeleteConfirmationModalOpen,
] = React.useState(false);
const onCloseDeleteConfirmation = () => {
setIsSlackDeleteConfirmationModalOpen(false);
};
const { slackOauthStatus, startSlackOAuth, deleteSlackApp } =
useSlackIntegration(onCloseDeleteConfirmation);
const [slackIntegrationStatus, setSlackIntegrationStatus] =
React.useState<SlackOauthStatus>(slackOauthStatus);
const onIntegrateSlack = () => {
startSlackOAuth();
};
const onDelete = () => {
setIsSlackDeleteConfirmationModalOpen(true);
};
const onDeleteSlack = React.useCallback(() => {
deleteSlackApp.mutate({ projectID: projectID });
}, [projectID, deleteSlackApp]);
const onDismiss = React.useCallback(() => {
if (slackIntegrationStatus.status === 'error') {
setSlackIntegrationStatus({ status: 'idle' });
}
}, [slackIntegrationStatus]);
React.useEffect(() => {
setSlackIntegrationStatus(slackOauthStatus);
if (
!doesSlackIntegrationExist &&
slackOauthStatus.status === 'authenticated'
)
setSlackIntegrationStatus({ status: 'idle' });
}, [slackOauthStatus, doesSlackIntegrationExist]);
return (
<div className="ml-[-14px]">
<AlertHeader
icon={<FaBell className="w-9 h-9 mr-md mt-xs fill-current" />}
title="Slack Alerts"
description="Integrate with a Slack channel to enable alerts."
/>
{doesSlackIntegrationExist ? (
<SlackIntegrationStatusTile
status={{
status: 'authenticated',
channelName: slackChannel,
teamName: slackTeam,
}}
onAction={onDelete}
/>
) : (
<SlackIntegrationState
slackIntegrationStatus={slackIntegrationStatus}
onIntegrationSlack={onIntegrateSlack}
onDelete={onDelete}
onDismiss={onDismiss}
/>
)}
{isSlackDeleteConfirmationModalOpen && (
<SlackDeleteConfirmationDialog
onClose={() => setIsSlackDeleteConfirmationModalOpen(false)}
onSubmit={onDeleteSlack}
/>
)}
</div>
);
};

View File

@ -0,0 +1,30 @@
import React from 'react';
export const SlackButtonSVG: React.FC = () => {
return (
<svg
height="16"
width="16"
xmlns="http://www.w3.org/2000/svg"
className="height:16px;width:16px;margin-right:12px"
viewBox="0 0 122.8 122.8"
>
<path
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9zm6.5 0c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
fill="#e01e5a"
></path>
<path
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2zm0 6.5c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
fill="#36c5f0"
></path>
<path
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2zm-6.5 0c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
fill="#2eb67d"
></path>
<path
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9zm0-6.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
fill="#ecb22e"
></path>
</svg>
);
};

View File

@ -0,0 +1,29 @@
import React from 'react';
import { Dialog } from '../../../new-components/Dialog';
type SlackDeleteConfirmationDialogProps = {
onClose: () => void;
onSubmit: () => void;
};
export const SlackDeleteConfirmationDialog: React.FC<
SlackDeleteConfirmationDialogProps
> = ({ onClose, onSubmit }) => {
return (
<Dialog hasBackdrop>
<>
<p className="font-bold text-lg ml-4 my-4">
Are you sure you want to disable Slack alerts?
</p>
<Dialog.Footer
callToDeny="Cancel"
callToAction="Yes"
onClose={onClose}
onSubmit={onSubmit}
onSubmitAnalyticsName="delete-schema-registry-slack-alerts-submit"
onCancelAnalyticsName="delete-schema-registry-slack-alerts-cancel"
/>
</>
</Dialog>
);
};

View File

@ -0,0 +1,17 @@
import * as React from 'react';
import { persistSlackCallbackSearch } from '../utils';
/*
* This component is only used for local development
* It's used for listening to Slack's OAuth callback (/slack-integration/callback) in localdev.
* In production, Slack's OAuth callback is handled by cloud dashboard
*/
export function SlackCallbackHandler() {
React.useEffect(() => {
// set the value for slack callback search in local storage
persistSlackCallbackSearch(window.location.search);
window.close();
}, []);
return <>Please wait...</>;
}

View File

@ -3,6 +3,7 @@ export const FETCH_REGISTRY_SCHEMAS_QUERY_NAME =
export const FETCH_REGISTRY_SCHEMA_QUERY_NAME = export const FETCH_REGISTRY_SCHEMA_QUERY_NAME =
'FETCH_REGISTRY_SCHEMA_QUERY_NAME'; 'FETCH_REGISTRY_SCHEMA_QUERY_NAME';
export const FETCH_ALERT_CONFIG_QUERY_NAME = 'FETCH_ALERT_CONFIG_QUERY_NAME'; export const FETCH_ALERT_CONFIG_QUERY_NAME = 'FETCH_ALERT_CONFIG_QUERY_NAME';
export const FETCH_SLACK_STATE_QUERY_NAME = 'FETCH_SLACK_STATE_QUERY_NAME';
// 5 minutes as default stale time // 5 minutes as default stale time
export const SCHEMA_REGISTRY_REFRESH_TIME = 5 * 60 * 1000; export const SCHEMA_REGISTRY_REFRESH_TIME = 5 * 60 * 1000;

View File

@ -0,0 +1,61 @@
import * as React from 'react';
import { useMutation, useQueryClient } from 'react-query';
import { DELETE_SLACK_APP } from '../queries';
import { DeleteSlackAppMutationResponseWithError } from '../types';
import { FETCH_SLACK_STATE_QUERY_NAME } from '../constants';
import { hasuraToast } from '../../../new-components/Toasts';
import { controlPlaneClient } from '../../ControlPlane';
type DeleteSlackAppMutationFnArgs = {
projectID: string;
};
export const useDeleteSlackApp = (onClose: () => void) => {
const deleteSlackAppMutationFn = (variables: { projectID: string }) => {
return controlPlaneClient.query<
DeleteSlackAppMutationResponseWithError,
{ projectID: string }
>(DELETE_SLACK_APP, variables);
};
const queryClient = useQueryClient();
const deleteSlackApp = useMutation(
(args: DeleteSlackAppMutationFnArgs) => deleteSlackAppMutationFn(args),
{
onSuccess: response => {
if (response.errors && response.errors.length > 0) {
hasuraToast({
type: 'error',
title: 'Error!',
message:
'Something unexpected happened while deleting Slack Alerts!',
});
} else {
hasuraToast({
type: 'success',
title: 'Success!',
message: 'Slack Integration deleted successfully',
});
if (response?.data) {
queryClient.invalidateQueries(FETCH_SLACK_STATE_QUERY_NAME);
onClose();
}
}
},
onError: () => {
hasuraToast({
type: 'error',
title: 'Error!',
message:
'Something went wrong while deleting the tag for Schema Registry',
});
},
}
);
return {
deleteSlackApp,
};
};

View File

@ -1,9 +1,9 @@
import * as React from 'react'; import * as React from 'react';
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
import { FETCH_ALERT_CONFIG } from '../queries'; import { FETCH_ALERT_CONFIG } from '../queries';
import { schemaRegsitryControlPlaneClient } from '../utils'; import { GetAlertConfigResponseWithError, AlertType } from '../types';
import { GetAlertConfigResponseWithError } from '../types';
import { FETCH_ALERT_CONFIG_QUERY_NAME } from '../constants'; import { FETCH_ALERT_CONFIG_QUERY_NAME } from '../constants';
import { controlPlaneClient } from '../../ControlPlane';
type FetchAlertResponse = type FetchAlertResponse =
| { | {
@ -18,13 +18,17 @@ type FetchAlertResponse =
response: NonNullable<GetAlertConfigResponseWithError['data']>; response: NonNullable<GetAlertConfigResponseWithError['data']>;
}; };
export const useGetAlertConfig = (projectId: string): FetchAlertResponse => { export const useGetAlertConfig = (
projectId: string,
type: AlertType
): FetchAlertResponse => {
const fetchAlertConfigQueryFn = (projectId: string) => { const fetchAlertConfigQueryFn = (projectId: string) => {
return schemaRegsitryControlPlaneClient.query< return controlPlaneClient.query<
GetAlertConfigResponseWithError, GetAlertConfigResponseWithError,
{ projectId: string } { projectId: string; type: AlertType }
>(FETCH_ALERT_CONFIG, { >(FETCH_ALERT_CONFIG, {
projectId: projectId, projectId: projectId,
type: type,
}); });
}; };

View File

@ -0,0 +1,56 @@
import * as React from 'react';
import { useQuery } from 'react-query';
import { FETCH_SLACK_STATE } from '../queries';
import { GetSlackStateResponseWithError } from '../types';
import { FETCH_SLACK_STATE_QUERY_NAME } from '../constants';
import { controlPlaneClient } from '../../ControlPlane';
type FetchSlackStateResponse =
| {
kind: 'loading';
}
| {
kind: 'error';
message: string;
}
| {
kind: 'success';
response: NonNullable<GetSlackStateResponseWithError['data']>;
};
export const useGetSlackState = (
projectId: string
): FetchSlackStateResponse => {
const fetchSlackStateQueryFn = (projectId: string) => {
return controlPlaneClient.query<
GetSlackStateResponseWithError,
{ projectId: string }
>(FETCH_SLACK_STATE, {
projectId: projectId,
});
};
const { data, error, isLoading } = useQuery({
queryKey: FETCH_SLACK_STATE_QUERY_NAME,
queryFn: () => fetchSlackStateQueryFn(projectId),
refetchOnMount: true,
});
if (isLoading) {
return {
kind: 'loading',
};
}
if (error || !data || !!data.errors || !data.data) {
return {
kind: 'error',
message: 'error',
};
}
return {
kind: 'success',
response: data.data,
};
};

View File

@ -1,25 +1,25 @@
import * as React from 'react'; import * as React from 'react';
import { useMutation } from 'react-query'; import { useMutation } from 'react-query';
import { SET_ALERT_CONFIG } from '../queries'; import { SET_ALERT_CONFIG } from '../queries';
import { schemaRegsitryControlPlaneClient } from '../utils';
import { SetAlertConfigResponseWithError } from '../types'; import { SetAlertConfigResponseWithError } from '../types';
import { FETCH_ALERT_CONFIG_QUERY_NAME } from '../constants'; import { FETCH_ALERT_CONFIG_QUERY_NAME } from '../constants';
import { reactQueryClient } from '../../../lib/reactQuery'; import { reactQueryClient } from '../../../lib/reactQuery';
import { hasuraToast } from '../../../new-components/Toasts'; import { hasuraToast } from '../../../new-components/Toasts';
import { controlPlaneClient } from '../../ControlPlane';
type setEmailAlertMutationFnArgs = { type setEmailAlertMutationFnArgs = {
projectId: string; projectId: string;
config: Record<string, boolean>; rules: Record<string, boolean>;
}; };
export const useSetEmailAlertConfig = (onSuccess: VoidFunction) => { export const useSetEmailAlertConfig = (onSuccess: VoidFunction) => {
const setSchemaRegistryEmailAlertMutationFn = (variables: { const setSchemaRegistryEmailAlertMutationFn = (variables: {
projectId: string; projectId: string;
config: Record<string, boolean>; rules: Record<string, boolean>;
}) => { }) => {
return schemaRegsitryControlPlaneClient.query< return controlPlaneClient.query<
SetAlertConfigResponseWithError, SetAlertConfigResponseWithError,
{ projectId: string; config: Record<string, boolean> } { projectId: string; rules: Record<string, boolean> }
>(SET_ALERT_CONFIG, variables); >(SET_ALERT_CONFIG, variables);
}; };
@ -27,14 +27,22 @@ export const useSetEmailAlertConfig = (onSuccess: VoidFunction) => {
(args: setEmailAlertMutationFnArgs) => (args: setEmailAlertMutationFnArgs) =>
setSchemaRegistryEmailAlertMutationFn(args), setSchemaRegistryEmailAlertMutationFn(args),
{ {
onSuccess: () => { onSuccess: data => {
hasuraToast({ if (data.errors && data.errors.length > 0) {
type: 'success', hasuraToast({
title: 'Success!', type: 'error',
message: 'Email configuration set successfully!', title: 'Error!',
}); message: 'Something unexpected happened!',
});
} else {
hasuraToast({
type: 'success',
title: 'Success!',
message: 'Email configuration set successfully!',
});
onSuccess(); if (data.data) onSuccess();
}
}, },
onError: () => { onError: () => {
hasuraToast({ hasuraToast({

View File

@ -0,0 +1,15 @@
import * as React from 'react';
import { useDeleteSlackApp } from './useDeleteSlackApp';
import { useSlackOAuth } from './useSlackOAuth';
export { SlackOauthStatus } from './useSlackOAuth';
export const useSlackIntegration = (
onDeleteClose: () => void,
oauthString?: string
) => {
const { slackOauthStatus, startSlackOAuth } = useSlackOAuth(oauthString);
const { deleteSlackApp } = useDeleteSlackApp(onDeleteClose);
return { slackOauthStatus, startSlackOAuth, deleteSlackApp };
};

View File

@ -0,0 +1,316 @@
import * as React from 'react';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { renderHook, act } from '@testing-library/react-hooks';
import { QueryClient, QueryClientProvider } from 'react-query';
import type { GraphQLError } from 'graphql';
import { ExchangeTokenResponse, useSlackOAuth } from './useSlackOAuth';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import 'whatwg-fetch';
import { SLACK_CALLBACK_SEARCH } from '../utils';
function TestComponent() {
const { startSlackOAuth, slackOauthStatus, oauth2State } = useSlackOAuth();
return (
<div>
<p data-testid="status">{slackOauthStatus.status}</p>
<p data-testid="error">
{slackOauthStatus.status === 'error'
? slackOauthStatus.error.message
: ''}
</p>
<p data-testid="channelName">
{slackOauthStatus.status === 'authenticated'
? slackOauthStatus.channelName
: ''}
</p>
<p data-testid="oauth2-state">{oauth2State}</p>
<button data-testid="start" onClick={startSlackOAuth}>
start
</button>
</div>
);
}
// --------------------------------------------------
// LOCALSTORAGE MOCK
// --------------------------------------------------
const getMockLocalStorage = () => {
let store: Record<string, string> = {};
return {
getItem(key: string): string | undefined {
return store[key];
},
setItem(key: string, value: string) {
store[key] = value.toString();
},
clear() {
store = {};
},
removeItem(key: string) {
delete store[key];
},
};
};
const mockLocalStorage = getMockLocalStorage();
Object.defineProperty(window, 'localStorage', { value: mockLocalStorage });
// --------------------------------------------------
// POPUP MOCK
// --------------------------------------------------
const getMockPopupImpl = () => {
const popup = {
closed: false,
};
const openPopup = () => {
popup.closed = false;
return popup;
};
const closePopup = () => {
popup.closed = true;
return popup;
};
return {
openPopup,
closePopup,
};
};
const mockPopupImpl = getMockPopupImpl();
Object.defineProperty(window, 'open', { value: mockPopupImpl.openPopup });
// --------------------------------------------------
// NETWORK MOCK
// --------------------------------------------------
const server = setupServer();
server.events.on('response:mocked', () => {
//
jest.advanceTimersByTime(10);
jest.runAllTicks();
});
const mockHTTPResponse = (status = 200, returnBody: any) => {
server.use(
rest.all('*', (req, res, context) => {
return res(context.json(returnBody), context.status(status));
})
);
};
type Props = {
children?: React.ReactNode;
};
const queryClient = new QueryClient();
const wrapper = ({ children }: Props) => {
// const reactQueryClient = useQueryClient();
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
// --------------------------------------------------
// TEST UTILS
// --------------------------------------------------
// use fake timers because the hook uses setTimeout and setInterval
function closeFakePopup() {
act(() => {
mockPopupImpl.closePopup();
jest.advanceTimersByTime(4000);
jest.runAllTicks();
});
}
// --------------------------------------------------
// TESTS
// --------------------------------------------------
describe('Slack', () => {
// reset test state after each test
beforeAll(() => {
server.listen();
});
afterAll(() => {
server.close();
jest.useRealTimers();
});
beforeEach(() => {
cleanup();
mockLocalStorage.clear();
mockPopupImpl.closePopup();
jest.useFakeTimers();
jest.clearAllTimers();
jest.clearAllMocks();
server.resetHandlers();
});
it('Happy path', async () => {
// Arrange
const { waitForValueToChange, result } = renderHook(() => useSlackOAuth(), {
wrapper,
});
expect(result.current.slackOauthStatus.status).toEqual('idle');
// Act
act(() => {
result.current.startSlackOAuth();
});
// Wait for the loading state to be triggered
expect(result.current.slackOauthStatus.status).toEqual('authenticating');
// --------------------------------------------------
// CONTROLLING TEST MOCKS
// set search params in local storage and close popup
// mock success exchange of token
const response: ExchangeTokenResponse = {
data: {
slackExchangeOAuthToken: {
channel_name: '#test-channel',
team_name: 'test-team',
},
},
};
mockHTTPResponse(200, response);
const oauth2State = result.current.oauth2State;
mockLocalStorage.setItem(
SLACK_CALLBACK_SEARCH,
`code=test_code&state=${oauth2State}`
);
closeFakePopup();
jest.runAllTicks();
// --------------------------------------------------
// --------------------------------------------------
// ALl good until here
// Assert
await waitForValueToChange(() => result.current.slackOauthStatus);
expect(result.current.slackOauthStatus.status).toEqual('authenticated');
// @ts-expect-error we know better than typescript here
expect(result.current.slackOauthStatus?.channelName).toEqual(
response.data?.slackExchangeOAuthToken.channel_name
);
});
it('Renders idle state correctly', () => {
// Arrange
const { result } = renderHook(() => useSlackOAuth(), { wrapper });
// Assert
expect(result.current.slackOauthStatus.status).toEqual('idle');
});
it('throws unexpected error when the popup is closed before the parameters are stored in localstorage', () => {
// Arrange
render(<TestComponent />, { wrapper });
// Act
fireEvent.click(screen.getByTestId('start'));
// --------------------------------------------------
// CONTROLLING TEST MOCKS
closeFakePopup();
// --------------------------------------------------
// Assert
expect(screen.getByTestId('error')).toHaveTextContent(
'Slack integration closed unexpectedly. Please try again.'
);
});
it('throws OAuth error when the OAuth code does not exist in search params in local storage', () => {
// Arrange
render(<TestComponent />, { wrapper });
// Act
fireEvent.click(screen.getByTestId('start'));
// --------------------------------------------------
// CONTROLLING TEST MOCKS
// set search params in localstorage and close popup
// code is not set in search params
// results in authentication error
mockLocalStorage.setItem(SLACK_CALLBACK_SEARCH, 'state=test_state');
closeFakePopup();
// --------------------------------------------------
// Assert
expect(screen.getByTestId('error')).toHaveTextContent(
'Error authenticating with Slack. Please try again.'
);
});
it('Throw forgery error when the OAuth state mismatch', () => {
// Arrange
render(<TestComponent />, { wrapper });
// Act
fireEvent.click(screen.getByTestId('start'));
// --------------------------------------------------
// CONTROLLING TEST MOCKS
// set search params localstorage and close popup
// results in state mismatch error
mockLocalStorage.setItem(
SLACK_CALLBACK_SEARCH,
'code=test_code&state=test_state'
);
closeFakePopup();
// --------------------------------------------------
// Assert
expect(screen.getByTestId('error')).toHaveTextContent(
'Invalid OAuth session state. Please try again.'
);
});
it('Renders oauth error when there is an error exchanging the token', async () => {
// Arrange
const { waitForValueToChange, result } = renderHook(() => useSlackOAuth(), {
wrapper,
});
// Act
act(() => {
result.current.startSlackOAuth();
});
// --------------------------------------------------
// CONTROLLING TEST MOCKS
// set the right in local storage along with a code
const oauth2State = result.current.oauth2State;
mockLocalStorage.setItem(
SLACK_CALLBACK_SEARCH,
`code=test_code&state=${oauth2State}`
);
const response: { errors: GraphQLError[] } = {
// @ts-expect-error The error format seems not to match the declared type returned by
// controlPlaneDataApiClient, we should fix it
errors: [{ message: 'Something went wrong while integrating Slack.' }],
};
// mock HTTP response for exchanging token to return an exchange error
mockHTTPResponse(200, response);
closeFakePopup();
// --------------------------------------------------
// Assert
await waitForValueToChange(() => result.current.slackOauthStatus);
console.log(result.current.slackOauthStatus);
expect(result.current.slackOauthStatus.status).toEqual('error');
// @ts-expect-error we know better than typescript here
expect(result.current.slackOauthStatus?.error?.message).toEqual(
'Something went wrong while integrating Slack.'
);
});
});

View File

@ -0,0 +1,238 @@
import * as React from 'react';
import { GraphQLError } from 'graphql';
import { useMemo, useState, useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { FETCH_SLACK_STATE_QUERY_NAME } from '../constants';
import globals from '../../../Globals';
import { useIsUnmounted, generateRandomString } from '../utils';
import {
getPersistedSlackCallbackSearch,
clearPersistedSlackCallbackSearch,
} from '../utils';
import { controlPlaneClient } from '../../ControlPlane';
import { SLACK_TOKEN_EXCHANGE_QUERY } from '../queries';
export type ExchangeTokenResponse = {
data?: {
slackExchangeOAuthToken: {
channel_name: string;
team_name: string;
};
};
errors?: GraphQLError[];
};
export type SlackOauthStatus =
| {
status: 'idle';
}
| {
status: 'error';
error: Error;
}
| {
status: 'authenticating';
}
| {
status: 'authenticated';
channelName: string;
teamName: string;
};
export const useSlackOAuth = (oauthString?: string) => {
const isUnmounted = useIsUnmounted();
const queryClient = useQueryClient();
const [status, setStatus] = useState<SlackOauthStatus>({
status: 'idle',
});
const oauth2State = useMemo(
() => oauthString || generateRandomString(),
[oauthString]
);
/*
* Handle the redirect params to get the oauth session:
* */
const startFetchingControlPlaneData = useCallback(
async (callbackSearch: string) => {
const params = new URLSearchParams(callbackSearch);
// if grant code is absent, the redirect was unsuccessful
if (!params.get('code')) {
setStatus({
status: 'error',
error: new Error(
'Error authenticating with Slack. Please try again.'
),
});
return;
}
/* if the state in params does not match the local state,
* the request was probably tampered with
* */
if (params.get('state') !== oauth2State) {
setStatus({
status: 'error',
error: new Error('Invalid OAuth session state. Please try again.'),
});
return;
}
try {
const slackTokenExchangeResponse = await controlPlaneClient.query<
ExchangeTokenResponse,
{ code: string; projectId: string }
>(SLACK_TOKEN_EXCHANGE_QUERY, {
code: params.get('code') || '',
projectId: globals.hasuraCloudProjectId || '',
});
if (isUnmounted()) return;
if (typeof slackTokenExchangeResponse === 'string') {
setStatus({
status: 'error',
error: new Error(slackTokenExchangeResponse),
});
return;
}
if (
slackTokenExchangeResponse.errors &&
slackTokenExchangeResponse.errors.length > 0
) {
throw Error(`Something went wrong while integrating Slack.`);
}
if (slackTokenExchangeResponse?.data) {
queryClient.invalidateQueries(FETCH_SLACK_STATE_QUERY_NAME);
setStatus({
status: 'authenticated',
channelName:
slackTokenExchangeResponse.data.slackExchangeOAuthToken
.channel_name,
teamName:
slackTokenExchangeResponse.data.slackExchangeOAuthToken.team_name,
});
}
return;
} catch (error) {
console.error(error);
setStatus({
status: 'error',
error: error instanceof Error ? error : new Error('Unknown error'),
});
}
},
[oauth2State]
);
// function to start the oauth process
const startSlackOAuth = React.useCallback(() => {
setStatus({ status: 'authenticating' });
const searchParams = generateUrlSearchParams(
globals.slackOAuthClientId ?? '',
oauth2State
);
// open Slack auth page in a popup
// Sample URL should look like this: https://slack.com/oauth/v2/authorize?scope=incoming-webhook&client_id=33336676.569200954261
const popup = window.open(
`https://${
globals.slackRootDomain
}/oauth/v2/authorize?${searchParams.toString()}`,
'slack-oauth2',
'menubar=no,toolbar=no,location=no,width=800,height=600'
);
// Usually means that a popup blocker blocked the popup
if (!popup) {
setStatus({
status: 'error',
error: new Error(
'Could not open popup for logging in with Slack. Please disable your popup blocker and try again.'
),
});
return;
}
/*
* After OAuth success, slack redirects the user to
* /slack-integration/callback path of Hasura Cloud.
* The Callback component passes the search params of the redirect
* to the current context through localstorage.
* We read the the localstorage and clear the redirect params.
* */
const intervalId = setInterval(() => {
if (isUnmounted()) return;
if (!popup.closed) return;
clearInterval(intervalId);
/*
* Once the popup is closed,
* the redirect params are expected to be available in localstorage. Check ../components/TempCallback
* Here we read the params from localstorage and store them in local state.
* These params are handled in another effect.
* */
setTimeout(() => {
if (isUnmounted()) return;
const search = getPersistedSlackCallbackSearch();
if (!search) {
setStatus({
status: 'error',
error: new Error(
'Slack integration closed unexpectedly. Please try again.'
),
});
return;
}
startFetchingControlPlaneData(search);
clearPersistedSlackCallbackSearch();
}, 500);
}, 500);
}, [oauth2State, isUnmounted]);
return {
oauth2State,
startSlackOAuth,
slackOauthStatus: status,
};
};
/*
// Sample Slack pop-up window URL
https://slack.com/oauth?
client_id=254593026723.5374035727812&
scope=incoming-webhook&
user_scope=&
redirect_uri=&
state=&
granular_bot_scope=1&
single_channel=0&
install_redirect=&
tracked=1&
team=
*/
function generateUrlSearchParams(
slackOAuthClientId: string,
oauth2State: string
) {
const searchParams = new URLSearchParams();
searchParams.set('client_id', slackOAuthClientId);
searchParams.set('scope', 'incoming-webhook');
searchParams.set('user_scope', '');
searchParams.set('state', oauth2State);
return searchParams;
}

View File

@ -65,27 +65,22 @@ query fetchRegistrySchema ($schemaId: uuid!) {
`); `);
export const FETCH_ALERT_CONFIG = gql(` export const FETCH_ALERT_CONFIG = gql(`
query QueryAlerts($projectId: uuid!) { query fetchAlertConfig($projectId: uuid!, $type: alert_service_type_enum!) {
schema_registry_alerts(where: {project_id: {_eq: $projectId}}) { alert_config_service(where: {project_id: {_eq: $projectId}, type: {_eq: $type}}) {
config
alert_type
id
meta
project_id project_id
slack_webhook type
metadata
rules
} }
} }
`); `);
export const SET_ALERT_CONFIG = gql(` export const SET_ALERT_CONFIG = gql(`
mutation UpsertEmailAlertConfig($projectId: uuid, $config: jsonb) { mutation UpsertAlertConfig($projectId: uuid, $rules: jsonb) {
insert_schema_registry_alerts_one(object: {alert_type: email, config: $config, project_id: $projectId}, on_conflict: {constraint: schema_registry_alerts_project_id_alert_type_key, update_columns: config}) { insert_alert_config(objects: {alert_types: {data: {type: "SchemaRegistryUpdates"}, on_conflict: {constraint: alert_config_alert_type_pkey, update_columns: type}}, project_id: $projectId, enabled: true, alert_config_services: {data: {metadata: "", rules: $rules, type: mail}, on_conflict: {constraint: alert_config_service_pkey, update_columns: rules}}}, on_conflict: {constraint: alert_config_pkey, update_columns: enabled}) {
id affected_rows
project_id
alert_type
config
} }
} }
`); `);
export const ADD_SCHEMA_TAG = gql(` export const ADD_SCHEMA_TAG = gql(`
@ -105,3 +100,38 @@ mutation DeleteSchemaTag($ID: uuid!) {
} }
} }
`); `);
export const DELETE_SLACK_APP = gql(`
mutation DeleteSlackApp($projectID: uuid!) {
deleteSlackApp(args: {projectID: $projectID}) {
status
}
}
`);
export const FETCH_SLACK_STATE = gql(`
query FetchSlackState($projectId: uuid!) {
slack_config(where: {project_id: {_eq: $projectId}}) {
channel_name
webhook_url
project_id
channel_id
slack_team_id
team_name
}
}
`);
export const SLACK_TOKEN_EXCHANGE_QUERY = gql(`
mutation slackTokenExchange (
$code: String!
$projectId: uuid!
) {
slackExchangeOAuthToken (
code: $code
projectId: $projectId
) {
channel_name
}
}
`);

View File

@ -95,7 +95,19 @@ export type GetAlertConfigResponseWithError = {
}; };
export type GetAlertConfigQueryResponse = { export type GetAlertConfigQueryResponse = {
schema_registry_alerts: SchemaRegistryAlert[]; alert_config_service: AlertConfig[];
};
export type AlertConfig = {
project_id: string;
type: AlertType;
metadata: Record<string, any>;
rules: Record<string, boolean>;
};
export type SetAlertConfigResponseWithError = {
data?: SetAlertConfigQueryResponse;
errors?: GraphQLError[];
}; };
export type SchemaRegistryAlert = { export type SchemaRegistryAlert = {
@ -107,11 +119,6 @@ export type SchemaRegistryAlert = {
meta: Record<string, any>; meta: Record<string, any>;
}; };
export type SetAlertConfigResponseWithError = {
data?: SetAlertConfigQueryResponse;
errors?: GraphQLError[];
};
export type SetAlertConfigQueryResponse = { export type SetAlertConfigQueryResponse = {
schema_registry_alerts: SchemaRegistryAlert; schema_registry_alerts: SchemaRegistryAlert;
}; };
@ -161,3 +168,37 @@ export type DeleteSchemaRegistryTagMutationResponse = {
export type DeletedTagID = { export type DeletedTagID = {
id: string; id: string;
}; };
type MailAlertType = 'mail';
type SlackAlertType = 'slack';
export type AlertType = MailAlertType | SlackAlertType;
export type DeleteSlackAppMutationResponseWithError = {
data?: DeleteSlackAppMutationResponse;
errors?: GraphQLError[];
};
export type DeleteSlackAppMutationResponse = {
deleteSlackApp: {
status: string;
};
};
export type GetSlackStateResponseWithError = {
data?: GetSlackStateQueryResponse;
errors?: GraphQLError[];
};
export type GetSlackStateQueryResponse = {
slack_config: SlackConfig[];
};
export type SlackConfig = {
channel_name: string;
webhook_url: string;
project_id: string;
channel_id: string;
slack_team_id: string;
team_name: string;
};

View File

@ -9,6 +9,13 @@ import {
SiblingSchema, SiblingSchema,
GetRegistrySchemaResponseWithError, GetRegistrySchemaResponseWithError,
} from './types'; } from './types';
import {
getLSItem,
LS_KEYS,
removeLSItem,
setLSItem,
} from '../../utils/localStorage';
import { useCallback, useLayoutEffect, useRef } from 'react';
import moment from 'moment'; import moment from 'moment';
export const CapitalizeFirstLetter = (str: string) => { export const CapitalizeFirstLetter = (str: string) => {
@ -119,6 +126,45 @@ export const getPublishTime = (isoStringTs: string) => {
return published.format('DD/MM/YYYY HH:mm:ss'); return published.format('DD/MM/YYYY HH:mm:ss');
}; };
export const SLACK_CALLBACK_SEARCH = LS_KEYS.slackCallbackSearch;
export const persistSlackCallbackSearch = (value: string) => {
setLSItem(SLACK_CALLBACK_SEARCH, value);
};
export const getPersistedSlackCallbackSearch = () => {
return getLSItem(SLACK_CALLBACK_SEARCH);
};
export const clearPersistedSlackCallbackSearch = () => {
removeLSItem(SLACK_CALLBACK_SEARCH);
};
export function useIsUnmounted() {
const rIsUnmounted = useRef<'mounting' | 'mounted' | 'unmounted'>('mounting');
useLayoutEffect(() => {
rIsUnmounted.current = 'mounted';
return () => {
rIsUnmounted.current = 'unmounted';
};
}, []);
return useCallback(() => rIsUnmounted.current !== 'mounted', []);
}
export const generateRandomString = (stringLength = 16) => {
const allChars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let str = '';
for (let i = 0; i < stringLength; i++) {
const randomNum = Math.floor(Math.random() * allChars.length);
str += allChars.charAt(randomNum);
}
return str;
};
export const hexToRGB = (hex: string, alpha: number) => { export const hexToRGB = (hex: string, alpha: number) => {
const r = parseInt(hex.slice(1, 3), 16), const r = parseInt(hex.slice(1, 3), 16),
g = parseInt(hex.slice(3, 5), 16), g = parseInt(hex.slice(3, 5), 16),

View File

@ -29,6 +29,7 @@ import HelpPage from './components/Services/Support/HelpPage';
import FormRestView from './components/Services/ApiExplorer/Rest/Form'; import FormRestView from './components/Services/ApiExplorer/Rest/Form';
import { HerokuCallbackHandler } from './components/Services/Data/DataSources/CreateDataSource/Heroku/TempCallback'; import { HerokuCallbackHandler } from './components/Services/Data/DataSources/CreateDataSource/Heroku/TempCallback';
import { NeonCallbackHandler } from './components/Services/Data/DataSources/CreateDataSource/Neon/TempCallback'; import { NeonCallbackHandler } from './components/Services/Data/DataSources/CreateDataSource/Neon/TempCallback';
import { SlackCallbackHandler } from './features/SchemaRegistry/components/TempSlackCallback';
import InsecureDomains from './components/Services/Settings/InsercureDomains/AllowInsecureDomains'; import InsecureDomains from './components/Services/Settings/InsercureDomains/AllowInsecureDomains';
import AuthContainer from './components/Services/Auth/AuthContainer'; import AuthContainer from './components/Services/Auth/AuthContainer';
import { FeatureFlags } from './features/FeatureFlags'; import { FeatureFlags } from './features/FeatureFlags';
@ -100,6 +101,10 @@ const routes = store => {
<Route path="login" component={generatedLoginConnector(connect)} /> <Route path="login" component={generatedLoginConnector(connect)} />
<Route path="heroku-callback" component={HerokuCallbackHandler} /> <Route path="heroku-callback" component={HerokuCallbackHandler} />
<Route path="neon-integration/callback" component={NeonCallbackHandler} /> <Route path="neon-integration/callback" component={NeonCallbackHandler} />
<Route
path="slack-integration/callback"
component={SlackCallbackHandler}
/>
<Route path="" component={AuthContainer}> <Route path="" component={AuthContainer}>
<Route <Route
path="" path=""

View File

@ -119,6 +119,7 @@ export const LS_KEYS = {
permissionConfirmationModalStatus: permissionConfirmationModalStatus:
'console:permissionConfirmationModalStatus', 'console:permissionConfirmationModalStatus',
neonCallbackSearch: 'neon:authCallbackSearch', neonCallbackSearch: 'neon:authCallbackSearch',
slackCallbackSearch: 'slack:authCallbackSearch',
herokuCallbackSearch: 'HEROKU_CALLBACK_SEARCH', herokuCallbackSearch: 'HEROKU_CALLBACK_SEARCH',
consolePersonalAccessToken: 'PERSONAL_ACCESS_TOKEN', consolePersonalAccessToken: 'PERSONAL_ACCESS_TOKEN',
notificationsData: 'notifications:data', notificationsData: 'notifications:data',