mirror of
https://github.com/immich-app/immich.git
synced 2025-12-23 01:11:36 +03:00
wip
This commit is contained in:
@@ -1,217 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ActionBuilder from '$lib/components/workflow/ActionBuilder.svelte';
|
||||
import FilterBuilder from '$lib/components/workflow/FilterBuilder.svelte';
|
||||
import GroupTab from '$lib/elements/GroupTab.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
createWorkflow,
|
||||
PluginTriggerType,
|
||||
updateWorkflow,
|
||||
type PluginResponseDto,
|
||||
type WorkflowResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Switch, Textarea } from '@immich/ui';
|
||||
import { mdiAutoFix } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
workflow?: WorkflowResponseDto;
|
||||
plugins: PluginResponseDto[];
|
||||
onClose: () => void;
|
||||
onSave: (workflow: WorkflowResponseDto) => void;
|
||||
}
|
||||
|
||||
let { workflow, plugins, onClose, onSave }: Props = $props();
|
||||
|
||||
const isEditMode = !!workflow;
|
||||
|
||||
// Form state
|
||||
let name = $state(workflow?.name || '');
|
||||
let description = $state(workflow?.description || '');
|
||||
let triggerType = $state<PluginTriggerType>(workflow?.triggerType || PluginTriggerType.AssetCreate);
|
||||
let enabled = $state(workflow?.enabled ?? true);
|
||||
let filters = $state<Array<{ filterId: string; filterConfig?: object }>>(
|
||||
workflow?.filters.map((f) => ({ filterId: f.filterId, filterConfig: f.filterConfig || undefined })) || [],
|
||||
);
|
||||
let actions = $state<Array<{ actionId: string; actionConfig?: object }>>(
|
||||
workflow?.actions.map((a) => ({ actionId: a.actionId, actionConfig: a.actionConfig || undefined })) || [],
|
||||
);
|
||||
|
||||
// Editor mode state
|
||||
let editorMode = $state<'visual' | 'json'>('visual');
|
||||
let jsonText = $state('');
|
||||
let jsonError = $state('');
|
||||
|
||||
// Sync JSON when switching to JSON mode
|
||||
const syncToJson = () => {
|
||||
const workflowData = {
|
||||
...(isEditMode ? { id: workflow!.id } : {}),
|
||||
name,
|
||||
description,
|
||||
triggerType,
|
||||
enabled,
|
||||
filters,
|
||||
actions,
|
||||
};
|
||||
jsonText = JSON.stringify(workflowData, null, 2);
|
||||
jsonError = '';
|
||||
};
|
||||
|
||||
// Sync visual form when switching from JSON mode
|
||||
const syncFromJson = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
name = parsed.name || '';
|
||||
description = parsed.description || '';
|
||||
triggerType = parsed.triggerType || PluginTriggerType.AssetCreate;
|
||||
enabled = parsed.enabled ?? true;
|
||||
filters = parsed.filters || [];
|
||||
actions = parsed.actions || [];
|
||||
jsonError = '';
|
||||
} catch (error) {
|
||||
jsonError = error instanceof Error ? error.message : 'Invalid JSON';
|
||||
}
|
||||
};
|
||||
|
||||
const handleModeChange = (newMode: 'visual' | 'json') => {
|
||||
if (newMode === 'json' && editorMode === 'visual') {
|
||||
syncToJson();
|
||||
} else if (newMode === 'visual' && editorMode === 'json') {
|
||||
syncFromJson();
|
||||
}
|
||||
editorMode = newMode;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// If in JSON mode, sync from JSON first
|
||||
if (editorMode === 'json') {
|
||||
syncFromJson();
|
||||
if (jsonError) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!name.trim()) {
|
||||
handleError(new Error($t('name_required')), $t('validation_error'));
|
||||
return;
|
||||
}
|
||||
|
||||
const trigger =
|
||||
triggerType === PluginTriggerType.AssetCreate
|
||||
? PluginTriggerType.AssetCreate
|
||||
: PluginTriggerType.PersonRecognized;
|
||||
|
||||
try {
|
||||
let result: WorkflowResponseDto;
|
||||
result = await (isEditMode
|
||||
? updateWorkflow({
|
||||
id: workflow!.id,
|
||||
workflowUpdateDto: {
|
||||
name,
|
||||
description: description || undefined,
|
||||
enabled,
|
||||
filters,
|
||||
actions,
|
||||
},
|
||||
})
|
||||
: createWorkflow({
|
||||
workflowCreateDto: {
|
||||
name,
|
||||
description: description || undefined,
|
||||
triggerType: trigger,
|
||||
enabled,
|
||||
filters,
|
||||
actions,
|
||||
},
|
||||
}));
|
||||
onSave(result);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
handleError(error, isEditMode ? $t('errors.unable_to_create') : $t('errors.unable_to_create'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal title={isEditMode ? $t('edit_workflow') : $t('create_workflow')} icon={mdiAutoFix} {onClose} size="large">
|
||||
<ModalBody>
|
||||
<div class="mb-4">
|
||||
<GroupTab
|
||||
filters={['visual', 'json']}
|
||||
labels={[$t('visual_builder'), $t('json_editor')]}
|
||||
selected={editorMode}
|
||||
label={$t('editor_mode')}
|
||||
onSelect={(mode) => handleModeChange(mode as 'visual' | 'json')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if editorMode === 'visual'}
|
||||
<form
|
||||
id="workflow-form"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void handleSubmit();
|
||||
}}
|
||||
class="mt-4 flex flex-col gap-4"
|
||||
>
|
||||
<Field label={$t('name')} required>
|
||||
<Input bind:value={name} required />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('description')}>
|
||||
<Textarea bind:value={description} />
|
||||
</Field>
|
||||
|
||||
{#if !isEditMode}
|
||||
<Field label={$t('trigger_type')} required>
|
||||
<select bind:value={triggerType} class="immich-form-input w-full" required>
|
||||
<option value={PluginTriggerType.AssetCreate}>{$t('asset_created')}</option>
|
||||
<option value={PluginTriggerType.PersonRecognized}>{$t('person_recognized')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
{:else}
|
||||
<Field label={$t('trigger_type')}>
|
||||
<Input value={triggerType} disabled />
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
<Field label={$t('enabled')}>
|
||||
<Switch bind:checked={enabled} />
|
||||
</Field>
|
||||
|
||||
<div class="border-t pt-4 dark:border-gray-700">
|
||||
<h3 class="mb-2 font-semibold">{$t('filter')}</h3>
|
||||
<FilterBuilder bind:filters {triggerType} {plugins} />
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4 dark:border-gray-700">
|
||||
<h3 class="mb-2 font-semibold">{$t('actions')}</h3>
|
||||
<ActionBuilder bind:actions {triggerType} {plugins} />
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="mt-4 flex flex-col gap-4">
|
||||
{#if jsonError}
|
||||
<div class="rounded-lg bg-red-100 p-3 text-sm text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
{$t('json_error')}: {jsonError}
|
||||
</div>
|
||||
{/if}
|
||||
<Field label={$t('workflow_json')}>
|
||||
<textarea bind:value={jsonText} class="immich-form-input h-96 w-full font-mono text-sm" spellcheck="false"
|
||||
></textarea>
|
||||
</Field>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{$t('workflow_json_help')}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button color="secondary" fullWidth onclick={onClose}>{$t('cancel')}</Button>
|
||||
<Button type="submit" fullWidth form="workflow-form" onclick={handleSubmit}>
|
||||
{isEditMode ? $t('save') : $t('create')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
16
web/src/lib/modals/WorkflowNavigationConfirmModal.svelte
Normal file
16
web/src/lib/modals/WorkflowNavigationConfirmModal.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { ConfirmModal } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
onClose: (confirmed: boolean) => void;
|
||||
};
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<ConfirmModal
|
||||
confirmColor="primary"
|
||||
prompt={$t('workflow_navigation_prompt')}
|
||||
onClose={(confirmed) => (confirmed ? onClose(true) : onClose(false))}
|
||||
/>
|
||||
19
web/src/lib/modals/WorkflowTriggerUpdateConfirmModal.svelte
Normal file
19
web/src/lib/modals/WorkflowTriggerUpdateConfirmModal.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { ConfirmModal } from '@immich/ui';
|
||||
import { mdiLightningBolt } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
onClose: (confirmed: boolean) => void;
|
||||
};
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<ConfirmModal
|
||||
confirmColor="primary"
|
||||
title={$t('change_trigger')}
|
||||
icon={mdiLightningBolt}
|
||||
prompt={$t('change_trigger_prompt')}
|
||||
onClose={(confirmed) => (confirmed ? onClose(true) : onClose(false))}
|
||||
/>
|
||||
@@ -2,28 +2,37 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
import type { WorkflowPayload } from '$lib/services/workflow.service';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
createWorkflow,
|
||||
deleteWorkflow,
|
||||
PluginTriggerType,
|
||||
updateWorkflow,
|
||||
type PluginActionResponseDto,
|
||||
type PluginFilterResponseDto,
|
||||
type PluginResponseDto,
|
||||
type WorkflowResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Button, Card, CardBody, CardTitle, HStack, Icon, IconButton, toastManager } from '@immich/ui';
|
||||
import {
|
||||
mdiChevronDown,
|
||||
mdiChevronUp,
|
||||
mdiContentCopy,
|
||||
mdiDelete,
|
||||
mdiPencil,
|
||||
mdiPlay,
|
||||
mdiPlayPause,
|
||||
mdiPlus,
|
||||
} from '@mdi/js';
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CodeBlock,
|
||||
HStack,
|
||||
Icon,
|
||||
IconButton,
|
||||
MenuItemType,
|
||||
menuManager,
|
||||
Text,
|
||||
toastManager,
|
||||
} from '@immich/ui';
|
||||
import { mdiCodeJson, mdiDelete, mdiDotsVertical, mdiPause, mdiPencil, mdiPlay, mdiPlus } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
@@ -33,24 +42,57 @@
|
||||
let { data }: Props = $props();
|
||||
|
||||
let workflows = $state<WorkflowResponseDto[]>(data.workflows);
|
||||
// svelte-ignore non_reactive_update
|
||||
let expandedWorkflows = new SvelteSet();
|
||||
const expandedWorkflows = new SvelteSet<string>();
|
||||
|
||||
const pluginFilterLookup = new SvelteMap<string, PluginFilterResponseDto & { pluginTitle: string }>();
|
||||
const pluginActionLookup = new SvelteMap<string, PluginActionResponseDto & { pluginTitle: string }>();
|
||||
|
||||
for (const plugin of data.plugins as PluginResponseDto[]) {
|
||||
for (const filter of plugin.filters ?? []) {
|
||||
pluginFilterLookup.set(filter.id, { ...filter, pluginTitle: plugin.title });
|
||||
}
|
||||
|
||||
for (const action of plugin.actions ?? []) {
|
||||
pluginActionLookup.set(action.id, { ...action, pluginTitle: plugin.title });
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
const newExpanded = new SvelteSet(expandedWorkflows);
|
||||
if (newExpanded.has(id)) {
|
||||
newExpanded.delete(id);
|
||||
if (expandedWorkflows.has(id)) {
|
||||
expandedWorkflows.delete(id);
|
||||
} else {
|
||||
newExpanded.add(id);
|
||||
expandedWorkflows.add(id);
|
||||
}
|
||||
expandedWorkflows = newExpanded;
|
||||
};
|
||||
|
||||
const handleCopyWorkflow = async (workflow: WorkflowResponseDto) => {
|
||||
const workflowJson = JSON.stringify(workflow, null, 2);
|
||||
await copyToClipboard(workflowJson);
|
||||
const buildShareableWorkflow = (workflow: WorkflowResponseDto): WorkflowPayload => {
|
||||
const orderedFilters = [...(workflow.filters ?? [])].sort((a, b) => a.order - b.order);
|
||||
const orderedActions = [...(workflow.actions ?? [])].sort((a, b) => a.order - b.order);
|
||||
|
||||
return {
|
||||
name: workflow.name ?? '',
|
||||
description: workflow.description ?? '',
|
||||
enabled: workflow.enabled,
|
||||
triggerType: workflow.triggerType,
|
||||
filters: orderedFilters.map((wfFilter) => {
|
||||
const meta = pluginFilterLookup.get(wfFilter.filterId);
|
||||
const key = meta?.methodName ?? wfFilter.filterId;
|
||||
return {
|
||||
[key]: wfFilter.filterConfig ?? {},
|
||||
};
|
||||
}),
|
||||
actions: orderedActions.map((wfAction) => {
|
||||
const meta = pluginActionLookup.get(wfAction.actionId);
|
||||
const key = meta?.methodName ?? wfAction.actionId;
|
||||
return {
|
||||
[key]: wfAction.actionConfig ?? {},
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const getWorkflowJson = (workflow: WorkflowResponseDto) => JSON.stringify(buildShareableWorkflow(workflow), null, 2);
|
||||
|
||||
const handleToggleEnabled = async (workflow: WorkflowResponseDto) => {
|
||||
try {
|
||||
const updated = await updateWorkflow({
|
||||
@@ -93,6 +135,38 @@
|
||||
await goto(`${AppRoute.WORKFLOWS_EDIT}/${workflow.id}?editMode=visual`);
|
||||
};
|
||||
|
||||
type WorkflowChip = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
const getFilterChips = (workflow: WorkflowResponseDto): WorkflowChip[] => {
|
||||
return [...(workflow.filters ?? [])]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((filter) => {
|
||||
const meta = pluginFilterLookup.get(filter.filterId);
|
||||
return {
|
||||
id: filter.id,
|
||||
title: meta?.title ?? $t('filter'),
|
||||
subtitle: meta?.pluginTitle ?? $t('workflow'),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getActionChips = (workflow: WorkflowResponseDto): WorkflowChip[] => {
|
||||
return [...(workflow.actions ?? [])]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((action) => {
|
||||
const meta = pluginActionLookup.get(action.actionId);
|
||||
return {
|
||||
id: action.id,
|
||||
title: meta?.title ?? $t('action'),
|
||||
subtitle: meta?.pluginTitle ?? $t('workflow'),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getTriggerLabel = (triggerType: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
AssetCreate: $t('asset_created'),
|
||||
@@ -100,12 +174,39 @@
|
||||
};
|
||||
return labels[triggerType] || triggerType;
|
||||
};
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
|
||||
const formatTimestamp = (iso?: string) => {
|
||||
if (!iso) {
|
||||
return '—';
|
||||
}
|
||||
return dateFormatter.format(new Date(iso));
|
||||
};
|
||||
|
||||
type WorkflowWithMeta = {
|
||||
workflow: WorkflowResponseDto;
|
||||
filterChips: WorkflowChip[];
|
||||
actionChips: WorkflowChip[];
|
||||
workflowJson: string;
|
||||
};
|
||||
|
||||
const getWorkflowsWithMeta = (): WorkflowWithMeta[] =>
|
||||
workflows.map((workflow) => ({
|
||||
workflow,
|
||||
filterChips: getFilterChips(workflow),
|
||||
actionChips: getActionChips(workflow),
|
||||
workflowJson: getWorkflowJson(workflow),
|
||||
}));
|
||||
</script>
|
||||
|
||||
<UserPageLayout title={data.meta.title} scrollbar={false}>
|
||||
{#snippet buttons()}
|
||||
<HStack gap={1}>
|
||||
<Button shape="round" color="primary" onclick={handleCreateWorkflow}>
|
||||
<Button size="small" variant="ghost" color="secondary" onclick={handleCreateWorkflow}>
|
||||
<Icon icon={mdiPlus} size="18" />
|
||||
{$t('create_workflow')}
|
||||
</Button>
|
||||
@@ -127,95 +228,165 @@
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="my-5 flex flex-col gap-4">
|
||||
{#each workflows as workflow (workflow.id)}
|
||||
<Card color="secondary">
|
||||
<CardBody>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<CardTitle>{workflow.name || $t('untitled_workflow')}</CardTitle>
|
||||
{#if workflow.description}
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{workflow.description}</p>
|
||||
<div class="my-6 grid gap-6">
|
||||
{#each getWorkflowsWithMeta() as { workflow, filterChips, actionChips, workflowJson } (workflow.id)}
|
||||
<Card class="border border-gray-200/70 shadow-xl shadow-gray-900/5 dark:border-gray-700/60">
|
||||
<CardHeader
|
||||
class={`flex flex-col px-8 py-6 gap-4 sm:flex-row sm:items-center sm:gap-6 ${workflow.enabled ? 'bg-linear-to-r from-green-50 to-white dark:from-green-950/40 dark:to-gray-900' : 'bg-neutral-50 dark:bg-neutral-900'}`}
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class={workflow.enabled ? 'relative flex h-3 w-3' : 'flex h-3 w-3'}>
|
||||
{#if workflow.enabled}
|
||||
<span class="absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
{/if}
|
||||
</div>
|
||||
<HStack gap={1}>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={workflow.enabled ? mdiPlay : mdiPlayPause}
|
||||
aria-label={workflow.enabled ? $t('disabled') : $t('enabled')}
|
||||
onclick={() => handleToggleEnabled(workflow)}
|
||||
class={workflow.enabled ? 'text-green-500' : 'text-gray-400'}
|
||||
/>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={mdiContentCopy}
|
||||
aria-label={$t('copy_to_clipboard')}
|
||||
onclick={() => handleCopyWorkflow(workflow)}
|
||||
/>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={mdiPencil}
|
||||
aria-label={$t('edit')}
|
||||
onclick={() => handleEditWorkflow(workflow)}
|
||||
/>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={mdiDelete}
|
||||
aria-label={$t('delete')}
|
||||
onclick={() => handleDeleteWorkflow(workflow)}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-sm">
|
||||
<span
|
||||
class="rounded-full px-3 py-1 {workflow.enabled
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200'}"
|
||||
>
|
||||
{workflow.enabled ? $t('enabled') : $t('disabled')}
|
||||
<span
|
||||
class={workflow.enabled
|
||||
? 'relative inline-flex h-3 w-3 rounded-full bg-green-500'
|
||||
: 'relative inline-flex h-3 w-3 rounded-full bg-gray-400 dark:bg-gray-600'}
|
||||
></span>
|
||||
</span>
|
||||
<span class="rounded-full bg-blue-100 px-3 py-1 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
<CardTitle>{workflow.name}</CardTitle>
|
||||
</div>
|
||||
<CardDescription class="mt-1 text-sm">
|
||||
{workflow.description || $t('workflows_help_text')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-right">
|
||||
<Text size="tiny" class="text-gray-500 dark:text-gray-400">{$t('created_at')}</Text>
|
||||
<Text size="small" class="font-medium">
|
||||
{formatTimestamp(workflow.createdAt)}
|
||||
</Text>
|
||||
</div>
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
icon={mdiDotsVertical}
|
||||
aria-label={$t('menu')}
|
||||
onclick={(event: MouseEvent) => {
|
||||
void menuManager.show({
|
||||
target: event.currentTarget as HTMLElement,
|
||||
position: 'top-left',
|
||||
items: [
|
||||
{
|
||||
title: workflow.enabled ? $t('disable') : $t('enable'),
|
||||
color: workflow.enabled ? 'warning' : 'success',
|
||||
icon: workflow.enabled ? mdiPause : mdiPlay,
|
||||
onSelect: () => void handleToggleEnabled(workflow),
|
||||
},
|
||||
{
|
||||
title: $t('edit'),
|
||||
icon: mdiPencil,
|
||||
onSelect: () => void handleEditWorkflow(workflow),
|
||||
},
|
||||
|
||||
{
|
||||
title: expandedWorkflows.has(workflow.id) ? $t('hide_json') : $t('show_json'),
|
||||
icon: mdiCodeJson,
|
||||
onSelect: () => toggleExpanded(workflow.id),
|
||||
},
|
||||
MenuItemType.Divider,
|
||||
{
|
||||
title: $t('delete'),
|
||||
icon: mdiDelete,
|
||||
color: 'danger',
|
||||
onSelect: () => void handleDeleteWorkflow(workflow),
|
||||
},
|
||||
],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody class="space-y-6">
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<!-- Trigger Section -->
|
||||
<div
|
||||
class="rounded-2xl border border-gray-100/80 bg-gray-50/90 p-4 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<dt class="mb-3 text-xs font-semibold uppercase tracking-widest text-gray-500 dark:text-gray-400">
|
||||
{$t('trigger')}
|
||||
</dt>
|
||||
<span
|
||||
class="inline-block rounded-xl border border-gray-200/80 bg-white/70 px-3 py-1.5 text-sm font-medium shadow-sm dark:border-gray-600 dark:bg-gray-700/80 dark:text-white"
|
||||
>
|
||||
{getTriggerLabel(workflow.triggerType)}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full bg-purple-100 px-3 py-1 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
|
||||
>
|
||||
{workflow.filters.length}
|
||||
{workflow.filters.length === 1 ? $t('filter') : $t('filter')}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full bg-orange-100 px-3 py-1 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
|
||||
>
|
||||
{workflow.actions.length}
|
||||
{workflow.actions.length === 1 ? $t('action') : $t('actions')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleExpanded(workflow.id)}
|
||||
class="flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
<!-- Filters Section -->
|
||||
<div
|
||||
class="rounded-2xl border border-gray-100/80 bg-gray-50/90 p-4 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<Icon icon={expandedWorkflows.has(workflow.id) ? mdiChevronUp : mdiChevronDown} size="18" />
|
||||
{expandedWorkflows.has(workflow.id) ? $t('hide_json') : $t('show_json')}
|
||||
</button>
|
||||
|
||||
{#if expandedWorkflows.has(workflow.id)}
|
||||
<div class="mt-2">
|
||||
<pre class="overflow-x-auto rounded-lg bg-gray-100 p-4 text-xs dark:bg-gray-800">{JSON.stringify(
|
||||
workflow,
|
||||
null,
|
||||
2,
|
||||
)}</pre>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<dt class="text-xs font-semibold uppercase tracking-widest text-gray-500 dark:text-gray-400">
|
||||
{$t('filter')}
|
||||
</dt>
|
||||
<dd
|
||||
class="rounded-full bg-gray-200 px-2.5 py-0.5 text-sm font-semibold text-gray-700 dark:bg-gray-700 dark:text-gray-200"
|
||||
>
|
||||
{workflow.filters.length}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if filterChips.length === 0}
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{$t('no_filters_added')}
|
||||
</span>
|
||||
{:else}
|
||||
{#each filterChips as chip (chip.id)}
|
||||
<span
|
||||
class="rounded-xl border border-gray-200/80 bg-white/70 px-3 py-1.5 text-sm shadow-sm dark:border-gray-600 dark:bg-gray-700/80"
|
||||
>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{chip.title}</span>
|
||||
</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions Section -->
|
||||
<div
|
||||
class="rounded-2xl border border-gray-100/80 bg-gray-50/90 p-4 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<dt class="text-xs font-semibold uppercase tracking-widest text-gray-500 dark:text-gray-400">
|
||||
{$t('actions')}
|
||||
</dt>
|
||||
<dd
|
||||
class="rounded-full bg-gray-200 px-2.5 py-0.5 text-sm font-semibold text-gray-700 dark:bg-gray-700 dark:text-gray-200"
|
||||
>
|
||||
{workflow.actions.length}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if actionChips.length === 0}
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{$t('no_actions_added')}
|
||||
</span>
|
||||
{:else}
|
||||
{#each actionChips as chip (chip.id)}
|
||||
<span
|
||||
class="rounded-xl border border-gray-200/80 bg-white/70 px-3 py-1.5 text-sm shadow-sm dark:border-gray-600 dark:bg-gray-700/80"
|
||||
>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{chip.title}</span>
|
||||
</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if expandedWorkflows.has(workflow.id)}
|
||||
<div>
|
||||
<p class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200">Workflow JSON</p>
|
||||
<CodeBlock code={workflowJson} />
|
||||
</div>
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/each}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
import { dragAndDrop } from '$lib/actions/drag-and-drop';
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import SchemaFormFields from '$lib/components/workflow/schema-form/SchemaFormFields.svelte';
|
||||
@@ -6,8 +7,11 @@
|
||||
import WorkflowJsonEditor from '$lib/components/workflows/workflow-json-editor.svelte';
|
||||
import WorkflowTriggerCard from '$lib/components/workflows/workflow-trigger-card.svelte';
|
||||
import AddWorkflowStepModal from '$lib/modals/AddWorkflowStepModal.svelte';
|
||||
import WorkflowNavigationConfirmModal from '$lib/modals/WorkflowNavigationConfirmModal.svelte';
|
||||
import WorkflowTriggerUpdateConfirmModal from '$lib/modals/WorkflowTriggerUpdateConfirmModal.svelte';
|
||||
import { WorkflowService, type WorkflowPayload } from '$lib/services/workflow.service';
|
||||
import type { PluginActionResponseDto, PluginFilterResponseDto } from '@immich/sdk';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import type { PluginActionResponseDto, PluginFilterResponseDto, PluginTriggerResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -39,6 +43,7 @@
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
@@ -97,6 +102,18 @@
|
||||
|
||||
const updateWorkflow = async () => {
|
||||
try {
|
||||
console.log('Updating workflow with:', {
|
||||
id: editWorkflow.id,
|
||||
name,
|
||||
description,
|
||||
enabled: editWorkflow.enabled,
|
||||
triggerType,
|
||||
orderedFilters: orderedFilters.map((f) => ({ id: f.id, methodName: f.methodName })),
|
||||
orderedActions: orderedActions.map((a) => ({ id: a.id, methodName: a.methodName })),
|
||||
filterConfigs,
|
||||
actionConfigs,
|
||||
});
|
||||
|
||||
const updated = await workflowService.updateWorkflow(
|
||||
editWorkflow.id,
|
||||
name,
|
||||
@@ -113,7 +130,8 @@
|
||||
previousWorkflow = updated;
|
||||
editWorkflow = updated;
|
||||
} catch (error) {
|
||||
console.error('Failed to update workflow:', error);
|
||||
console.log('error', error);
|
||||
handleError(error, 'Failed to update workflow');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -261,6 +279,34 @@
|
||||
const handleRemoveAction = (index: number) => {
|
||||
orderedActions = orderedActions.filter((_, i) => i !== index);
|
||||
};
|
||||
|
||||
const handleTriggerChange = async (newTrigger: PluginTriggerResponseDto) => {
|
||||
const isConfirmed = await modalManager.show(WorkflowTriggerUpdateConfirmModal);
|
||||
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedTrigger = newTrigger;
|
||||
};
|
||||
|
||||
let allowNavigation = $state(false);
|
||||
|
||||
beforeNavigate(({ cancel, to }) => {
|
||||
if (hasChanges && !allowNavigation) {
|
||||
cancel();
|
||||
|
||||
modalManager
|
||||
.show(WorkflowNavigationConfirmModal)
|
||||
.then((isConfirmed) => {
|
||||
if (isConfirmed && to) {
|
||||
allowNavigation = true;
|
||||
void goto(to.url);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet cardOrder(index: number)}
|
||||
@@ -387,7 +433,7 @@
|
||||
<WorkflowTriggerCard
|
||||
{trigger}
|
||||
selected={selectedTrigger.triggerType === trigger.triggerType}
|
||||
onclick={() => (selectedTrigger = trigger)}
|
||||
onclick={() => handleTriggerChange(trigger)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user