mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-15 01:12:56 +03:00
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:
parent
7120ae7a44
commit
7a3b3aa59b
@ -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)
|
||||||
|
@ -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',
|
||||||
|
content: <SlackAlerts onClose={onClose} />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : kind === 'error' ? (
|
|
||||||
<div className="flex items-top p-md">
|
|
||||||
<AlertHeader
|
|
||||||
icon={<FaBan className="w-9 h-9 mt-sm mr-md fill-red-500" />}
|
|
||||||
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)} changes</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
|
@ -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)} changes</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<CustomDialogFooter
|
||||||
|
onSet={onSet}
|
||||||
|
onClose={onClose}
|
||||||
|
isLoading={setEmailAlertMutation.isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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...</>;
|
||||||
|
}
|
@ -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;
|
||||||
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
@ -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 => {
|
||||||
|
if (data.errors && data.errors.length > 0) {
|
||||||
|
hasuraToast({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error!',
|
||||||
|
message: 'Something unexpected happened!',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
hasuraToast({
|
hasuraToast({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: 'Success!',
|
title: 'Success!',
|
||||||
message: 'Email configuration set successfully!',
|
message: 'Email configuration set successfully!',
|
||||||
});
|
});
|
||||||
|
|
||||||
onSuccess();
|
if (data.data) onSuccess();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
hasuraToast({
|
hasuraToast({
|
||||||
|
@ -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 };
|
||||||
|
};
|
@ -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.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
@ -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;
|
||||||
|
}
|
@ -65,25 +65,20 @@ 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
@ -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;
|
||||||
|
};
|
||||||
|
@ -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),
|
||||||
|
@ -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=""
|
||||||
|
@ -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',
|
||||||
|
Loading…
Reference in New Issue
Block a user