mirror of
https://github.com/immich-app/immich.git
synced 2025-12-20 17:25:35 +03:00
Merge branch 'main' of github.com:immich-app/immich into workflow-ui
This commit is contained in:
14
mobile/openapi/lib/model/workflow_update_dto.dart
generated
14
mobile/openapi/lib/model/workflow_update_dto.dart
generated
@@ -18,6 +18,7 @@ class WorkflowUpdateDto {
|
|||||||
this.enabled,
|
this.enabled,
|
||||||
this.filters = const [],
|
this.filters = const [],
|
||||||
this.name,
|
this.name,
|
||||||
|
required this.triggerType,
|
||||||
});
|
});
|
||||||
|
|
||||||
List<WorkflowActionItemDto> actions;
|
List<WorkflowActionItemDto> actions;
|
||||||
@@ -48,13 +49,16 @@ class WorkflowUpdateDto {
|
|||||||
///
|
///
|
||||||
String? name;
|
String? name;
|
||||||
|
|
||||||
|
PluginTriggerType triggerType;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowUpdateDto &&
|
bool operator ==(Object other) => identical(this, other) || other is WorkflowUpdateDto &&
|
||||||
_deepEquality.equals(other.actions, actions) &&
|
_deepEquality.equals(other.actions, actions) &&
|
||||||
other.description == description &&
|
other.description == description &&
|
||||||
other.enabled == enabled &&
|
other.enabled == enabled &&
|
||||||
_deepEquality.equals(other.filters, filters) &&
|
_deepEquality.equals(other.filters, filters) &&
|
||||||
other.name == name;
|
other.name == name &&
|
||||||
|
other.triggerType == triggerType;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
@@ -63,10 +67,11 @@ class WorkflowUpdateDto {
|
|||||||
(description == null ? 0 : description!.hashCode) +
|
(description == null ? 0 : description!.hashCode) +
|
||||||
(enabled == null ? 0 : enabled!.hashCode) +
|
(enabled == null ? 0 : enabled!.hashCode) +
|
||||||
(filters.hashCode) +
|
(filters.hashCode) +
|
||||||
(name == null ? 0 : name!.hashCode);
|
(name == null ? 0 : name!.hashCode) +
|
||||||
|
(triggerType.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name]';
|
String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@@ -87,6 +92,7 @@ class WorkflowUpdateDto {
|
|||||||
} else {
|
} else {
|
||||||
// json[r'name'] = null;
|
// json[r'name'] = null;
|
||||||
}
|
}
|
||||||
|
json[r'triggerType'] = this.triggerType;
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +110,7 @@ class WorkflowUpdateDto {
|
|||||||
enabled: mapValueOfType<bool>(json, r'enabled'),
|
enabled: mapValueOfType<bool>(json, r'enabled'),
|
||||||
filters: WorkflowFilterItemDto.listFromJson(json[r'filters']),
|
filters: WorkflowFilterItemDto.listFromJson(json[r'filters']),
|
||||||
name: mapValueOfType<String>(json, r'name'),
|
name: mapValueOfType<String>(json, r'name'),
|
||||||
|
triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -151,6 +158,7 @@ class WorkflowUpdateDto {
|
|||||||
|
|
||||||
/// The list of required keys that must be present in a JSON.
|
/// The list of required keys that must be present in a JSON.
|
||||||
static const requiredKeys = <String>{
|
static const requiredKeys = <String>{
|
||||||
|
'triggerType',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22976,8 +22976,18 @@
|
|||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"triggerType": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/PluginTriggerType"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"required": [
|
||||||
|
"triggerType"
|
||||||
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1763,6 +1763,7 @@ export type WorkflowUpdateDto = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
filters?: WorkflowFilterItemDto[];
|
filters?: WorkflowFilterItemDto[];
|
||||||
name?: string;
|
name?: string;
|
||||||
|
triggerType: PluginTriggerType;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* List all activities
|
* List all activities
|
||||||
|
|||||||
645
pnpm-lock.yaml
generated
645
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
FROM ghcr.io/immich-app/base-server-dev:202511041104@sha256:7558931a4a71989e7fd9fa3e1ba6c28da15891867310edda8c58236171839f2f AS builder
|
FROM ghcr.io/immich-app/base-server-dev:202511181104@sha256:fd445b91d4db131aae71b143b647d2262818dac80946078ce231c79cb9acecba AS builder
|
||||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||||
CI=1 \
|
CI=1 \
|
||||||
COREPACK_HOME=/tmp \
|
COREPACK_HOME=/tmp \
|
||||||
@@ -69,7 +69,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
|
|||||||
--mount=type=cache,id=mise-tools,target=/buildcache/mise \
|
--mount=type=cache,id=mise-tools,target=/buildcache/mise \
|
||||||
cd plugins && mise run build
|
cd plugins && mise run build
|
||||||
|
|
||||||
FROM ghcr.io/immich-app/base-server-prod:202511041104@sha256:57c0379977fd5521d83cdf661aecd1497c83a9a661ebafe0a5243a09fc1064cb
|
FROM ghcr.io/immich-app/base-server-prod:202511181104@sha256:1bc2b7cebc4fd3296dc33a5779411e1c7d854ea713066c1e024d54f45f176f89
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# dev build
|
# dev build
|
||||||
FROM ghcr.io/immich-app/base-server-dev:202511041104@sha256:7558931a4a71989e7fd9fa3e1ba6c28da15891867310edda8c58236171839f2f AS dev
|
FROM ghcr.io/immich-app/base-server-dev:202511181104@sha256:fd445b91d4db131aae71b143b647d2262818dac80946078ce231c79cb9acecba AS dev
|
||||||
|
|
||||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||||
CI=1 \
|
CI=1 \
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export class WorkflowCreateDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class WorkflowUpdateDto {
|
export class WorkflowUpdateDto {
|
||||||
|
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' })
|
||||||
|
triggerType!: PluginTriggerType;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@Optional()
|
@Optional()
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
"socket.io-client": "~4.8.0",
|
"socket.io-client": "~4.8.0",
|
||||||
"svelte-gestures": "^5.2.2",
|
"svelte-gestures": "^5.2.2",
|
||||||
"svelte-i18n": "^4.0.1",
|
"svelte-i18n": "^4.0.1",
|
||||||
|
"svelte-jsoneditor": "^3.10.0",
|
||||||
"svelte-maplibre": "^1.2.5",
|
"svelte-maplibre": "^1.2.5",
|
||||||
"svelte-persisted-store": "^0.12.0",
|
"svelte-persisted-store": "^0.12.0",
|
||||||
"tabbable": "^6.2.0",
|
"tabbable": "^6.2.0",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte';
|
import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte';
|
||||||
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
import { formatLabel, getComponentFromSchema } from '$lib/utils/workflow';
|
import { formatLabel, getComponentFromSchema } from '$lib/utils/workflow';
|
||||||
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
|
import { getAlbumInfo, getPerson, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||||
import { Button, Field, Input, MultiSelect, Select, Switch, modalManager, type SelectItem } from '@immich/ui';
|
import { Button, Field, Input, MultiSelect, Select, Switch, modalManager, type SelectItem } from '@immich/ui';
|
||||||
import { mdiPlus } from '@mdi/js';
|
import { mdiPlus } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@@ -37,6 +37,64 @@
|
|||||||
Record<string, AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[]>
|
Record<string, AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[]>
|
||||||
>({});
|
>({});
|
||||||
|
|
||||||
|
// Fetch metadata for existing picker values (albums/people)
|
||||||
|
$effect(() => {
|
||||||
|
if (!components) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMetadata = async () => {
|
||||||
|
const metadataUpdates: Record<
|
||||||
|
string,
|
||||||
|
AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[]
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
for (const [key, component] of Object.entries(components)) {
|
||||||
|
const value = actualConfig[key];
|
||||||
|
if (!value || pickerMetadata[key]) {
|
||||||
|
continue; // Skip if no value or already loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAlbumPicker = component.subType === 'album-picker';
|
||||||
|
const isPeoplePicker = component.subType === 'people-picker';
|
||||||
|
|
||||||
|
if (!isAlbumPicker && !isPeoplePicker) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
// Multiple selection
|
||||||
|
if (isAlbumPicker) {
|
||||||
|
const albums = await Promise.all(value.map((id) => getAlbumInfo({ id })));
|
||||||
|
metadataUpdates[key] = albums;
|
||||||
|
} else if (isPeoplePicker) {
|
||||||
|
const people = await Promise.all(value.map((id) => getPerson({ id })));
|
||||||
|
metadataUpdates[key] = people;
|
||||||
|
}
|
||||||
|
} else if (typeof value === 'string' && value) {
|
||||||
|
// Single selection
|
||||||
|
if (isAlbumPicker) {
|
||||||
|
const album = await getAlbumInfo({ id: value });
|
||||||
|
metadataUpdates[key] = album;
|
||||||
|
} else if (isPeoplePicker) {
|
||||||
|
const person = await getPerson({ id: value });
|
||||||
|
metadataUpdates[key] = person;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch metadata for ${key}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(metadataUpdates).length > 0) {
|
||||||
|
pickerMetadata = { ...pickerMetadata, ...metadataUpdates };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void fetchMetadata();
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Initialize config for actions/filters with empty schemas
|
// Initialize config for actions/filters with empty schemas
|
||||||
if (configKey && !config[configKey]) {
|
if (configKey && !config[configKey]) {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
{#if animated}
|
{#if animated}
|
||||||
<div class="absolute inset-0 bg-linear-to-b from-transparent via-primary to-transparent flow-pulse"></div>
|
<div class="absolute inset-0 bg-linear-to-b from-transparent via-primary to-transparent flow-pulse"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Connection nodes -->
|
|
||||||
<div class="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2">
|
<div class="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2">
|
||||||
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
|
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
71
web/src/lib/components/workflows/workflow-json-editor.svelte
Normal file
71
web/src/lib/components/workflows/workflow-json-editor.svelte
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||||
|
import type { WorkflowPayload } from '$lib/services/workflow.service';
|
||||||
|
import { Button, Card, CardBody, CardDescription, CardHeader, CardTitle, Icon, VStack } from '@immich/ui';
|
||||||
|
import { mdiCodeJson } from '@mdi/js';
|
||||||
|
import { JSONEditor, Mode, type Content, type OnChangeStatus } from 'svelte-jsoneditor';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
jsonContent: WorkflowPayload;
|
||||||
|
onApply: () => void;
|
||||||
|
onContentChange: (content: WorkflowPayload) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { jsonContent, onApply, onContentChange }: Props = $props();
|
||||||
|
|
||||||
|
let content: Content = $derived({ json: jsonContent });
|
||||||
|
let canApply = $state(false);
|
||||||
|
let editorClass = $derived(themeManager.isDark ? 'jse-theme-dark' : '');
|
||||||
|
|
||||||
|
const handleChange = (updated: Content, _: Content, status: OnChangeStatus) => {
|
||||||
|
if (status.contentErrors) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canApply = true;
|
||||||
|
|
||||||
|
if ('text' in updated && updated.text !== undefined) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(updated.text);
|
||||||
|
onContentChange(parsed);
|
||||||
|
} catch (error_) {
|
||||||
|
console.error('Invalid JSON in text mode:', error_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
onApply();
|
||||||
|
canApply = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<Icon icon={mdiCodeJson} size="20" class="mt-1" />
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<CardTitle>Workflow JSON</CardTitle>
|
||||||
|
<CardDescription>Edit the workflow configuration directly in JSON format</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="small" color="primary" onclick={handleApply} disabled={!canApply}>Apply Changes</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<VStack gap={2}>
|
||||||
|
<div
|
||||||
|
class="w-full h-[600px] rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600 {editorClass}"
|
||||||
|
>
|
||||||
|
<JSONEditor {content} onChange={handleChange} mainMenuBar={false} mode={Mode.text} />
|
||||||
|
</div>
|
||||||
|
</VStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import 'svelte-jsoneditor/themes/jse-theme-dark.css';
|
||||||
|
</style>
|
||||||
88
web/src/lib/modals/AddWorkflowStepModal.svelte
Normal file
88
web/src/lib/modals/AddWorkflowStepModal.svelte
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PluginActionResponseDto, PluginFilterResponseDto } from '@immich/sdk';
|
||||||
|
import { Icon, Modal, ModalBody } from '@immich/ui';
|
||||||
|
import { mdiFilterOutline, mdiPlayCircleOutline } from '@mdi/js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
filters: PluginFilterResponseDto[];
|
||||||
|
actions: PluginActionResponseDto[];
|
||||||
|
addedFilters?: PluginFilterResponseDto[];
|
||||||
|
addedActions?: PluginActionResponseDto[];
|
||||||
|
onClose: (result?: { type: 'filter' | 'action'; item: PluginFilterResponseDto | PluginActionResponseDto }) => void;
|
||||||
|
type?: 'filter' | 'action';
|
||||||
|
}
|
||||||
|
|
||||||
|
let { filters, actions, addedFilters = [], addedActions = [], onClose, type }: Props = $props();
|
||||||
|
|
||||||
|
// Filter out already-added items
|
||||||
|
const availableFilters = $derived(filters.filter((f) => !addedFilters.some((af) => af.id === f.id)));
|
||||||
|
const availableActions = $derived(actions.filter((a) => !addedActions.some((aa) => aa.id === a.id)));
|
||||||
|
|
||||||
|
type StepType = 'filter' | 'action';
|
||||||
|
|
||||||
|
const handleSelect = (type: StepType, item: PluginFilterResponseDto | PluginActionResponseDto) => {
|
||||||
|
onClose({ type, item });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal title="Add Workflow Step" icon={false} onClose={() => onClose()}>
|
||||||
|
<ModalBody>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Filters Section -->
|
||||||
|
{#if availableFilters.length > 0 && (!type || type === 'filter')}
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<div class="h-6 w-6 rounded-md bg-amber-100 dark:bg-amber-950 flex items-center justify-center">
|
||||||
|
<Icon icon={mdiFilterOutline} size="16" class="text-warning" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Filters</h3>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-2">
|
||||||
|
{#each availableFilters as filter (filter.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => handleSelect('filter', filter)}
|
||||||
|
class="flex items-start gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-amber-300 dark:hover:border-amber-700 hover:bg-amber-50/50 dark:hover:bg-amber-950/20 transition-all text-left"
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-medium text-sm text-gray-900 dark:text-gray-100">{filter.title}</p>
|
||||||
|
{#if filter.description}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{filter.description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Actions Section -->
|
||||||
|
{#if availableActions.length > 0 && (!type || type === 'action')}
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<div class="h-6 w-6 rounded-md bg-teal-100 dark:bg-teal-950 flex items-center justify-center">
|
||||||
|
<Icon icon={mdiPlayCircleOutline} size="16" class="text-success" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Actions</h3>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-2">
|
||||||
|
{#each availableActions as action (action.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => handleSelect('action', action)}
|
||||||
|
class="flex items-start gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-teal-300 dark:hover:border-teal-700 hover:bg-teal-50/50 dark:hover:bg-teal-950/20 transition-all text-left"
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-medium text-sm text-gray-900 dark:text-gray-100">{action.title}</p>
|
||||||
|
{#if action.description}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{action.description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
</Modal>
|
||||||
333
web/src/lib/services/workflow.service.ts
Normal file
333
web/src/lib/services/workflow.service.ts
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import {
|
||||||
|
PluginTriggerType,
|
||||||
|
updateWorkflow as updateWorkflowApi,
|
||||||
|
type PluginActionResponseDto,
|
||||||
|
type PluginContext,
|
||||||
|
type PluginFilterResponseDto,
|
||||||
|
type PluginTriggerResponseDto,
|
||||||
|
type WorkflowResponseDto,
|
||||||
|
type WorkflowUpdateDto,
|
||||||
|
} from '@immich/sdk';
|
||||||
|
|
||||||
|
export interface WorkflowPayload {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
triggerType: string;
|
||||||
|
filters: Record<string, unknown>[];
|
||||||
|
actions: Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkflowService {
|
||||||
|
private availableTriggers: PluginTriggerResponseDto[];
|
||||||
|
private availableFilters: PluginFilterResponseDto[];
|
||||||
|
private availableActions: PluginActionResponseDto[];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
triggers: PluginTriggerResponseDto[],
|
||||||
|
filters: PluginFilterResponseDto[],
|
||||||
|
actions: PluginActionResponseDto[],
|
||||||
|
) {
|
||||||
|
this.availableTriggers = triggers;
|
||||||
|
this.availableFilters = filters;
|
||||||
|
this.availableActions = actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get filters that support the given context
|
||||||
|
*/
|
||||||
|
getFiltersByContext(context: PluginContext): PluginFilterResponseDto[] {
|
||||||
|
return this.availableFilters.filter((filter) => filter.supportedContexts.includes(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get actions that support the given context
|
||||||
|
*/
|
||||||
|
getActionsByContext(context: PluginContext): PluginActionResponseDto[] {
|
||||||
|
return this.availableActions.filter((action) => action.supportedContexts.includes(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize filter configurations from existing workflow
|
||||||
|
*/
|
||||||
|
initializeFilterConfigs(
|
||||||
|
workflow: WorkflowResponseDto,
|
||||||
|
contextFilters?: PluginFilterResponseDto[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const filters = contextFilters ?? this.availableFilters;
|
||||||
|
const configs: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (workflow.filters) {
|
||||||
|
for (const workflowFilter of workflow.filters) {
|
||||||
|
const filterDef = filters.find((f) => f.id === workflowFilter.filterId);
|
||||||
|
if (filterDef) {
|
||||||
|
configs[filterDef.methodName] = workflowFilter.filterConfig ?? {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize action configurations from existing workflow
|
||||||
|
*/
|
||||||
|
initializeActionConfigs(
|
||||||
|
workflow: WorkflowResponseDto,
|
||||||
|
contextActions?: PluginActionResponseDto[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const actions = contextActions ?? this.availableActions;
|
||||||
|
const configs: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (workflow.actions) {
|
||||||
|
for (const workflowAction of workflow.actions) {
|
||||||
|
const actionDef = actions.find((a) => a.id === workflowAction.actionId);
|
||||||
|
if (actionDef) {
|
||||||
|
configs[actionDef.methodName] = workflowAction.actionConfig ?? {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize ordered filters from existing workflow
|
||||||
|
*/
|
||||||
|
initializeOrderedFilters(
|
||||||
|
workflow: WorkflowResponseDto,
|
||||||
|
contextFilters?: PluginFilterResponseDto[],
|
||||||
|
): PluginFilterResponseDto[] {
|
||||||
|
if (!workflow.filters) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = contextFilters ?? this.availableFilters;
|
||||||
|
return workflow.filters
|
||||||
|
.map((wf) => filters.find((f) => f.id === wf.filterId))
|
||||||
|
.filter(Boolean) as PluginFilterResponseDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize ordered actions from existing workflow
|
||||||
|
*/
|
||||||
|
initializeOrderedActions(
|
||||||
|
workflow: WorkflowResponseDto,
|
||||||
|
contextActions?: PluginActionResponseDto[],
|
||||||
|
): PluginActionResponseDto[] {
|
||||||
|
if (!workflow.actions) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = contextActions ?? this.availableActions;
|
||||||
|
return workflow.actions
|
||||||
|
.map((wa) => actions.find((a) => a.id === wa.actionId))
|
||||||
|
.filter(Boolean) as PluginActionResponseDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build workflow payload from current state
|
||||||
|
*/
|
||||||
|
buildWorkflowPayload(
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
enabled: boolean,
|
||||||
|
triggerType: string,
|
||||||
|
orderedFilters: PluginFilterResponseDto[],
|
||||||
|
orderedActions: PluginActionResponseDto[],
|
||||||
|
filterConfigs: Record<string, unknown>,
|
||||||
|
actionConfigs: Record<string, unknown>,
|
||||||
|
): WorkflowPayload {
|
||||||
|
const filters = orderedFilters.map((filter) => ({
|
||||||
|
[filter.methodName]: filterConfigs[filter.methodName] ?? {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const actions = orderedActions.map((action) => ({
|
||||||
|
[action.methodName]: actionConfigs[action.methodName] ?? {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
enabled,
|
||||||
|
triggerType,
|
||||||
|
filters,
|
||||||
|
actions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse JSON workflow and update state
|
||||||
|
*/
|
||||||
|
parseWorkflowJson(jsonString: string): {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
trigger?: PluginTriggerResponseDto;
|
||||||
|
filters: PluginFilterResponseDto[];
|
||||||
|
actions: PluginActionResponseDto[];
|
||||||
|
filterConfigs: Record<string, unknown>;
|
||||||
|
actionConfigs: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
} {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonString);
|
||||||
|
|
||||||
|
// Find trigger
|
||||||
|
const trigger = this.availableTriggers.find((t) => t.triggerType === parsed.triggerType);
|
||||||
|
|
||||||
|
// Parse filters
|
||||||
|
const filters: PluginFilterResponseDto[] = [];
|
||||||
|
const filterConfigs: Record<string, unknown> = {};
|
||||||
|
if (Array.isArray(parsed.filters)) {
|
||||||
|
for (const filterObj of parsed.filters) {
|
||||||
|
const methodName = Object.keys(filterObj)[0];
|
||||||
|
const filter = this.availableFilters.find((f) => f.methodName === methodName);
|
||||||
|
if (filter) {
|
||||||
|
filters.push(filter);
|
||||||
|
filterConfigs[methodName] = (filterObj as Record<string, unknown>)[methodName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse actions
|
||||||
|
const actions: PluginActionResponseDto[] = [];
|
||||||
|
const actionConfigs: Record<string, unknown> = {};
|
||||||
|
if (Array.isArray(parsed.actions)) {
|
||||||
|
for (const actionObj of parsed.actions) {
|
||||||
|
const methodName = Object.keys(actionObj)[0];
|
||||||
|
const action = this.availableActions.find((a) => a.methodName === methodName);
|
||||||
|
if (action) {
|
||||||
|
actions.push(action);
|
||||||
|
actionConfigs[methodName] = (actionObj as Record<string, unknown>)[methodName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
name: parsed.name ?? '',
|
||||||
|
description: parsed.description ?? '',
|
||||||
|
enabled: parsed.enabled ?? false,
|
||||||
|
trigger,
|
||||||
|
filters,
|
||||||
|
actions,
|
||||||
|
filterConfigs,
|
||||||
|
actionConfigs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Invalid JSON',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if workflow has changes compared to previous version
|
||||||
|
*/
|
||||||
|
hasWorkflowChanged(
|
||||||
|
previousWorkflow: WorkflowResponseDto,
|
||||||
|
enabled: boolean,
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
triggerType: string,
|
||||||
|
orderedFilters: PluginFilterResponseDto[],
|
||||||
|
orderedActions: PluginActionResponseDto[],
|
||||||
|
filterConfigs: Record<string, unknown>,
|
||||||
|
actionConfigs: Record<string, unknown>,
|
||||||
|
): boolean {
|
||||||
|
// Check enabled state
|
||||||
|
if (enabled !== previousWorkflow.enabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check name or description
|
||||||
|
if (name !== (previousWorkflow.name ?? '') || description !== (previousWorkflow.description ?? '')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check trigger
|
||||||
|
if (triggerType !== previousWorkflow.triggerType) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check filters order/items
|
||||||
|
const previousFilterIds = previousWorkflow.filters?.map((f) => f.filterId) ?? [];
|
||||||
|
const currentFilterIds = orderedFilters.map((f) => f.id);
|
||||||
|
if (JSON.stringify(previousFilterIds) !== JSON.stringify(currentFilterIds)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check actions order/items
|
||||||
|
const previousActionIds = previousWorkflow.actions?.map((a) => a.actionId) ?? [];
|
||||||
|
const currentActionIds = orderedActions.map((a) => a.id);
|
||||||
|
if (JSON.stringify(previousActionIds) !== JSON.stringify(currentActionIds)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check filter configs
|
||||||
|
const previousFilterConfigs: Record<string, unknown> = {};
|
||||||
|
for (const wf of previousWorkflow.filters ?? []) {
|
||||||
|
const filterDef = this.availableFilters.find((f) => f.id === wf.filterId);
|
||||||
|
if (filterDef) {
|
||||||
|
previousFilterConfigs[filterDef.methodName] = wf.filterConfig ?? {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (JSON.stringify(previousFilterConfigs) !== JSON.stringify(filterConfigs)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check action configs
|
||||||
|
const previousActionConfigs: Record<string, unknown> = {};
|
||||||
|
for (const wa of previousWorkflow.actions ?? []) {
|
||||||
|
const actionDef = this.availableActions.find((a) => a.id === wa.actionId);
|
||||||
|
if (actionDef) {
|
||||||
|
previousActionConfigs[actionDef.methodName] = wa.actionConfig ?? {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (JSON.stringify(previousActionConfigs) !== JSON.stringify(actionConfigs)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateWorkflow(
|
||||||
|
workflowId: string,
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
enabled: boolean,
|
||||||
|
triggerType: PluginTriggerType,
|
||||||
|
orderedFilters: PluginFilterResponseDto[],
|
||||||
|
orderedActions: PluginActionResponseDto[],
|
||||||
|
filterConfigs: Record<string, unknown>,
|
||||||
|
actionConfigs: Record<string, unknown>,
|
||||||
|
): Promise<WorkflowResponseDto> {
|
||||||
|
const filters = orderedFilters.map((filter) => ({
|
||||||
|
filterId: filter.id,
|
||||||
|
filterConfig: filterConfigs[filter.methodName] ?? {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const actions = orderedActions.map((action) => ({
|
||||||
|
actionId: action.id,
|
||||||
|
actionConfig: actionConfigs[action.methodName] ?? {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const updateDto: WorkflowUpdateDto = {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
enabled,
|
||||||
|
filters,
|
||||||
|
actions,
|
||||||
|
triggerType,
|
||||||
|
};
|
||||||
|
|
||||||
|
return updateWorkflowApi({ id: workflowId, workflowUpdateDto: updateDto });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEditWorkflow = async (workflow: WorkflowResponseDto) => {
|
const handleEditWorkflow = async (workflow: WorkflowResponseDto) => {
|
||||||
await goto(`${AppRoute.WORKFLOWS_EDIT}/${workflow.id}`);
|
await goto(`${AppRoute.WORKFLOWS_EDIT}/${workflow.id}?editMode=visual`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateWorkflow = async () => {
|
const handleCreateWorkflow = async () => {
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await goto(`${AppRoute.WORKFLOWS_EDIT}/${workflow.id}`);
|
await goto(`${AppRoute.WORKFLOWS_EDIT}/${workflow.id}?editMode=visual`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTriggerLabel = (triggerType: string) => {
|
const getTriggerLabel = (triggerType: string) => {
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||||
import SchemaFormFields from '$lib/components/workflow/schema-form/SchemaFormFields.svelte';
|
import SchemaFormFields from '$lib/components/workflow/schema-form/SchemaFormFields.svelte';
|
||||||
import WorkflowCardConnector from '$lib/components/workflows/workflow-card-connector.svelte';
|
import WorkflowCardConnector from '$lib/components/workflows/workflow-card-connector.svelte';
|
||||||
|
import WorkflowJsonEditor from '$lib/components/workflows/workflow-json-editor.svelte';
|
||||||
import WorkflowTriggerCard from '$lib/components/workflows/workflow-trigger-card.svelte';
|
import WorkflowTriggerCard from '$lib/components/workflows/workflow-trigger-card.svelte';
|
||||||
|
import AddWorkflowStepModal from '$lib/modals/AddWorkflowStepModal.svelte';
|
||||||
|
import { WorkflowService, type WorkflowPayload } from '$lib/services/workflow.service';
|
||||||
import type { PluginActionResponseDto, PluginFilterResponseDto } from '@immich/sdk';
|
import type { PluginActionResponseDto, PluginFilterResponseDto } from '@immich/sdk';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -21,16 +24,19 @@
|
|||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
VStack,
|
VStack,
|
||||||
|
modalManager,
|
||||||
} from '@immich/ui';
|
} from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
|
mdiCodeJson,
|
||||||
mdiContentSave,
|
mdiContentSave,
|
||||||
mdiDragVertical,
|
|
||||||
mdiFilterOutline,
|
mdiFilterOutline,
|
||||||
mdiFlashOutline,
|
mdiFlashOutline,
|
||||||
mdiInformationOutline,
|
mdiInformationOutline,
|
||||||
mdiPlayCircleOutline,
|
mdiPlayCircleOutline,
|
||||||
|
mdiPlus,
|
||||||
|
mdiTrashCanOutline,
|
||||||
|
mdiViewDashboard,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import { isEqual } from 'lodash-es';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -41,33 +47,134 @@
|
|||||||
|
|
||||||
const triggers = data.triggers;
|
const triggers = data.triggers;
|
||||||
const filters = data.plugins.flatMap((plugin) => plugin.filters);
|
const filters = data.plugins.flatMap((plugin) => plugin.filters);
|
||||||
const action = data.plugins.flatMap((plugin) => plugin.actions);
|
const actions = data.plugins.flatMap((plugin) => plugin.actions);
|
||||||
|
const workflowService = new WorkflowService(triggers, filters, actions);
|
||||||
|
|
||||||
let previousWorkflow = data.workflow;
|
let previousWorkflow = data.workflow;
|
||||||
let editWorkflow = $state(data.workflow);
|
let editWorkflow = $state(data.workflow);
|
||||||
|
|
||||||
let name: string = $state(editWorkflow.name ?? '');
|
let viewMode: 'visual' | 'json' = $state('visual');
|
||||||
let description: string = $state(editWorkflow.description ?? '');
|
|
||||||
|
let name: string = $derived(editWorkflow.name ?? '');
|
||||||
|
let description: string = $derived(editWorkflow.description ?? '');
|
||||||
|
|
||||||
let selectedTrigger = $state(triggers.find((t) => t.triggerType === editWorkflow.triggerType) ?? triggers[0]);
|
let selectedTrigger = $state(triggers.find((t) => t.triggerType === editWorkflow.triggerType) ?? triggers[0]);
|
||||||
|
|
||||||
let triggerType = $derived(selectedTrigger.triggerType);
|
let triggerType = $derived(selectedTrigger.triggerType);
|
||||||
|
|
||||||
let supportFilters = $derived(filters.filter((filter) => filter.supportedContexts.includes(selectedTrigger.context)));
|
let supportFilters = $derived(workflowService.getFiltersByContext(selectedTrigger.context));
|
||||||
let supportActions = $derived(action.filter((action) => action.supportedContexts.includes(selectedTrigger.context)));
|
let supportActions = $derived(workflowService.getActionsByContext(selectedTrigger.context));
|
||||||
|
|
||||||
let orderedFilters: PluginFilterResponseDto[] = $derived(supportFilters);
|
let orderedFilters: PluginFilterResponseDto[] = $derived(
|
||||||
let orderedActions: PluginActionResponseDto[] = $derived(supportActions);
|
workflowService.initializeOrderedFilters(editWorkflow, supportFilters),
|
||||||
|
);
|
||||||
|
let orderedActions: PluginActionResponseDto[] = $derived(
|
||||||
|
workflowService.initializeOrderedActions(editWorkflow, supportActions),
|
||||||
|
);
|
||||||
|
let filterConfigs: Record<string, unknown> = $derived(
|
||||||
|
workflowService.initializeFilterConfigs(editWorkflow, supportFilters),
|
||||||
|
);
|
||||||
|
let actionConfigs: Record<string, unknown> = $derived(
|
||||||
|
workflowService.initializeActionConfigs(editWorkflow, supportActions),
|
||||||
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
editWorkflow.triggerType = triggerType;
|
editWorkflow.triggerType = triggerType;
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateWorkflow = async () => {};
|
// Clear filters and actions when trigger changes (context changes)
|
||||||
|
let previousContext = $state<string | undefined>(undefined);
|
||||||
|
$effect(() => {
|
||||||
|
const currentContext = selectedTrigger.context;
|
||||||
|
if (previousContext !== undefined && previousContext !== currentContext) {
|
||||||
|
orderedFilters = [];
|
||||||
|
orderedActions = [];
|
||||||
|
filterConfigs = {};
|
||||||
|
actionConfigs = {};
|
||||||
|
}
|
||||||
|
previousContext = currentContext;
|
||||||
|
});
|
||||||
|
|
||||||
let canSave: boolean = $derived(!isEqual(previousWorkflow, editWorkflow));
|
const updateWorkflow = async () => {
|
||||||
|
try {
|
||||||
|
const updated = await workflowService.updateWorkflow(
|
||||||
|
editWorkflow.id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
editWorkflow.enabled,
|
||||||
|
triggerType,
|
||||||
|
orderedFilters,
|
||||||
|
orderedActions,
|
||||||
|
filterConfigs,
|
||||||
|
actionConfigs,
|
||||||
|
);
|
||||||
|
|
||||||
let filterConfigs: Record<string, unknown> = $state({});
|
// Update the previous workflow state to the new values
|
||||||
let actionConfigs: Record<string, unknown> = $state({});
|
previousWorkflow = updated;
|
||||||
|
editWorkflow = updated;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update workflow:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonContent = $derived(
|
||||||
|
workflowService.buildWorkflowPayload(
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
editWorkflow.enabled,
|
||||||
|
triggerType,
|
||||||
|
orderedFilters,
|
||||||
|
orderedActions,
|
||||||
|
filterConfigs,
|
||||||
|
actionConfigs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let jsonEditorContent: WorkflowPayload = $state({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
enabled: false,
|
||||||
|
triggerType: '',
|
||||||
|
filters: [],
|
||||||
|
actions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncFromJson = () => {
|
||||||
|
const result = workflowService.parseWorkflowJson(JSON.stringify(jsonEditorContent));
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.data) {
|
||||||
|
name = result.data.name;
|
||||||
|
description = result.data.description;
|
||||||
|
editWorkflow.enabled = result.data.enabled;
|
||||||
|
|
||||||
|
if (result.data.trigger) {
|
||||||
|
selectedTrigger = result.data.trigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
orderedFilters = result.data.filters;
|
||||||
|
orderedActions = result.data.actions;
|
||||||
|
filterConfigs = result.data.filterConfigs;
|
||||||
|
actionConfigs = result.data.actionConfigs;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let hasChanges: boolean = $derived(
|
||||||
|
workflowService.hasWorkflowChanged(
|
||||||
|
previousWorkflow,
|
||||||
|
editWorkflow.enabled,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
triggerType,
|
||||||
|
orderedFilters,
|
||||||
|
orderedActions,
|
||||||
|
filterConfigs,
|
||||||
|
actionConfigs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Drag and drop handlers
|
// Drag and drop handlers
|
||||||
let draggedFilterIndex: number | null = $state(null);
|
let draggedFilterIndex: number | null = $state(null);
|
||||||
@@ -128,6 +235,32 @@
|
|||||||
draggedActionIndex = null;
|
draggedActionIndex = null;
|
||||||
dragOverActionIndex = null;
|
dragOverActionIndex = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddStep = async (type?: 'action' | 'filter') => {
|
||||||
|
const result = (await modalManager.show(AddWorkflowStepModal, {
|
||||||
|
filters: supportFilters,
|
||||||
|
actions: supportActions,
|
||||||
|
addedFilters: orderedFilters,
|
||||||
|
addedActions: orderedActions,
|
||||||
|
type,
|
||||||
|
})) as { type: 'filter' | 'action'; item: PluginFilterResponseDto | PluginActionResponseDto } | undefined;
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
if (result.type === 'filter') {
|
||||||
|
orderedFilters = [...orderedFilters, result.item as PluginFilterResponseDto];
|
||||||
|
} else if (result.type === 'action') {
|
||||||
|
orderedActions = [...orderedActions, result.item as PluginActionResponseDto];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFilter = (index: number) => {
|
||||||
|
orderedFilters = orderedFilters.filter((_, i) => i !== index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveAction = (index: number) => {
|
||||||
|
orderedActions = orderedActions.filter((_, i) => i !== index);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet cardOrder(index: number)}
|
{#snippet cardOrder(index: number)}
|
||||||
@@ -151,174 +284,257 @@
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet emptyCreateButton(title: string, description: string, onclick: () => Promise<void>)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
{onclick}
|
||||||
|
class="w-full p-8 rounded-lg border-2 border-dashed border-gray-300 hover:bg-gray-50 dark:hover:bg-gray-900 dark:border-gray-600 transition-all flex flex-col items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Icon icon={mdiPlus} size="32" />
|
||||||
|
<p class="text-sm font-medium">{title}</p>
|
||||||
|
<p class="text-xs">{description}</p>
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<UserPageLayout title={data.meta.title} scrollbar={false}>
|
<UserPageLayout title={data.meta.title} scrollbar={false}>
|
||||||
<!-- <WorkflowSummarySidebar trigger={selectedTrigger} filters={orderedFilters} actions={orderedActions} /> -->
|
<!-- <WorkflowSummarySidebar trigger={selectedTrigger} filters={orderedFilters} actions={orderedActions} /> -->
|
||||||
|
|
||||||
{#snippet buttons()}
|
{#snippet buttons()}
|
||||||
<HStack gap={4} class="me-4">
|
<HStack gap={4} class="me-4">
|
||||||
|
<HStack gap={1} class="border rounded-lg p-1 dark:border-gray-600">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={viewMode === 'visual' ? 'outline' : 'ghost'}
|
||||||
|
color={viewMode === 'visual' ? 'primary' : 'secondary'}
|
||||||
|
leadingIcon={mdiViewDashboard}
|
||||||
|
onclick={() => (viewMode = 'visual')}
|
||||||
|
>
|
||||||
|
Visual
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={viewMode === 'json' ? 'outline' : 'ghost'}
|
||||||
|
color={viewMode === 'json' ? 'primary' : 'secondary'}
|
||||||
|
leadingIcon={mdiCodeJson}
|
||||||
|
onclick={() => {
|
||||||
|
viewMode = 'json';
|
||||||
|
jsonEditorContent = jsonContent;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
JSON
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
<HStack gap={2}>
|
<HStack gap={2}>
|
||||||
<Text class="text-sm">{editWorkflow.enabled ? 'ON' : 'OFF'}</Text>
|
<Text class="text-sm">{editWorkflow.enabled ? 'ON' : 'OFF'}</Text>
|
||||||
<Switch bind:checked={editWorkflow.enabled} />
|
<Switch bind:checked={editWorkflow.enabled} />
|
||||||
</HStack>
|
</HStack>
|
||||||
<Button
|
|
||||||
leadingIcon={mdiContentSave}
|
<Button leadingIcon={mdiContentSave} size="small" color="primary" onclick={updateWorkflow} disabled={!hasChanges}>
|
||||||
size="small"
|
|
||||||
shape="round"
|
|
||||||
color="primary"
|
|
||||||
onclick={updateWorkflow}
|
|
||||||
disabled={!canSave}
|
|
||||||
>
|
|
||||||
{$t('save')}
|
{$t('save')}
|
||||||
</Button>
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<Container size="medium" class="p-4" center>
|
<Container size="medium" class="p-4" center>
|
||||||
<VStack gap={0}>
|
{#if viewMode === 'json'}
|
||||||
<Card expandable expanded={false}>
|
<WorkflowJsonEditor
|
||||||
<CardHeader>
|
jsonContent={jsonEditorContent}
|
||||||
<div class="flex place-items-start gap-3">
|
onApply={syncFromJson}
|
||||||
<Icon icon={mdiInformationOutline} size="20" class="mt-1" />
|
onContentChange={(content) => (jsonEditorContent = content)}
|
||||||
<div class="flex flex-col">
|
/>
|
||||||
<CardTitle>Basic information</CardTitle>
|
{:else}
|
||||||
<CardDescription>Describing the workflow</CardDescription>
|
<VStack gap={0}>
|
||||||
</div>
|
<Card expandable expanded={false}>
|
||||||
</div>
|
<CardHeader>
|
||||||
</CardHeader>
|
<div class="flex place-items-start gap-3">
|
||||||
|
<Icon icon={mdiInformationOutline} size="20" class="mt-1" />
|
||||||
<CardBody>
|
<div class="flex flex-col">
|
||||||
<VStack gap={6}>
|
<CardTitle>Basic information</CardTitle>
|
||||||
<Field class="text-sm" label="Name" for="workflow-name" required>
|
<CardDescription>Describing the workflow</CardDescription>
|
||||||
<Input placeholder="Workflow name" bind:value={name} />
|
|
||||||
</Field>
|
|
||||||
<Field class="text-sm" label="Description" for="workflow-description">
|
|
||||||
<Textarea placeholder="Workflow description" bind:value={description} />
|
|
||||||
</Field>
|
|
||||||
</VStack>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div class="my-10 h-px w-[98%] bg-gray-200 dark:bg-gray-700"></div>
|
|
||||||
|
|
||||||
<Card expandable expanded={true}>
|
|
||||||
<CardHeader class="bg-indigo-50 dark:bg-primary-800">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<Icon icon={mdiFlashOutline} size="20" class="mt-1 text-primary" />
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<CardTitle class="text-left text-primary">Trigger</CardTitle>
|
|
||||||
<CardDescription>An event that kick off the workflow</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardBody>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
{#each triggers as trigger (trigger.name)}
|
|
||||||
<WorkflowTriggerCard
|
|
||||||
{trigger}
|
|
||||||
selected={selectedTrigger.triggerType === trigger.triggerType}
|
|
||||||
onclick={() => (selectedTrigger = trigger)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<WorkflowCardConnector />
|
|
||||||
|
|
||||||
<Card expandable expanded={true}>
|
|
||||||
<CardHeader class="bg-amber-50 dark:bg-[#5e4100]">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<Icon icon={mdiFilterOutline} size="20" class="mt-1 text-warning" />
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<CardTitle class="text-left text-warning">Filter</CardTitle>
|
|
||||||
<CardDescription>Conditions to filter the target assets</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardBody>
|
|
||||||
<!-- <div class="my-4">
|
|
||||||
<p>Payload</p>
|
|
||||||
<CodeBlock code={JSON.stringify(orderedFilterPayload, null, 2)} lineNumbers></CodeBlock>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
{#each orderedFilters as filter, index (filter.id)}
|
|
||||||
{#if index > 0}
|
|
||||||
{@render stepSeparator()}
|
|
||||||
{/if}
|
|
||||||
<div
|
|
||||||
use:dragAndDrop={{
|
|
||||||
index,
|
|
||||||
onDragStart: handleFilterDragStart,
|
|
||||||
onDragEnter: handleFilterDragEnter,
|
|
||||||
onDrop: handleFilterDrop,
|
|
||||||
onDragEnd: handleFilterDragEnd,
|
|
||||||
isDragging: draggedFilterIndex === index,
|
|
||||||
isDragOver: dragOverFilterIndex === index,
|
|
||||||
}}
|
|
||||||
class="mb-4 cursor-move rounded-lg border-2 p-4 transition-all bg-gray-50 dark:bg-subtle border-dashed border-transparent hover:border-gray-300 dark:hover:border-gray-600"
|
|
||||||
>
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
{@render cardOrder(index)}
|
|
||||||
<div class="flex-1">
|
|
||||||
<h1 class="font-bold text-lg mb-3">{filter.title}</h1>
|
|
||||||
<SchemaFormFields schema={filter.schema} bind:config={filterConfigs} configKey={filter.methodName} />
|
|
||||||
</div>
|
|
||||||
<Icon icon={mdiDragVertical} class="mt-1 text-primary shrink-0" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
</CardHeader>
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<WorkflowCardConnector />
|
<CardBody>
|
||||||
|
<VStack gap={6}>
|
||||||
|
<Field class="text-sm" label="Name" for="workflow-name" required>
|
||||||
|
<Input placeholder="Workflow name" bind:value={name} />
|
||||||
|
</Field>
|
||||||
|
<Field class="text-sm" label="Description" for="workflow-description">
|
||||||
|
<Textarea placeholder="Workflow description" bind:value={description} />
|
||||||
|
</Field>
|
||||||
|
</VStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card expandable expanded>
|
<div class="my-10 h-px w-[98%] bg-gray-200 dark:bg-gray-700"></div>
|
||||||
<CardHeader class="bg-success/10 dark:bg-teal-950">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<Icon icon={mdiPlayCircleOutline} size="20" class="mt-1 text-success" />
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<CardTitle class="text-left text-success">Action</CardTitle>
|
|
||||||
<CardDescription>A set of action to perform on the filtered assets</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardBody>
|
<Card expandable expanded={true}>
|
||||||
<!-- <div class="my-4">
|
<CardHeader class="bg-indigo-50 dark:bg-primary-800">
|
||||||
<p>Payload</p>
|
<div class="flex items-start gap-3">
|
||||||
<CodeBlock code={JSON.stringify(orderedActionPayload, null, 2)} lineNumbers></CodeBlock>
|
<Icon icon={mdiFlashOutline} size="20" class="mt-1 text-primary" />
|
||||||
</div> -->
|
<div class="flex flex-col">
|
||||||
|
<CardTitle class="text-left text-primary">Trigger</CardTitle>
|
||||||
{#each orderedActions as action, index (action.id)}
|
<CardDescription>An event that kick off the workflow</CardDescription>
|
||||||
{#if index > 0}
|
|
||||||
{@render stepSeparator()}
|
|
||||||
{/if}
|
|
||||||
<div
|
|
||||||
use:dragAndDrop={{
|
|
||||||
index,
|
|
||||||
onDragStart: handleActionDragStart,
|
|
||||||
onDragEnter: handleActionDragEnter,
|
|
||||||
onDrop: handleActionDrop,
|
|
||||||
onDragEnd: handleActionDragEnd,
|
|
||||||
isDragging: draggedActionIndex === index,
|
|
||||||
isDragOver: dragOverActionIndex === index,
|
|
||||||
}}
|
|
||||||
class="mb-4 cursor-move rounded-lg border-2 p-4 transition-all bg-gray-50 dark:bg-subtle border-dashed border-transparent hover:border-gray-300 dark:hover:border-gray-600"
|
|
||||||
>
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
{@render cardOrder(index)}
|
|
||||||
<div class="flex-1">
|
|
||||||
<h1 class="font-bold text-lg mb-3">{action.title}</h1>
|
|
||||||
<SchemaFormFields schema={action.schema} bind:config={actionConfigs} configKey={action.methodName} />
|
|
||||||
</div>
|
|
||||||
<Icon icon={mdiDragVertical} class="mt-1 text-primary shrink-0" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
</CardHeader>
|
||||||
</CardBody>
|
|
||||||
</Card>
|
<CardBody>
|
||||||
</VStack>
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
{#each triggers as trigger (trigger.name)}
|
||||||
|
<WorkflowTriggerCard
|
||||||
|
{trigger}
|
||||||
|
selected={selectedTrigger.triggerType === trigger.triggerType}
|
||||||
|
onclick={() => (selectedTrigger = trigger)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<WorkflowCardConnector />
|
||||||
|
|
||||||
|
<Card expandable expanded={true}>
|
||||||
|
<CardHeader class="bg-amber-50 dark:bg-[#5e4100]">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<Icon icon={mdiFilterOutline} size="20" class="mt-1 text-warning" />
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<CardTitle class="text-left text-warning">Filter</CardTitle>
|
||||||
|
<CardDescription>Conditions to filter the target assets</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardBody>
|
||||||
|
{#if orderedFilters.length === 0}
|
||||||
|
{@render emptyCreateButton('Add Filter', 'Click to add a filter condition', () =>
|
||||||
|
handleAddStep('filter'),
|
||||||
|
)}
|
||||||
|
{:else}
|
||||||
|
{#each orderedFilters as filter, index (filter.id)}
|
||||||
|
{#if index > 0}
|
||||||
|
{@render stepSeparator()}
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
use:dragAndDrop={{
|
||||||
|
index,
|
||||||
|
onDragStart: handleFilterDragStart,
|
||||||
|
onDragEnter: handleFilterDragEnter,
|
||||||
|
onDrop: handleFilterDrop,
|
||||||
|
onDragEnd: handleFilterDragEnd,
|
||||||
|
isDragging: draggedFilterIndex === index,
|
||||||
|
isDragOver: dragOverFilterIndex === index,
|
||||||
|
}}
|
||||||
|
class="mb-4 cursor-move rounded-lg border-2 p-4 transition-all bg-gray-50 dark:bg-subtle border-dashed border-transparent hover:border-gray-300 dark:hover:border-gray-600"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
{@render cardOrder(index)}
|
||||||
|
<div class="flex-1">
|
||||||
|
<h1 class="font-bold text-lg mb-3">{filter.title}</h1>
|
||||||
|
<SchemaFormFields
|
||||||
|
schema={filter.schema}
|
||||||
|
bind:config={filterConfigs}
|
||||||
|
configKey={filter.methodName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
size="medium"
|
||||||
|
variant="ghost"
|
||||||
|
color="danger"
|
||||||
|
onclick={() => handleRemoveFilter(index)}
|
||||||
|
leadingIcon={mdiTrashCanOutline}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
variant="ghost"
|
||||||
|
leadingIcon={mdiPlus}
|
||||||
|
onclick={() => handleAddStep('filter')}
|
||||||
|
>
|
||||||
|
Add more
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<WorkflowCardConnector />
|
||||||
|
|
||||||
|
<Card expandable expanded>
|
||||||
|
<CardHeader class="bg-success/10 dark:bg-teal-950">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<Icon icon={mdiPlayCircleOutline} size="20" class="mt-1 text-success" />
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<CardTitle class="text-left text-success">Action</CardTitle>
|
||||||
|
<CardDescription>A set of action to perform on the filtered assets</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardBody>
|
||||||
|
{#if orderedActions.length === 0}
|
||||||
|
{@render emptyCreateButton('Add Action', 'Click to add an action to perform', () =>
|
||||||
|
handleAddStep('action'),
|
||||||
|
)}
|
||||||
|
{:else}
|
||||||
|
{#each orderedActions as action, index (action.id)}
|
||||||
|
{#if index > 0}
|
||||||
|
{@render stepSeparator()}
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
use:dragAndDrop={{
|
||||||
|
index,
|
||||||
|
onDragStart: handleActionDragStart,
|
||||||
|
onDragEnter: handleActionDragEnter,
|
||||||
|
onDrop: handleActionDrop,
|
||||||
|
onDragEnd: handleActionDragEnd,
|
||||||
|
isDragging: draggedActionIndex === index,
|
||||||
|
isDragOver: dragOverActionIndex === index,
|
||||||
|
}}
|
||||||
|
class="mb-4 cursor-move rounded-lg border-2 p-4 transition-all bg-gray-50 dark:bg-subtle border-dashed border-transparent hover:border-gray-300 dark:hover:border-gray-600"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
{@render cardOrder(index)}
|
||||||
|
<div class="flex-1">
|
||||||
|
<h1 class="font-bold text-lg mb-3">{action.title}</h1>
|
||||||
|
<SchemaFormFields
|
||||||
|
schema={action.schema}
|
||||||
|
bind:config={actionConfigs}
|
||||||
|
configKey={action.methodName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
size="medium"
|
||||||
|
variant="ghost"
|
||||||
|
color="danger"
|
||||||
|
onclick={() => handleRemoveAction(index)}
|
||||||
|
leadingIcon={mdiTrashCanOutline}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
variant="ghost"
|
||||||
|
leadingIcon={mdiPlus}
|
||||||
|
onclick={() => handleAddStep('action')}
|
||||||
|
>
|
||||||
|
Add more
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</VStack>
|
||||||
|
{/if}
|
||||||
</Container>
|
</Container>
|
||||||
</UserPageLayout>
|
</UserPageLayout>
|
||||||
|
|||||||
Reference in New Issue
Block a user