diff --git a/.claude/skills/native-trigger/SKILL.md b/.claude/skills/native-trigger/SKILL.md index 5952179d6a..026c1900bf 100644 --- a/.claude/skills/native-trigger/SKILL.md +++ b/.claude/skills/native-trigger/SKILL.md @@ -581,7 +581,28 @@ In `frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte`: 2. Add to `baseConfig` with `countKey` (the dynamic `availableNativeServices` loop does NOT set `countKey`) 3. Add to the `allTypes` array -### Step 14: Update OpenAPI Spec and Regenerate Types +### Step 14: Update TriggersWrapper.svelte + +In `frontend/src/lib/components/triggers/TriggersWrapper.svelte`: + +Add a `{:else if selectedTrigger.type === 'yourservice'}` case that renders `` with the same props pattern as the existing native trigger cases (e.g., `nextcloud`). + +### Step 15: Update AddTriggersButton.svelte + +In `frontend/src/lib/components/triggers/AddTriggersButton.svelte`: + +1. Add `yourserviceAvailable` state variable +2. Add `setYourserviceState()` async function using `isServiceAvailable('yourservice', $workspaceStore!)` +3. Call it at module level +4. Add a dropdown entry to `addTriggerItems` with `hidden: !yourserviceAvailable` + +### Step 16: Update TriggersEditor.svelte Delete Handling + +In `frontend/src/lib/components/triggers/TriggersEditor.svelte`: + +Add your service to the `nativeTriggerServices` map in `deleteDeployedTrigger()`. Native triggers use `NativeTriggerService.deleteNativeTrigger({ workspace, serviceName, externalId })` instead of the standard `path`-based delete. + +### Step 17: Update OpenAPI Spec and Regenerate Types Add to `JobTriggerKind` enum in `backend/windmill-api/openapi.yaml`, then: diff --git a/frontend/src/lib/components/triggers/AddTriggersButton.svelte b/frontend/src/lib/components/triggers/AddTriggersButton.svelte index ee4c106324..094b303765 100644 --- a/frontend/src/lib/components/triggers/AddTriggersButton.svelte +++ b/frontend/src/lib/components/triggers/AddTriggersButton.svelte @@ -35,12 +35,18 @@ const cloudHosted = isCloudHosted() let nextcloudAvailable = $state(false) + let googleAvailable = $state(false) async function setNextcloudState() { nextcloudAvailable = await isServiceAvailable('nextcloud', $workspaceStore!) } + async function setGoogleState() { + googleAvailable = await isServiceAvailable('google', $workspaceStore!) + } + setNextcloudState() + setGoogleState() const addTriggerItems = $derived( [ @@ -112,6 +118,12 @@ action: () => onAddDraftTrigger?.('nextcloud'), icon: triggerIconMap.nextcloud, hidden: !nextcloudAvailable + }, + { + displayName: 'Google', + action: () => onAddDraftTrigger?.('google'), + icon: triggerIconMap.google, + hidden: !googleAvailable } ].filter((item) => !item.hidden) ) diff --git a/frontend/src/lib/components/triggers/TriggersEditor.svelte b/frontend/src/lib/components/triggers/TriggersEditor.svelte index a0e65641ca..605c2e4c76 100644 --- a/frontend/src/lib/components/triggers/TriggersEditor.svelte +++ b/frontend/src/lib/components/triggers/TriggersEditor.svelte @@ -29,7 +29,9 @@ HttpTriggerService, GcpTriggerService, SqsTriggerService, - EmailTriggerService + EmailTriggerService, + NativeTriggerService, + type NativeServiceName } from '$lib/gen' import { sendUserToast } from '$lib/toast' import Alert from '../common/alert/Alert.svelte' @@ -116,20 +118,37 @@ email: () => EmailTriggerService.deleteEmailTrigger } + const nativeTriggerServices: Record = { + nextcloud: 'nextcloud', + google: 'google' + } + const deleteHandler = deleteHandlers[triggerType as keyof typeof deleteHandlers] - if (deleteHandler && deletingTrigger !== triggerIndex) { - deletingTrigger = triggerIndex - try { + const nativeServiceName = nativeTriggerServices[triggerType] + + if (deletingTrigger === triggerIndex) return + + deletingTrigger = triggerIndex + try { + if (nativeServiceName) { + await NativeTriggerService.deleteNativeTrigger({ + workspace: $workspaceStore ?? '', + serviceName: nativeServiceName, + externalId: triggerPath ?? '' + }) + } else if (deleteHandler) { await deleteHandler()({ workspace: $workspaceStore ?? '', path: triggerPath ?? '' }) - sendUserToast(`Successfully deleted ${triggerType} trigger: ${triggerPath}`) - } catch (error) { - sendUserToast(error.body || error.message, true) - } finally { - deletingTrigger = undefined + } else { + return } + sendUserToast(`Successfully deleted ${triggerType} trigger: ${triggerPath}`) + } catch (error) { + sendUserToast(error.body || error.message, true) + } finally { + deletingTrigger = undefined } } diff --git a/frontend/src/lib/components/triggers/TriggersWrapper.svelte b/frontend/src/lib/components/triggers/TriggersWrapper.svelte index 213d8361fe..5f1eb53614 100644 --- a/frontend/src/lib/components/triggers/TriggersWrapper.svelte +++ b/frontend/src/lib/components/triggers/TriggersWrapper.svelte @@ -182,6 +182,16 @@ {customLabel} {...props} /> +{:else if selectedTrigger.type === 'google'} + {:else if selectedTrigger.type === 'cli'}
diff --git a/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte b/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte index eba301d849..7334f0a62e 100644 --- a/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte @@ -97,7 +97,7 @@ let externalId = $state(null) let can_write = $state(true) let originalConfig = $state | undefined>(undefined) - let initialConfig: Record | undefined = undefined + let initialConfig = $state | undefined>(undefined) export function openNew( nis_flow?: boolean, @@ -119,6 +119,7 @@ itemKind = nis_flow ? 'flow' : 'script' externalId = null loadingConfig = false + loadingForm = false can_write = true originalConfig = undefined initialConfig = undefined @@ -166,6 +167,7 @@ externalId = externalIdOrPath loadingConfig = true loadingForm = true + originalConfig = undefined initialConfig = undefined itemKind = nis_flow ? 'flow' : 'script' @@ -187,15 +189,10 @@ serviceConfig = { ...serviceConfig, ...defaultValues } externalData = { ...externalData, ...defaultValues } } - originalConfig = structuredClone($state.snapshot(getSaveCfg())) } catch (err: any) { sendUserToast(`Failed to load trigger configuration: ${err}`, true) externalData = null } finally { - // For drawer mode, set initialConfig here; for inline mode, the effect handles it after form settles - if (!defaultValues) { - initialConfig = structuredClone($state.snapshot(getSaveCfg())) - } clearTimeout(loadingTimeout) loadingConfig = false showLoading = false @@ -210,6 +207,15 @@ } } + // Capture originalConfig after the form has settled (loadingConfig and loadingForm both false) + // This ensures we compare against the form's normalized config, not raw backend data + $effect(() => { + if (!loadingConfig && !loadingForm && originalConfig === undefined && !isNew) { + originalConfig = structuredClone($state.snapshot(getSaveCfg())) + initialConfig = structuredClone($state.snapshot(getSaveCfg())) + } + }) + function close() { drawer?.closeDrawer() } @@ -246,7 +252,7 @@ const saveCfg = $derived.by(getSaveCfg) $effect(() => { - if (!loadingConfig && !loadingForm) { + if (!loadingConfig && !loadingForm && (isNew || initialConfig)) { handleConfigChange(saveCfg, initialConfig, saveDisabled, !isNew, onConfigChange) } }) diff --git a/frontend/src/lib/components/triggers/native/services/google/GoogleTriggerForm.svelte b/frontend/src/lib/components/triggers/native/services/google/GoogleTriggerForm.svelte index 0bd1d427d7..9c52552a95 100644 --- a/frontend/src/lib/components/triggers/native/services/google/GoogleTriggerForm.svelte +++ b/frontend/src/lib/components/triggers/native/services/google/GoogleTriggerForm.svelte @@ -4,7 +4,7 @@ import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import GoogleDrivePicker from './GoogleDrivePicker.svelte' import GoogleCalendarPicker from './GoogleCalendarPicker.svelte' - import { Loader2, Eye, FileText } from 'lucide-svelte' + import { Eye, FileText } from 'lucide-svelte' import GoogleDriveIcon from '$lib/components/icons/GoogleDriveIcon.svelte' import GoogleCalendarIcon from '$lib/components/icons/GoogleCalendarIcon.svelte' @@ -13,15 +13,14 @@ errors: Record disabled?: boolean externalData?: any - loading: boolean + loading?: boolean } let { serviceConfig = $bindable(), errors = $bindable(), - disabled = false, - externalData = undefined, - loading = $bindable() + loading = $bindable(false), + disabled = false }: Props = $props() let triggerType = $state<'drive' | 'calendar'>(serviceConfig.triggerType ?? 'drive') @@ -37,6 +36,11 @@ let calendarId = $state(serviceConfig.calendarId ?? 'primary') let calendarName = $state(serviceConfig.calendarName ?? '') + // No async loading — signal ready immediately + $effect(() => { + loading = false + }) + // Sync form state back to serviceConfig $effect(() => { if (triggerType === 'drive') { @@ -71,105 +75,65 @@ return serviceErrors } - - // Apply external data when editing existing trigger - let externalDataApplied = $state(false) - - $effect(() => { - if (externalData && !externalDataApplied) { - if (externalData.triggerType) { - triggerType = externalData.triggerType - } - driveResourceId = externalData.resourceId ?? driveResourceId - driveResourceName = externalData.resourceName ?? driveResourceName - driveWatchMode = externalData.resourceId ? 'file' : 'all' - calendarId = externalData.calendarId ?? calendarId - calendarName = externalData.calendarName ?? calendarName - externalDataApplied = true - loading = false - } - }) - - $effect(() => { - if (!externalData) { - externalDataApplied = false - loading = false - } - })
- {#if loading} -
- - Loading configuration... +
+
+

Trigger Type

+ + {#snippet children({ item })} + + + {/snippet} +
- {:else} -
-
-

Trigger Type

- + + {#if triggerType === 'drive'} +
+

Watch Mode

+ {#snippet children({ item })} - - + + {/snippet} -
- {#if triggerType === 'drive'} -
-

Watch Mode

- - {#snippet children({ item })} - - - {/snippet} - - - {#if driveWatchMode === 'file'} -

File

- - {#if errors.resourceId} -

{errors.resourceId}

- {/if} - {:else} -

- Watches all changes across your Google Drive. The trigger will fire whenever any file is created, modified, or deleted. -

- {/if} -
- {:else if triggerType === 'calendar'} -
-

Calendar

- File

+ - {#if errors.calendarId} -

{errors.calendarId}

+ {#if errors.resourceId} +

{errors.resourceId}

{/if} -
-

- Requires the Google Calendar API to be enabled in your Google Cloud project. -

-
+ {:else} +

+ Watches all changes across your Google Drive. The trigger will fire whenever any file is + created, modified, or deleted. +

+ {/if} +
+ {:else if triggerType === 'calendar'} +
+

Calendar

+ + {#if errors.calendarId} +

{errors.calendarId}

+ {/if} +
+

+ Requires the Google Calendar API to be enabled in your Google Cloud project. +

- {/if} -
- {/if} +
+ {/if} +
diff --git a/frontend/src/lib/components/triggers/utils.ts b/frontend/src/lib/components/triggers/utils.ts index c188220e25..863d49e673 100644 --- a/frontend/src/lib/components/triggers/utils.ts +++ b/frontend/src/lib/components/triggers/utils.ts @@ -529,10 +529,10 @@ export function getTriggerLabel(trigger: Trigger): string { } else if (type === 'google' && path) { const triggerType = config?.trigger_type ?? config?.triggerType if (triggerType === 'calendar') { - const name = config?.calendar_name ?? config?.calendarName ?? config?.calendar_id ?? '' + const name = config?.resource_name ?? config?.calendar_id ?? '' return `Calendar: ${name || path}` } else { - const name = config?.resource_name ?? config?.resourceName ?? '' + const name = config?.resource_name ?? '' return name ? `Drive: ${name}` : config?.resource_id ? `Drive: ${path}` : `Drive: All changes` } } else if (isDraft && draftConfig?.path) {