1
0
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.


![image](https://github.com/Unleash/unleash/assets/14320932/5d37a72e-2777-4c8b-b71b-3c0610959a52)
This commit is contained in:
Nuno Góis 2024-01-18 11:38:05 +00:00 committed by GitHub
parent 4b02d6aa9c
commit 5b56fac66f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1162 additions and 84 deletions

View File

@ -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),

View File

@ -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,

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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 &ldquo;
{searchValue}
&rdquo;
</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>
</>
);
};

View File

@ -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>
);
};

View File

@ -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>
);

View File

@ -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,
};
};

View File

@ -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>
);
};

View File

@ -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}

View File

@ -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}
/> />
} }

View File

@ -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(

View File

@ -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(

View File

@ -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(),