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:
parent
850cc7a9a1
commit
efd6150db0
16 changed files with 475 additions and 224 deletions
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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');
|
||||
}} />
|
||||
);
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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={() => {
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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');
|
||||
|
|
30
apps/admin-x-settings/src/hooks/useDetailModalRoute.tsx
Normal file
30
apps/admin-x-settings/src/hooks/useDetailModalRoute.tsx
Normal 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;
|
|
@ -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;
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue