mirror of
https://github.com/immich-app/immich.git
synced 2026-05-24 00:22:29 -04:00
feat: workflows & plugins (#26727)
feat: plugins chore: better types feat: plugins
This commit is contained in:
@@ -1,18 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import { Button } from '@immich/ui';
|
||||
import { Button, type Variants } from '@immich/ui';
|
||||
|
||||
type Props = {
|
||||
action: HeaderButtonActionItem;
|
||||
variant?: Variants;
|
||||
};
|
||||
|
||||
const { action }: Props = $props();
|
||||
const { action, variant }: Props = $props();
|
||||
const { title, icon, color = 'secondary', onAction } = $derived(action);
|
||||
</script>
|
||||
|
||||
{#if action.$if?.() ?? true}
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant={variant ?? 'ghost'}
|
||||
size="small"
|
||||
{color}
|
||||
leadingIcon={icon}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import AlbumThumbnail from '$lib/components/album-page/AlbumThumbnail.svelte';
|
||||
import { Button, Text, Label, modalManager } from '@immich/ui';
|
||||
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
description?: string;
|
||||
albumIds: string[];
|
||||
array?: boolean;
|
||||
};
|
||||
|
||||
let { array, label, description, albumIds = $bindable([]) }: Props = $props();
|
||||
|
||||
const onAlbums = async () => {
|
||||
const albums = await modalManager.show(AlbumPickerModal);
|
||||
if (!albums || albums.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
albumIds = array ? [...albumIds, ...albums.map((album) => album.id)] : [albums[0].id];
|
||||
};
|
||||
</script>
|
||||
|
||||
{#snippet button()}
|
||||
<Button size="small" shape="round" color="secondary" onclick={() => onAlbums()}>{$t('choose')}</Button>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<Label for="album-picker" size="small" class="font-medium" {label} />
|
||||
{#if description}
|
||||
<Text color="muted" size="small">{description}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if array}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each albumIds as albumId, i (i)}
|
||||
<AlbumThumbnail
|
||||
{albumId}
|
||||
onDelete={() => {
|
||||
albumIds.splice(i, 1);
|
||||
albumIds = [...albumIds];
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
{@render button()}
|
||||
</div>
|
||||
{:else}
|
||||
{@const albumId = albumIds[0]}
|
||||
{#if albumId}
|
||||
<AlbumThumbnail {albumId} onDelete={() => (albumIds = [])} />
|
||||
{:else}
|
||||
{@render button()}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import SchemaAlbumPicker from '$lib/components/SchemaAlbumPicker.svelte';
|
||||
import Self from '$lib/components/SchemaConfiguration.svelte';
|
||||
import type { JSONSchemaProperty, SchemaConfig } from '$lib/types';
|
||||
import { CodeBlock, Field, Input, Label, MultiSelect, NumberInput, Select, Switch, Text } from '@immich/ui';
|
||||
|
||||
type Props = {
|
||||
schema: JSONSchemaProperty;
|
||||
root?: boolean;
|
||||
key?: string;
|
||||
config: SchemaConfig;
|
||||
};
|
||||
|
||||
let { schema, key = '', root = false, config = $bindable() }: Props = $props();
|
||||
|
||||
const label = $derived(schema.title ?? key);
|
||||
const description = $derived(schema.description);
|
||||
|
||||
const getValue = <T,>(defaultValue?: T) => (root === true ? config : (config?.[key] ?? defaultValue)) as T;
|
||||
const setValue = <T,>(value: T) => {
|
||||
if (root === true) {
|
||||
config = value;
|
||||
} else {
|
||||
if (config === undefined) {
|
||||
config = {};
|
||||
}
|
||||
|
||||
config[key] = value;
|
||||
}
|
||||
};
|
||||
|
||||
const getUiHintValue = () => {
|
||||
if (schema.array) {
|
||||
return getValue<string[]>([]);
|
||||
}
|
||||
|
||||
const values = getValue<string>();
|
||||
return values ? [values] : [];
|
||||
};
|
||||
|
||||
const setUiHintValue = (values: string[]) => setValue(schema.array ? values : values[0]);
|
||||
|
||||
const getBoolean = (defaultValue = false) => getValue<boolean>(defaultValue);
|
||||
const getString = () => getValue<string>();
|
||||
const getEnum = () => getValue<string[]>([]);
|
||||
const getNumber = () => getValue<number>();
|
||||
</script>
|
||||
|
||||
<!-- Empty schema object -->
|
||||
{#if Object.keys(schema).length === 0}
|
||||
<!-- noop -->
|
||||
<!-- nested configuration (also top level objects) -->
|
||||
{:else if schema.type === 'object'}
|
||||
{#if !root}
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label size="small" class="font-medium" {label}></Label>
|
||||
{#if description}
|
||||
<Text color="muted" size="small">{description}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-4 {root ? '' : 'border-l-4 border-gray-200 ps-2'}">
|
||||
{#each Object.entries(schema.properties ?? {}) as [childKey, childSchema] (childKey)}
|
||||
<Self schema={childSchema} key={childKey} bind:config={getValue, setValue} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if schema.uiHint === 'albumId'}
|
||||
<SchemaAlbumPicker {label} {description} array={schema.array} bind:albumIds={getUiHintValue, setUiHintValue} />
|
||||
{:else if schema.enum && schema.array}
|
||||
<Field {label} {description}>
|
||||
<MultiSelect options={schema.enum} bind:values={getEnum, setValue} />
|
||||
</Field>
|
||||
{:else if schema.enum}
|
||||
<Field {label} {description}>
|
||||
<Select options={schema.enum} bind:value={getString, setValue} />
|
||||
</Field>
|
||||
{:else if schema.array}
|
||||
<!-- {@const values = getValue<string[]>([])}
|
||||
<Field {label} {description}>
|
||||
{#each values as value, i (i)}
|
||||
<Input bind:value={() => getValue<string>(), setValue} />
|
||||
{/each}
|
||||
</Field> -->
|
||||
{:else if schema.type === 'boolean'}
|
||||
<Field {label} {description}>
|
||||
<Switch bind:checked={() => getBoolean(schema.default ?? false), setValue} />
|
||||
</Field>
|
||||
{:else if schema.type === 'number'}
|
||||
<Field {label} {description}>
|
||||
<NumberInput bind:value={getNumber, setValue} />
|
||||
</Field>
|
||||
{:else if schema.type === 'string'}
|
||||
<Field {label} {description}>
|
||||
<Input bind:value={() => getValue<string>(), setValue} />
|
||||
</Field>
|
||||
{:else}
|
||||
<Text>Unknown schema</Text>
|
||||
<CodeBlock code={JSON.stringify(schema, null, 2)} />
|
||||
{/if}
|
||||
@@ -23,7 +23,7 @@
|
||||
showDateRange = false,
|
||||
showItemCount = false,
|
||||
preload = false,
|
||||
onShowContextMenu = undefined,
|
||||
onShowContextMenu,
|
||||
}: Props = $props();
|
||||
|
||||
const showAlbumContextMenu = (e: MouseEvent) => {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import AlbumCover from '$lib/components/album-page/AlbumCover.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { getAlbumInfo } from '@immich/sdk';
|
||||
import { IconButton, LoadingSpinner } from '@immich/ui';
|
||||
import { mdiTrashCanOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
albumId: string;
|
||||
onDelete: () => unknown;
|
||||
}
|
||||
|
||||
let { albumId, onDelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#await getAlbumInfo({ ...authManager.params, id: albumId })}
|
||||
<LoadingSpinner />
|
||||
{:then album}
|
||||
<div class="flex gap-2">
|
||||
<AlbumCover {album} class="size-24" />
|
||||
<p
|
||||
class="line-clamp-2 grow text-lg/6 font-semibold text-black group-hover:text-primary dark:text-white"
|
||||
data-testid="album-name"
|
||||
title={album.albumName}
|
||||
>
|
||||
{album.albumName}
|
||||
</p>
|
||||
{#if album.description}
|
||||
<p
|
||||
class="line-clamp-2 grow text-lg/6 font-semibold text-black group-hover:text-primary dark:text-white"
|
||||
data-testid="album-name"
|
||||
>
|
||||
{album.description}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="">
|
||||
<IconButton
|
||||
icon={mdiTrashCanOutline}
|
||||
shape="round"
|
||||
color="danger"
|
||||
variant="ghost"
|
||||
onclick={onDelete}
|
||||
aria-label={$t('remove')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
getWorkflowTriggers,
|
||||
searchPluginMethods,
|
||||
WorkflowTrigger,
|
||||
type PluginMethodResponseDto,
|
||||
type WorkflowTriggerResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { get } from 'svelte/store';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
|
||||
class PluginManager {
|
||||
#loading: Promise<void> | undefined;
|
||||
#methodMap = new SvelteMap<string, PluginMethodResponseDto>();
|
||||
#methods = $state<PluginMethodResponseDto[]>([]);
|
||||
#triggers = $state<WorkflowTriggerResponseDto[]>([]);
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
AuthLogout: () => this.clearCache(),
|
||||
AuthUserLoaded: () => this.initialize(),
|
||||
});
|
||||
|
||||
// loaded event might have already happened
|
||||
if (authManager.authenticated) {
|
||||
void this.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
get triggers() {
|
||||
return this.#triggers;
|
||||
}
|
||||
|
||||
ready() {
|
||||
return this.initialize();
|
||||
}
|
||||
|
||||
getMethod(key: string) {
|
||||
return this.#methodMap.get(key);
|
||||
}
|
||||
|
||||
getMethodLabel(key: string) {
|
||||
const method = this.getMethod(key);
|
||||
return method?.title ?? get(t)('unknown');
|
||||
}
|
||||
|
||||
getTrigger(trigger: WorkflowTrigger) {
|
||||
const result = this.#triggers.find((t) => t.trigger === trigger);
|
||||
|
||||
if (!result) {
|
||||
throw new Error(`Unknown trigger type: ${trigger}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private clearCache() {
|
||||
this.#loading = undefined;
|
||||
this.#methodMap = new SvelteMap();
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
if (!this.#loading) {
|
||||
this.#loading = this.load();
|
||||
}
|
||||
|
||||
return this.#loading;
|
||||
}
|
||||
|
||||
private async load() {
|
||||
const [methods, triggers] = await Promise.all([searchPluginMethods({}), getWorkflowTriggers()]);
|
||||
|
||||
this.#methods = methods;
|
||||
for (const method of this.#methods) {
|
||||
this.#methodMap.set(method.key, method);
|
||||
}
|
||||
|
||||
this.#triggers = triggers;
|
||||
}
|
||||
}
|
||||
|
||||
export const pluginManager = new PluginManager();
|
||||
@@ -1,80 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PluginActionResponseDto, PluginFilterResponseDto } from '@immich/sdk';
|
||||
import { Modal, ModalBody, Text } from '@immich/ui';
|
||||
import { mdiFilterOutline, mdiPlayCircleOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
filters: PluginFilterResponseDto[];
|
||||
actions: PluginActionResponseDto[];
|
||||
onClose: (result?: { type: 'filter' | 'action'; item: PluginFilterResponseDto | PluginActionResponseDto }) => void;
|
||||
type?: 'filter' | 'action';
|
||||
};
|
||||
|
||||
let { filters, actions, onClose, type }: Props = $props();
|
||||
|
||||
type StepType = 'filter' | 'action';
|
||||
|
||||
const handleSelect = (type: StepType, item: PluginFilterResponseDto | PluginActionResponseDto) => {
|
||||
onClose({ type, item });
|
||||
};
|
||||
|
||||
const getModalTitle = () => {
|
||||
if (type === 'filter') {
|
||||
return $t('add_filter');
|
||||
} else if (type === 'action') {
|
||||
return $t('add_action');
|
||||
} else {
|
||||
return $t('add_workflow_step');
|
||||
}
|
||||
};
|
||||
|
||||
const getModalIcon = () => {
|
||||
if (type === 'filter') {
|
||||
return mdiFilterOutline;
|
||||
} else if (type === 'action') {
|
||||
return mdiPlayCircleOutline;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#snippet stepButton(title: string, description?: string, onclick?: () => void)}
|
||||
<button
|
||||
type="button"
|
||||
{onclick}
|
||||
class="flex items-start gap-3 rounded-lg border bg-light-100 p-3 text-left text-dark hover:border-primary"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<Text color="primary" fontWeight="medium">{title}</Text>
|
||||
{#if description}
|
||||
<Text size="small" class="mt-1">{description}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
<Modal title={getModalTitle()} icon={getModalIcon()} onClose={() => onClose()}>
|
||||
<ModalBody>
|
||||
<div class="space-y-6">
|
||||
<!-- Filters Section -->
|
||||
{#if filters.length > 0 && (!type || type === 'filter')}
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
{#each filters as filter (filter.id)}
|
||||
{@render stepButton(filter.title, filter.description, () => handleSelect('filter', filter))}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions Section -->
|
||||
{#if actions.length > 0 && (!type || type === 'action')}
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
{#each actions as action (action.id)}
|
||||
{@render stepButton(action.title, action.description, () => handleSelect('action', action))}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
@@ -21,9 +21,9 @@
|
||||
let search = $state('');
|
||||
let selectedRowIndex: number = $state(-1);
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
onClose: (albums?: AlbumResponseDto[]) => void;
|
||||
}
|
||||
};
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { searchPluginMethods, WorkflowTrigger, type PluginMethodResponseDto } from '@immich/sdk';
|
||||
import { Badge, BasicModal, ListButton, LoadingSpinner, Stack, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
trigger: WorkflowTrigger;
|
||||
selectedKey?: string;
|
||||
onClose: (method?: PluginMethodResponseDto) => void;
|
||||
};
|
||||
|
||||
const { trigger, selectedKey, onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<BasicModal title={$t('add_step')} {onClose}>
|
||||
{#await searchPluginMethods({ trigger })}
|
||||
<div class="flex w-full place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:then methods}
|
||||
<Stack>
|
||||
{#each methods as method (method.key)}
|
||||
<ListButton selected={method.key === selectedKey} onclick={() => onClose(method)}>
|
||||
<div class="grow text-start">
|
||||
<Text fontWeight="medium" class="flex items-center gap-1"
|
||||
>{method.title}
|
||||
{#if method.uiHints.includes('filter')}
|
||||
<Badge size="tiny" color="info" title={$t('plugin_method_filter_type_description')}
|
||||
>{$t('plugin_method_filter_type')}</Badge
|
||||
>
|
||||
{/if}
|
||||
</Text>
|
||||
{#if method.description}
|
||||
<Text size="tiny" color="muted">{method.description}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
</ListButton>
|
||||
{/each}
|
||||
</Stack>
|
||||
{/await}
|
||||
</BasicModal>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import SchemaConfiguration from '$lib/components/SchemaConfiguration.svelte';
|
||||
import PluginMethodPicker from '$lib/modals/PluginMethodPicker.svelte';
|
||||
import { type JSONSchemaProperty, type SchemaConfig } from '$lib/types';
|
||||
import { WorkflowTrigger, type PluginMethodResponseDto, type WorkflowStepDto } from '@immich/sdk';
|
||||
import { FormModal, IconButton, modalManager, Stack, Text, Textarea } from '@immich/ui';
|
||||
import { mdiPencilOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
trigger: WorkflowTrigger;
|
||||
onClose: (step?: WorkflowStepDto) => void;
|
||||
};
|
||||
|
||||
const { trigger, onClose }: Props = $props();
|
||||
|
||||
const onSubmit = () => {
|
||||
if (method) {
|
||||
onClose({ method: method.key, config, enabled: true });
|
||||
}
|
||||
};
|
||||
|
||||
let method = $state<PluginMethodResponseDto>();
|
||||
let config = $state<SchemaConfig>({});
|
||||
let debug = $state(false);
|
||||
|
||||
const onPickMethod = async () => {
|
||||
const selected = await modalManager.show(PluginMethodPicker, { trigger, selectedKey: method?.key });
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
method = selected;
|
||||
config = selected.schema ? {} : null;
|
||||
};
|
||||
|
||||
void onPickMethod();
|
||||
</script>
|
||||
|
||||
{#if method}
|
||||
<FormModal title={$t('add_step')} {onClose} {onSubmit} disabled={!method} size="small">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="grow text-start">
|
||||
<Text fontWeight="medium">{method.title}</Text>
|
||||
{#if method.description}
|
||||
<Text size="tiny" color="muted">{method.description}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
<IconButton
|
||||
icon={mdiPencilOutline}
|
||||
onclick={onPickMethod}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
aria-label={$t('edit')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if method.schema}
|
||||
<hr class="my-4" />
|
||||
<div class="mt-4 grow text-start">
|
||||
<Text fontWeight="medium">{$t('configuration')}</Text>
|
||||
<Stack gap={4}>
|
||||
<SchemaConfiguration schema={method.schema as JSONSchemaProperty} bind:config root />
|
||||
</Stack>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if debug}
|
||||
<hr class="my-4" />
|
||||
<Text fontWeight="medium">{$t('preview')}</Text>
|
||||
<Textarea class="mt-2" readonly grow value={JSON.stringify({ method: method.key, config }, null, 2)} />
|
||||
{/if}
|
||||
</FormModal>
|
||||
{/if}
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import SchemaConfiguration from '$lib/components/SchemaConfiguration.svelte';
|
||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||
import PluginMethodPicker from '$lib/modals/PluginMethodPicker.svelte';
|
||||
import { type JSONSchemaProperty, type SchemaConfig } from '$lib/types';
|
||||
import { WorkflowTrigger, type WorkflowStepDto } from '@immich/sdk';
|
||||
import { Button, FormModal, modalManager, Stack, Text, Textarea } from '@immich/ui';
|
||||
import { mdiPencilOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
trigger: WorkflowTrigger;
|
||||
step: WorkflowStepDto;
|
||||
onClose: (step?: WorkflowStepDto) => void;
|
||||
};
|
||||
|
||||
const { trigger, step, onClose }: Props = $props();
|
||||
|
||||
const onSubmit = () => onClose(method ? { method: method.key, config, enabled } : undefined);
|
||||
|
||||
let enabled = $state<boolean>(true);
|
||||
let method = $state(pluginManager.getMethod(step.method));
|
||||
let config = $state<SchemaConfig>(step.config);
|
||||
let debug = $state(false);
|
||||
|
||||
const onPickMethod = async () => {
|
||||
const selected = await modalManager.show(PluginMethodPicker, { trigger, selectedKey: method?.key });
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
method = selected;
|
||||
config = selected.schema ? {} : null;
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if method}
|
||||
<FormModal title={$t('step_details')} {onClose} {onSubmit} disabled={!method} size="small">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="grow text-start">
|
||||
<Text fontWeight="medium">{method.title}</Text>
|
||||
{#if method.description}
|
||||
<Text size="small" color="muted">{method.description}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
<Button size="small" color="secondary" shape="round" onclick={onPickMethod} leadingIcon={mdiPencilOutline}
|
||||
>{$t('change')}</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if method.schema}
|
||||
<hr class="my-4" />
|
||||
<div class="mt-4 grow text-start">
|
||||
<Text fontWeight="medium">{$t('configuration')}</Text>
|
||||
<Stack gap={4}>
|
||||
<SchemaConfiguration schema={method.schema as JSONSchemaProperty} bind:config root />
|
||||
</Stack>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if debug}
|
||||
<hr class="my-4" />
|
||||
<Text fontWeight="medium">{$t('preview')}</Text>
|
||||
<Textarea class="mt-2" readonly grow value={JSON.stringify({ method: method.key, config }, null, 2)} />
|
||||
{/if}
|
||||
</FormModal>
|
||||
{/if}
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
|
||||
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
|
||||
import { type WorkflowResponseDto } from '@immich/sdk';
|
||||
import { FormModal, ListButton, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
workflow: WorkflowResponseDto;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const { workflow, onClose }: Props = $props();
|
||||
|
||||
let selected = $state(pluginManager.getTrigger(workflow.trigger));
|
||||
|
||||
const onSubmit = async () => {
|
||||
const success = await handleUpdateWorkflow(workflow.id, { trigger: selected.trigger });
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="small">
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each pluginManager.triggers as item (item.trigger)}
|
||||
<ListButton selected={item.trigger === selected.trigger} onclick={() => (selected = item)}>
|
||||
<div class="grow text-start">
|
||||
<Text fontWeight="medium">{getTriggerName($t, item.trigger)}</Text>
|
||||
<Text size="tiny" color="muted">{getTriggerDescription($t, item.trigger)}</Text>
|
||||
</div>
|
||||
</ListButton>
|
||||
{/each}
|
||||
</div>
|
||||
</FormModal>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
|
||||
import { WorkflowTrigger } from '@immich/sdk';
|
||||
import { FormModal, ListButton, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
selected?: WorkflowTrigger;
|
||||
onClose: (trigger?: WorkflowTrigger) => void;
|
||||
};
|
||||
|
||||
const { selected: initialSelected, onClose }: Props = $props();
|
||||
|
||||
let selected = $state(initialSelected);
|
||||
|
||||
const onSubmit = () => onClose(selected);
|
||||
</script>
|
||||
|
||||
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="small">
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each pluginManager.triggers as item (item.trigger)}
|
||||
<ListButton selected={selected === item.trigger} onclick={() => (selected = item.trigger)}>
|
||||
<div class="grow text-start">
|
||||
<Text fontWeight="medium">{getTriggerName($t, item.trigger)}</Text>
|
||||
<Text size="tiny" color="muted">{getTriggerDescription($t, item.trigger)}</Text>
|
||||
</div>
|
||||
</ListButton>
|
||||
{/each}
|
||||
</div>
|
||||
</FormModal>
|
||||
@@ -146,8 +146,8 @@ export const Route = {
|
||||
geolocationUtility: () => '/utilities/geolocation',
|
||||
|
||||
// workflows
|
||||
workflows: () => '/utilities/workflows',
|
||||
viewWorkflow: ({ id }: { id: string }) => `/utilities/workflows/${id}`,
|
||||
workflows: () => '/workflows',
|
||||
viewWorkflow: ({ id }: { id: string }) => `/workflows/${id}`,
|
||||
|
||||
// queues
|
||||
queues: () => '/admin/queues',
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import {
|
||||
createWorkflow,
|
||||
deleteWorkflow,
|
||||
getAlbumInfo,
|
||||
getPerson,
|
||||
PluginTriggerType,
|
||||
updateWorkflow,
|
||||
WorkflowTrigger,
|
||||
type AlbumResponseDto,
|
||||
type PersonResponseDto,
|
||||
type PluginActionResponseDto,
|
||||
type PluginContextType,
|
||||
type PluginFilterResponseDto,
|
||||
type PluginTriggerResponseDto,
|
||||
type WorkflowActionItemDto,
|
||||
type WorkflowCreateDto,
|
||||
type WorkflowFilterItemDto,
|
||||
type WorkflowResponseDto,
|
||||
type WorkflowUpdateDto,
|
||||
} from '@immich/sdk';
|
||||
@@ -29,306 +21,14 @@ import { getFormatter } from '$lib/utils/i18n';
|
||||
export type PickerSubType = 'album-picker' | 'people-picker';
|
||||
export type PickerMetadata = AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[];
|
||||
|
||||
export interface WorkflowPayload {
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
triggerType: string;
|
||||
filters: Record<string, unknown>[];
|
||||
actions: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filters that support the given context
|
||||
*/
|
||||
export const getFiltersByContext = (
|
||||
availableFilters: PluginFilterResponseDto[],
|
||||
context: PluginContextType,
|
||||
): PluginFilterResponseDto[] => {
|
||||
return availableFilters.filter((filter) => filter.supportedContexts.includes(context));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get actions that support the given context
|
||||
*/
|
||||
export const getActionsByContext = (
|
||||
availableActions: PluginActionResponseDto[],
|
||||
context: PluginContextType,
|
||||
): PluginActionResponseDto[] => {
|
||||
return availableActions.filter((action) => action.supportedContexts.includes(context));
|
||||
};
|
||||
|
||||
export const remapConfigsOnReorder = (
|
||||
configs: Record<string, unknown>,
|
||||
prefix: 'filter' | 'action',
|
||||
fromIndex: number,
|
||||
toIndex: number,
|
||||
totalCount: number,
|
||||
): Record<string, unknown> => {
|
||||
const newConfigs: Record<string, unknown> = {};
|
||||
|
||||
// Create an array of configs in order
|
||||
const configArray: unknown[] = [];
|
||||
for (let i = 0; i < totalCount; i++) {
|
||||
configArray.push(configs[`${prefix}_${i}`] ?? {});
|
||||
}
|
||||
|
||||
// Move the item from fromIndex to toIndex
|
||||
const [movedItem] = configArray.splice(fromIndex, 1);
|
||||
configArray.splice(toIndex, 0, movedItem);
|
||||
|
||||
// Rebuild the configs object with new indices
|
||||
for (let i = 0; i < configArray.length; i++) {
|
||||
newConfigs[`${prefix}_${i}`] = configArray[i];
|
||||
}
|
||||
|
||||
return newConfigs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remap configs when an item is removed
|
||||
* Shifts all configs after the removed index down by one
|
||||
*/
|
||||
export const remapConfigsOnRemove = (
|
||||
configs: Record<string, unknown>,
|
||||
prefix: 'filter' | 'action',
|
||||
removedIndex: number,
|
||||
totalCount: number,
|
||||
): Record<string, unknown> => {
|
||||
const newConfigs: Record<string, unknown> = {};
|
||||
|
||||
let newIndex = 0;
|
||||
for (let i = 0; i < totalCount; i++) {
|
||||
if (i !== removedIndex) {
|
||||
newConfigs[`${prefix}_${newIndex}`] = configs[`${prefix}_${i}`] ?? {};
|
||||
newIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return newConfigs;
|
||||
};
|
||||
|
||||
export const initializeConfigs = (
|
||||
type: 'action' | 'filter',
|
||||
workflow: WorkflowResponseDto,
|
||||
): Record<string, unknown> => {
|
||||
const configs: Record<string, unknown> = {};
|
||||
|
||||
if (workflow.filters && type == 'filter') {
|
||||
for (const [index, workflowFilter] of workflow.filters.entries()) {
|
||||
configs[`filter_${index}`] = workflowFilter.filterConfig ?? {};
|
||||
}
|
||||
}
|
||||
|
||||
if (workflow.actions && type == 'action') {
|
||||
for (const [index, workflowAction] of workflow.actions.entries()) {
|
||||
configs[`action_${index}`] = workflowAction.actionConfig ?? {};
|
||||
}
|
||||
}
|
||||
|
||||
return configs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build workflow payload from current state
|
||||
* Uses index-based keys to support multiple filters/actions of the same type
|
||||
*/
|
||||
export const 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, index) => ({
|
||||
[filter.methodName]: filterConfigs[`filter_${index}`] ?? {},
|
||||
}));
|
||||
|
||||
const actions = orderedActions.map((action, index) => ({
|
||||
[action.methodName]: actionConfigs[`action_${index}`] ?? {},
|
||||
}));
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
enabled,
|
||||
triggerType,
|
||||
filters,
|
||||
actions,
|
||||
};
|
||||
};
|
||||
|
||||
export const parseWorkflowJson = (
|
||||
jsonString: string,
|
||||
availableTriggers: PluginTriggerResponseDto[],
|
||||
availableFilters: PluginFilterResponseDto[],
|
||||
availableActions: PluginActionResponseDto[],
|
||||
): {
|
||||
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);
|
||||
|
||||
const trigger = availableTriggers.find((t) => t.type === parsed.triggerType);
|
||||
|
||||
const filters: PluginFilterResponseDto[] = [];
|
||||
const filterConfigs: Record<string, unknown> = {};
|
||||
if (Array.isArray(parsed.filters)) {
|
||||
for (const [index, filterObj] of parsed.filters.entries()) {
|
||||
const methodName = Object.keys(filterObj)[0];
|
||||
const filter = availableFilters.find((f) => f.methodName === methodName);
|
||||
if (filter) {
|
||||
filters.push(filter);
|
||||
filterConfigs[`filter_${index}`] = (filterObj as Record<string, unknown>)[methodName];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const actions: PluginActionResponseDto[] = [];
|
||||
const actionConfigs: Record<string, unknown> = {};
|
||||
if (Array.isArray(parsed.actions)) {
|
||||
for (const [index, actionObj] of parsed.actions.entries()) {
|
||||
const methodName = Object.keys(actionObj)[0];
|
||||
const action = availableActions.find((a) => a.methodName === methodName);
|
||||
if (action) {
|
||||
actions.push(action);
|
||||
actionConfigs[`action_${index}`] = (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',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const hasWorkflowChanged = (
|
||||
previousWorkflow: WorkflowResponseDto,
|
||||
enabled: boolean,
|
||||
name: string,
|
||||
description: string,
|
||||
triggerType: string,
|
||||
orderedFilters: PluginFilterResponseDto[],
|
||||
orderedActions: PluginActionResponseDto[],
|
||||
filterConfigs: Record<string, unknown>,
|
||||
actionConfigs: Record<string, unknown>,
|
||||
): boolean => {
|
||||
if (enabled !== previousWorkflow.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (name !== (previousWorkflow.name ?? '') || description !== (previousWorkflow.description ?? '')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (triggerType !== previousWorkflow.triggerType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const previousFilterIds = previousWorkflow.filters?.map((f) => f.pluginFilterId) ?? [];
|
||||
const currentFilterIds = orderedFilters.map((f) => f.id);
|
||||
if (JSON.stringify(previousFilterIds) !== JSON.stringify(currentFilterIds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const previousActionIds = previousWorkflow.actions?.map((a) => a.pluginActionId) ?? [];
|
||||
const currentActionIds = orderedActions.map((a) => a.id);
|
||||
if (JSON.stringify(previousActionIds) !== JSON.stringify(currentActionIds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const previousFilterConfigs: Record<string, unknown> = {};
|
||||
for (const [index, wf] of (previousWorkflow.filters ?? []).entries()) {
|
||||
previousFilterConfigs[`filter_${index}`] = wf.filterConfig ?? {};
|
||||
}
|
||||
if (JSON.stringify(previousFilterConfigs) !== JSON.stringify(filterConfigs)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const previousActionConfigs: Record<string, unknown> = {};
|
||||
for (const [index, wa] of (previousWorkflow.actions ?? []).entries()) {
|
||||
previousActionConfigs[`action_${index}`] = wa.actionConfig ?? {};
|
||||
}
|
||||
if (JSON.stringify(previousActionConfigs) !== JSON.stringify(actionConfigs)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const handleUpdateWorkflow = async (
|
||||
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, index) => ({
|
||||
pluginFilterId: filter.id,
|
||||
filterConfig: filterConfigs[`filter_${index}`] ?? {},
|
||||
})) as WorkflowFilterItemDto[];
|
||||
|
||||
const actions = orderedActions.map((action, index) => ({
|
||||
pluginActionId: action.id,
|
||||
actionConfig: actionConfigs[`action_${index}`] ?? {},
|
||||
})) as WorkflowActionItemDto[];
|
||||
|
||||
const updateDto: WorkflowUpdateDto = {
|
||||
name,
|
||||
description,
|
||||
enabled,
|
||||
filters,
|
||||
actions,
|
||||
triggerType,
|
||||
};
|
||||
|
||||
return updateWorkflow({ id: workflowId, workflowUpdateDto: updateDto });
|
||||
};
|
||||
|
||||
export const getWorkflowsActions = ($t: MessageFormatter) => {
|
||||
const Create: ActionItem = {
|
||||
title: $t('create_workflow'),
|
||||
icon: mdiPlus,
|
||||
onAction: () =>
|
||||
handleCreateWorkflow({
|
||||
name: $t('untitled_workflow'),
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
filters: [],
|
||||
actions: [],
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
steps: [],
|
||||
enabled: false,
|
||||
}),
|
||||
};
|
||||
@@ -341,9 +41,7 @@ export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowRespo
|
||||
title: workflow.enabled ? $t('disable') : $t('enable'),
|
||||
icon: workflow.enabled ? mdiPause : mdiPlay,
|
||||
color: workflow.enabled ? 'danger' : 'primary',
|
||||
onAction: async () => {
|
||||
await handleToggleWorkflowEnabled(workflow);
|
||||
},
|
||||
onAction: () => handleUpdateWorkflow(workflow.id, { enabled: !workflow.enabled }),
|
||||
};
|
||||
|
||||
const Edit: ActionItem = {
|
||||
@@ -385,22 +83,17 @@ const handleCreateWorkflow = async (dto: WorkflowCreateDto) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const handleToggleWorkflowEnabled = async (
|
||||
workflow: WorkflowResponseDto,
|
||||
): Promise<WorkflowResponseDto | undefined> => {
|
||||
export const handleUpdateWorkflow = async (id: string, dto: WorkflowUpdateDto) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
const updated = await updateWorkflow({
|
||||
id: workflow.id,
|
||||
workflowUpdateDto: { enabled: !workflow.enabled },
|
||||
});
|
||||
|
||||
eventManager.emit('WorkflowUpdate', updated);
|
||||
toastManager.primary($t('workflow_updated'));
|
||||
return updated;
|
||||
const response = await updateWorkflow({ id, workflowUpdateDto: dto });
|
||||
eventManager.emit('WorkflowUpdate', response);
|
||||
toastManager.primary($t('workflow_update_success'), { closable: true });
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_workflow'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -426,30 +119,3 @@ export const handleDeleteWorkflow = async (workflow: WorkflowResponseDto): Promi
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchPickerMetadata = async (
|
||||
value: string | string[] | undefined,
|
||||
subType: PickerSubType,
|
||||
): Promise<PickerMetadata | undefined> => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isAlbum = subType === 'album-picker';
|
||||
|
||||
try {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
// Multiple selection
|
||||
return isAlbum
|
||||
? await Promise.all(value.map((id) => getAlbumInfo({ id })))
|
||||
: await Promise.all(value.map((id) => getPerson({ id })));
|
||||
} else if (typeof value === 'string' && value) {
|
||||
// Single selection
|
||||
return isAlbum ? await getAlbumInfo({ id: value }) : await getPerson({ id: value });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch picker metadata:`, error);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -91,3 +91,21 @@ export type SearchFilter = {
|
||||
mediaType: MediaType;
|
||||
rating?: number | null;
|
||||
};
|
||||
|
||||
export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object';
|
||||
|
||||
export type JSONSchemaProperty = {
|
||||
type: JSONSchemaType;
|
||||
title?: string;
|
||||
description?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default?: any;
|
||||
enum?: string[];
|
||||
array?: boolean;
|
||||
properties?: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
uiHint?: 'albumId' | 'assetId' | 'personId';
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type SchemaConfig = any;
|
||||
|
||||
+24
-122
@@ -1,128 +1,30 @@
|
||||
export type ComponentType = 'select' | 'multiselect' | 'text' | 'switch' | 'checkbox';
|
||||
import { WorkflowTrigger } from '@immich/sdk';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
|
||||
export interface ComponentConfig {
|
||||
type: ComponentType;
|
||||
label?: string;
|
||||
description?: string;
|
||||
defaultValue?: unknown;
|
||||
required?: boolean;
|
||||
options?: Array<{ label: string; value: string | number | boolean }>;
|
||||
placeholder?: string;
|
||||
subType?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface JSONSchemaProperty {
|
||||
type?: string;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
enum?: unknown[];
|
||||
items?: JSONSchemaProperty;
|
||||
subType?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface JSONSchema {
|
||||
type?: string;
|
||||
properties?: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
}
|
||||
|
||||
export const getComponentDefaultValue = (component: ComponentConfig): unknown => {
|
||||
if (component.defaultValue !== undefined) {
|
||||
return component.defaultValue;
|
||||
}
|
||||
|
||||
if (component.type === 'multiselect' || (component.type === 'text' && component.subType === 'people-picker')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (component.type === 'switch') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
export const getComponentFromSchema = (schema: object | null): Record<string, ComponentConfig> | null => {
|
||||
if (!schema || !isJSONSchema(schema) || !schema.properties) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const components: Record<string, ComponentConfig> = {};
|
||||
const requiredFields = schema.required || [];
|
||||
|
||||
for (const [propertyName, property] of Object.entries(schema.properties)) {
|
||||
const config = getComponentForProperty(property, propertyName);
|
||||
if (config) {
|
||||
config.required = requiredFields.includes(propertyName);
|
||||
components[propertyName] = config;
|
||||
export const getTriggerName = ($t: MessageFormatter, type: WorkflowTrigger) => {
|
||||
switch (type) {
|
||||
case WorkflowTrigger.AssetCreate: {
|
||||
return $t('trigger_asset_uploaded');
|
||||
}
|
||||
case WorkflowTrigger.PersonRecognized: {
|
||||
return $t('trigger_person_recognized');
|
||||
}
|
||||
default: {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(components).length > 0 ? components : null;
|
||||
};
|
||||
|
||||
function isJSONSchema(obj: object): obj is JSONSchema {
|
||||
return 'properties' in obj || 'type' in obj;
|
||||
}
|
||||
|
||||
function getComponentForProperty(property: JSONSchemaProperty, propertyName: string): ComponentConfig | null {
|
||||
const { type, title, enum: enumValues, description, default: defaultValue, items } = property;
|
||||
|
||||
const config: ComponentConfig = {
|
||||
type: 'text',
|
||||
label: formatLabel(propertyName),
|
||||
description,
|
||||
defaultValue,
|
||||
title,
|
||||
};
|
||||
|
||||
if (enumValues && enumValues.length > 0) {
|
||||
config.type = 'select';
|
||||
config.options = enumValues.map((value: unknown) => ({
|
||||
label: formatLabel(String(value)),
|
||||
value: value as string | number | boolean,
|
||||
}));
|
||||
return config;
|
||||
export const getTriggerDescription = ($t: MessageFormatter, type: WorkflowTrigger) => {
|
||||
switch (type) {
|
||||
case WorkflowTrigger.AssetCreate: {
|
||||
return $t('trigger_asset_uploaded_description');
|
||||
}
|
||||
case WorkflowTrigger.PersonRecognized: {
|
||||
return $t('trigger_person_recognized_description');
|
||||
}
|
||||
default: {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'array' && items?.enum && items.enum.length > 0) {
|
||||
config.type = 'multiselect';
|
||||
config.subType = items.subType;
|
||||
config.options = items.enum.map((value: unknown) => ({
|
||||
label: formatLabel(String(value)),
|
||||
value: value as string | number | boolean,
|
||||
}));
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
if (type === 'boolean') {
|
||||
config.type = 'switch';
|
||||
return config;
|
||||
}
|
||||
|
||||
if (type === 'string') {
|
||||
config.type = 'text';
|
||||
config.subType = property.subType;
|
||||
config.placeholder = description;
|
||||
return config;
|
||||
}
|
||||
|
||||
if (type === 'array') {
|
||||
config.type = 'multiselect';
|
||||
config.subType = property.subType;
|
||||
return config;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function formatLabel(propertyName: string): string {
|
||||
return propertyName
|
||||
.replaceAll(/([A-Z])/g, ' $1')
|
||||
.replaceAll('_', ' ')
|
||||
.replace(/^./, (str) => str.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user