mirror of
https://github.com/Unleash/unleash.git
synced 2025-03-04 00:18:40 +01:00
Chore inc webhooks modal form (#5938)
https://linear.app/unleash/issue/2-1818/ui-create-incoming-webhook-newedit-modal Adds the incoming webhooks modal form, which allows users to create and edit incoming webhooks, along with their respective tokens. Follows a logic similar to service accounts and their tokens, and tries to use the newest form validation flow that we implemented in the roles form. 
This commit is contained in:
parent
4b02d6aa9c
commit
5b56fac66f
@ -10,7 +10,7 @@ import { ChangeEvent, Dispatch, SetStateAction, useState } from 'react';
|
|||||||
import { Visibility } from '@mui/icons-material';
|
import { Visibility } from '@mui/icons-material';
|
||||||
import { BannerDialog } from 'component/banners/Banner/BannerDialog/BannerDialog';
|
import { BannerDialog } from 'component/banners/Banner/BannerDialog/BannerDialog';
|
||||||
|
|
||||||
const StyledForm = styled('form')(({ theme }) => ({
|
const StyledForm = styled('div')(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: theme.spacing(4),
|
gap: theme.spacing(4),
|
||||||
|
@ -23,13 +23,7 @@ import {
|
|||||||
IPersonalAPIToken,
|
IPersonalAPIToken,
|
||||||
} from 'interfaces/personalAPIToken';
|
} from 'interfaces/personalAPIToken';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import {
|
import { useTable, SortingRule, useSortBy, useFlexLayout } from 'react-table';
|
||||||
useTable,
|
|
||||||
SortingRule,
|
|
||||||
useSortBy,
|
|
||||||
useFlexLayout,
|
|
||||||
Column,
|
|
||||||
} from 'react-table';
|
|
||||||
import { sortTypes } from 'utils/sortTypes';
|
import { sortTypes } from 'utils/sortTypes';
|
||||||
import { ServiceAccountCreateTokenDialog } from './ServiceAccountCreateTokenDialog/ServiceAccountCreateTokenDialog';
|
import { ServiceAccountCreateTokenDialog } from './ServiceAccountCreateTokenDialog/ServiceAccountCreateTokenDialog';
|
||||||
import { ServiceAccountTokenDialog } from 'component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokenDialog/ServiceAccountTokenDialog';
|
import { ServiceAccountTokenDialog } from 'component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokenDialog/ServiceAccountTokenDialog';
|
||||||
@ -157,8 +151,7 @@ export const ServiceAccountTokens = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() =>
|
() => [
|
||||||
[
|
|
||||||
{
|
{
|
||||||
Header: 'Description',
|
Header: 'Description',
|
||||||
accessor: 'description',
|
accessor: 'description',
|
||||||
@ -171,10 +164,7 @@ export const ServiceAccountTokens = ({
|
|||||||
accessor: 'expiresAt',
|
accessor: 'expiresAt',
|
||||||
Cell: ({ value }: { value: string }) => {
|
Cell: ({ value }: { value: string }) => {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (
|
if (date.getFullYear() > new Date().getFullYear() + 100) {
|
||||||
date.getFullYear() >
|
|
||||||
new Date().getFullYear() + 100
|
|
||||||
) {
|
|
||||||
return <TextCell>Never</TextCell>;
|
return <TextCell>Never</TextCell>;
|
||||||
}
|
}
|
||||||
return <DateCell value={value} />;
|
return <DateCell value={value} />;
|
||||||
@ -216,7 +206,7 @@ export const ServiceAccountTokens = ({
|
|||||||
maxWidth: 100,
|
maxWidth: 100,
|
||||||
disableSortBy: true,
|
disableSortBy: true,
|
||||||
},
|
},
|
||||||
] as Column<IPersonalAPIToken>[],
|
],
|
||||||
[setSelectedToken, setDeleteOpen],
|
[setSelectedToken, setDeleteOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -228,7 +218,7 @@ export const ServiceAccountTokens = ({
|
|||||||
|
|
||||||
const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable(
|
const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable(
|
||||||
{
|
{
|
||||||
columns,
|
columns: columns as any[],
|
||||||
data,
|
data,
|
||||||
initialState,
|
initialState,
|
||||||
sortTypes,
|
sortTypes,
|
||||||
|
@ -0,0 +1,248 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
FormControl,
|
||||||
|
FormControlLabel,
|
||||||
|
Link,
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
|
styled,
|
||||||
|
} from '@mui/material';
|
||||||
|
import Input from 'component/common/Input/Input';
|
||||||
|
import { FormSwitch } from 'component/common/FormSwitch/FormSwitch';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { IIncomingWebhook } from 'interfaces/incomingWebhook';
|
||||||
|
import {
|
||||||
|
IncomingWebhooksFormErrors,
|
||||||
|
TokenGeneration,
|
||||||
|
} from './useIncomingWebhooksForm';
|
||||||
|
import { IncomingWebhooksFormURL } from './IncomingWebhooksFormURL';
|
||||||
|
import { IncomingWebhooksTokens } from './IncomingWebhooksTokens/IncomingWebhooksTokens';
|
||||||
|
|
||||||
|
const StyledRaisedSection = styled('div')(({ theme }) => ({
|
||||||
|
background: theme.palette.background.elevation1,
|
||||||
|
padding: theme.spacing(2, 3),
|
||||||
|
borderRadius: theme.shape.borderRadiusLarge,
|
||||||
|
marginBottom: theme.spacing(4),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledInputDescription = styled('p')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
'&:not(:first-of-type)': {
|
||||||
|
marginTop: theme.spacing(4),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledInputSecondaryDescription = styled('p')(({ theme }) => ({
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledInput = styled(Input)(({ theme }) => ({
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: theme.spacing(50),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledSecondarySection = styled('div')(({ theme }) => ({
|
||||||
|
padding: theme.spacing(3),
|
||||||
|
backgroundColor: theme.palette.background.elevation2,
|
||||||
|
borderRadius: theme.shape.borderRadiusMedium,
|
||||||
|
marginTop: theme.spacing(4),
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledInlineContainer = styled('div')(({ theme }) => ({
|
||||||
|
padding: theme.spacing(0, 4),
|
||||||
|
'& > p:not(:first-of-type)': {
|
||||||
|
marginTop: theme.spacing(2),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IIncomingWebhooksFormProps {
|
||||||
|
incomingWebhook?: IIncomingWebhook;
|
||||||
|
enabled: boolean;
|
||||||
|
setEnabled: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
name: string;
|
||||||
|
setName: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
description: string;
|
||||||
|
setDescription: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
tokenGeneration: TokenGeneration;
|
||||||
|
setTokenGeneration: React.Dispatch<React.SetStateAction<TokenGeneration>>;
|
||||||
|
tokenName: string;
|
||||||
|
setTokenName: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
errors: IncomingWebhooksFormErrors;
|
||||||
|
validateName: (name: string) => boolean;
|
||||||
|
validateTokenName: (
|
||||||
|
tokenGeneration: TokenGeneration,
|
||||||
|
name: string,
|
||||||
|
) => boolean;
|
||||||
|
validated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncomingWebhooksForm = ({
|
||||||
|
incomingWebhook,
|
||||||
|
enabled,
|
||||||
|
setEnabled,
|
||||||
|
name,
|
||||||
|
setName,
|
||||||
|
description,
|
||||||
|
setDescription,
|
||||||
|
tokenGeneration,
|
||||||
|
setTokenGeneration,
|
||||||
|
tokenName,
|
||||||
|
setTokenName,
|
||||||
|
errors,
|
||||||
|
validateName,
|
||||||
|
validateTokenName,
|
||||||
|
validated,
|
||||||
|
}: IIncomingWebhooksFormProps) => {
|
||||||
|
const handleOnBlur = (callback: Function) => {
|
||||||
|
setTimeout(() => callback(), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showErrors = validated && Object.values(errors).some(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<IncomingWebhooksFormURL name={name} />
|
||||||
|
<StyledRaisedSection>
|
||||||
|
<FormSwitch checked={enabled} setChecked={setEnabled}>
|
||||||
|
Incoming webhook status
|
||||||
|
</FormSwitch>
|
||||||
|
</StyledRaisedSection>
|
||||||
|
<StyledInputDescription>
|
||||||
|
What is your new incoming webhook name?
|
||||||
|
</StyledInputDescription>
|
||||||
|
<StyledInput
|
||||||
|
autoFocus
|
||||||
|
label='Incoming webhook name'
|
||||||
|
error={Boolean(errors.name)}
|
||||||
|
errorText={errors.name}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
validateName(e.target.value);
|
||||||
|
setName(e.target.value);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => handleOnBlur(() => validateName(e.target.value))}
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
<StyledInputDescription>
|
||||||
|
What is your new incoming webhook description?
|
||||||
|
</StyledInputDescription>
|
||||||
|
<StyledInput
|
||||||
|
label='Incoming webhook description'
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={incomingWebhook === undefined}
|
||||||
|
show={
|
||||||
|
<StyledSecondarySection>
|
||||||
|
<StyledInputDescription>Token</StyledInputDescription>
|
||||||
|
<StyledInputSecondaryDescription>
|
||||||
|
In order to connect your newly created incoming
|
||||||
|
webhook, you will also need a token.{' '}
|
||||||
|
<Link
|
||||||
|
href='https://docs.getunleash.io/reference/api-tokens-and-client-keys'
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
>
|
||||||
|
Read more about API tokens
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</StyledInputSecondaryDescription>
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroup
|
||||||
|
value={tokenGeneration}
|
||||||
|
onChange={(e) => {
|
||||||
|
const tokenGeneration = e.target
|
||||||
|
.value as TokenGeneration;
|
||||||
|
|
||||||
|
if (validated) {
|
||||||
|
validateTokenName(
|
||||||
|
tokenGeneration,
|
||||||
|
tokenName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setTokenGeneration(tokenGeneration);
|
||||||
|
}}
|
||||||
|
name='token-generation'
|
||||||
|
>
|
||||||
|
<FormControlLabel
|
||||||
|
value={TokenGeneration.LATER}
|
||||||
|
control={<Radio />}
|
||||||
|
label='I want to generate a token later'
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
value={TokenGeneration.NOW}
|
||||||
|
control={<Radio />}
|
||||||
|
label='Generate a token now'
|
||||||
|
/>
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
<StyledInlineContainer>
|
||||||
|
<StyledInputSecondaryDescription>
|
||||||
|
A new incoming webhook token will be generated
|
||||||
|
for the incoming webhook, so you can get started
|
||||||
|
right away.
|
||||||
|
</StyledInputSecondaryDescription>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={
|
||||||
|
tokenGeneration === TokenGeneration.NOW
|
||||||
|
}
|
||||||
|
show={
|
||||||
|
<>
|
||||||
|
<StyledInputSecondaryDescription>
|
||||||
|
What is your new token name?
|
||||||
|
</StyledInputSecondaryDescription>
|
||||||
|
<StyledInput
|
||||||
|
autoFocus
|
||||||
|
label='Token name'
|
||||||
|
error={Boolean(errors.tokenName)}
|
||||||
|
errorText={errors.tokenName}
|
||||||
|
value={tokenName}
|
||||||
|
onChange={(e) => {
|
||||||
|
validateTokenName(
|
||||||
|
tokenGeneration,
|
||||||
|
e.target.value,
|
||||||
|
);
|
||||||
|
setTokenName(e.target.value);
|
||||||
|
}}
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</StyledInlineContainer>
|
||||||
|
</StyledSecondarySection>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<>
|
||||||
|
<StyledInputDescription>
|
||||||
|
Incoming webhook tokens
|
||||||
|
</StyledInputDescription>
|
||||||
|
<IncomingWebhooksTokens
|
||||||
|
incomingWebhook={incomingWebhook!}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={showErrors}
|
||||||
|
show={() => (
|
||||||
|
<Alert severity='error' icon={false}>
|
||||||
|
<ul>
|
||||||
|
{Object.values(errors)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((error) => (
|
||||||
|
<li key={error}>{error}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,70 @@
|
|||||||
|
import { IconButton, Tooltip, styled } from '@mui/material';
|
||||||
|
import CopyIcon from '@mui/icons-material/FileCopy';
|
||||||
|
import copy from 'copy-to-clipboard';
|
||||||
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import useToast from 'hooks/useToast';
|
||||||
|
|
||||||
|
const StyledIncomingWebhookUrlSection = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: theme.spacing(1.5),
|
||||||
|
gap: theme.spacing(1.5),
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
borderRadius: theme.shape.borderRadiusMedium,
|
||||||
|
marginBottom: theme.spacing(4),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledIncomingWebhookUrlSectionDescription = styled('p')(({ theme }) => ({
|
||||||
|
fontSize: theme.fontSizes.smallBody,
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledIncomingWebhookUrl = styled('div')(({ theme }) => ({
|
||||||
|
fontSize: theme.fontSizes.smallBody,
|
||||||
|
backgroundColor: theme.palette.background.elevation2,
|
||||||
|
padding: theme.spacing(1),
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: theme.shape.borderRadiusMedium,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IIncomingWebhooksFormURLProps {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncomingWebhooksFormURL = ({
|
||||||
|
name,
|
||||||
|
}: IIncomingWebhooksFormURLProps) => {
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
const { setToastData } = useToast();
|
||||||
|
|
||||||
|
const url = `${uiConfig.unleashUrl}/api/incoming-webhook/${name}`;
|
||||||
|
|
||||||
|
const onCopyToClipboard = () => {
|
||||||
|
copy(url);
|
||||||
|
setToastData({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Copied to clipboard',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledIncomingWebhookUrlSection>
|
||||||
|
<StyledIncomingWebhookUrlSectionDescription>
|
||||||
|
Incoming webhook URL:
|
||||||
|
</StyledIncomingWebhookUrlSectionDescription>
|
||||||
|
<StyledIncomingWebhookUrl>
|
||||||
|
{url}
|
||||||
|
<Tooltip title='Copy URL' arrow>
|
||||||
|
<IconButton onClick={onCopyToClipboard} size='small'>
|
||||||
|
<CopyIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</StyledIncomingWebhookUrl>
|
||||||
|
</StyledIncomingWebhookUrlSection>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,305 @@
|
|||||||
|
import { Delete } from '@mui/icons-material';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
styled,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Search } from 'component/common/Search/Search';
|
||||||
|
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
|
||||||
|
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
|
||||||
|
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
|
||||||
|
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
|
||||||
|
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||||
|
import { PAT_LIMIT } from '@server/util/constants';
|
||||||
|
import { useIncomingWebhookTokens } from 'hooks/api/getters/useIncomingWebhookTokens/useIncomingWebhookTokens';
|
||||||
|
import { useSearch } from 'hooks/useSearch';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useTable, SortingRule, useSortBy, useFlexLayout } from 'react-table';
|
||||||
|
import { sortTypes } from 'utils/sortTypes';
|
||||||
|
import { IncomingWebhooksTokensCreateDialog } from './IncomingWebhooksTokensCreateDialog';
|
||||||
|
import { IncomingWebhooksTokensDialog } from './IncomingWebhooksTokensDialog';
|
||||||
|
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
|
import {
|
||||||
|
IncomingWebhookTokenPayload,
|
||||||
|
useIncomingWebhookTokensApi,
|
||||||
|
} from 'hooks/api/actions/useIncomingWebhookTokensApi/useIncomingWebhookTokensApi';
|
||||||
|
import useToast from 'hooks/useToast';
|
||||||
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
import {
|
||||||
|
IIncomingWebhook,
|
||||||
|
IIncomingWebhookToken,
|
||||||
|
} from 'interfaces/incomingWebhook';
|
||||||
|
import { useIncomingWebhooks } from 'hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks';
|
||||||
|
|
||||||
|
const StyledHeader = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
gap: theme.spacing(2),
|
||||||
|
'& > div': {
|
||||||
|
[theme.breakpoints.down('md')]: {
|
||||||
|
marginTop: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledTablePlaceholder = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: theme.spacing(3),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledPlaceholderTitle = styled(Typography)(({ theme }) => ({
|
||||||
|
fontSize: theme.fontSizes.bodySize,
|
||||||
|
marginBottom: theme.spacing(0.5),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledPlaceholderSubtitle = styled(Typography)(({ theme }) => ({
|
||||||
|
fontSize: theme.fontSizes.smallBody,
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
marginBottom: theme.spacing(1.5),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export type PageQueryType = Partial<
|
||||||
|
Record<'sort' | 'order' | 'search', string>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const defaultSort: SortingRule<string> = { id: 'createdAt', desc: true };
|
||||||
|
|
||||||
|
interface IIncomingWebhooksTokensProps {
|
||||||
|
incomingWebhook: IIncomingWebhook;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncomingWebhooksTokens = ({
|
||||||
|
incomingWebhook,
|
||||||
|
}: IIncomingWebhooksTokensProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { setToastData, setToastApiError } = useToast();
|
||||||
|
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
const { incomingWebhookTokens, refetch: refetchTokens } =
|
||||||
|
useIncomingWebhookTokens(incomingWebhook.id);
|
||||||
|
const { refetch } = useIncomingWebhooks();
|
||||||
|
const { addIncomingWebhookToken, removeIncomingWebhookToken } =
|
||||||
|
useIncomingWebhookTokensApi();
|
||||||
|
|
||||||
|
const [initialState] = useState(() => ({
|
||||||
|
sortBy: [defaultSort],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [tokenOpen, setTokenOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [newToken, setNewToken] = useState('');
|
||||||
|
const [selectedToken, setSelectedToken] = useState<IIncomingWebhookToken>();
|
||||||
|
|
||||||
|
const onCreateClick = async (newToken: IncomingWebhookTokenPayload) => {
|
||||||
|
try {
|
||||||
|
const { token } = await addIncomingWebhookToken(
|
||||||
|
incomingWebhook.id,
|
||||||
|
newToken,
|
||||||
|
);
|
||||||
|
refetch();
|
||||||
|
refetchTokens();
|
||||||
|
setCreateOpen(false);
|
||||||
|
setNewToken(token);
|
||||||
|
setTokenOpen(true);
|
||||||
|
setToastData({
|
||||||
|
title: 'Token created successfully',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setToastApiError(formatUnknownError(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteClick = async () => {
|
||||||
|
if (selectedToken) {
|
||||||
|
try {
|
||||||
|
await removeIncomingWebhookToken(
|
||||||
|
incomingWebhook.id,
|
||||||
|
selectedToken.id,
|
||||||
|
);
|
||||||
|
refetch();
|
||||||
|
refetchTokens();
|
||||||
|
setDeleteOpen(false);
|
||||||
|
setToastData({
|
||||||
|
title: 'Token deleted successfully',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setToastApiError(formatUnknownError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
Header: 'Name',
|
||||||
|
accessor: 'name',
|
||||||
|
Cell: HighlightCell,
|
||||||
|
minWidth: 100,
|
||||||
|
searchable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Created',
|
||||||
|
accessor: 'createdAt',
|
||||||
|
Cell: DateCell,
|
||||||
|
maxWidth: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Actions',
|
||||||
|
id: 'Actions',
|
||||||
|
align: 'center',
|
||||||
|
Cell: ({ row: { original: rowToken } }: any) => (
|
||||||
|
<ActionCell>
|
||||||
|
<Tooltip title='Delete token' arrow describeChild>
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedToken(rowToken);
|
||||||
|
setDeleteOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Delete />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</ActionCell>
|
||||||
|
),
|
||||||
|
maxWidth: 100,
|
||||||
|
disableSortBy: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[setSelectedToken, setDeleteOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, getSearchText, getSearchContext } = useSearch(
|
||||||
|
columns,
|
||||||
|
searchValue,
|
||||||
|
incomingWebhookTokens,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable(
|
||||||
|
{
|
||||||
|
columns: columns as any[],
|
||||||
|
data,
|
||||||
|
initialState,
|
||||||
|
sortTypes,
|
||||||
|
autoResetHiddenColumns: false,
|
||||||
|
autoResetSortBy: false,
|
||||||
|
disableSortRemove: true,
|
||||||
|
disableMultiSort: true,
|
||||||
|
},
|
||||||
|
useSortBy,
|
||||||
|
useFlexLayout,
|
||||||
|
);
|
||||||
|
|
||||||
|
useConditionallyHiddenColumns(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
condition: isSmallScreen,
|
||||||
|
columns: ['createdAt'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
setHiddenColumns,
|
||||||
|
columns,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StyledHeader>
|
||||||
|
<Search
|
||||||
|
initialValue={searchValue}
|
||||||
|
onChange={setSearchValue}
|
||||||
|
getSearchContext={getSearchContext}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant='contained'
|
||||||
|
color='primary'
|
||||||
|
disabled={incomingWebhookTokens.length >= PAT_LIMIT}
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
>
|
||||||
|
New token
|
||||||
|
</Button>
|
||||||
|
</StyledHeader>
|
||||||
|
<SearchHighlightProvider value={getSearchText(searchValue)}>
|
||||||
|
<VirtualizedTable
|
||||||
|
rows={rows}
|
||||||
|
headerGroups={headerGroups}
|
||||||
|
prepareRow={prepareRow}
|
||||||
|
/>
|
||||||
|
</SearchHighlightProvider>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={rows.length === 0}
|
||||||
|
show={
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={searchValue?.length > 0}
|
||||||
|
show={
|
||||||
|
<TablePlaceholder>
|
||||||
|
No tokens found matching “
|
||||||
|
{searchValue}
|
||||||
|
”
|
||||||
|
</TablePlaceholder>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<StyledTablePlaceholder>
|
||||||
|
<StyledPlaceholderTitle>
|
||||||
|
You have no tokens for this incoming webhook
|
||||||
|
yet.
|
||||||
|
</StyledPlaceholderTitle>
|
||||||
|
<StyledPlaceholderSubtitle>
|
||||||
|
Create a token to start using this incoming
|
||||||
|
webhook.
|
||||||
|
</StyledPlaceholderSubtitle>
|
||||||
|
<Button
|
||||||
|
variant='outlined'
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
>
|
||||||
|
Create new incoming webhook token
|
||||||
|
</Button>
|
||||||
|
</StyledTablePlaceholder>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<IncomingWebhooksTokensCreateDialog
|
||||||
|
open={createOpen}
|
||||||
|
setOpen={setCreateOpen}
|
||||||
|
tokens={incomingWebhookTokens}
|
||||||
|
onCreateClick={onCreateClick}
|
||||||
|
/>
|
||||||
|
<IncomingWebhooksTokensDialog
|
||||||
|
open={tokenOpen}
|
||||||
|
setOpen={setTokenOpen}
|
||||||
|
token={newToken}
|
||||||
|
/>
|
||||||
|
<Dialogue
|
||||||
|
open={deleteOpen}
|
||||||
|
primaryButtonText='Delete token'
|
||||||
|
secondaryButtonText='Cancel'
|
||||||
|
onClick={onDeleteClick}
|
||||||
|
onClose={() => {
|
||||||
|
setDeleteOpen(false);
|
||||||
|
}}
|
||||||
|
title='Delete token?'
|
||||||
|
>
|
||||||
|
<Typography>
|
||||||
|
Any applications or scripts using this token "
|
||||||
|
<strong>{selectedToken?.name}</strong>" will no longer be
|
||||||
|
able to make requests to this incoming webhook. You cannot
|
||||||
|
undo this action.
|
||||||
|
</Typography>
|
||||||
|
</Dialogue>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,94 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
|
import { IncomingWebhookTokenPayload } from 'hooks/api/actions/useIncomingWebhookTokensApi/useIncomingWebhookTokensApi';
|
||||||
|
import { IIncomingWebhookToken } from 'interfaces/incomingWebhook';
|
||||||
|
import { styled } from '@mui/material';
|
||||||
|
import Input from 'component/common/Input/Input';
|
||||||
|
|
||||||
|
const StyledForm = styled('div')(({ theme }) => ({
|
||||||
|
minHeight: theme.spacing(12),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledInputSecondaryDescription = styled('p')(({ theme }) => ({
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledInput = styled(Input)(({ theme }) => ({
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: theme.spacing(50),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IIncomingWebhooksTokensCreateDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
tokens: IIncomingWebhookToken[];
|
||||||
|
onCreateClick: (newToken: IncomingWebhookTokenPayload) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncomingWebhooksTokensCreateDialog = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
tokens,
|
||||||
|
onCreateClick,
|
||||||
|
}: IIncomingWebhooksTokensCreateDialogProps) => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
|
||||||
|
const [nameError, setNameError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setName('');
|
||||||
|
setNameError('');
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const isNameUnique = (name: string) =>
|
||||||
|
!tokens?.some((token) => token.name === name);
|
||||||
|
|
||||||
|
const validateName = (name: string) => {
|
||||||
|
if (!name.length) {
|
||||||
|
setNameError('Name is required');
|
||||||
|
} else if (!isNameUnique(name)) {
|
||||||
|
setNameError('Name must be unique');
|
||||||
|
} else {
|
||||||
|
setNameError('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValid = name.length && isNameUnique(name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialogue
|
||||||
|
open={open}
|
||||||
|
primaryButtonText='Create token'
|
||||||
|
secondaryButtonText='Cancel'
|
||||||
|
onClick={() =>
|
||||||
|
onCreateClick({
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabledPrimaryButton={!isValid}
|
||||||
|
onClose={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
title='New token'
|
||||||
|
>
|
||||||
|
<StyledForm>
|
||||||
|
<StyledInputSecondaryDescription>
|
||||||
|
What is your new token name?
|
||||||
|
</StyledInputSecondaryDescription>
|
||||||
|
<StyledInput
|
||||||
|
autoFocus
|
||||||
|
label='Token name'
|
||||||
|
error={Boolean(nameError)}
|
||||||
|
errorText={nameError}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
validateName(e.target.value);
|
||||||
|
setName(e.target.value);
|
||||||
|
}}
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
</StyledForm>
|
||||||
|
</Dialogue>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,37 @@
|
|||||||
|
import { Alert, styled, Typography } from '@mui/material';
|
||||||
|
import { UserToken } from 'component/admin/apiToken/ConfirmToken/UserToken/UserToken';
|
||||||
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
|
|
||||||
|
const StyledAlert = styled(Alert)(({ theme }) => ({
|
||||||
|
marginBottom: theme.spacing(3),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IIncomingWebhooksTokensDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncomingWebhooksTokensDialog = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
token,
|
||||||
|
}: IIncomingWebhooksTokensDialogProps) => (
|
||||||
|
<Dialogue
|
||||||
|
open={open}
|
||||||
|
secondaryButtonText='Close'
|
||||||
|
onClose={(_, muiCloseReason?: string) => {
|
||||||
|
if (!muiCloseReason) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title='Incoming webhook token created'
|
||||||
|
>
|
||||||
|
<StyledAlert severity='info'>
|
||||||
|
Make sure to copy your incoming webhook token now. You won't be able
|
||||||
|
to see it again!
|
||||||
|
</StyledAlert>
|
||||||
|
<Typography variant='body1'>Your token:</Typography>
|
||||||
|
<UserToken token={token || ''} />
|
||||||
|
</Dialogue>
|
||||||
|
);
|
@ -0,0 +1,136 @@
|
|||||||
|
import { useIncomingWebhooks } from 'hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks';
|
||||||
|
import { IIncomingWebhook } from 'interfaces/incomingWebhook';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const INCOMING_WEBHOOK_NAME_REGEX = /^[A-Za-z0-9\-_]*$/;
|
||||||
|
|
||||||
|
enum ErrorField {
|
||||||
|
NAME = 'name',
|
||||||
|
TOKEN_NAME = 'tokenName',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_INCOMING_WEBHOOKS_FORM_ERRORS = {
|
||||||
|
[ErrorField.NAME]: undefined,
|
||||||
|
[ErrorField.TOKEN_NAME]: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IncomingWebhooksFormErrors = Record<ErrorField, string | undefined>;
|
||||||
|
|
||||||
|
export enum TokenGeneration {
|
||||||
|
LATER = 'later',
|
||||||
|
NOW = 'now',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useIncomingWebhooksForm = (incomingWebhook?: IIncomingWebhook) => {
|
||||||
|
const { incomingWebhooks } = useIncomingWebhooks();
|
||||||
|
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [tokenGeneration, setTokenGeneration] = useState<TokenGeneration>(
|
||||||
|
TokenGeneration.LATER,
|
||||||
|
);
|
||||||
|
const [tokenName, setTokenName] = useState('');
|
||||||
|
|
||||||
|
const reloadForm = () => {
|
||||||
|
setEnabled(incomingWebhook?.enabled ?? true);
|
||||||
|
setName(incomingWebhook?.name || '');
|
||||||
|
setDescription(incomingWebhook?.description || '');
|
||||||
|
setTokenGeneration(TokenGeneration.LATER);
|
||||||
|
setTokenName('');
|
||||||
|
setValidated(false);
|
||||||
|
setErrors(DEFAULT_INCOMING_WEBHOOKS_FORM_ERRORS);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reloadForm();
|
||||||
|
}, [incomingWebhook]);
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<IncomingWebhooksFormErrors>(
|
||||||
|
DEFAULT_INCOMING_WEBHOOKS_FORM_ERRORS,
|
||||||
|
);
|
||||||
|
const [validated, setValidated] = useState(false);
|
||||||
|
|
||||||
|
const clearError = (field: ErrorField) => {
|
||||||
|
setErrors((errors) => ({ ...errors, [field]: undefined }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setError = (field: ErrorField, error: string) => {
|
||||||
|
setErrors((errors) => ({ ...errors, [field]: error }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEmpty = (value: string) => !value.length;
|
||||||
|
|
||||||
|
const isNameNotUnique = (value: string) =>
|
||||||
|
incomingWebhooks?.some(
|
||||||
|
({ id, name }) => id !== incomingWebhook?.id && name === value,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isNameInvalid = (value: string) =>
|
||||||
|
!INCOMING_WEBHOOK_NAME_REGEX.test(value);
|
||||||
|
|
||||||
|
const validateName = (name: string) => {
|
||||||
|
if (isEmpty(name)) {
|
||||||
|
setError(ErrorField.NAME, 'Name is required.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNameNotUnique(name)) {
|
||||||
|
setError(ErrorField.NAME, 'Name must be unique.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNameInvalid(name)) {
|
||||||
|
setError(
|
||||||
|
ErrorField.NAME,
|
||||||
|
'Name must only contain alphanumeric characters, dashes and underscores.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearError(ErrorField.NAME);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateTokenName = (
|
||||||
|
tokenGeneration: TokenGeneration,
|
||||||
|
tokenName: string,
|
||||||
|
) => {
|
||||||
|
if (tokenGeneration === TokenGeneration.NOW && isEmpty(tokenName)) {
|
||||||
|
setError(ErrorField.TOKEN_NAME, 'Token name is required.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearError(ErrorField.TOKEN_NAME);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const validName = validateName(name);
|
||||||
|
const validTokenName = validateTokenName(tokenGeneration, tokenName);
|
||||||
|
|
||||||
|
setValidated(true);
|
||||||
|
|
||||||
|
return validName && validTokenName;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
setEnabled,
|
||||||
|
name,
|
||||||
|
setName,
|
||||||
|
description,
|
||||||
|
setDescription,
|
||||||
|
tokenGeneration,
|
||||||
|
setTokenGeneration,
|
||||||
|
tokenName,
|
||||||
|
setTokenName,
|
||||||
|
errors,
|
||||||
|
setErrors,
|
||||||
|
validated,
|
||||||
|
validateName,
|
||||||
|
validateTokenName,
|
||||||
|
validate,
|
||||||
|
reloadForm,
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,184 @@
|
|||||||
|
import { FormEvent, useEffect } from 'react';
|
||||||
|
import { Button, styled } from '@mui/material';
|
||||||
|
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
||||||
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
||||||
|
import useToast from 'hooks/useToast';
|
||||||
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
import { IIncomingWebhook } from 'interfaces/incomingWebhook';
|
||||||
|
import { useIncomingWebhooks } from 'hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks';
|
||||||
|
import {
|
||||||
|
IncomingWebhookPayload,
|
||||||
|
useIncomingWebhooksApi,
|
||||||
|
} from 'hooks/api/actions/useIncomingWebhooksApi/useIncomingWebhooksApi';
|
||||||
|
import { useIncomingWebhookTokensApi } from 'hooks/api/actions/useIncomingWebhookTokensApi/useIncomingWebhookTokensApi';
|
||||||
|
import { IncomingWebhooksForm } from './IncomingWebhooksForm/IncomingWebhooksForm';
|
||||||
|
import {
|
||||||
|
TokenGeneration,
|
||||||
|
useIncomingWebhooksForm,
|
||||||
|
} from './IncomingWebhooksForm/useIncomingWebhooksForm';
|
||||||
|
|
||||||
|
const StyledForm = styled('form')(() => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledButtonContainer = styled('div')(({ theme }) => ({
|
||||||
|
marginTop: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
paddingTop: theme.spacing(4),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledCancelButton = styled(Button)(({ theme }) => ({
|
||||||
|
marginLeft: theme.spacing(3),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IIncomingWebhooksModalProps {
|
||||||
|
incomingWebhook?: IIncomingWebhook;
|
||||||
|
open: boolean;
|
||||||
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
newToken: (token: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncomingWebhooksModal = ({
|
||||||
|
incomingWebhook,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
newToken,
|
||||||
|
}: IIncomingWebhooksModalProps) => {
|
||||||
|
const { refetch } = useIncomingWebhooks();
|
||||||
|
const { addIncomingWebhook, updateIncomingWebhook, loading } =
|
||||||
|
useIncomingWebhooksApi();
|
||||||
|
const { addIncomingWebhookToken } = useIncomingWebhookTokensApi();
|
||||||
|
const { setToastData, setToastApiError } = useToast();
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
|
||||||
|
const {
|
||||||
|
enabled,
|
||||||
|
setEnabled,
|
||||||
|
name,
|
||||||
|
setName,
|
||||||
|
description,
|
||||||
|
setDescription,
|
||||||
|
tokenGeneration,
|
||||||
|
setTokenGeneration,
|
||||||
|
tokenName,
|
||||||
|
setTokenName,
|
||||||
|
errors,
|
||||||
|
validateName,
|
||||||
|
validateTokenName,
|
||||||
|
validate,
|
||||||
|
validated,
|
||||||
|
reloadForm,
|
||||||
|
} = useIncomingWebhooksForm(incomingWebhook);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reloadForm();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const editing = incomingWebhook !== undefined;
|
||||||
|
const title = `${editing ? 'Edit' : 'New'} incoming webhook`;
|
||||||
|
|
||||||
|
const payload: IncomingWebhookPayload = {
|
||||||
|
enabled,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatApiCode = () => `curl --location --request ${
|
||||||
|
editing ? 'PUT' : 'POST'
|
||||||
|
} '${uiConfig.unleashUrl}/api/admin/incoming-webhooks${
|
||||||
|
editing ? `/${incomingWebhook.id}` : ''
|
||||||
|
}' \\
|
||||||
|
--header 'Authorization: INSERT_API_KEY' \\
|
||||||
|
--header 'Content-Type: application/json' \\
|
||||||
|
--data-raw '${JSON.stringify(payload, undefined, 2)}'`;
|
||||||
|
|
||||||
|
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await updateIncomingWebhook(incomingWebhook.id, payload);
|
||||||
|
} else {
|
||||||
|
const { id } = await addIncomingWebhook(payload);
|
||||||
|
if (tokenGeneration === TokenGeneration.NOW) {
|
||||||
|
const { token } = await addIncomingWebhookToken(id, {
|
||||||
|
name: tokenName,
|
||||||
|
});
|
||||||
|
newToken(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setToastData({
|
||||||
|
title: `Incoming webhook ${
|
||||||
|
editing ? 'updated' : 'added'
|
||||||
|
} successfully`,
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
refetch();
|
||||||
|
setOpen(false);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setToastApiError(formatUnknownError(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarModal
|
||||||
|
open={open}
|
||||||
|
onClose={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
label={title}
|
||||||
|
>
|
||||||
|
<FormTemplate
|
||||||
|
loading={loading}
|
||||||
|
modal
|
||||||
|
title={title}
|
||||||
|
description='Incoming Webhooks allow third-party services to send observable events to Unleash.'
|
||||||
|
documentationLink='https://docs.getunleash.io/reference/incoming-webhooks'
|
||||||
|
documentationLinkLabel='Incoming webhooks documentation'
|
||||||
|
formatApiCode={formatApiCode}
|
||||||
|
>
|
||||||
|
<StyledForm onSubmit={onSubmit}>
|
||||||
|
<IncomingWebhooksForm
|
||||||
|
incomingWebhook={incomingWebhook}
|
||||||
|
enabled={enabled}
|
||||||
|
setEnabled={setEnabled}
|
||||||
|
name={name}
|
||||||
|
setName={setName}
|
||||||
|
description={description}
|
||||||
|
setDescription={setDescription}
|
||||||
|
tokenGeneration={tokenGeneration}
|
||||||
|
setTokenGeneration={setTokenGeneration}
|
||||||
|
tokenName={tokenName}
|
||||||
|
setTokenName={setTokenName}
|
||||||
|
errors={errors}
|
||||||
|
validateName={validateName}
|
||||||
|
validateTokenName={validateTokenName}
|
||||||
|
validated={validated}
|
||||||
|
/>
|
||||||
|
<StyledButtonContainer>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
variant='contained'
|
||||||
|
color='primary'
|
||||||
|
>
|
||||||
|
{editing ? 'Save' : 'Add'} incoming webhook
|
||||||
|
</Button>
|
||||||
|
<StyledCancelButton
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</StyledCancelButton>
|
||||||
|
</StyledButtonContainer>
|
||||||
|
</StyledForm>
|
||||||
|
</FormTemplate>
|
||||||
|
</SidebarModal>
|
||||||
|
);
|
||||||
|
};
|
@ -20,7 +20,8 @@ import { HighlightCell } from 'component/common/Table/cells/HighlightCell/Highli
|
|||||||
import copy from 'copy-to-clipboard';
|
import copy from 'copy-to-clipboard';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import { IncomingWebhookTokensCell } from './IncomingWebhooksTokensCell';
|
import { IncomingWebhookTokensCell } from './IncomingWebhooksTokensCell';
|
||||||
// import { IncomingWebhooksModal } from '../IncomingWebhooksModal/IncomingWebhooksModal';
|
import { IncomingWebhooksModal } from '../IncomingWebhooksModal/IncomingWebhooksModal';
|
||||||
|
import { IncomingWebhooksTokensDialog } from '../IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokensDialog';
|
||||||
|
|
||||||
interface IIncomingWebhooksTableProps {
|
interface IIncomingWebhooksTableProps {
|
||||||
modalOpen: boolean;
|
modalOpen: boolean;
|
||||||
@ -40,10 +41,12 @@ export const IncomingWebhooksTable = ({
|
|||||||
const { setToastData, setToastApiError } = useToast();
|
const { setToastData, setToastApiError } = useToast();
|
||||||
const { uiConfig } = useUiConfig();
|
const { uiConfig } = useUiConfig();
|
||||||
|
|
||||||
const { incomingWebhooks, refetch, loading } = useIncomingWebhooks();
|
const { incomingWebhooks, refetch } = useIncomingWebhooks();
|
||||||
const { toggleIncomingWebhook, removeIncomingWebhook } =
|
const { toggleIncomingWebhook, removeIncomingWebhook } =
|
||||||
useIncomingWebhooksApi();
|
useIncomingWebhooksApi();
|
||||||
|
|
||||||
|
const [tokenDialog, setTokenDialog] = useState(false);
|
||||||
|
const [newToken, setNewToken] = useState('');
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
const onToggleIncomingWebhook = async (
|
const onToggleIncomingWebhook = async (
|
||||||
@ -123,7 +126,7 @@ export const IncomingWebhooksTable = ({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
searchable: true,
|
searchable: true,
|
||||||
maxWidth: 100,
|
maxWidth: 120,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Created',
|
Header: 'Created',
|
||||||
@ -233,11 +236,20 @@ export const IncomingWebhooksTable = ({
|
|||||||
</TablePlaceholder>
|
</TablePlaceholder>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* <IncomingWebhooksModal
|
<IncomingWebhooksModal
|
||||||
incomingWebhook={selectedIncomingWebhook}
|
incomingWebhook={selectedIncomingWebhook}
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
setOpen={setModalOpen}
|
setOpen={setModalOpen}
|
||||||
/> */}
|
newToken={(token: string) => {
|
||||||
|
setNewToken(token);
|
||||||
|
setTokenDialog(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IncomingWebhooksTokensDialog
|
||||||
|
open={tokenDialog}
|
||||||
|
setOpen={setTokenDialog}
|
||||||
|
token={newToken}
|
||||||
|
/>
|
||||||
<IncomingWebhooksDeleteDialog
|
<IncomingWebhooksDeleteDialog
|
||||||
incomingWebhook={selectedIncomingWebhook}
|
incomingWebhook={selectedIncomingWebhook}
|
||||||
open={deleteOpen}
|
open={deleteOpen}
|
||||||
|
@ -103,7 +103,7 @@ export const AvailableIntegrations: VFC<IAvailableIntegrationsProps> = ({
|
|||||||
<IntegrationCard
|
<IntegrationCard
|
||||||
icon='webhook'
|
icon='webhook'
|
||||||
title='Incoming Webhooks'
|
title='Incoming Webhooks'
|
||||||
description='Incoming Webhooks allow third party services to send observable events to Unleash.'
|
description='Incoming Webhooks allow third-party services to send observable events to Unleash.'
|
||||||
onClick={onNewIncomingWebhook}
|
onClick={onNewIncomingWebhook}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
@ -3,12 +3,12 @@ import useAPI from '../useApi/useApi';
|
|||||||
|
|
||||||
const ENDPOINT = 'api/admin/incoming-webhooks';
|
const ENDPOINT = 'api/admin/incoming-webhooks';
|
||||||
|
|
||||||
export type AddOrUpdateIncomingWebhookToken = Omit<
|
export type IncomingWebhookTokenPayload = Omit<
|
||||||
IIncomingWebhookToken,
|
IIncomingWebhookToken,
|
||||||
'id' | 'incomingWebhookId' | 'createdAt' | 'createdByUserId'
|
'id' | 'incomingWebhookId' | 'createdAt' | 'createdByUserId'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type IncomingWebhookTokenWithTokenSecret = IIncomingWebhookToken & {
|
type IncomingWebhookTokenWithTokenSecret = IIncomingWebhookToken & {
|
||||||
token: string;
|
token: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ export const useIncomingWebhookTokensApi = () => {
|
|||||||
|
|
||||||
const addIncomingWebhookToken = async (
|
const addIncomingWebhookToken = async (
|
||||||
incomingWebhookId: number,
|
incomingWebhookId: number,
|
||||||
incomingWebhookToken: AddOrUpdateIncomingWebhookToken,
|
incomingWebhookToken: IncomingWebhookTokenPayload,
|
||||||
): Promise<IncomingWebhookTokenWithTokenSecret> => {
|
): Promise<IncomingWebhookTokenWithTokenSecret> => {
|
||||||
const requestId = 'addIncomingWebhookToken';
|
const requestId = 'addIncomingWebhookToken';
|
||||||
const req = createRequest(
|
const req = createRequest(
|
||||||
@ -38,7 +38,7 @@ export const useIncomingWebhookTokensApi = () => {
|
|||||||
const updateIncomingWebhookToken = async (
|
const updateIncomingWebhookToken = async (
|
||||||
incomingWebhookId: number,
|
incomingWebhookId: number,
|
||||||
incomingWebhookTokenId: number,
|
incomingWebhookTokenId: number,
|
||||||
incomingWebhookToken: AddOrUpdateIncomingWebhookToken,
|
incomingWebhookToken: IncomingWebhookTokenPayload,
|
||||||
) => {
|
) => {
|
||||||
const requestId = 'updateIncomingWebhookToken';
|
const requestId = 'updateIncomingWebhookToken';
|
||||||
const req = createRequest(
|
const req = createRequest(
|
||||||
|
@ -3,9 +3,9 @@ import useAPI from '../useApi/useApi';
|
|||||||
|
|
||||||
const ENDPOINT = 'api/admin/incoming-webhooks';
|
const ENDPOINT = 'api/admin/incoming-webhooks';
|
||||||
|
|
||||||
export type AddOrUpdateIncomingWebhook = Omit<
|
export type IncomingWebhookPayload = Omit<
|
||||||
IIncomingWebhook,
|
IIncomingWebhook,
|
||||||
'id' | 'createdAt' | 'createdByUserId'
|
'id' | 'createdAt' | 'createdByUserId' | 'tokens'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export const useIncomingWebhooksApi = () => {
|
export const useIncomingWebhooksApi = () => {
|
||||||
@ -14,7 +14,7 @@ export const useIncomingWebhooksApi = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const addIncomingWebhook = async (
|
const addIncomingWebhook = async (
|
||||||
incomingWebhook: AddOrUpdateIncomingWebhook,
|
incomingWebhook: IncomingWebhookPayload,
|
||||||
) => {
|
) => {
|
||||||
const requestId = 'addIncomingWebhook';
|
const requestId = 'addIncomingWebhook';
|
||||||
const req = createRequest(
|
const req = createRequest(
|
||||||
@ -32,7 +32,7 @@ export const useIncomingWebhooksApi = () => {
|
|||||||
|
|
||||||
const updateIncomingWebhook = async (
|
const updateIncomingWebhook = async (
|
||||||
incomingWebhookId: number,
|
incomingWebhookId: number,
|
||||||
incomingWebhook: AddOrUpdateIncomingWebhook,
|
incomingWebhook: IncomingWebhookPayload,
|
||||||
) => {
|
) => {
|
||||||
const requestId = 'updateIncomingWebhook';
|
const requestId = 'updateIncomingWebhook';
|
||||||
const req = createRequest(
|
const req = createRequest(
|
||||||
|
@ -4,14 +4,16 @@ import handleErrorResponses from '../httpErrorResponseHandler';
|
|||||||
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
|
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
|
||||||
import useUiConfig from '../useUiConfig/useUiConfig';
|
import useUiConfig from '../useUiConfig/useUiConfig';
|
||||||
import { IIncomingWebhookToken } from 'interfaces/incomingWebhook';
|
import { IIncomingWebhookToken } from 'interfaces/incomingWebhook';
|
||||||
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
|
|
||||||
const ENDPOINT = 'api/admin/incoming-webhooks';
|
const ENDPOINT = 'api/admin/incoming-webhooks';
|
||||||
|
|
||||||
export const useIncomingWebhookTokens = (incomingWebhookId: number) => {
|
export const useIncomingWebhookTokens = (incomingWebhookId: number) => {
|
||||||
const { isEnterprise } = useUiConfig();
|
const { isEnterprise } = useUiConfig();
|
||||||
|
const incomingWebhooksEnabled = useUiFlag('incomingWebhooks');
|
||||||
|
|
||||||
const { data, error, mutate } = useConditionalSWR(
|
const { data, error, mutate } = useConditionalSWR(
|
||||||
isEnterprise(),
|
isEnterprise() && incomingWebhooksEnabled,
|
||||||
{ incomingWebhookTokens: [] },
|
{ incomingWebhookTokens: [] },
|
||||||
formatApiPath(`${ENDPOINT}/${incomingWebhookId}/tokens`),
|
formatApiPath(`${ENDPOINT}/${incomingWebhookId}/tokens`),
|
||||||
fetcher,
|
fetcher,
|
||||||
@ -19,7 +21,7 @@ export const useIncomingWebhookTokens = (incomingWebhookId: number) => {
|
|||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
incomingWebhookTokens: (data?.incomingWebhooks ??
|
incomingWebhookTokens: (data?.incomingWebhookTokens ??
|
||||||
[]) as IIncomingWebhookToken[],
|
[]) as IIncomingWebhookToken[],
|
||||||
loading: !error && !data,
|
loading: !error && !data,
|
||||||
refetch: () => mutate(),
|
refetch: () => mutate(),
|
||||||
|
Loading…
Reference in New Issue
Block a user