Enhance Activepieces automation flows with restore UI and publishing

- Add "Restore Defaults" dropdown to Automations page with confirmation
- Create flows in "Defaults" folder for organization
- Pre-populate trigger sample data when creating/restoring flows
- Auto-publish flows (lock and enable) after creation
- Fix email template context variables to match template tags
- Fix dark mode logo switching in Activepieces iframe
- Add iframe refresh on flow restore
- Auto-populate business context (name, email, phone, address) in emails

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-21 23:38:10 -05:00
parent ac3115a5a1
commit 7b380fa903
14 changed files with 786 additions and 149 deletions

View File

@@ -0,0 +1,36 @@
import api from './client';
export interface DefaultFlow {
flow_type: string;
display_name: string;
activepieces_flow_id: string | null;
is_modified: boolean;
is_enabled: boolean;
}
export interface RestoreFlowResponse {
success: boolean;
flow_type: string;
message: string;
}
export interface RestoreAllResponse {
success: boolean;
restored: string[];
failed: string[];
}
export const getDefaultFlows = async (): Promise<DefaultFlow[]> => {
const response = await api.get('/activepieces/default-flows/');
return response.data.flows;
};
export const restoreFlow = async (flowType: string): Promise<RestoreFlowResponse> => {
const response = await api.post(`/activepieces/default-flows/${flowType}/restore/`);
return response.data;
};
export const restoreAllFlows = async (): Promise<RestoreAllResponse> => {
const response = await api.post('/activepieces/default-flows/restore-all/');
return response.data;
};

View File

@@ -0,0 +1,35 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as activepiecesApi from '../api/activepieces';
export const activepiecesKeys = {
all: ['activepieces'] as const,
defaultFlows: () => [...activepiecesKeys.all, 'defaultFlows'] as const,
};
export const useDefaultFlows = () => {
return useQuery({
queryKey: activepiecesKeys.defaultFlows(),
queryFn: activepiecesApi.getDefaultFlows,
staleTime: 30 * 1000,
});
};
export const useRestoreFlow = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: activepiecesApi.restoreFlow,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: activepiecesKeys.defaultFlows() });
},
});
};
export const useRestoreAllFlows = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: activepiecesApi.restoreAllFlows,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: activepiecesKeys.defaultFlows() });
},
});
};

View File

