Add event status trigger, improve test coverage, and UI enhancements
- Add event-status-changed trigger for SmoothSchedule Activepieces piece - Add comprehensive test coverage for payments, tickets, messaging, mobile - Add test coverage for core services, signals, consumers, and views - Improve Activepieces UI: templates, billing hooks, project hooks - Update marketing automation showcase and workflow visual components - Add public API endpoints for availability 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
1766103708902
|
||||
1766127780415
|
||||
@@ -93,7 +93,7 @@ export const STANDARD_CLOUD_PLAN: PlatformPlanWithOnlyLimits = {
|
||||
}
|
||||
|
||||
export const OPEN_SOURCE_PLAN: PlatformPlanWithOnlyLimits = {
|
||||
embeddingEnabled: false,
|
||||
embeddingEnabled: true,
|
||||
globalConnectionsEnabled: false,
|
||||
customRolesEnabled: false,
|
||||
mcpsEnabled: true,
|
||||
@@ -107,9 +107,9 @@ export const OPEN_SOURCE_PLAN: PlatformPlanWithOnlyLimits = {
|
||||
analyticsEnabled: true,
|
||||
showPoweredBy: false,
|
||||
auditLogEnabled: false,
|
||||
managePiecesEnabled: false,
|
||||
manageTemplatesEnabled: false,
|
||||
customAppearanceEnabled: false,
|
||||
managePiecesEnabled: true,
|
||||
manageTemplatesEnabled: true,
|
||||
customAppearanceEnabled: true,
|
||||
teamProjectsLimit: TeamProjectsLimit.NONE,
|
||||
projectRolesEnabled: false,
|
||||
customDomainsEnabled: false,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,148 @@
|
||||
import { createTrigger, TriggerStrategy, Property } from '@activepieces/pieces-framework';
|
||||
import { HttpMethod } from '@activepieces/pieces-common';
|
||||
import { smoothScheduleAuth, SmoothScheduleAuth } from '../../index';
|
||||
import { makeRequest } from '../common';
|
||||
|
||||
const TRIGGER_KEY = 'last_status_change_at';
|
||||
|
||||
// Event status options from SmoothSchedule backend
|
||||
const EVENT_STATUSES = [
|
||||
{ label: 'Any Status', value: '' },
|
||||
{ label: 'Scheduled', value: 'SCHEDULED' },
|
||||
{ label: 'En Route', value: 'EN_ROUTE' },
|
||||
{ label: 'In Progress', value: 'IN_PROGRESS' },
|
||||
{ label: 'Canceled', value: 'CANCELED' },
|
||||
{ label: 'Completed', value: 'COMPLETED' },
|
||||
{ label: 'Awaiting Payment', value: 'AWAITING_PAYMENT' },
|
||||
{ label: 'Paid', value: 'PAID' },
|
||||
{ label: 'No Show', value: 'NOSHOW' },
|
||||
];
|
||||
|
||||
export const eventStatusChangedTrigger = createTrigger({
|
||||
auth: smoothScheduleAuth,
|
||||
name: 'event_status_changed',
|
||||
displayName: 'Event Status Changed',
|
||||
description: 'Triggers when an event status changes (e.g., Scheduled → In Progress).',
|
||||
props: {
|
||||
oldStatus: Property.StaticDropdown({
|
||||
displayName: 'Previous Status (From)',
|
||||
description: 'Only trigger when changing from this status (optional)',
|
||||
required: false,
|
||||
options: {
|
||||
options: EVENT_STATUSES,
|
||||
},
|
||||
}),
|
||||
newStatus: Property.StaticDropdown({
|
||||
displayName: 'New Status (To)',
|
||||
description: 'Only trigger when changing to this status (optional)',
|
||||
required: false,
|
||||
options: {
|
||||
options: EVENT_STATUSES,
|
||||
},
|
||||
}),
|
||||
},
|
||||
type: TriggerStrategy.POLLING,
|
||||
async onEnable(context) {
|
||||
// Store the current timestamp as the starting point
|
||||
await context.store.put(TRIGGER_KEY, new Date().toISOString());
|
||||
},
|
||||
async onDisable(context) {
|
||||
await context.store.delete(TRIGGER_KEY);
|
||||
},
|
||||
async test(context) {
|
||||
const auth = context.auth as SmoothScheduleAuth;
|
||||
const { oldStatus, newStatus } = context.propsValue;
|
||||
|
||||
const queryParams: Record<string, string> = {
|
||||
limit: '5',
|
||||
};
|
||||
|
||||
if (oldStatus) {
|
||||
queryParams['old_status'] = oldStatus;
|
||||
}
|
||||
if (newStatus) {
|
||||
queryParams['new_status'] = newStatus;
|
||||
}
|
||||
|
||||
const statusChanges = await makeRequest<Array<Record<string, unknown>>>(
|
||||
auth,
|
||||
HttpMethod.GET,
|
||||
'/events/status_changes/',
|
||||
undefined,
|
||||
queryParams
|
||||
);
|
||||
|
||||
return statusChanges;
|
||||
},
|
||||
async run(context) {
|
||||
const auth = context.auth as SmoothScheduleAuth;
|
||||
const { oldStatus, newStatus } = context.propsValue;
|
||||
|
||||
const lastChangeAt = await context.store.get<string>(TRIGGER_KEY) || new Date(0).toISOString();
|
||||
|
||||
const queryParams: Record<string, string> = {
|
||||
changed_at__gt: lastChangeAt,
|
||||
};
|
||||
|
||||
if (oldStatus) {
|
||||
queryParams['old_status'] = oldStatus;
|
||||
}
|
||||
if (newStatus) {
|
||||
queryParams['new_status'] = newStatus;
|
||||
}
|
||||
|
||||
const statusChanges = await makeRequest<Array<{ changed_at: string } & Record<string, unknown>>>(
|
||||
auth,
|
||||
HttpMethod.GET,
|
||||
'/events/status_changes/',
|
||||
undefined,
|
||||
queryParams
|
||||
);
|
||||
|
||||
if (statusChanges.length > 0) {
|
||||
// Update the last change timestamp
|
||||
const maxChangedAt = statusChanges.reduce((max, c) =>
|
||||
c.changed_at > max ? c.changed_at : max,
|
||||
lastChangeAt
|
||||
);
|
||||
await context.store.put(TRIGGER_KEY, maxChangedAt);
|
||||
}
|
||||
|
||||
return statusChanges;
|
||||
},
|
||||
sampleData: {
|
||||
id: 1,
|
||||
event_id: 12345,
|
||||
event: {
|
||||
id: 12345,
|
||||
title: 'Consultation',
|
||||
start_time: '2024-12-01T10:00:00Z',
|
||||
end_time: '2024-12-01T11:00:00Z',
|
||||
status: 'IN_PROGRESS',
|
||||
service: {
|
||||
id: 1,
|
||||
name: 'Consultation',
|
||||
},
|
||||
customer: {
|
||||
id: 100,
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@example.com',
|
||||
},
|
||||
resources: [
|
||||
{ id: 1, name: 'Dr. Smith', type: 'STAFF' },
|
||||
],
|
||||
},
|
||||
old_status: 'SCHEDULED',
|
||||
old_status_display: 'Scheduled',
|
||||
new_status: 'IN_PROGRESS',
|
||||
new_status_display: 'In Progress',
|
||||
changed_by: 'John Smith',
|
||||
changed_by_email: 'john@example.com',
|
||||
changed_at: '2024-12-01T10:05:00Z',
|
||||
notes: 'Started working on the job',
|
||||
source: 'mobile_app',
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
},
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './event-created';
|
||||
export * from './event-updated';
|
||||
export * from './event-cancelled';
|
||||
export * from './event-status-changed';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from 'i18next';
|
||||
import { ArrowLeft, Search, SearchX } from 'lucide-react';
|
||||
import { ArrowLeft, Search, SearchX, Sparkles, Building2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
@@ -24,14 +24,19 @@ import {
|
||||
import { LoadingSpinner } from '@/components/ui/spinner';
|
||||
import { TemplateCard } from '@/features/templates/components/template-card';
|
||||
import { TemplateDetailsView } from '@/features/templates/components/template-details-view';
|
||||
import { useTemplates } from '@/features/templates/hooks/templates-hook';
|
||||
import { useAllTemplates } from '@/features/templates/hooks/templates-hook';
|
||||
import { userHooks } from '@/hooks/user-hooks';
|
||||
import { PlatformRole, Template, TemplateType } from '@activepieces/shared';
|
||||
import { PlatformRole, Template } from '@activepieces/shared';
|
||||
|
||||
export const ExplorePage = () => {
|
||||
const { filteredTemplates, isLoading, search, setSearch } = useTemplates({
|
||||
type: TemplateType.OFFICIAL,
|
||||
});
|
||||
const {
|
||||
filteredCustomTemplates,
|
||||
filteredOfficialTemplates,
|
||||
filteredTemplates,
|
||||
isLoading,
|
||||
search,
|
||||
setSearch,
|
||||
} = useAllTemplates();
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
||||
null,
|
||||
);
|
||||
@@ -47,6 +52,20 @@ export const ExplorePage = () => {
|
||||
setSelectedTemplate(null);
|
||||
};
|
||||
|
||||
const renderTemplateGrid = (templates: Template[]) => (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{templates.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
onSelectTemplate={(template) => {
|
||||
setSelectedTemplate(template);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ProjectDashboardPageHeader title={t('Explore Templates')} />
|
||||
@@ -67,7 +86,7 @@ export const ExplorePage = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredTemplates?.length === 0 && (
|
||||
{filteredTemplates.length === 0 && (
|
||||
<Empty className="min-h-[300px]">
|
||||
<EmptyHeader className="max-w-xl">
|
||||
<EmptyMedia variant="icon">
|
||||
@@ -93,17 +112,38 @@ export const ExplorePage = () => {
|
||||
)}
|
||||
</Empty>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-4">
|
||||
{filteredTemplates?.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
onSelectTemplate={(template) => {
|
||||
setSelectedTemplate(template);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom Templates Section (SmoothSchedule-specific) */}
|
||||
{filteredCustomTemplates.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Building2 className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t('SmoothSchedule Templates')}
|
||||
</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({filteredCustomTemplates.length})
|
||||
</span>
|
||||
</div>
|
||||
{renderTemplateGrid(filteredCustomTemplates)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Official Templates Section (from Activepieces cloud) */}
|
||||
{filteredOfficialTemplates.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Sparkles className="w-5 h-5 text-amber-500" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t('Community Templates')}
|
||||
</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({filteredOfficialTemplates.length})
|
||||
</span>
|
||||
</div>
|
||||
{renderTemplateGrid(filteredOfficialTemplates)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -123,7 +123,15 @@ export const billingQueries = {
|
||||
usePlatformSubscription: (platformId: string) => {
|
||||
return useQuery({
|
||||
queryKey: billingKeys.platformSubscription(platformId),
|
||||
queryFn: platformBillingApi.getSubscriptionInfo,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await platformBillingApi.getSubscriptionInfo();
|
||||
} catch {
|
||||
// Return null if endpoint doesn't exist (community edition)
|
||||
return null;
|
||||
}
|
||||
},
|
||||
retry: false, // Don't retry on failure
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,20 +12,26 @@ export const projectMembersHooks = {
|
||||
const query = useQuery<ProjectMemberWithUser[]>({
|
||||
queryKey: ['project-members', authenticationSession.getProjectId()],
|
||||
queryFn: async () => {
|
||||
const projectId = authenticationSession.getProjectId();
|
||||
assertNotNullOrUndefined(projectId, 'Project ID is null');
|
||||
const res = await projectMembersApi.list({
|
||||
projectId: projectId,
|
||||
projectRoleId: undefined,
|
||||
cursor: undefined,
|
||||
limit: 100,
|
||||
});
|
||||
return res.data;
|
||||
try {
|
||||
const projectId = authenticationSession.getProjectId();
|
||||
assertNotNullOrUndefined(projectId, 'Project ID is null');
|
||||
const res = await projectMembersApi.list({
|
||||
projectId: projectId,
|
||||
projectRoleId: undefined,
|
||||
cursor: undefined,
|
||||
limit: 100,
|
||||
});
|
||||
return res.data;
|
||||
} catch {
|
||||
// Return empty array if endpoint doesn't exist (community edition)
|
||||
return [];
|
||||
}
|
||||
},
|
||||
staleTime: Infinity,
|
||||
retry: false, // Don't retry on failure
|
||||
});
|
||||
return {
|
||||
projectMembers: query.data,
|
||||
projectMembers: query.data ?? [],
|
||||
isLoading: query.isLoading,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
|
||||
@@ -79,10 +79,14 @@ export const TemplateCard = ({
|
||||
className="rounded-lg border border-solid border-dividers overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2 p-4">
|
||||
<PieceIconList
|
||||
trigger={template.flows![0].trigger}
|
||||
maxNumberOfIconsToShow={2}
|
||||
/>
|
||||
{template.flows && template.flows.length > 0 && template.flows[0].trigger ? (
|
||||
<PieceIconList
|
||||
trigger={template.flows[0].trigger}
|
||||
maxNumberOfIconsToShow={2}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded bg-muted" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium px-4 min-h-16">{template.name}</div>
|
||||
<div className="py-2 px-4 gap-1 flex items-center">
|
||||
|
||||
@@ -13,11 +13,15 @@ export const TemplateDetailsView = ({ template }: TemplateDetailsViewProps) => {
|
||||
return (
|
||||
<div className="px-2">
|
||||
<div className="mb-4 p-8 flex items-center justify-center gap-2 width-full bg-green-300 rounded-lg">
|
||||
<PieceIconList
|
||||
size="xxl"
|
||||
trigger={template.flows![0].trigger}
|
||||
maxNumberOfIconsToShow={3}
|
||||
/>
|
||||
{template.flows && template.flows.length > 0 && template.flows[0].trigger ? (
|
||||
<PieceIconList
|
||||
size="xxl"
|
||||
trigger={template.flows[0].trigger}
|
||||
maxNumberOfIconsToShow={3}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-16 w-16 rounded bg-muted" />
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="px-2 min-h-[156px] h-[calc(70vh-144px)] max-h-[536px]">
|
||||
<div className="mb-4 text-lg font-medium font-black">
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { ListTemplatesRequestQuery, Template } from '@activepieces/shared';
|
||||
import {
|
||||
ListTemplatesRequestQuery,
|
||||
Template,
|
||||
TemplateType,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { templatesApi } from '../lib/templates-api';
|
||||
|
||||
@@ -9,7 +13,7 @@ export const useTemplates = (request: ListTemplatesRequestQuery) => {
|
||||
const [search, setSearch] = useState<string>('');
|
||||
|
||||
const { data: templates, isLoading } = useQuery<Template[], Error>({
|
||||
queryKey: ['templates'],
|
||||
queryKey: ['templates', request.type],
|
||||
queryFn: async () => {
|
||||
const templates = await templatesApi.list(request);
|
||||
return templates.data;
|
||||
@@ -34,3 +38,86 @@ export const useTemplates = (request: ListTemplatesRequestQuery) => {
|
||||
setSearch,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to fetch both custom (platform) and official templates
|
||||
*/
|
||||
export const useAllTemplates = () => {
|
||||
const [search, setSearch] = useState<string>('');
|
||||
|
||||
// Fetch custom templates (platform-specific)
|
||||
const {
|
||||
data: customTemplates,
|
||||
isLoading: isLoadingCustom,
|
||||
} = useQuery<Template[], Error>({
|
||||
queryKey: ['templates', TemplateType.CUSTOM],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const templates = await templatesApi.list({
|
||||
type: TemplateType.CUSTOM,
|
||||
});
|
||||
return templates.data;
|
||||
} catch {
|
||||
// If custom templates fail (e.g., feature not enabled), return empty array
|
||||
return [];
|
||||
}
|
||||
},
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// Fetch official templates from Activepieces cloud
|
||||
const {
|
||||
data: officialTemplates,
|
||||
isLoading: isLoadingOfficial,
|
||||
} = useQuery<Template[], Error>({
|
||||
queryKey: ['templates', TemplateType.OFFICIAL],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const templates = await templatesApi.list({
|
||||
type: TemplateType.OFFICIAL,
|
||||
});
|
||||
return templates.data;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const isLoading = isLoadingCustom || isLoadingOfficial;
|
||||
|
||||
// Combine all templates
|
||||
const allTemplates = [
|
||||
...(customTemplates || []),
|
||||
...(officialTemplates || []),
|
||||
];
|
||||
|
||||
const filteredTemplates = allTemplates.filter((template) => {
|
||||
const templateName = template.name.toLowerCase();
|
||||
const templateDescription = template.description.toLowerCase();
|
||||
return (
|
||||
templateName.includes(search.toLowerCase()) ||
|
||||
templateDescription.includes(search.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
// Separate filtered results by type
|
||||
const filteredCustomTemplates = filteredTemplates.filter(
|
||||
(t) => t.type === TemplateType.CUSTOM,
|
||||
);
|
||||
const filteredOfficialTemplates = filteredTemplates.filter(
|
||||
(t) => t.type === TemplateType.OFFICIAL,
|
||||
);
|
||||
|
||||
return {
|
||||
customTemplates: customTemplates || [],
|
||||
officialTemplates: officialTemplates || [],
|
||||
allTemplates,
|
||||
filteredTemplates,
|
||||
filteredCustomTemplates,
|
||||
filteredOfficialTemplates,
|
||||
isLoading,
|
||||
search,
|
||||
setSearch,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -57,15 +57,21 @@ export const projectHooks = {
|
||||
return useQuery<ProjectWithLimits[], Error>({
|
||||
queryKey: ['projects', params],
|
||||
queryFn: async () => {
|
||||
const results = await projectApi.list({
|
||||
cursor,
|
||||
limit,
|
||||
displayName,
|
||||
...restParams,
|
||||
});
|
||||
return results.data;
|
||||
try {
|
||||
const results = await projectApi.list({
|
||||
cursor,
|
||||
limit,
|
||||
displayName,
|
||||
...restParams,
|
||||
});
|
||||
return results.data;
|
||||
} catch {
|
||||
// Return empty array if endpoint doesn't exist (embedded mode)
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: !displayName || displayName.length > 0,
|
||||
retry: false,
|
||||
});
|
||||
},
|
||||
useProjectsInfinite: (limit = 20) => {
|
||||
@@ -77,11 +83,18 @@ export const projectHooks = {
|
||||
queryKey: ['projects-infinite', limit],
|
||||
getNextPageParam: (lastPage) => lastPage.next,
|
||||
initialPageParam: undefined,
|
||||
queryFn: ({ pageParam }) =>
|
||||
projectApi.list({
|
||||
cursor: pageParam as string | undefined,
|
||||
limit,
|
||||
}),
|
||||
queryFn: async ({ pageParam }) => {
|
||||
try {
|
||||
return await projectApi.list({
|
||||
cursor: pageParam as string | undefined,
|
||||
limit,
|
||||
});
|
||||
} catch {
|
||||
// Return empty page if endpoint doesn't exist (embedded mode)
|
||||
return { data: [], next: null, previous: null };
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
},
|
||||
useProjectsForPlatforms: () => {
|
||||
|
||||
Reference in New Issue
Block a user