0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

Wired up custom integrations in AdminX (#17752)

refs https://github.com/TryGhost/Product/issues/3729
This commit is contained in:
Jono M 2023-08-17 20:24:39 +01:00 committed by GitHub
parent 850cc7a9a1
commit efd6150db0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 475 additions and 224 deletions

View file

@ -27,8 +27,8 @@ export type Integration = {
description: string;
created_at: string;
updated_at: string;
api_keys: APIKey[];
webhooks: IntegrationWebhook[];
api_keys?: APIKey[];
webhooks?: IntegrationWebhook[];
}
export interface IntegrationsResponseType {

View file

@ -37,7 +37,7 @@ export const useCreateWebhook = createMutation<WebhooksResponseType, Partial<Web
const webhook = newData.webhooks[0];
if (webhook.integration_id === integration.id) {
return {...integration, webhooks: [...integration.webhooks, webhook]};
return {...integration, webhooks: [...(integration.webhooks || []), webhook]};
}
return integration;
@ -56,7 +56,7 @@ export const useEditWebhook = createMutation<WebhooksResponseType, Webhook>({
...(currentData as IntegrationsResponseType),
integrations: (currentData as IntegrationsResponseType).integrations.map(integration => ({
...integration,
webhooks: integration.webhooks.map(webhook => (
webhooks: integration.webhooks?.map(webhook => (
webhook.id === newData.webhooks[0].id ? newData.webhooks[0] : webhook
))
}))
@ -73,7 +73,7 @@ export const useDeleteWebhook = createMutation<unknown, string>({
...(currentData as IntegrationsResponseType),
integrations: (currentData as IntegrationsResponseType).integrations.map(integration => ({
...integration,
webhooks: integration.webhooks.filter(webhook => webhook.id !== id)
webhooks: integration.webhooks?.filter(webhook => webhook.id !== id)
}))
})
}

View file

@ -2,7 +2,6 @@ import AddIntegrationModal from '../settings/advanced/integrations/AddIntegratio
import AddNewsletterModal from '../settings/email/newsletters/AddNewsletterModal';
import AmpModal from '../settings/advanced/integrations/AmpModal';
import ChangeThemeModal from '../settings/site/ThemeModal';
import CustomIntegrationModal from '../settings/advanced/integrations/CustomIntegrationModal';
import DesignModal from '../settings/site/DesignModal';
import FirstpromoterModal from '../settings/advanced/integrations/FirstPromoterModal';
import HistoryModal from '../settings/advanced/HistoryModal';
@ -51,7 +50,7 @@ export const RouteContext = createContext<RoutingContextData>({
});
// These routes need to be handled by a SettingGroup (or other component) with the
// useHandleRoute hook. The idea is that those components will open a modal after
// useDetailModalRoute hook. The idea is that those components will open a modal after
// loading any data required for the route
export const modalRoutes = {
showUser: 'users/show/:slug',
@ -124,8 +123,6 @@ const handleNavigation = (scroll: boolean = true) => {
NiceModal.show(PinturaModal);
} else if (pathName === 'integrations/add') {
NiceModal.show(AddIntegrationModal);
} else if (pathName === 'integrations/show/custom/:id') { // TODO: move this to modalRoutes
NiceModal.show(CustomIntegrationModal);
}
if (scroll) {

View file

@ -1,5 +1,6 @@
import Button from '../../../admin-x-ds/global/Button';
import ConfirmationModal from '../../../admin-x-ds/global/modal/ConfirmationModal';
import CustomIntegrationModal from './integrations/CustomIntegrationModal';
import Icon from '../../../admin-x-ds/global/Icon';
import List from '../../../admin-x-ds/global/List';
import ListItem from '../../../admin-x-ds/global/ListItem';
@ -7,15 +8,17 @@ import NiceModal from '@ebay/nice-modal-react';
import React, {useState} from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import TabView from '../../../admin-x-ds/global/TabView';
import useDetailModalRoute from '../../../hooks/useDetailModalRoute';
import useRouting from '../../../hooks/useRouting';
import {ReactComponent as AmpIcon} from '../../../assets/icons/amp.svg';
import {ReactComponent as FirstPromoterIcon} from '../../../assets/icons/firstpromoter.svg';
import {Integration, useBrowseIntegrations, useCreateIntegration, useDeleteIntegration, useEditIntegration} from '../../../api/integrations';
import {Integration, useBrowseIntegrations, useDeleteIntegration} from '../../../api/integrations';
import {ReactComponent as PinturaIcon} from '../../../assets/icons/pintura.svg';
import {ReactComponent as SlackIcon} from '../../../assets/icons/slack.svg';
import {ReactComponent as UnsplashIcon} from '../../../assets/icons/unsplash.svg';
import {ReactComponent as ZapierIcon} from '../../../assets/icons/zapier.svg';
import {useCreateWebhook, useDeleteWebhook, useEditWebhook} from '../../../api/webhooks';
import {modalRoutes} from '../../providers/RoutingProvider';
import {showToast} from '../../../admin-x-ds/global/Toast';
import {useGlobalData} from '../../providers/GlobalDataProvider';
interface IntegrationItemProps {
@ -23,6 +26,7 @@ interface IntegrationItemProps {
title: string,
detail: string,
action: () => void;
onDelete?: () => void;
disabled?: boolean;
testId?: string;
custom?: boolean;
@ -33,6 +37,7 @@ const IntegrationItem: React.FC<IntegrationItemProps> = ({
title,
detail,
action,
onDelete,
disabled,
testId,
custom = false
@ -48,7 +53,7 @@ const IntegrationItem: React.FC<IntegrationItemProps> = ({
};
const buttons = custom ?
<Button color='red' label='Delete' link onClick={() => {}} />
<Button color='red' label='Delete' link onClick={onDelete} />
:
(disabled ?
<Button icon='lock-locked' label='Upgrade' link onClick={handleClick} /> :
@ -133,53 +138,40 @@ const BuiltInIntegrations: React.FC = () => {
};
const CustomIntegrations: React.FC<{integrations: Integration[]}> = ({integrations}) => {
const {mutateAsync: createIntegration} = useCreateIntegration();
const {mutateAsync: editIntegration} = useEditIntegration();
const {mutateAsync: deleteIntegration} = useDeleteIntegration();
const {mutateAsync: createWebhook} = useCreateWebhook();
const {mutateAsync: editWebhook} = useEditWebhook();
const {mutateAsync: deleteWebhook} = useDeleteWebhook();
const {updateRoute} = useRouting();
const openCustomIntegrationModal = () => {
updateRoute('integrations/show/custom/:id');
};
const {mutateAsync: deleteIntegration} = useDeleteIntegration();
return (
<List>
{integrations.map(integration => (
<IntegrationItem
action={() => {
NiceModal.show(ConfirmationModal, {
title: 'TEST API actions',
prompt: <>
Webhooks (will not update until you close and reopen this modal)
<pre><code>{JSON.stringify(integration.webhooks)}</code></pre>
<Button label='Create integration' onClick={() => createIntegration({name: 'Test'})} />
<Button label='Update integration' onClick={() => editIntegration({...integration, name: integration.name + '*'})} />
<Button label='Delete integration' onClick={() => deleteIntegration(integration.id)} />
<Button label='Create webhook' onClick={() => createWebhook({integration_id: integration.id, event: 'post.edited', name: 'Test', target_url: 'https://test.com'})} />
<Button label='Update webhook' onClick={() => editWebhook({...integration.webhooks[0], name: integration.webhooks[0].name + '*'})} />
<Button label='Delete webhook' onClick={() => deleteWebhook(integration.webhooks[0].id)} />
</>,
onOk: modal => modal?.remove()
});
}}
action={() => updateRoute({route: modalRoutes.showIntegration, params: {id: integration.id}})}
detail={integration.description || 'No description'}
icon={<Icon className='w-8' name='integration' />}
icon={
integration.icon_image ?
<img className='h-8 w-8 object-cover' role='presentation' src={integration.icon_image} /> :
<Icon className='w-8' name='integration' />
}
title={integration.name}
custom
onDelete={() => {
NiceModal.show(ConfirmationModal, {
title: 'Are you sure?',
prompt: 'Deleting this integration will remove all webhooks and api keys associated with it.',
okColor: 'red',
okLabel: 'Delete Integration',
onOk: async (confirmModal) => {
await deleteIntegration(integration.id);
confirmModal?.remove();
showToast({
message: 'Integration deleted',
type: 'success'
});
}
});
}}
/>)
)}
<IntegrationItem
action={openCustomIntegrationModal}
detail='This is just a static placeholder to open the custom modal'
icon={<Icon className='w-8' name='integration' />} // Should be custom icon when uploaded
title='Custom integration modal'
custom
/>
</List>
);
};
@ -189,6 +181,12 @@ const Integrations: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {data: {integrations} = {integrations: []}} = useBrowseIntegrations();
const {updateRoute} = useRouting();
useDetailModalRoute({
route: modalRoutes.showIntegration,
items: integrations,
showModal: integration => NiceModal.show(CustomIntegrationModal, {integration})
});
const tabs = [
{
id: 'built-in',
@ -205,6 +203,7 @@ const Integrations: React.FC<{ keywords: string[] }> = ({keywords}) => {
const buttons = (
<Button color='green' label='Add custom integration' link={true} onClick={() => {
updateRoute('integrations/add');
setSelectedTab('custom');
}} />
);

View file

@ -1,15 +1,19 @@
import Form from '../../../../admin-x-ds/global/form/Form';
import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react';
import React from 'react';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useState} from 'react';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import useRouting from '../../../../hooks/useRouting';
import {modalRoutes} from '../../../providers/RoutingProvider';
import {useCreateIntegration} from '../../../../api/integrations';
interface AddIntegrationModalProps {}
const AddIntegrationModal: React.FC<AddIntegrationModalProps> = () => {
// const modal = useModal();
const modal = useModal();
const {updateRoute} = useRouting();
const [name, setName] = useState('');
const {mutateAsync: createIntegration} = useCreateIntegration();
return <Modal
afterClose={() => {
@ -20,7 +24,11 @@ const AddIntegrationModal: React.FC<AddIntegrationModalProps> = () => {
size='sm'
testId='add-integration-modal'
title='Add integration'
onOk={async () => {}}
onOk={async () => {
const data = await createIntegration({name});
modal.remove();
updateRoute({route: modalRoutes.showIntegration, params: {id: data.integrations[0].id}});
}}
>
<div className='mt-5'>
<Form
@ -30,6 +38,8 @@ const AddIntegrationModal: React.FC<AddIntegrationModalProps> = () => {
<TextField
placeholder='Custom integration'
title='Name'
value={name}
onChange={e => setName(e.target.value)}
/>
</Form>
</div>

View file

@ -1,70 +1,145 @@
import APIKeys from './APIKeys';
import Button from '../../../../admin-x-ds/global/Button';
import ConfirmationModal from '../../../../admin-x-ds/global/modal/ConfirmationModal';
import Form from '../../../../admin-x-ds/global/form/Form';
import ImageUpload from '../../../../admin-x-ds/global/form/ImageUpload';
import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react';
import React from 'react';
import Table from '../../../../admin-x-ds/global/Table';
import TableCell from '../../../../admin-x-ds/global/TableCell';
import TableHead from '../../../../admin-x-ds/global/TableHead';
import TableRow from '../../../../admin-x-ds/global/TableRow';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useEffect, useState} from 'react';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import WebhookModal from './WebhookModal';
import WebhooksTable from './WebhooksTable';
import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
import {APIKey, useRefreshAPIKey} from '../../../../api/apiKeys';
import {Integration, useEditIntegration} from '../../../../api/integrations';
import {getGhostPaths} from '../../../../utils/helpers';
import {getImageUrl, useUploadImage} from '../../../../api/images';
import {showToast} from '../../../../admin-x-ds/global/Toast';
import {toast} from 'react-hot-toast';
interface CustomIntegrationModalProps {}
interface CustomIntegrationModalProps {
integration: Integration
}
const CustomIntegrationModal: React.FC<CustomIntegrationModalProps> = () => {
// const modal = useModal();
const CustomIntegrationModal: React.FC<CustomIntegrationModalProps> = ({integration}) => {
const modal = useModal();
const {updateRoute} = useRouting();
const integrationTitle = 'A custom integration';
const regenerated = false;
const {mutateAsync: editIntegration} = useEditIntegration();
const {mutateAsync: refreshAPIKey} = useRefreshAPIKey();
const {mutateAsync: uploadImage} = useUploadImage();
const {formState, updateForm, handleSave, saveState, errors, clearError, validate} = useForm({
initialState: integration,
onSave: async () => {
await editIntegration(formState);
},
onValidate: () => {
const newErrors: Record<string, string> = {};
if (!formState.name) {
newErrors.name = 'Please enter a name';
}
return newErrors;
}
});
const adminApiKey = integration.api_keys?.find(key => key.type === 'admin');
const contentApiKey = integration.api_keys?.find(key => key.type === 'content');
const [adminKeyRegenerated, setAdminKeyRegenerated] = useState(false);
const [contentKeyRegenerated, setContentKeyRegenerated] = useState(false);
useEffect(() => {
if (integration.type !== 'custom') {
modal.remove();
updateRoute('integrations');
}
}, [integration.type, modal, updateRoute]);
const handleRegenerate = (apiKey: APIKey, setRegenerated: (value: boolean) => void) => {
setRegenerated(false);
const name = apiKey.type === 'content' ? 'Content' : 'Admin';
NiceModal.show(ConfirmationModal, {
title: `Regenerate ${name} API Key`,
prompt: `You can regenerate ${name} API Key any time, but any scripts or applications using it will need to be updated.`,
okLabel: `Regenerate ${name} API Key`,
onOk: async (confirmModal) => {
const data = await refreshAPIKey({integrationId: integration.id, apiKeyId: apiKey.id});
modal.show({integration: data.integrations[0]});
setRegenerated(true);
confirmModal?.remove();
}
});
};
return <Modal
afterClose={() => {
updateRoute('integrations');
}}
dirty={saveState === 'unsaved'}
okColor='black'
okLabel='Save & close'
size='md'
testId='custom-integration-modal'
title={integrationTitle}
title={formState.name}
stickyFooter
onOk={async () => {}}
onOk={async () => {
toast.remove();
if (await handleSave()) {
modal.remove();
updateRoute('integrations');
} else {
showToast({
type: 'pageError',
message: 'Can\'t save integration! One or more fields have errors, please doublecheck you filled all mandatory fields'
});
}
}}
>
<div className='mt-7 flex w-full gap-7'>
<div>
<ImageUpload
height='120px'
id='custom-integration-icon'
imageURL={formState.icon_image || undefined}
width='120px'
onDelete={() => {}}
onImageClick={() => {}}
onUpload={() => {}}
onDelete={() => updateForm(state => ({...state, icon_image: null}))}
onUpload={async (file) => {
const imageUrl = getImageUrl(await uploadImage({file}));
updateForm(state => ({...state, icon_image: imageUrl}));
}}
>
Upload icon
</ImageUpload>
</div>
<div className='flex grow flex-col'>
<Form>
<TextField title='Title' />
<TextField title='Description' />
<TextField
error={Boolean(errors.name)}
hint={errors.name}
title='Title'
value={formState.name}
onBlur={validate}
onChange={e => updateForm(state => ({...state, name: e.target.value}))}
onKeyDown={() => clearError('name')}
/>
<TextField title='Description' value={formState.description} onChange={e => updateForm(state => ({...state, description: e.target.value}))} />
<div>
<APIKeys keys={[
{
label: 'Content API key',
text: '[content key here]',
hint: regenerated ? <div className='text-green'>Content API Key was successfully regenerated</div> : undefined
// onRegenerate: handleRegenerate
text: contentApiKey?.secret,
hint: contentKeyRegenerated ? <div className='text-green'>Content API Key was successfully regenerated</div> : undefined,
onRegenerate: () => contentApiKey && handleRegenerate(contentApiKey, setContentKeyRegenerated)
},
{
label: 'Admin API key',
text: '[api key here]',
hint: regenerated ? <div className='text-green'>Admin API Key was successfully regenerated</div> : undefined
// onRegenerate: handleRegenerate
text: adminApiKey?.secret,
hint: adminKeyRegenerated ? <div className='text-green'>Admin API Key was successfully regenerated</div> : undefined,
onRegenerate: () => adminApiKey && handleRegenerate(adminApiKey, setAdminKeyRegenerated)
},
{
label: 'API URL',
@ -77,51 +152,8 @@ const CustomIntegrationModal: React.FC<CustomIntegrationModalProps> = () => {
</div>
<div>
<Table>
<TableRow bgOnHover={false}>
<TableHead>1 webhook</TableHead>
<TableHead>Last triggered</TableHead>
<TableHead />
</TableRow>
<TableRow
action={
<Button color='red' label='Delete' link onClick={() => {}} />
}
hideActions
onClick={() => {
NiceModal.show(WebhookModal);
}}
>
<TableCell className='w-1/2'>
<div className='text-sm font-semibold'>Rebuild on post published</div>
<div className='grid grid-cols-[max-content_1fr] gap-x-1 text-xs leading-snug'>
<span className='text-grey-600'>Event:</span>
<span>Post published</span>
<span className='text-grey-600'>URL:</span>
<span>https://example.com</span>
</div>
</TableCell>
<TableCell className='w-1/2 text-sm'>
Tue Aug 15 2023 13:03:33
</TableCell>
</TableRow>
<TableRow bgOnHover={false} separator={false}>
<TableCell colSpan={3}>
<Button
color='green'
icon='add'
iconColorClass='text-green'
label='Add webhook'
size='sm'
link
onClick={() => {
NiceModal.show(WebhookModal);
}} />
</TableCell>
</TableRow>
</Table>
<WebhooksTable integration={integration} />
</div>
</Modal>;
};

View file

@ -1,13 +1,59 @@
import Form from '../../../../admin-x-ds/global/form/Form';
import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React from 'react';
import Select from '../../../../admin-x-ds/global/form/Select';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import toast from 'react-hot-toast';
import useForm from '../../../../hooks/useForm';
import validator from 'validator';
import webhookEventOptions from './webhookEventOptions';
import {Webhook, WebhooksResponseType, useCreateWebhook, useEditWebhook} from '../../../../api/webhooks';
import {showToast} from '../../../../admin-x-ds/global/Toast';
interface WebhookModalProps {}
interface WebhookModalProps {
webhook?: Webhook
integrationId: string
onSaved: (response: WebhooksResponseType) => void
}
const WebhookModal: React.FC<WebhookModalProps> = ({webhook, integrationId, onSaved}) => {
const modal = useModal();
const {mutateAsync: createWebhook} = useCreateWebhook();
const {mutateAsync: editWebhook} = useEditWebhook();
const {formState, updateForm, handleSave, errors, clearError, validate} = useForm<Partial<Webhook>>({
initialState: webhook || {},
onSave: async () => {
if (formState.id) {
onSaved(await editWebhook(formState as Webhook));
} else {
onSaved(await createWebhook({...formState, integration_id: integrationId}));
}
},
onValidate: () => {
const newErrors: Record<string, string> = {};
if (!formState.name) {
newErrors.name = 'Please enter a name';
}
if (!formState.event) {
newErrors.event = 'Please select an event';
}
if (!formState.target_url) {
newErrors.target_url = 'Please enter a target URL';
}
if (formState.target_url && !validator.isURL(formState.target_url)) {
newErrors.target_url = 'Please enter a valid URL';
}
return newErrors;
}
});
const WebhookModal: React.FC<WebhookModalProps> = () => {
return <Modal
okColor='black'
okLabel='Add'
@ -15,7 +61,17 @@ const WebhookModal: React.FC<WebhookModalProps> = () => {
testId='webhook-modal'
title='Add webhook'
formSheet
onOk={async () => {}}
onOk={async () => {
toast.remove();
if (await handleSave()) {
modal.remove();
} else {
showToast({
type: 'pageError',
message: 'Can\'t save webhook! One or more fields have errors, please doublecheck you filled all mandatory fields'
});
}
}}
>
<div className='mt-5'>
<Form
@ -23,70 +79,42 @@ const WebhookModal: React.FC<WebhookModalProps> = () => {
marginTop={false}
>
<TextField
error={Boolean(errors.name)}
hint={errors.name}
placeholder='Custom webhook'
title='Name'
value={formState.name}
onBlur={validate}
onChange={e => updateForm(state => ({...state, name: e.target.value}))}
onKeyDown={() => clearError('name')}
/>
<Select
options={[
{
label: 'Global',
options: [{label: 'Site changed', value: ''}]
},
{
label: 'Posts',
options: [
{label: 'Post created', value: ''},
{label: 'Post deleted', value: ''},
{label: 'Post updated', value: ''},
{label: 'Post published', value: ''},
{label: 'Published post updated', value: ''},
{label: 'Post unpublished', value: ''},
{label: 'Post scheduled', value: ''},
{label: 'Post unscheduled', value: ''},
{label: 'Tag added to post', value: ''},
{label: 'Tag removed from post', value: ''}
]
},
{
label: 'Pages',
options: [
{label: 'Page created', value: ''},
{label: 'Page deleted', value: ''},
{label: 'Page updated', value: ''},
{label: 'Page published', value: ''},
{label: 'Published page updated', value: ''},
{label: 'Page unpublished', value: ''},
{label: 'Tag added to page', value: ''},
{label: 'Tag removed from page', value: ''}
]
},
{
label: 'Tags',
options: [
{label: 'Tag created', value: ''},
{label: 'Tag deleted', value: ''},
{label: 'Tag updated', value: ''}
]
},
{
label: 'Members',
options: [
{label: 'Members created', value: ''},
{label: 'Members deleted', value: ''},
{label: 'Members updated', value: ''}
]
}
]}
error={Boolean(errors.event)}
hint={errors.event}
options={webhookEventOptions}
prompt='Select an event'
onSelect={() => {}}
selectedOption={formState.event}
onSelect={(event) => {
updateForm(state => ({...state, event}));
clearError('event');
}}
/>
<TextField
error={Boolean(errors.target_url)}
hint={errors.target_url}
placeholder='https://example.com'
title='Target URL'
type='url'
value={formState.target_url}
onBlur={validate}
onChange={e => updateForm(state => ({...state, target_url: e.target.value}))}
onKeyDown={() => clearError('target_url')}
/>
<TextField
placeholder='Psst...'
title='Secret'
value={formState.secret || undefined}
onChange={e => updateForm(state => ({...state, secret: e.target.value}))}
/>
</Form>
</div>

View file

@ -0,0 +1,117 @@
import Button from '../../../../admin-x-ds/global/Button';
import ConfirmationModal from '../../../../admin-x-ds/global/modal/ConfirmationModal';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import Table from '../../../../admin-x-ds/global/Table';
import TableCell from '../../../../admin-x-ds/global/TableCell';
import TableHead from '../../../../admin-x-ds/global/TableHead';
import TableRow from '../../../../admin-x-ds/global/TableRow';
import WebhookModal from './WebhookModal';
import {Integration} from '../../../../api/integrations';
import {getWebhookEventLabel} from './webhookEventOptions';
import {showToast} from '../../../../admin-x-ds/global/Toast';
import {useDeleteWebhook} from '../../../../api/webhooks';
const WebhooksTable: React.FC<{integration: Integration}> = ({integration}) => {
const {mutateAsync: deleteWebhook} = useDeleteWebhook();
const modal = useModal();
const handleDelete = (id: string) => {
NiceModal.show(ConfirmationModal, {
title: 'Are you sure?',
prompt: 'Deleting this webhook may prevent the integration from functioning.',
okColor: 'red',
okLabel: 'Delete Webhook',
onOk: async (confirmModal) => {
await deleteWebhook(id);
confirmModal?.remove();
modal.show({
integration: {
...integration,
webhooks: integration.webhooks?.filter(webhook => webhook.id !== id)
}
});
showToast({
message: 'Webhook deleted',
type: 'success'
});
}
});
};
return <Table>
<TableRow bgOnHover={false}>
<TableHead>{integration.webhooks?.length || 0} {integration.webhooks?.length === 1 ? 'webhook' : 'webhooks'}</TableHead>
<TableHead>Last triggered</TableHead>
<TableHead />
</TableRow>
{integration.webhooks?.map(webhook => (
<TableRow
action={
<Button color='red' label='Delete' link onClick={(e) => {
e?.stopPropagation();
handleDelete(webhook.id);
}} />
}
hideActions
onClick={() => {
NiceModal.show(WebhookModal, {
webhook,
integrationId:
integration.id,
onSaved: ({webhooks: [updated]}) => modal.show({
integration: {
...integration,
webhooks: integration.webhooks?.map(current => (current.id === updated.id ? updated : current))
}
})
});
}}
>
<TableCell className='w-1/2'>
<div className='text-sm font-semibold'>{webhook.name}</div>
<div className='grid grid-cols-[max-content_1fr] gap-x-1 text-xs leading-snug'>
<span className='text-grey-600'>Event:</span>
<span>{getWebhookEventLabel(webhook.event)}</span>
<span className='text-grey-600'>URL:</span>
<span>{webhook.target_url}</span>
</div>
</TableCell>
<TableCell className='w-1/2 text-sm'>
{webhook.last_triggered_at && new Date(webhook.last_triggered_at).toLocaleString('default', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})}
</TableCell>
</TableRow>
))}
<TableRow bgOnHover={false} separator={false}>
<TableCell colSpan={3}>
<Button
color='green'
icon='add'
iconColorClass='text-green'
label='Add webhook'
size='sm'
link
onClick={() => {
NiceModal.show(WebhookModal, {
integrationId: integration.id,
onSaved: ({webhooks: [added]}) => modal.show({
integration: {
...integration,
webhooks: (integration.webhooks || []).concat(added)
}
})
});
}} />
</TableCell>
</TableRow>
</Table>;
};
export default WebhooksTable;

View file

@ -37,7 +37,7 @@ const ZapierModal = NiceModal.create(() => {
const zapierDisabled = config.hostSettings?.limits?.customIntegrations?.disabled;
const integration = integrations.find(({slug}) => slug === 'zapier');
const adminApiKey = integration?.api_keys.find(key => key.type === 'admin');
const adminApiKey = integration?.api_keys?.find(key => key.type === 'admin');
useEffect(() => {
if (zapierDisabled) {

View file

@ -0,0 +1,59 @@
import {SelectOptionGroup} from '../../../../admin-x-ds/global/form/Select';
const webhookEventOptions: SelectOptionGroup[] = [
{
label: 'Global',
options: [{value: 'site.changed', label: 'Site changed (rebuild)'}]
},
{
label: 'Posts',
options: [
{value: 'post.added', label: 'Post created'},
{value: 'post.deleted', label: 'Post deleted'},
{value: 'post.edited', label: 'Post updated'},
{value: 'post.published', label: 'Post published'},
{value: 'post.published.edited', label: 'Published post updated'},
{value: 'post.unpublished', label: 'Post unpublished'},
{value: 'post.scheduled', label: 'Post scheduled'},
{value: 'post.unscheduled', label: 'Post unscheduled'},
{value: 'post.tag.attached', label: 'Tag added to post'},
{value: 'post.tag.detached', label: 'Tag removed from post'}
]
},
{
label: 'Pages',
options: [
{value: 'page.added', label: 'Page created'},
{value: 'page.deleted', label: 'Page deleted'},
{value: 'page.edited', label: 'Page updated'},
{value: 'page.published', label: 'Page published'},
{value: 'page.published.edited', label: 'Published page updated'},
{value: 'page.unpublished', label: 'Page unpublished'},
{value: 'page.tag.attached', label: 'Tag added to page'},
{value: 'page.tag.detached', label: 'Tag removed from page'}
]
},
{
label: 'Tags',
options: [
{value: 'tag.added', label: 'Tag created'},
{value: 'tag.edited', label: 'Tag updated'},
{value: 'tag.deleted', label: 'Tag deleted'}
]
},
{
label: 'Members',
options: [
{value: 'member.added', label: 'Member added'},
{value: 'member.edited', label: 'Member updated'},
{value: 'member.deleted', label: 'Member deleted'}
]
}
];
export default webhookEventOptions;
export const getWebhookEventLabel = (value: string) => {
const option = webhookEventOptions.flatMap(({options}) => options).find(opt => opt.value === value);
return option?.label;
};

View file

@ -5,7 +5,7 @@ import NiceModal from '@ebay/nice-modal-react';
import React, {useState} from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import TabView from '../../../admin-x-ds/global/TabView';
import useHandleRoute from '../../../hooks/useHandleRoute';
import useDetailModalRoute from '../../../hooks/useDetailModalRoute';
import useRouting from '../../../hooks/useRouting';
import {modalRoutes} from '../../providers/RoutingProvider';
import {useBrowseNewsletters} from '../../../api/newsletters';
@ -18,15 +18,11 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
const [selectedTab, setSelectedTab] = useState('active-newsletters');
const {data: {newsletters} = {}} = useBrowseNewsletters();
useHandleRoute(modalRoutes.showNewsletter, ({id}) => {
const newsletter = newsletters?.find(u => u.id === id);
if (!newsletter) {
return;
}
NiceModal.show(NewsletterDetailModal, {newsletter});
}, [newsletters]);
useDetailModalRoute({
route: modalRoutes.showNewsletter,
items: newsletters || [],
showModal: newsletter => NiceModal.show(NewsletterDetailModal, {newsletter})
});
const buttons = (
<Button color='green' label='Add newsletter' link={true} onClick={() => {

View file

@ -8,7 +8,7 @@ import React, {useState} from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import TabView from '../../../admin-x-ds/global/TabView';
import UserDetailModal from './UserDetailModal';
import useHandleRoute from '../../../hooks/useHandleRoute';
import useDetailModalRoute from '../../../hooks/useDetailModalRoute';
import useRouting from '../../../hooks/useRouting';
import useStaffUsers from '../../../hooks/useStaffUsers';
import {User} from '../../../api/users';
@ -196,15 +196,12 @@ const Users: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {updateRoute} = useRouting();
useHandleRoute(modalRoutes.showUser, ({slug}) => {
const user = users.find(u => u.slug === slug);
if (!user) {
return;
}
NiceModal.show(UserDetailModal, {user});
}, [users]);
useDetailModalRoute({
route: modalRoutes.showUser,
items: users,
field: 'slug',
showModal: user => NiceModal.show(UserDetailModal, {user})
});
const showInviteModal = () => {
updateRoute('users/invite');

View file

@ -5,7 +5,7 @@ import StripeButton from '../../../admin-x-ds/settings/StripeButton';
import TabView from '../../../admin-x-ds/global/TabView';
import TierDetailModal from './tiers/TierDetailModal';
import TiersList from './tiers/TiersList';
import useHandleRoute from '../../../hooks/useHandleRoute';
import useDetailModalRoute from '../../../hooks/useDetailModalRoute';
import useRouting from '../../../hooks/useRouting';
import {Tier, getActiveTiers, getArchivedTiers, useBrowseTiers} from '../../../api/tiers';
import {checkStripeEnabled} from '../../../api/settings';
@ -20,13 +20,11 @@ const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => {
const archivedTiers = getArchivedTiers(tiers || []);
const {updateRoute} = useRouting();
useHandleRoute(modalRoutes.showTier, ({id}) => {
const tier = tiers?.find(t => t.id === id);
if (tier) {
NiceModal.show(TierDetailModal, {tier});
}
}, [tiers]);
useDetailModalRoute({
route: modalRoutes.showTier,
items: tiers || [],
showModal: tier => NiceModal.show(TierDetailModal, {tier})
});
const openConnectModal = () => {
updateRoute('stripe-connect');

View file

@ -0,0 +1,30 @@
import {RouteContext} from '../components/providers/RoutingProvider';
import {useContext, useEffect} from 'react';
const useDetailModalRoute = <Item extends {[key in Field]: string}, Field extends string = 'id'>({route, items, field = 'id' as Field, showModal}: {
route: string;
field?: Field;
items: Item[];
showModal: (item: Item) => void;
}) => {
const {addRouteChangeListener} = useContext(RouteContext);
// The dependencies here are a bit tricky. We want to re-evaluate the useEffect if a new item is
// added (because we may want to show a detail modal for the new item) but not if an existing item
// is modified (because we likely want to remove the edit modal for that item)
const itemIds = items.map(item => item[field]).join(',');
useEffect(() => {
const unsubscribe = addRouteChangeListener({route, callback: (params) => {
const item = items.find(it => it[field] === params[field]);
if (item) {
showModal(item);
}
}});
return unsubscribe;
}, [route, itemIds]); // eslint-disable-line react-hooks/exhaustive-deps
};
export default useDetailModalRoute;

View file

@ -1,14 +0,0 @@
import {RouteContext, RouteParams} from '../components/providers/RoutingProvider';
import {useContext, useEffect} from 'react';
const useHandleRoute = (route: string, callback: (params: RouteParams) => void, dependencies: unknown[]) => {
const {addRouteChangeListener} = useContext(RouteContext);
useEffect(() => {
const unsubscribe = addRouteChangeListener({route, callback});
return unsubscribe;
}, [route, ...dependencies]); // eslint-disable-line react-hooks/exhaustive-deps
};
export default useHandleRoute;

View file

@ -12,6 +12,7 @@ export type UsersHook = {
authorUsers: User[];
contributorUsers: User[];
currentUser: User|null;
isLoading: boolean;
};
function getUsersByRole(users: User[], role: string): User[] {
@ -28,9 +29,9 @@ function getOwnerUser(users: User[]): User {
const useStaffUsers = (): UsersHook => {
const {currentUser} = useGlobalData();
const {data: {users} = {users: []}} = useBrowseUsers();
const {data: {invites} = {invites: []}} = useBrowseInvites();
const {data: {roles} = {}} = useBrowseRoles();
const {data: {users} = {users: []}, isLoading: usersLoading} = useBrowseUsers();
const {data: {invites} = {invites: []}, isLoading: invitesLoading} = useBrowseInvites();
const {data: {roles} = {}, isLoading: rolesLoading} = useBrowseRoles();
const ownerUser = getOwnerUser(users);
const adminUsers = getUsersByRole(users, 'Administrator');
@ -55,7 +56,8 @@ const useStaffUsers = (): UsersHook => {
authorUsers,
contributorUsers,
currentUser,
invites: mappedInvites
invites: mappedInvites,
isLoading: usersLoading || invitesLoading || rolesLoading
};
};