From b18e2eaf49f26998090ccfa5bc57228fc74e82f7 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Wed, 10 Sep 2025 13:48:26 +0200 Subject: [PATCH] WIP: Refactor triggers to TriggerConfig pattern - Convert webhook, global, and cron triggers to use TriggerConfig pattern like collectionTrigger - Simplify trigger slug names (remove '-trigger' suffix) - Update validation to use new slug names - Add perfectionist/sort-exports rule disable - Note: Workflow.ts integration still needs fixes for type compatibility --- eslint.config.js | 1 + src/collections/Workflow.ts | 310 ++++++++++++++--------------- src/plugin/config-types.ts | 11 +- src/triggers/collection-trigger.ts | 23 +-- src/triggers/cron-trigger.ts | 27 ++- src/triggers/global-trigger.ts | 21 +- src/triggers/helpers.ts | 14 +- src/triggers/index.ts | 8 +- src/triggers/webhook-trigger.ts | 18 +- 9 files changed, 208 insertions(+), 225 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index ab109e6..59916c2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -30,6 +30,7 @@ export default [ 'no-console': 'off', 'perfectionist/sort-object-types': 'off', 'perfectionist/sort-objects': 'off', + 'perfectionist/sort-exports': 'off', }, }, { diff --git a/src/collections/Workflow.ts b/src/collections/Workflow.ts index 77c0033..05c8d68 100644 --- a/src/collections/Workflow.ts +++ b/src/collections/Workflow.ts @@ -1,180 +1,178 @@ import type {CollectionConfig, Field} from 'payload' import type {WorkflowsPluginConfig} from "../plugin/config-types.js" + import { - getCollectionTriggerFields, - getCronTriggerFields, - getGlobalTriggerFields, - getWebhookTriggerFields + collectionTrigger, + cronTrigger, + globalTrigger, + webhookTrigger } from '../triggers/index.js' +import {trigger} from '../triggers/helpers.js' -export const createWorkflowCollection: (options: WorkflowsPluginConfig) => CollectionConfig = ({ - collectionTriggers, - steps, - triggers - }) => ({ - slug: 'workflows', - access: { +export const createWorkflowCollection: (options: WorkflowsPluginConfig) => CollectionConfig = (options) => { + const {steps, collectionTriggers} = options + const triggers = (options.triggers || []).map(t => t(options)) + return { + slug: 'workflows', + access: { create: () => true, - delete: () => true, - read: () => true, - update: () => true, + delete: () => true, + read: () => true, + update: () => true, }, - admin: { - defaultColumns: ['name', 'updatedAt'], - description: 'Create and manage automated workflows.', - group: 'Automation', - useAsTitle: 'name', - }, - fields: [ - { - name: 'name', - type: 'text', - admin: { - description: 'Human-readable name for the workflow', + admin: { + defaultColumns: ['name', 'updatedAt'], + description: 'Create and manage automated workflows.', + group: 'Automation', + useAsTitle: 'name', + }, + fields: [ + { + name: 'name', + type: 'text', + admin: { + description: 'Human-readable name for the workflow', + }, + required: true, }, - required: true, - }, - { - name: 'description', - type: 'textarea', - admin: { - description: 'Optional description of what this workflow does', + { + name: 'description', + type: 'textarea', + admin: { + description: 'Optional description of what this workflow does', + }, }, - }, - { - name: 'executionStatus', - type: 'ui', - admin: { - components: { - Field: '@xtr-dev/payload-automation/client#WorkflowExecutionStatus' - }, - condition: (data) => !!data?.id // Only show for existing workflows - } - }, - { - name: 'triggers', - type: 'array', - fields: [ - { - name: 'type', - type: 'select', - options: [ - 'collection-trigger', - 'webhook-trigger', - 'global-trigger', - 'cron-trigger', - ...(triggers || []).map(t => t.slug) - ] - }, - { - name: 'parameters', - type: 'json', - admin: { - hidden: true, + { + name: 'executionStatus', + type: 'ui', + admin: { + components: { + Field: '@xtr-dev/payload-automation/client#WorkflowExecutionStatus' }, - defaultValue: {} - }, - // Virtual fields for built-in triggers - ...getCollectionTriggerFields(collectionTriggers), - ...getWebhookTriggerFields(), - ...getGlobalTriggerFields(), - ...getCronTriggerFields(), - { - name: 'condition', - type: 'text', - admin: { - description: 'JSONPath expression that must evaluate to true for this trigger to execute the workflow (e.g., "$.trigger.doc.status == \'published\'")' + condition: (data) => !!data?.id // Only show for existing workflows + } + }, + { + name: 'triggers', + type: 'array', + fields: [ + { + name: 'type', + type: 'select', + options: [ + ...(triggers || []).map(t => t.slug) + ] }, - required: false - }, - // Virtual fields for custom triggers - // Note: Custom trigger fields from trigger-helpers already have unique names - // We just need to pass them through without modification - ...(triggers || []).flatMap(t => (t.inputs || [])) - ] - }, - { - name: 'steps', - type: 'array', - fields: [ - { - type: 'row', - fields: [ - { - name: 'step', - type: 'select', - options: steps.map(t => t.slug) - }, - { - name: 'name', - type: 'text', - } - ] - }, - ...(steps || []).flatMap(step => (step.inputSchema || []).map(field => { - const originalName = (field as any).name; - const resultField: any = { - ...field, - // Prefix field name with step slug to avoid conflicts - name: `__step_${step.slug}_${originalName}`, + { + name: 'parameters', + type: 'json', admin: { - ...(field.admin || {}), - condition: (...args: any[]) => args[1]?.step === step.slug && ( - (field.admin as any)?.condition ? - (field.admin as any).condition.call(this, ...args) : - true - ), + hidden: true, }, - virtual: true, - }; - - // Add hooks to store/retrieve from the step's input data - resultField.hooks = { - ...((field as any).hooks || {}), - afterRead: [ - ...(((field as any).hooks)?.afterRead || []), - ({ siblingData }: any) => { - // Read from step input data using original field name - return siblingData?.[originalName] || (field as any).defaultValue; - } - ], - beforeChange: [ - ...(((field as any).hooks)?.beforeChange || []), - ({ siblingData, value }: any) => { - // Store in step data using original field name - siblingData[originalName] = value; - return undefined; // Don't store the prefixed field + defaultValue: {} + }, + // Virtual fields for built-in triggers + ...trigger({slug: 'collection', fields: collectionTrigger(options).fields}).fields, + ...trigger({slug: 'webhook', fields: webhookTrigger().fields}).fields, + ...trigger({slug: 'global', fields: globalTrigger().fields}).fields, + ...trigger({slug: 'cron', fields: cronTrigger().fields}).fields, + { + name: 'condition', + type: 'text', + admin: { + description: 'JSONPath expression that must evaluate to true for this trigger to execute the workflow (e.g., "$.trigger.doc.status == \'published\'")' + }, + required: false + }, + // Virtual fields for custom triggers + // Note: Custom trigger fields from trigger-helpers already have unique names + // We just need to pass them through without modification + ...(triggers || []).flatMap(t => (t.fields || [])) + ] + }, + { + name: 'steps', + type: 'array', + fields: [ + { + type: 'row', + fields: [ + { + name: 'step', + type: 'select', + options: steps.map(t => t.slug) + }, + { + name: 'name', + type: 'text', } ] - }; + }, + ...(steps || []).flatMap(step => (step.inputSchema || []).map(field => { + const originalName = (field as any).name; + const resultField: any = { + ...field, + // Prefix field name with step slug to avoid conflicts + name: `__step_${step.slug}_${originalName}`, + admin: { + ...(field.admin || {}), + condition: (...args: any[]) => args[1]?.step === step.slug && ( + (field.admin as any)?.condition ? + (field.admin as any).condition.call(this, ...args) : + true + ), + }, + virtual: true, + }; - return resultField as Field; - })), - { - name: 'dependencies', - type: 'text', - admin: { - description: 'Step names that must complete before this step can run' + // Add hooks to store/retrieve from the step's input data + resultField.hooks = { + ...((field as any).hooks || {}), + afterRead: [ + ...(((field as any).hooks)?.afterRead || []), + ({ siblingData }: any) => { + // Read from step input data using original field name + return siblingData?.[originalName] || (field as any).defaultValue; + } + ], + beforeChange: [ + ...(((field as any).hooks)?.beforeChange || []), + ({ siblingData, value }: any) => { + // Store in step data using original field name + siblingData[originalName] = value; + return undefined; // Don't store the prefixed field + } + ] + }; + + return resultField as Field; + })), + { + name: 'dependencies', + type: 'text', + admin: { + description: 'Step names that must complete before this step can run' + }, + hasMany: true, + required: false }, - hasMany: true, - required: false - }, - { - name: 'condition', - type: 'text', - admin: { - description: 'JSONPath expression that must evaluate to true for this step to execute (e.g., "$.trigger.doc.status == \'published\'")' + { + name: 'condition', + type: 'text', + admin: { + description: 'JSONPath expression that must evaluate to true for this step to execute (e.g., "$.trigger.doc.status == \'published\'")' + }, + required: false }, - required: false - }, - ], - } - ], - versions: { + ], + } + ], + versions: { drafts: { autosave: false, }, maxPerDoc: 10, }, -}) + } +} diff --git a/src/plugin/config-types.ts b/src/plugin/config-types.ts index ce7e0c1..9d465f0 100644 --- a/src/plugin/config-types.ts +++ b/src/plugin/config-types.ts @@ -1,4 +1,6 @@ -import type {Field, TaskConfig} from "payload" +import type {TaskConfig} from "payload" + +import type {Trigger} from "../triggers/types.js" export type CollectionTriggerConfigCrud = { create?: true @@ -9,10 +11,7 @@ export type CollectionTriggerConfigCrud = { export type CollectionTriggerConfig = CollectionTriggerConfigCrud | true -export type CustomTriggerConfig = { - inputs?: Field[] - slug: string, -} +export type TriggerConfig = (config: WorkflowsPluginConfig) => Trigger export type WorkflowsPluginConfig = { collectionTriggers: { @@ -20,6 +19,6 @@ export type WorkflowsPluginConfig = { } enabled?: boolean steps: TaskConfig[], - triggers?: CustomTriggerConfig[] + triggers?: TriggerConfig[] webhookPrefix?: string } diff --git a/src/triggers/collection-trigger.ts b/src/triggers/collection-trigger.ts index aa1e496..c83ccd3 100644 --- a/src/triggers/collection-trigger.ts +++ b/src/triggers/collection-trigger.ts @@ -1,19 +1,14 @@ -import type {Field} from 'payload' +import type {TriggerConfig} from '../plugin/config-types.js' -import type { WorkflowsPluginConfig } from '../plugin/config-types.js' - -import {triggerField} from "./helpers.js" - -export function getCollectionTriggerFields( - collectionTriggers: WorkflowsPluginConfig['collectionTriggers'] -): Field[] { - return [ - triggerField({ +export const collectionTrigger: TriggerConfig = ({collectionTriggers}) => ({ + slug: 'collection', + fields: [ + { name: 'collectionSlug', type: 'select', options: Object.keys(collectionTriggers || {}), - }), - triggerField({ + }, + { name: 'operation', type: 'select', options: [ @@ -22,6 +17,6 @@ export function getCollectionTriggerFields( 'read', 'update', ], - }) + } ] -} +}) diff --git a/src/triggers/cron-trigger.ts b/src/triggers/cron-trigger.ts index 8144b1b..b8686e0 100644 --- a/src/triggers/cron-trigger.ts +++ b/src/triggers/cron-trigger.ts @@ -1,25 +1,23 @@ -import type {Field} from 'payload' +import type {TriggerConfig} from '../plugin/config-types.js' -import {triggerField} from "./helpers.js" - -export function getCronTriggerFields(): Field[] { - return [ - triggerField({ +export const cronTrigger: TriggerConfig = () => ({ + slug: 'cron', + fields: [ + { name: 'cronExpression', type: 'text', admin: { - condition: (_, siblingData) => siblingData?.type === 'cron-trigger', description: 'Cron expression for scheduled execution (e.g., "0 0 * * *" for daily at midnight)', placeholder: '0 0 * * *' }, validate: (value: any, {siblingData}: any) => { const cronValue = value || siblingData?.parameters?.cronExpression - if (siblingData?.type === 'cron-trigger' && !cronValue) { + if (siblingData?.type === 'cron' && !cronValue) { return 'Cron expression is required for cron triggers' } // Validate cron expression format if provided - if (siblingData?.type === 'cron-trigger' && cronValue) { + if (siblingData?.type === 'cron' && cronValue) { // Basic format validation - should be 5 parts separated by spaces const cronParts = cronValue.trim().split(/\s+/) if (cronParts.length !== 5) { @@ -32,19 +30,18 @@ export function getCronTriggerFields(): Field[] { return true }, - }), - triggerField({ + }, + { name: 'timezone', type: 'text', admin: { - condition: (_, siblingData) => siblingData?.type === 'cron-trigger', description: 'Timezone for cron execution (e.g., "America/New_York", "Europe/London"). Defaults to UTC.', placeholder: 'UTC' }, defaultValue: 'UTC', validate: (value: any, {siblingData}: any) => { const tzValue = value || siblingData?.parameters?.timezone - if (siblingData?.type === 'cron-trigger' && tzValue) { + if (siblingData?.type === 'cron' && tzValue) { try { // Test if timezone is valid by trying to create a date with it new Intl.DateTimeFormat('en', {timeZone: tzValue}) @@ -55,6 +52,6 @@ export function getCronTriggerFields(): Field[] { } return true }, - }) + } ] -} \ No newline at end of file +}) \ No newline at end of file diff --git a/src/triggers/global-trigger.ts b/src/triggers/global-trigger.ts index ff4f73f..598888d 100644 --- a/src/triggers/global-trigger.ts +++ b/src/triggers/global-trigger.ts @@ -1,28 +1,25 @@ -import type {Field} from 'payload' +import type {TriggerConfig} from '../plugin/config-types.js' -import {triggerField} from "./helpers.js" - -export function getGlobalTriggerFields(): Field[] { - return [ - triggerField({ +export const globalTrigger: TriggerConfig = () => ({ + slug: 'global', + fields: [ + { name: 'global', type: 'select', admin: { - condition: (_, siblingData) => siblingData?.type === 'global-trigger', description: 'Global that triggers the workflow', }, options: [], // Will be populated dynamically based on available globals - }), - triggerField({ + }, + { name: 'globalOperation', type: 'select', admin: { - condition: (_, siblingData) => siblingData?.type === 'global-trigger', description: 'Global operation that triggers the workflow', }, options: [ 'update' ], - }) + } ] -} \ No newline at end of file +}) \ No newline at end of file diff --git a/src/triggers/helpers.ts b/src/triggers/helpers.ts index 1f82c92..2fe7b4c 100644 --- a/src/triggers/helpers.ts +++ b/src/triggers/helpers.ts @@ -1,12 +1,10 @@ -import type {Field, TextField, SelectField} from "payload" +import type {Field} from "payload" import type {Trigger} from "./types.js" -type FieldWithName = TextField | SelectField | (Field & { name: string }) - type Options = { slug: string, - fields?: FieldWithName[] + fields?: ({name: string} & Field)[] } export const trigger = ({ @@ -15,18 +13,18 @@ export const trigger = ({ }: Options): Trigger => { return { slug, - fields: (fields || []).map(triggerField) as Field[] + fields: (fields || []).map(f => triggerField(slug, f)) } } -export const triggerField = (field: FieldWithName): Field => ({ +export const triggerField = (slug: string, field: {name: string} & Field): Field => ({ ...field, name: '__trigger_' + field.name, admin: { - ...(field.admin as any || {}), + ...(field.admin as unknown || {}), condition: (_, siblingData, __) => { const previous = field.admin?.condition?.call(null, _, siblingData, __) - return previous !== false // Preserve existing condition if it exists + return previous || (siblingData?.type === slug) }, }, hooks: { diff --git a/src/triggers/index.ts b/src/triggers/index.ts index 29bc4d9..44df8e0 100644 --- a/src/triggers/index.ts +++ b/src/triggers/index.ts @@ -1,4 +1,4 @@ -export { getCollectionTriggerFields } from './collection-trigger.js' -export { getCronTriggerFields } from './cron-trigger.js' -export { getGlobalTriggerFields } from './global-trigger.js' -export { getWebhookTriggerFields } from './webhook-trigger.js' \ No newline at end of file +export { collectionTrigger } from './collection-trigger.js' +export { cronTrigger } from './cron-trigger.js' +export { globalTrigger } from './global-trigger.js' +export { webhookTrigger } from './webhook-trigger.js' diff --git a/src/triggers/webhook-trigger.ts b/src/triggers/webhook-trigger.ts index 7c9d290..873df94 100644 --- a/src/triggers/webhook-trigger.ts +++ b/src/triggers/webhook-trigger.ts @@ -1,22 +1,20 @@ -import type {Field} from 'payload' +import type {TriggerConfig} from '../plugin/config-types.js' -import {triggerField} from "./helpers.js" - -export function getWebhookTriggerFields(): Field[] { - return [ - triggerField({ +export const webhookTrigger: TriggerConfig = () => ({ + slug: 'webhook', + fields: [ + { name: 'webhookPath', type: 'text', admin: { - condition: (_, siblingData) => siblingData?.type === 'webhook-trigger', description: 'URL path for the webhook (e.g., "my-webhook"). Full URL will be /api/workflows-webhook/my-webhook', }, validate: (value: any, {siblingData}: any) => { - if (siblingData?.type === 'webhook-trigger' && !value && !siblingData?.parameters?.webhookPath) { + if (siblingData?.type === 'webhook' && !value && !siblingData?.parameters?.webhookPath) { return 'Webhook path is required for webhook triggers' } return true }, - }) + } ] -} \ No newline at end of file +}) \ No newline at end of file