@@ -1,10 +1,13 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Bot, RefreshCw, AlertTriangle, Loader2, ExternalLink, Sparkles } from 'lucide-react';
import { Bot, RefreshCw, AlertTriangle, Loader2, ExternalLink, Sparkles, RotateCcw, ChevronDown } from 'lucide-react';
import api from '../api/client';
import { usePlanFeatures } from '../hooks/usePlanFeatures';
import { LockedSection } from '../components/UpgradePrompt';
import { UpgradePrompt } from '../components/UpgradePrompt';
import { useDarkMode } from '../hooks/useDarkMode';
import { useDefaultFlows, useRestoreFlow, useRestoreAllFlows } from '../hooks/useActivepieces';
import ConfirmationModal from '../components/ConfirmationModal';
interface ActivepiecesEmbedData {
token: string;
@@ -40,11 +43,22 @@ const ActivepiecesVendorEventName = {
*/
export default function Automations() {
const { t, i18n } = useTranslation();
const { features, loading: featuresLoading } = usePlanFeatures();
const { permissions, isLoading: featuresLoading, canUse } = usePlanFeatures();
const iframeRef = useRef<HTMLIFrameElement>(null);
const [iframeReady, setIframeReady] = useState(false);
const [authenticated, setAuthenticated] = useState(false);
const initSentRef = useRef(false);
const [refreshKey, setRefreshKey] = useState(0);
// Dark mode support
const isDark = useDarkMode();
// Restore default flows
const { data: defaultFlows } = useDefaultFlows();
const restoreFlow = useRestoreFlow();
const restoreAll = useRestoreAllFlows();
const [showFlowsMenu, setShowFlowsMenu] = useState(false);
const [confirmRestore, setConfirmRestore] = useState<{ type: 'all' | string; name: string } | null>(null);
// Fetch embed token for Activepieces
const {
@@ -98,12 +112,13 @@ export default function Automations() {
hidePageHeader: false,
locale: i18n.language || 'en',
initialRoute: '/flows', // Start on flows page to show sidebar
mode: isDark ? 'dark' : 'light',
},
};
iframeRef.current.contentWindow.postMessage(initMessage, '*');
initSentRef.current = true;
}, [embedData?.token, i18n.language]);
}, [embedData?.token, i18n.language, isDark]);
// Listen for messages from Activepieces iframe
useEffect(() => {
@@ -151,8 +166,27 @@ export default function Automations() {
}
}, [embedData?.token]);
// Reset state when theme changes to force iframe reload
useEffect(() => {
// Reset state so the iframe reinitializes with new theme
setIframeReady(false);
setAuthenticated(false);
initSentRef.current = false;
}, [isDark]);
// Close flows menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (showFlowsMenu && !(event.target as Element).closest('.flows-menu-container')) {
setShowFlowsMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showFlowsMenu]);
// Check feature access
const canAccessAutomations = features?.can_access_automations ?? true;
const canAccessAutomations = canUse('automations');
// Loading state
if (isLoading || featuresLoading) {
@@ -172,14 +206,7 @@ export default function Automations() {
if (!canAccessAutomations) {
return (
<div className="p-6">
<LockedSection
title={t('automations.locked.title', 'Automations')}
description={t(
'automations.locked.description',
'Upgrade your plan to access powerful workflow automation with AI-powered flow creation.'
)}
featureName="automations"
/>
<UpgradePrompt feature="automations" variant="banner" />
</div>
);
}
@@ -212,8 +239,9 @@ export default function Automations() {
}
// Build iframe URL - use /embed route for SDK communication
// Include theme parameter for dark mode support
const iframeSrc = embedData?.embedUrl
? `${embedData.embedUrl}/embed`
? `${embedData.embedUrl}/embed?theme=${isDark ? 'dark' : 'light'}`
: '';
// Show loading until authenticated
@@ -221,6 +249,47 @@ export default function Automations() {
return (
<div className="h-full flex flex-col">
{/* Confirmation Modal */}
<ConfirmationModal
isOpen={!!confirmRestore}
onClose={() => setConfirmRestore(null)}
onConfirm={() => {
const refreshIframe = () => {
// Reset iframe state and increment key to force remount
initSentRef.current = false;
setAuthenticated(false);
setIframeReady(false);
setRefreshKey((k) => k + 1);
refetch();
setConfirmRestore(null);
};
if (confirmRestore?.type === 'all') {
restoreAll.mutate(undefined, {
onSuccess: refreshIframe,
});
} else if (confirmRestore) {
restoreFlow.mutate(confirmRestore.type, {
onSuccess: refreshIframe,
});
}
}}
title={t('automations.restore.title', 'Restore Default Flow')}
message={
confirmRestore?.type === 'all'
? t(
'automations.restore.allMessage',
'This will restore all default automation flows. Any customizations will be overwritten.'
)
: t('automations.restore.singleMessage', 'Restore "{{name}}" to its default configuration?', {
name: confirmRestore?.name,
})
}
confirmText={t('automations.restore.confirm', 'Restore')}
variant="warning"
isLoading={restoreFlow.isPending || restoreAll.isPending}
/>
{/* Header */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<div className="flex items-center justify-between">
@@ -228,17 +297,9 @@ export default function Automations() {
<div className="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
<Bot className="h-6 w-6 text-primary-600 dark:text-primary-400" />
</div>
<div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
{t('automations.title', 'Automations')}
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t(
'automations.subtitle',
'Build powerful workflows to automate your business'
)}
</p>
</div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
{t('automations.title', 'Automations')}
</h1>
</div>
<div className="flex items-center space-x-3">
@@ -250,12 +311,65 @@ export default function Automations() {
</span>
</div>
{/* Restore Default Flows dropdown */}
<div className="relative flows-menu-container">
<button
onClick={() => setShowFlowsMenu(!showFlowsMenu)}
className="flex items-center gap-2 px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
<RotateCcw className="h-4 w-4" />
<span className="hidden sm:inline">{t('automations.restoreDefaults', 'Restore Defaults')}</span>
<ChevronDown className="h-4 w-4" />
</button>
{showFlowsMenu && (
<div className="absolute right-0 mt-2 w-72 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div className="p-2 border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => {
setConfirmRestore({ type: 'all', name: 'All Flows' });
setShowFlowsMenu(false);
}}
className="w-full px-3 py-2 text-left text-sm font-medium text-primary-600 dark:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded transition-colors"
>
{t('automations.restoreAll', 'Restore All Default Flows')}
</button>
</div>
<div className="max-h-64 overflow-y-auto p-2">
{defaultFlows?.map((flow) => (
<button
key={flow.flow_type}
onClick={() => {
setConfirmRestore({ type: flow.flow_type, name: flow.display_name });
setShowFlowsMenu(false);
}}
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors flex justify-between items-center"
>
<span className="text-gray-700 dark:text-gray-300">{flow.display_name}</span>
{flow.is_modified && (
<span className="text-xs text-orange-500 dark:text-orange-400">
{t('automations.modified', 'Modified')}
</span>
)}
</button>
))}
{(!defaultFlows || defaultFlows.length === 0) && (
<p className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
{t('automations.noDefaultFlows', 'No default flows available')}
</p>
)}
</div>
</div>
)}
</div>
{/* Refresh button */}
<button
onClick={() => {
initSentRef.current = false;
setAuthenticated(false);
setIframeReady(false);
setRefreshKey((k) => k + 1);
refetch();
}}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
@@ -296,6 +410,7 @@ export default function Automations() {
{iframeSrc && (
<iframe
key={`activepieces-${isDark ? 'dark' : 'light'}-${refreshKey}`}
ref={iframeRef}
src={iframeSrc}
className="w-full h-full border-0"