PluginProbe ʕ •ᴥ•ʔ
Presto Player / trunk
Presto Player vtrunk
4.3.1 4.3.0 4.2.4 4.2.3 4.2.2 4.2.0 4.2.1 trunk 1.10.0 1.10.1 1.10.2 1.11.0 1.12.0 1.13.0 1.14.0 1.14.1 1.5.10 1.5.11 1.5.12 1.5.13 1.5.14 1.5.15 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.10 1.6.11 1.6.12 1.6.13 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.8.0 1.8.1 1.8.2 1.8.3 1.8.4 1.8.5 1.8.6 1.9.0 1.9.1 1.9.10 1.9.11 1.9.12 1.9.13 1.9.14 1.9.2 1.9.3 1.9.4 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.1 2.0.10 2.0.11 2.0.12 2.0.13 2.0.14 2.0.15 2.0.16 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.0.9 2.1.0 2.2.0 2.2.1 2.2.2 2.2.3 2.2.3-beta1 2.3.0 2.3.1 2.3.2 2.3.3 3.0.0 3.0.0-beta1 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.1.0 3.1.1 3.1.2 3.1.3 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4
presto-player / src / admin / dashboard / pages / settings / integrations / WebhooksPage.js
presto-player / src / admin / dashboard / pages / settings / integrations Last commit date
BunnyNetPage.js 1 month ago EmailCapturePage.js 1 month ago GoogleAnalyticsPage.js 1 month ago WebhooksPage.js 1 month ago YouTubePage.js 1 month ago
WebhooksPage.js
438 lines
1 import { __ } from '@wordpress/i18n';
2 import { useEffect, useRef, useState } from 'react';
3 import { useSelect, useDispatch } from '@wordpress/data';
4 import { store as coreStore } from '@wordpress/core-data';
5 import isEqual from 'lodash/isEqual';
6 import {
7 Button,
8 Container,
9 Dialog,
10 Input,
11 Label,
12 Select,
13 Loader,
14 Text,
15 toast,
16 Tooltip,
17 } from '@bsf/force-ui';
18 import { Pencil, Trash2, Plus, X, Info } from 'lucide-react';
19 import SettingsPageShell from '../shared/SettingsPageShell';
20 import SectionCard from '../shared/SectionCard';
21 import ProGate from '../shared/ProGate';
22 import ConfirmDialog from '../shared/ConfirmDialog';
23 import useRegisterActivePage from '../../../hooks/useRegisterActivePage';
24 import { WEBHOOK_ENTITY } from '../config';
25
26 const isPremium = window.prestoPlayer?.isPremium ?? false;
27
28 const METHOD_OPTIONS = [
29 { label: 'GET', value: 'GET' },
30 { label: 'POST', value: 'POST' },
31 { label: 'PUT', value: 'PUT' },
32 ];
33
34 const DEFAULT_FORM = {
35 email_name: 'email',
36 method: 'POST',
37 name: '',
38 url: '',
39 headers: [],
40 };
41
42 const HeadersEditor = ( { headers, setHeaders } ) => {
43 const update = ( index, patch ) =>
44 setHeaders(
45 ( headers || [] ).map( ( h, i ) =>
46 i === index ? { ...h, ...patch } : h
47 )
48 );
49 const remove = ( index ) =>
50 setHeaders( ( headers || [] ).filter( ( _, i ) => i !== index ) );
51 const add = () =>
52 setHeaders( [ ...( headers || [] ), { name: '', value: '' } ] );
53
54 return (
55 <Container direction="column" className="gap-2">
56 <Label size="sm">{ __( 'Headers', 'presto-player' ) }</Label>
57 { ( headers || [] ).map( ( { name, value }, i ) => (
58 <Container key={ i } align="center" className="gap-2">
59 <Container.Item grow={ 1 }>
60 <Input
61 size="md"
62 placeholder={ __( 'Header Name', 'presto-player' ) }
63 value={ name || '' }
64 onChange={ ( val ) => update( i, { name: val } ) }
65 />
66 </Container.Item>
67 <Container.Item grow={ 1 }>
68 <Input
69 size="md"
70 placeholder={ __( 'Value', 'presto-player' ) }
71 value={ value || '' }
72 onChange={ ( val ) => update( i, { value: val } ) }
73 />
74 </Container.Item>
75 <Container.Item>
76 <Button
77 variant="ghost"
78 size="xs"
79 icon={ <X className="w-4 h-4" /> }
80 onClick={ () => remove( i ) }
81 />
82 </Container.Item>
83 </Container>
84 ) ) }
85 <Button
86 variant="outline"
87 size="xs"
88 className="self-start"
89 onClick={ add }
90 >
91 { __( 'Add Header', 'presto-player' ) }
92 </Button>
93 </Container>
94 );
95 };
96
97 const WebhookDialog = ( { open, webhook, onClose } ) => {
98 const { saveEntityRecord } = useDispatch( coreStore );
99 const { invalidateResolution } = useDispatch( coreStore );
100 const [ form, setForm ] = useState( webhook || DEFAULT_FORM );
101 const [ busy, setBusy ] = useState( false );
102
103 // Snapshot of the form at dialog-open time. Used by closeWithGuard below
104 // to diff against the current draft so we only prompt when something
105 // actually changed. Re-snapshotting on (open, webhook) keeps the
106 // "edit-existing" case honest when the user reopens the same dialog.
107 const baselineRef = useRef( webhook || DEFAULT_FORM );
108
109 useEffect( () => {
110 if ( open ) {
111 const initial = webhook || DEFAULT_FORM;
112 setForm( initial );
113 baselineRef.current = initial;
114 }
115 }, [ open, webhook ] );
116
117 const update = ( patch ) => setForm( ( p ) => ( { ...p, ...patch } ) );
118 const setHeaders = ( headers ) =>
119 setForm( ( p ) => ( {
120 ...p,
121 headers: typeof headers === 'function' ? headers( p.headers ) : headers,
122 } ) );
123
124 const closeWithGuard = () => {
125 const isDirty = ! isEqual( form, baselineRef.current );
126 if (
127 isDirty &&
128 // eslint-disable-next-line no-alert
129 ! window.confirm(
130 __( 'Discard unsaved changes to this webhook?', 'presto-player' )
131 )
132 ) {
133 return;
134 }
135 onClose();
136 };
137
138 const submit = async () => {
139 if ( ! form.name?.trim() || ! form.url?.trim() ) {
140 toast.error( __( 'Name and URL are required.', 'presto-player' ) );
141 return;
142 }
143 setBusy( true );
144 try {
145 await saveEntityRecord( ...WEBHOOK_ENTITY, form, { throwOnError: true } );
146 // core-data caches the individual record, but the list query
147 // (getEntityRecords) has its own resolver state that doesn't
148 // auto-invalidate for custom entities. Force a re-resolve so the
149 // parent list picks up the newly-created or edited record.
150 await invalidateResolution( 'getEntityRecords', WEBHOOK_ENTITY );
151 toast.success(
152 form?.id
153 ? __( 'Webhook updated.', 'presto-player' )
154 : __( 'Webhook created.', 'presto-player' )
155 );
156 onClose();
157 } catch ( e ) {
158 toast.error(
159 e?.message || __( 'Something went wrong.', 'presto-player' )
160 );
161 } finally {
162 setBusy( false );
163 }
164 };
165
166 return (
167 <Dialog
168 design="simple"
169 exitOnEsc
170 scrollLock
171 open={ open }
172 setOpen={ ( next ) => {
173 if ( ! next ) {
174 closeWithGuard();
175 }
176 } }
177 >
178 <Dialog.Backdrop />
179 <Dialog.Panel>
180 <Dialog.Header>
181 <Dialog.Title>
182 { form?.id
183 ? __( 'Edit Webhook', 'presto-player' )
184 : __( 'Add Webhook', 'presto-player' ) }
185 </Dialog.Title>
186 </Dialog.Header>
187
188 <Container direction="column" className="px-6 pb-2 gap-4">
189 <Container.Item className="flex flex-col gap-2">
190 <Label size="sm">{ __( 'Name', 'presto-player' ) }</Label>
191 <Input
192 size="md"
193 value={ form.name || '' }
194 placeholder={ __( 'Webhook feed name', 'presto-player' ) }
195 onChange={ ( val ) => update( { name: val } ) }
196 />
197 </Container.Item>
198
199 <Container.Item className="flex flex-col gap-2">
200 <div className="flex items-center gap-1.5">
201 <Label size="sm">{ __( 'Request URL', 'presto-player' ) }</Label>
202 <Tooltip content="The external endpoint that receives webhook data when a form event fires." arrow placement="right">
203 <Info className="size-3.5 text-icon-secondary cursor-help shrink-0" />
204 </Tooltip>
205 </div>
206 <Input
207 size="md"
208 type="url"
209 value={ form.url || '' }
210 placeholder={ __( 'Webhook URL', 'presto-player' ) }
211 onChange={ ( val ) => update( { url: val } ) }
212 />
213 </Container.Item>
214
215 <Container.Item className="flex flex-col gap-2">
216 <div className="flex items-center gap-1.5">
217 <Label size="sm">{ __( 'Request Method', 'presto-player' ) }</Label>
218 <Tooltip content="POST sends data in the request body (recommended). GET appends data to the URL." arrow placement="right">
219 <Info className="size-3.5 text-icon-secondary cursor-help shrink-0" />
220 </Tooltip>
221 </div>
222 <Select
223 size="md"
224 value={ form.method || 'POST' }
225 onChange={ ( val ) => update( { method: val } ) }
226 >
227 <Select.Button />
228 <Select.Options>
229 { METHOD_OPTIONS.map( ( m ) => (
230 <Select.Option key={ m.value } value={ m.value }>
231 { m.label }
232 </Select.Option>
233 ) ) }
234 </Select.Options>
235 </Select>
236 </Container.Item>
237
238 <Container.Item className="flex flex-col gap-2">
239 <div className="flex items-center gap-1.5">
240 <Label size="sm">{ __( 'Email Name', 'presto-player' ) }</Label>
241 <Tooltip content='The key name used for the email field in the webhook payload. Default is "email".' arrow placement="right">
242 <Info className="size-3.5 text-icon-secondary cursor-help shrink-0" />
243 </Tooltip>
244 </div>
245 <Input
246 size="md"
247 value={ form.email_name || '' }
248 placeholder={ __(
249 'The name (key) of the email sent.',
250 'presto-player'
251 ) }
252 onChange={ ( val ) => update( { email_name: val } ) }
253 />
254 </Container.Item>
255
256 <Container.Item>
257 <HeadersEditor headers={ form.headers } setHeaders={ setHeaders } />
258 </Container.Item>
259 </Container>
260
261 <Dialog.Footer>
262 <Button variant="ghost" onClick={ closeWithGuard } disabled={ busy }>
263 { __( 'Cancel', 'presto-player' ) }
264 </Button>
265 <Button
266 variant="primary"
267 onClick={ submit }
268 disabled={ busy }
269 loading={ busy }
270 >
271 { form?.id
272 ? __( 'Update', 'presto-player' )
273 : __( 'Create', 'presto-player' ) }
274 </Button>
275 </Dialog.Footer>
276 </Dialog.Panel>
277 </Dialog>
278 );
279 };
280
281 const WebhookRow = ( { webhook, onEdit, onDelete, busy } ) => (
282 <Container
283 align="center"
284 className="justify-between border border-border-subtle rounded-lg p-4"
285 >
286 <Container.Item grow={ 1 } className="flex flex-col min-w-0">
287 <Text weight="semibold" size="sm" className="truncate">
288 { webhook.name || __( 'Untitled webhook', 'presto-player' ) }
289 </Text>
290 <Text size="xs" className="text-text-secondary truncate">
291 { webhook.url }
292 </Text>
293 </Container.Item>
294 <Container.Item className="flex items-center gap-1">
295 { busy && <Loader size="sm" variant="primary" /> }
296 <Button
297 variant="ghost"
298 size="xs"
299 icon={ <Pencil className="w-4 h-4" /> }
300 onClick={ onEdit }
301 disabled={ busy }
302 />
303 <Button
304 variant="ghost"
305 size="xs"
306 icon={ <Trash2 className="w-4 h-4" /> }
307 onClick={ onDelete }
308 disabled={ busy }
309 />
310 </Container.Item>
311 </Container>
312 );
313
314 const WebhooksPage = ( { registerActivePage } ) => {
315 const [ editing, setEditing ] = useState( null );
316 const [ creating, setCreating ] = useState( false );
317 const [ confirmDeleteId, setConfirmDeleteId ] = useState( null );
318
319 const { deleteEntityRecord, invalidateResolution } = useDispatch( coreStore );
320
321 // One parent-level subscription that yields a {id → busy} map, rather than
322 // one useSelect per row. Cheaper and lets WebhookRow stay purely
323 // presentational (takes `busy` as a prop instead of subscribing itself).
324 const { webhooks, loading, busyMap } = useSelect( ( select ) => {
325 const store = select( coreStore );
326 const list = store.getEntityRecords( ...WEBHOOK_ENTITY ) || [];
327 const busy = {};
328 list.forEach( ( w ) => {
329 busy[ w.id ] =
330 store.isSavingEntityRecord( ...WEBHOOK_ENTITY, w.id ) ||
331 store.isDeletingEntityRecord( ...WEBHOOK_ENTITY, w.id );
332 } );
333 return {
334 webhooks: list,
335 loading: store.isResolving( 'getEntityRecords', WEBHOOK_ENTITY ),
336 busyMap: busy,
337 };
338 }, [] );
339
340 useRegisterActivePage( registerActivePage, {
341 isDirty: false,
342 save: null,
343 discard: null,
344 } );
345
346 const handleDelete = async () => {
347 const id = confirmDeleteId;
348 if ( ! id ) {
349 return;
350 }
351 try {
352 await deleteEntityRecord( ...WEBHOOK_ENTITY, id, undefined, {
353 throwOnError: true,
354 } );
355 await invalidateResolution( 'getEntityRecords', WEBHOOK_ENTITY );
356 toast.success( __( 'Webhook deleted.', 'presto-player' ) );
357 setConfirmDeleteId( null );
358 } catch ( e ) {
359 toast.error(
360 e?.message || __( 'Could not delete webhook.', 'presto-player' )
361 );
362 }
363 };
364
365 return (
366 <SettingsPageShell
367 title={ __( 'Webhooks', 'presto-player' ) }
368 canSave={ false }
369 >
370 <SectionCard>
371 <ProGate
372 enabled={ isPremium }
373 title={ __( 'Webhooks', 'presto-player' ) }
374 description={ __(
375 'Connect captured emails to any external service via webhooks.',
376 'presto-player'
377 ) }
378 >
379 <Container direction="column" className="gap-4">
380 { ! loading && webhooks.length === 0 && (
381 <Text size="sm" className="text-text-secondary">
382 { __(
383 'No webhooks yet. Create one to get started.',
384 'presto-player'
385 ) }
386 </Text>
387 ) }
388
389 { webhooks.map( ( webhook ) => (
390 <WebhookRow
391 key={ webhook.id }
392 webhook={ webhook }
393 busy={ !! busyMap[ webhook.id ] }
394 onEdit={ () => setEditing( webhook ) }
395 onDelete={ () => setConfirmDeleteId( webhook.id ) }
396 />
397 ) ) }
398
399 <Button
400 variant="outline"
401 size="sm"
402 className="self-start"
403 icon={ <Plus className="w-4 h-4" /> }
404 onClick={ () => setCreating( true ) }
405 >
406 { __( 'Create Webhook', 'presto-player' ) }
407 </Button>
408 </Container>
409 </ProGate>
410 </SectionCard>
411
412 <WebhookDialog
413 open={ creating }
414 webhook={ null }
415 onClose={ () => setCreating( false ) }
416 />
417 <WebhookDialog
418 open={ !! editing }
419 webhook={ editing }
420 onClose={ () => setEditing( null ) }
421 />
422 <ConfirmDialog
423 open={ !! confirmDeleteId }
424 title={ __( 'Delete webhook?', 'presto-player' ) }
425 description={ __(
426 'This will permanently remove the webhook. This action cannot be undone.',
427 'presto-player'
428 ) }
429 confirmLabel={ __( 'Delete', 'presto-player' ) }
430 onConfirm={ handleDelete }
431 onCancel={ () => setConfirmDeleteId( null ) }
432 />
433 </SettingsPageShell>
434 );
435 };
436
437 export default WebhooksPage;
438