@flowuent-org/diagramming-core 1.4.3 → 1.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,680 @@
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { Handle, Position, useNodeId } from '@xyflow/react';
4
+ import {
5
+ Box,
6
+ Typography,
7
+ Chip,
8
+ IconButton,
9
+ Card,
10
+ Button,
11
+ CircularProgress,
12
+ Tooltip,
13
+ LinearProgress,
14
+ } from '@mui/material';
15
+ import {
16
+ AccessTime as AccessTimeIcon,
17
+ CheckCircle as CheckCircleIcon,
18
+ Error as ErrorIcon,
19
+ } from '@mui/icons-material';
20
+ import { RiCloseLine } from 'react-icons/ri';
21
+ import ReactJson from 'react-json-view';
22
+ import { useTranslation } from 'react-i18next';
23
+ import { useDiagram } from '../../contexts/DiagramProvider';
24
+ import { useSearch } from '../../contexts/SearchContext';
25
+ import { NodeActionButtons } from './NodeActionButtons';
26
+ import { getStatusColor } from './statusColors';
27
+ import { AutomationNodeHeaderSection } from './AutomationNodeHeader';
28
+ import {
29
+ GOOGLE_SERVICE_CONFIGS,
30
+ GoogleServiceConfig,
31
+ GoogleServiceType,
32
+ } from './googleServiceNodeConfig';
33
+
34
+ const getGoogleUserEmail = (data: AutomationGoogleServiceNodeData): string | undefined => {
35
+ const formData = data.formData || {};
36
+ const parameters = data.parameters || {};
37
+
38
+ return (
39
+ (formData.googleUserEmail as string | undefined) ||
40
+ parameters.googleUserEmail ||
41
+ (formData.googleEmail as string | undefined) ||
42
+ parameters.googleEmail ||
43
+ data.googleAuth?.email
44
+ );
45
+ };
46
+
47
+ const isGoogleAuthenticated = (data: AutomationGoogleServiceNodeData): boolean => {
48
+ const formData = data.formData || {};
49
+ const parameters = data.parameters || {};
50
+
51
+ return !!(
52
+ formData.googleAccessToken ||
53
+ parameters.googleAccessToken ||
54
+ getGoogleUserEmail(data)
55
+ );
56
+ };
57
+
58
+ export interface AutomationGoogleServiceNodeData {
59
+ label: string;
60
+ description: string;
61
+ headerText?: string;
62
+ serviceType: GoogleServiceType;
63
+ operationType?: string;
64
+ status:
65
+ | 'Ready'
66
+ | 'Running'
67
+ | 'Completed'
68
+ | 'Failed'
69
+ | 'Need to Config'
70
+ | 'authenticated'
71
+ | 'idle';
72
+ parameters?: {
73
+ useUserAccount?: boolean;
74
+ googleAccessToken?: string;
75
+ googleRefreshToken?: string;
76
+ googleTokenExpiresAt?: number;
77
+ googleUserEmail?: string;
78
+ googleEmail?: string;
79
+ googleUserName?: string;
80
+ googleUserId?: string;
81
+ to?: string;
82
+ subject?: string;
83
+ message?: string;
84
+ documentId?: string;
85
+ documentTitle?: string;
86
+ presentationId?: string;
87
+ presentationTitle?: string;
88
+ meetingTitle?: string;
89
+ meetingLink?: string;
90
+ [key: string]: unknown;
91
+ };
92
+ googleAuth?: {
93
+ isLoading?: boolean;
94
+ email?: string;
95
+ userName?: string;
96
+ };
97
+ formData?: {
98
+ nodeId?: string;
99
+ title?: string;
100
+ type?: string;
101
+ operationType?: string;
102
+ [key: string]: unknown;
103
+ };
104
+ lastRun?: string;
105
+ duration?: string;
106
+ executionResult?: {
107
+ success: boolean;
108
+ data?: unknown;
109
+ error?: string;
110
+ };
111
+ onGoogleConnect?: () => void;
112
+ onGoogleDisconnect?: () => void;
113
+ }
114
+
115
+ export interface AutomationGoogleServiceNodeProps {
116
+ data: AutomationGoogleServiceNodeData;
117
+ selected?: boolean;
118
+ config: GoogleServiceConfig;
119
+ }
120
+
121
+ const isTokenExpired = (expiresAt?: number): boolean => {
122
+ if (!expiresAt) return false;
123
+ return Date.now() > expiresAt;
124
+ };
125
+
126
+ const getTimeUntilExpiry = (expiresAt?: number): string => {
127
+ if (!expiresAt) return 'No expiry';
128
+ const diff = expiresAt - Date.now();
129
+ if (diff <= 0) return 'Expired';
130
+ const minutes = Math.floor(diff / 60000);
131
+ if (minutes < 60) return `${minutes}m`;
132
+ const hours = Math.floor(minutes / 60);
133
+ return `${hours}h ${minutes % 60}m`;
134
+ };
135
+
136
+ export const AutomationGoogleServiceNode: React.FC<AutomationGoogleServiceNodeProps> = ({
137
+ data,
138
+ selected,
139
+ config,
140
+ }) => {
141
+ const { t } = useTranslation();
142
+ const { highlightText } = useSearch();
143
+ const [isJsonOpen, setIsJsonOpen] = useState(false);
144
+ const rootRef = useRef<ReturnType<typeof createRoot> | null>(null);
145
+ const portalRef = useRef<HTMLDivElement | null>(null);
146
+ const nodeId = useNodeId();
147
+ const setSelectedNode = useDiagram((state) => state.setSelectedNode);
148
+
149
+ const operationType =
150
+ data.operationType || data.formData?.operationType || config.defaultOperation;
151
+ const operationConfig =
152
+ config.operations[operationType] || config.operations[config.defaultOperation];
153
+ const OperationIcon = operationConfig.icon;
154
+ const BrandIcon = config.BrandIcon;
155
+
156
+ const isAuthenticated = isGoogleAuthenticated(data);
157
+ const isAuthLoading = data.googleAuth?.isLoading || false;
158
+ const userEmail = getGoogleUserEmail(data);
159
+ const userName = data.googleAuth?.userName || data.parameters?.googleUserName;
160
+ const tokenExpiresAt =
161
+ data.parameters?.googleTokenExpiresAt ||
162
+ (data.formData?.googleTokenExpiresAt as number | undefined);
163
+ const tokenExpired = isTokenExpired(tokenExpiresAt);
164
+
165
+ const status = data.status || 'Ready';
166
+ const executionProgress = status === 'Running' ? 50 : 0;
167
+ const statusConfig = getStatusColor(
168
+ status,
169
+ status === 'authenticated' ? 'authenticated' : 'ready',
170
+ );
171
+
172
+ const handleClose = () => {
173
+ setIsJsonOpen(false);
174
+ if (rootRef.current) {
175
+ rootRef.current.unmount();
176
+ rootRef.current = null;
177
+ }
178
+ if (portalRef.current) {
179
+ document.body.removeChild(portalRef.current);
180
+ portalRef.current = null;
181
+ }
182
+ };
183
+
184
+ useEffect(() => {
185
+ const handleClickOutside = (event: MouseEvent) => {
186
+ if (isJsonOpen && !(event.target as Element).closest('#automation-json-popover')) {
187
+ handleClose();
188
+ }
189
+ };
190
+ document.addEventListener('mousedown', handleClickOutside);
191
+ return () => {
192
+ document.removeEventListener('mousedown', handleClickOutside);
193
+ };
194
+ }, [isJsonOpen]);
195
+
196
+ useEffect(() => {
197
+ if (isJsonOpen) {
198
+ const portalRoot = document.createElement('div');
199
+ document.body.appendChild(portalRoot);
200
+ portalRef.current = portalRoot;
201
+
202
+ const root = createRoot(portalRoot);
203
+ rootRef.current = root;
204
+
205
+ root.render(
206
+ <Card
207
+ id="automation-json-popover"
208
+ sx={{
209
+ position: 'fixed',
210
+ top: 0,
211
+ right: 0,
212
+ zIndex: 9999,
213
+ width: '400px',
214
+ height: '100vh',
215
+ overflow: 'auto',
216
+ bgcolor: '#242424',
217
+ color: '#fff',
218
+ border: '1px solid #333',
219
+ }}
220
+ >
221
+ <Box
222
+ sx={{
223
+ p: 2,
224
+ borderBottom: '1px solid #333',
225
+ display: 'flex',
226
+ justifyContent: 'space-between',
227
+ alignItems: 'center',
228
+ }}
229
+ >
230
+ <Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
231
+ {config.serviceLabel} Configuration
232
+ </Typography>
233
+ <IconButton size="small" onClick={handleClose} sx={{ color: '#fff' }}>
234
+ <RiCloseLine />
235
+ </IconButton>
236
+ </Box>
237
+ <Box sx={{ p: 2 }}>
238
+ <ReactJson
239
+ src={data}
240
+ theme="monokai"
241
+ collapsed={1}
242
+ displayDataTypes={false}
243
+ enableClipboard={false}
244
+ style={{
245
+ backgroundColor: 'transparent',
246
+ fontSize: '12px',
247
+ }}
248
+ />
249
+ </Box>
250
+ </Card>,
251
+ );
252
+ }
253
+
254
+ return () => {
255
+ if (rootRef.current) {
256
+ rootRef.current.unmount();
257
+ rootRef.current = null;
258
+ }
259
+ if (portalRef.current && portalRef.current.parentNode) {
260
+ document.body.removeChild(portalRef.current);
261
+ portalRef.current = null;
262
+ }
263
+ };
264
+ }, [config.serviceLabel, data, isJsonOpen]);
265
+
266
+ const renderStatusBadge = () => (
267
+ <Chip
268
+ size="small"
269
+ label={
270
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
271
+ {status === 'Completed' && (
272
+ <CheckCircleIcon sx={{ fontSize: 12 }} />
273
+ )}
274
+ {status === 'Failed' && <ErrorIcon sx={{ fontSize: 12 }} />}
275
+ <span>{statusConfig.label}</span>
276
+ </Box>
277
+ }
278
+ sx={{
279
+ height: 20,
280
+ fontSize: '10px',
281
+ fontWeight: 600,
282
+ color: statusConfig.color,
283
+ bgcolor: statusConfig.bgColor,
284
+ border: `1px solid ${statusConfig.color}20`,
285
+ '& .MuiChip-label': { px: 1 },
286
+ }}
287
+ />
288
+ );
289
+
290
+ const renderGoogleAuthButton = () => {
291
+ if (isAuthenticated && !tokenExpired) {
292
+ return (
293
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
294
+ <Button
295
+ onClick={(event) => event.stopPropagation()}
296
+ disabled={isAuthLoading}
297
+ fullWidth
298
+ sx={{
299
+ backgroundColor: config.brandColor,
300
+ borderRadius: '8px',
301
+ padding: '8px 12px',
302
+ gap: '8px',
303
+ color: '#FFFFFF',
304
+ textTransform: 'none',
305
+ fontSize: '13px',
306
+ fontWeight: 500,
307
+ justifyContent: 'flex-start',
308
+ '&:hover': {
309
+ backgroundColor: config.iconBgColor,
310
+ },
311
+ }}
312
+ >
313
+ <BrandIcon size={18} />
314
+ {userEmail || userName || 'Connected'}
315
+ </Button>
316
+ <Button
317
+ onClick={(event) => {
318
+ event.stopPropagation();
319
+ data.onGoogleDisconnect?.();
320
+ }}
321
+ disabled={isAuthLoading || !data.onGoogleDisconnect}
322
+ fullWidth
323
+ sx={{
324
+ backgroundColor: 'transparent',
325
+ border: '1px solid #FFFFFF1A',
326
+ borderRadius: '8px',
327
+ padding: '6px 12px',
328
+ color: '#FCA5A5',
329
+ textTransform: 'none',
330
+ fontSize: '12px',
331
+ fontWeight: 500,
332
+ '&:hover': {
333
+ backgroundColor: 'rgba(239, 68, 68, 0.12)',
334
+ },
335
+ }}
336
+ >
337
+ {t('automation.googleAuth.signOut')}
338
+ </Button>
339
+ </Box>
340
+ );
341
+ }
342
+
343
+ return (
344
+ <Button
345
+ onClick={(event) => {
346
+ event.stopPropagation();
347
+ data.onGoogleConnect?.();
348
+ }}
349
+ disabled={isAuthLoading || !data.onGoogleConnect}
350
+ fullWidth
351
+ sx={{
352
+ backgroundColor: '#171C29',
353
+ border: '1px solid #FFFFFF1A',
354
+ borderRadius: '8px',
355
+ padding: '8px 12px',
356
+ gap: '8px',
357
+ color: '#B2BCD8',
358
+ textTransform: 'none',
359
+ fontSize: '13px',
360
+ fontWeight: 400,
361
+ '&:hover': {
362
+ backgroundColor: '#1e293b',
363
+ borderColor: config.brandColor,
364
+ },
365
+ '&:disabled': {
366
+ opacity: 0.6,
367
+ },
368
+ }}
369
+ >
370
+ {isAuthLoading ? (
371
+ <>
372
+ <CircularProgress size={16} sx={{ color: '#B2BCD8' }} />
373
+ {t('automation.googleAuth.connecting')}
374
+ </>
375
+ ) : (
376
+ <>
377
+ <BrandIcon size={18} />
378
+ {t('automation.googleAuth.connectGoogle')}
379
+ </>
380
+ )}
381
+ </Button>
382
+ );
383
+ };
384
+
385
+ const renderParameterSummary = () => {
386
+ if (config.serviceType === 'gmail') {
387
+ return (
388
+ <>
389
+ {data.parameters?.to && (
390
+ <Typography variant="caption" sx={{ color: '#9CA3AF', fontSize: 10, display: 'block' }}>
391
+ To: {data.parameters.to}
392
+ </Typography>
393
+ )}
394
+ {data.parameters?.subject && (
395
+ <Typography variant="caption" sx={{ color: '#9CA3AF', fontSize: 10, display: 'block' }}>
396
+ Subject: {data.parameters.subject}
397
+ </Typography>
398
+ )}
399
+ </>
400
+ );
401
+ }
402
+
403
+ if (config.serviceType === 'docs') {
404
+ return (
405
+ <>
406
+ {data.parameters?.documentTitle && (
407
+ <Typography variant="caption" sx={{ color: '#9CA3AF', fontSize: 10, display: 'block' }}>
408
+ Document: {data.parameters.documentTitle}
409
+ </Typography>
410
+ )}
411
+ {data.parameters?.documentId && (
412
+ <Typography variant="caption" sx={{ color: '#9CA3AF', fontSize: 10, display: 'block' }}>
413
+ ID: {data.parameters.documentId}
414
+ </Typography>
415
+ )}
416
+ </>
417
+ );
418
+ }
419
+
420
+ if (config.serviceType === 'slides') {
421
+ return (
422
+ <>
423
+ {data.parameters?.presentationTitle && (
424
+ <Typography variant="caption" sx={{ color: '#9CA3AF', fontSize: 10, display: 'block' }}>
425
+ Presentation: {data.parameters.presentationTitle}
426
+ </Typography>
427
+ )}
428
+ {data.parameters?.presentationId && (
429
+ <Typography variant="caption" sx={{ color: '#9CA3AF', fontSize: 10, display: 'block' }}>
430
+ ID: {data.parameters.presentationId}
431
+ </Typography>
432
+ )}
433
+ </>
434
+ );
435
+ }
436
+
437
+ return (
438
+ <>
439
+ {data.parameters?.meetingTitle && (
440
+ <Typography variant="caption" sx={{ color: '#9CA3AF', fontSize: 10, display: 'block' }}>
441
+ Meeting: {data.parameters.meetingTitle}
442
+ </Typography>
443
+ )}
444
+ {data.parameters?.meetingLink && (
445
+ <Typography
446
+ variant="caption"
447
+ sx={{
448
+ color: '#9CA3AF',
449
+ fontSize: 10,
450
+ display: 'block',
451
+ overflow: 'hidden',
452
+ textOverflow: 'ellipsis',
453
+ whiteSpace: 'nowrap',
454
+ }}
455
+ >
456
+ Link: {data.parameters.meetingLink}
457
+ </Typography>
458
+ )}
459
+ </>
460
+ );
461
+ };
462
+
463
+ return (
464
+ <Box
465
+ sx={{
466
+ bgcolor: '#1E1E2E',
467
+ borderRadius: 2,
468
+ border: selected ? '2px solid rgba(59, 130, 246, 0.5)' : '1px solid #374151',
469
+ minWidth: 280,
470
+ maxWidth: 320,
471
+ overflow: 'hidden',
472
+ boxShadow: selected ? '0 0 0 2px rgba(59, 130, 246, 0.5)' : '0 4px 8px rgba(0, 0, 0, 0.3)',
473
+ transition: 'all 0.2s ease',
474
+ ...(status === 'Running' && {
475
+ animation: 'pulse-glow 2s ease-in-out infinite',
476
+ '@keyframes pulse-glow': {
477
+ '0%, 100%': {
478
+ boxShadow: '0 0 20px rgba(59, 130, 246, 0.4), 0 0 40px rgba(59, 130, 246, 0.2)',
479
+ borderColor: 'rgba(59, 130, 246, 0.6)',
480
+ },
481
+ '50%': {
482
+ boxShadow: '0 0 30px rgba(59, 130, 246, 0.6), 0 0 60px rgba(59, 130, 246, 0.3)',
483
+ borderColor: 'rgba(59, 130, 246, 0.9)',
484
+ },
485
+ },
486
+ }),
487
+ }}
488
+ onClick={() => setSelectedNode(nodeId || '')}
489
+ >
490
+ <Handle
491
+ type="target"
492
+ position={Position.Left}
493
+ style={{
494
+ background: operationConfig.color,
495
+ width: 10,
496
+ height: 10,
497
+ border: '2px solid #1E1E2E',
498
+ }}
499
+ />
500
+
501
+ <AutomationNodeHeaderSection data={data} highlightText={highlightText} />
502
+
503
+ <Box
504
+ sx={{
505
+ bgcolor: 'rgba(67, 93, 132, 0.1)',
506
+ borderBottom: '1px solid rgba(67, 93, 132, 0.2)',
507
+ px: 2,
508
+ py: 1.5,
509
+ display: 'flex',
510
+ alignItems: 'center',
511
+ justifyContent: 'space-between',
512
+ }}
513
+ >
514
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
515
+ <Box
516
+ sx={{
517
+ width: '32px',
518
+ height: '32px',
519
+ minWidth: '32px',
520
+ backgroundColor: config.iconBgColor,
521
+ borderRadius: '50%',
522
+ display: 'flex',
523
+ alignItems: 'center',
524
+ justifyContent: 'center',
525
+ flexShrink: 0,
526
+ }}
527
+ >
528
+ <OperationIcon sx={{ fontSize: 18, color: '#fff' }} />
529
+ </Box>
530
+ <Box>
531
+ <Typography variant="subtitle2" sx={{ color: '#fff', fontWeight: 600, fontSize: 13 }}>
532
+ {highlightText(data.label || operationConfig.label)}
533
+ </Typography>
534
+ </Box>
535
+ </Box>
536
+ {renderStatusBadge()}
537
+ </Box>
538
+
539
+ <Box sx={{ px: 2, py: 1.5 }}>
540
+ <Box sx={{ mb: 1.5 }}>
541
+ {renderGoogleAuthButton()}
542
+ </Box>
543
+
544
+ {status === 'Running' && (
545
+ <Box sx={{ mb: 1.5 }}>
546
+ <LinearProgress
547
+ variant="determinate"
548
+ value={executionProgress}
549
+ sx={{
550
+ height: 4,
551
+ borderRadius: 2,
552
+ bgcolor: 'rgba(59, 130, 246, 0.1)',
553
+ '& .MuiLinearProgress-bar': {
554
+ bgcolor: operationConfig.color,
555
+ },
556
+ }}
557
+ />
558
+ </Box>
559
+ )}
560
+
561
+ {isAuthenticated && tokenExpiresAt && (
562
+ <Box sx={{ mb: 1.5 }}>
563
+ <Tooltip
564
+ title={`Token expires in ${getTimeUntilExpiry(tokenExpiresAt)}`}
565
+ >
566
+ <Chip
567
+ size="small"
568
+ label={getTimeUntilExpiry(tokenExpiresAt)}
569
+ sx={{
570
+ height: 18,
571
+ fontSize: '9px',
572
+ backgroundColor: tokenExpired ? '#EF4444' : config.brandColor,
573
+ color: '#FFFFFF',
574
+ fontWeight: 500,
575
+ }}
576
+ />
577
+ </Tooltip>
578
+ </Box>
579
+ )}
580
+
581
+ <Box sx={{ mb: 1.5 }}>{renderParameterSummary()}</Box>
582
+
583
+ {data.parameters?.message && (
584
+ <Typography
585
+ variant="caption"
586
+ sx={{
587
+ color: '#9CA3AF',
588
+ fontSize: 10,
589
+ display: 'block',
590
+ mb: 1.5,
591
+ overflow: 'hidden',
592
+ textOverflow: 'ellipsis',
593
+ whiteSpace: 'nowrap',
594
+ }}
595
+ >
596
+ Message: {data.parameters.message}
597
+ </Typography>
598
+ )}
599
+
600
+ {data.executionResult && (
601
+ <Box
602
+ sx={{
603
+ bgcolor: data.executionResult.success
604
+ ? 'rgba(16, 185, 129, 0.1)'
605
+ : 'rgba(239, 68, 68, 0.1)',
606
+ borderRadius: 1,
607
+ p: 1,
608
+ mb: 1.5,
609
+ border: `1px solid ${
610
+ data.executionResult.success
611
+ ? 'rgba(16, 185, 129, 0.3)'
612
+ : 'rgba(239, 68, 68, 0.3)'
613
+ }`,
614
+ }}
615
+ >
616
+ <Typography
617
+ variant="caption"
618
+ sx={{
619
+ color: data.executionResult.success ? '#10B981' : '#EF4444',
620
+ fontSize: 10,
621
+ }}
622
+ >
623
+ {data.executionResult.success
624
+ ? '✓ Operation completed'
625
+ : `✗ ${data.executionResult.error || 'Operation failed'}`}
626
+ </Typography>
627
+ </Box>
628
+ )}
629
+
630
+ {data.lastRun && (
631
+ <Box
632
+ sx={{
633
+ display: 'flex',
634
+ alignItems: 'center',
635
+ gap: 0.5,
636
+ justifyContent: 'center',
637
+ }}
638
+ >
639
+ <AccessTimeIcon sx={{ fontSize: 10, color: '#6B7280' }} />
640
+ <Typography variant="caption" sx={{ color: '#6B7280', fontSize: 9 }}>
641
+ Last run: {data.lastRun}
642
+ {data.duration && ` · ${data.duration}`}
643
+ </Typography>
644
+ </Box>
645
+ )}
646
+ </Box>
647
+
648
+ <NodeActionButtons selected={selected} />
649
+
650
+ <Handle
651
+ type="source"
652
+ position={Position.Right}
653
+ style={{
654
+ background: operationConfig.color,
655
+ width: 10,
656
+ height: 10,
657
+ border: '2px solid #1E1E2E',
658
+ }}
659
+ />
660
+ </Box>
661
+ );
662
+ };
663
+
664
+ const createGoogleServiceNode = (serviceType: GoogleServiceType) => {
665
+ const config = GOOGLE_SERVICE_CONFIGS[serviceType];
666
+
667
+ const GoogleServiceNode: React.FC<Omit<AutomationGoogleServiceNodeProps, 'config'>> = (
668
+ props,
669
+ ) => <AutomationGoogleServiceNode {...props} config={config} />;
670
+
671
+ GoogleServiceNode.displayName = `Automation${config.serviceLabel.replace(/\s+/g, '')}Node`;
672
+ return GoogleServiceNode;
673
+ };
674
+
675
+ export const AutomationGmailNode = createGoogleServiceNode('gmail');
676
+ export const AutomationGoogleDocsNode = createGoogleServiceNode('docs');
677
+ export const AutomationGoogleSlidesNode = createGoogleServiceNode('slides');
678
+ export const AutomationGoogleMeetNode = createGoogleServiceNode('meet');
679
+
680
+ export default AutomationGoogleServiceNode;