feat: workflow actions (#28639)

This commit is contained in:
Jason Rasmussen
2026-05-27 10:24:31 -04:00
committed by GitHub
parent 748a13104a
commit cf991e7b1b
11 changed files with 267 additions and 167 deletions
@@ -0,0 +1,53 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Route } from '$lib/route';
import { handleCreateWorkflow } from '$lib/services/workflow.service';
import { WorkflowTrigger, type WorkflowResponseDto } from '@immich/sdk';
import { Field, FormModal, Input, Textarea, VStack } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
workflow: WorkflowResponseDto;
onClose: () => void;
};
const { workflow, onClose }: Props = $props();
let name = $state(workflow.name ?? '');
let description = $state(workflow.description ?? '');
let trigger = $state<WorkflowTrigger>(workflow.trigger);
const onSubmit = async () => {
const response = await handleCreateWorkflow({
name,
description,
trigger,
steps: workflow.steps,
enabled: false,
});
if (response) {
await goto(Route.viewWorkflow({ id: response.id }));
onClose();
}
};
</script>
<FormModal
title={$t('duplicate_workflow')}
{onClose}
{onSubmit}
disabled={!name || !trigger}
size="medium"
submitText={$t('create')}
>
<VStack gap={4}>
<Field label={$t('name')} required>
<Input placeholder={$t('workflow_name')} bind:value={name} />
</Field>
<Field label={$t('description')}>
<Textarea grow placeholder={$t('workflow_description')} bind:value={description} />
</Field>
</VStack>
</FormModal>
+1 -1
View File
@@ -42,7 +42,7 @@ import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils/asset-utils';
import { downloadUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
+3 -30
View File
@@ -3,10 +3,8 @@ import { toastManager, type ActionItem } from '@immich/ui';
import { mdiContentCopy, mdiDownload, mdiUpload } from '@mdi/js';
import { isEqual } from 'lodash-es';
import type { MessageFormatter } from 'svelte-i18n';
import { downloadManager } from '$lib/managers/download-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { copyToClipboard } from '$lib/utils';
import { downloadBlob } from '$lib/utils/asset-utils';
import { copyToClipboard, downloadJson } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
@@ -19,7 +17,7 @@ export const getSystemConfigActions = (
title: $t('copy_to_clipboard'),
description: $t('admin.copy_config_to_clipboard_description'),
icon: mdiContentCopy,
onAction: () => handleCopyToClipboard(config),
onAction: () => copyToClipboard(config),
shortcuts: { shift: true, key: 'c' },
};
@@ -27,7 +25,7 @@ export const getSystemConfigActions = (
title: $t('export_as_json'),
description: $t('admin.export_config_as_json_description'),
icon: mdiDownload,
onAction: () => handleDownloadConfig(config),
onAction: () => downloadJson(config, 'immich-config.json'),
shortcuts: [
{ shift: true, key: 's' },
{ shift: true, key: 'd' },
@@ -65,31 +63,6 @@ export const handleSystemConfigSave = async (update: Partial<SystemConfigDto>) =
}
};
// https://stackoverflow.com/questions/16167581/sort-object-properties-and-json-stringify/43636793#43636793
const jsonReplacer = (_key: string, value: unknown) =>
value instanceof Object && !Array.isArray(value)
? Object.keys(value)
.sort()
// eslint-disable-next-line unicorn/no-array-reduce
.reduce((sorted: { [key: string]: unknown }, key) => {
sorted[key] = (value as { [key: string]: unknown })[key];
return sorted;
}, {})
: value;
export const handleCopyToClipboard = async (config: SystemConfigDto) => {
await copyToClipboard(JSON.stringify(config, jsonReplacer, 2));
};
export const handleDownloadConfig = (config: SystemConfigDto) => {
const blob = new Blob([JSON.stringify(config, jsonReplacer, 2)], { type: 'application/json' });
const downloadKey = 'immich-config.json';
downloadManager.add(downloadKey, blob.size);
downloadManager.update(downloadKey, blob.size);
downloadBlob(blob, downloadKey);
setTimeout(() => downloadManager.clear(downloadKey), 5000);
};
export const handleUploadConfig = () => {
const input = globalThis.document.createElement('input');
input.setAttribute('type', 'file');
+60 -9
View File
@@ -10,12 +10,25 @@ import {
type WorkflowUpdateDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiCodeJson, mdiDelete, mdiFileDocumentMultipleOutline, mdiPause, mdiPencil, mdiPlay, mdiPlus } from '@mdi/js';
import {
mdiCodeJson,
mdiContentCopy,
mdiContentDuplicate,
mdiDeleteOutline,
mdiDownload,
mdiFileDocumentMultipleOutline,
mdiPause,
mdiPencil,
mdiPlay,
mdiPlus,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { eventManager } from '$lib/managers/event-manager.svelte';
import WorkflowDuplicateModal from '$lib/modals/WorkflowDuplicateModal.svelte';
import WorkflowTemplatePicker from '$lib/modals/WorkflowTemplatePicker.svelte';
import { Route } from '$lib/route';
import { copyToClipboard, downloadJson } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
@@ -47,10 +60,50 @@ export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowRespo
const ToggleEnabled: ActionItem = {
title: workflow.enabled ? $t('disable') : $t('enable'),
icon: workflow.enabled ? mdiPause : mdiPlay,
color: workflow.enabled ? 'danger' : 'primary',
onAction: () => handleUpdateWorkflow(workflow.id, { enabled: !workflow.enabled }),
};
const CopyJson: ActionItem = {
title: $t('copy_json'),
icon: mdiContentCopy,
onAction: () =>
copyToClipboard(
JSON.stringify(
{
name: workflow.name,
description: workflow.description,
enabled: workflow.enabled,
trigger: workflow.trigger,
steps: workflow.steps,
},
null,
2,
),
),
};
const Download: ActionItem = {
title: $t('download'),
icon: mdiDownload,
onAction: () =>
downloadJson(
{
name: workflow.name,
description: workflow.description,
enabled: workflow.enabled,
trigger: workflow.trigger,
steps: workflow.steps,
},
'workflow.json',
),
};
const Duplicate: ActionItem = {
title: $t('duplicate'),
icon: mdiContentDuplicate,
onAction: async () => modalManager.show(WorkflowDuplicateModal, { workflow }),
};
const Edit: ActionItem = {
title: $t('edit'),
icon: mdiPencil,
@@ -59,14 +112,12 @@ export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowRespo
const Delete: ActionItem = {
title: $t('delete'),
icon: mdiDelete,
icon: mdiDeleteOutline,
color: 'danger',
onAction: async () => {
await handleDeleteWorkflow(workflow);
},
onAction: () => handleDeleteWorkflow(workflow),
};
return { ToggleEnabled, Edit, Delete };
return { CopyJson, Download, Duplicate, ToggleEnabled, Edit, Delete };
};
export const getWorkflowShowSchemaAction = (
@@ -85,10 +136,10 @@ export const handleCreateWorkflow = async (dto: WorkflowCreateDto) => {
try {
const response = await createWorkflow({ workflowCreateDto: dto });
eventManager.emit('WorkflowCreate', response);
return true;
toastManager.success();
return response;
} catch (error) {
handleError(error, $t('errors.unable_to_create'));
return false;
}
};
+39 -2
View File
@@ -24,6 +24,7 @@ import { init, register, t } from 'svelte-i18n';
import { derived, get } from 'svelte/store';
import { defaultLang, locales } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { downloadManager } from '$lib/managers/download-manager.svelte';
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
@@ -249,17 +250,53 @@ export const getProfileImageUrl = (user: UserResponseDto) =>
export const getPeopleThumbnailUrl = (person: PersonResponseDto, updatedAt?: string) =>
createUrl(getPeopleThumbnailPath(person.id), { updatedAt: updatedAt ?? person.updatedAt });
export const copyToClipboard = async (secret: string) => {
export const copyToClipboard = async (secret: string | unknown) => {
const $t = get(t);
try {
await navigator.clipboard.writeText(secret);
const value = typeof secret === 'string' ? secret : JSON.stringify(secret, jsonReplacer, 2);
await navigator.clipboard.writeText(value);
toastManager.info($t('copied_to_clipboard'));
} catch (error) {
handleError(error, $t('errors.unable_to_copy_to_clipboard'));
}
};
// https://stackoverflow.com/questions/16167581/sort-object-properties-and-json-stringify/43636793#43636793
const jsonReplacer = (_key: string, value: unknown) =>
value instanceof Object && !Array.isArray(value)
? Object.keys(value)
.sort()
// eslint-disable-next-line unicorn/no-array-reduce
.reduce((sorted: { [key: string]: unknown }, key) => {
sorted[key] = (value as { [key: string]: unknown })[key];
return sorted;
}, {})
: value;
export const downloadUrl = (url: string, filename: string) => {
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
};
export const downloadBlob = (data: Blob, filename: string) => downloadUrl(URL.createObjectURL(data), filename);
export const downloadJson = (data: unknown, filename: string) => {
const blob = new Blob([JSON.stringify(data, jsonReplacer, 2)], { type: 'application/json' });
const downloadKey = filename;
downloadManager.add(downloadKey, blob.size);
downloadManager.update(downloadKey, blob.size);
downloadBlob(blob, downloadKey);
setTimeout(() => downloadManager.clear(downloadKey), 5000);
};
export const oauth = {
isCallback: (location: Location) => {
const search = location.search;
+1 -27
View File
@@ -26,7 +26,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte';
import { downloadManager } from '$lib/managers/download-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { downloadRequest, withError } from '$lib/utils';
import { downloadBlob, downloadRequest, withError } from '$lib/utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { getFormatter } from '$lib/utils/i18n';
import { navigate } from '$lib/utils/navigation';
@@ -73,32 +73,6 @@ export const removeTag = async ({
return assetIds;
};
export const downloadBlob = (data: Blob, filename: string) => {
const url = URL.createObjectURL(data);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
};
export const downloadUrl = (url: string, filename: string) => {
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
};
export const downloadArchive = async (fileName: string, options: Omit<DownloadInfoDto, 'archiveSize'>) => {
const archiveSize = authManager.authenticated ? authManager.preferences.download.archiveSize : undefined;
const dto = { ...options, archiveSize };
+16 -31
View File
@@ -16,12 +16,11 @@
CardTitle,
CodeBlock,
Container,
ContextMenuButton,
Icon,
IconButton,
MenuItemType,
menuManager,
} from '@immich/ui';
import { mdiClose, mdiDotsVertical, mdiFlashOutline } from '@mdi/js';
import { mdiClose, mdiFlashOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
import type { PageData } from './$types';
@@ -36,7 +35,7 @@
const expandedIds = new SvelteSet<string>();
const toggleExpanded = (id: string) => {
const onToggleExpand = (id: string) => {
if (expandedIds.has(id)) {
expandedIds.delete(id);
} else {
@@ -44,21 +43,6 @@
}
};
const showWorkflowMenu = (event: MouseEvent, workflow: WorkflowResponseDto) => {
const { ToggleEnabled, Edit, Delete } = getWorkflowActions($t, workflow);
void menuManager.show({
target: event.currentTarget as HTMLElement,
position: 'top-left',
items: [
ToggleEnabled,
Edit,
getWorkflowShowSchemaAction($t, expandedIds.has(workflow.id), () => toggleExpanded(workflow.id)),
MenuItemType.Divider,
Delete,
],
});
};
const { Create, UseTemplate } = $derived(getWorkflowsActions($t));
const onWorkflowCreate = async (response: WorkflowResponseDto) => {
@@ -91,6 +75,8 @@
{:else}
<div class="my-6 flex flex-col gap-3">
{#each workflows as workflow (workflow.id)}
{@const { ToggleEnabled, Duplicate, Edit, Delete } = getWorkflowActions($t, workflow)}
<Card class="group shadow-none transition-colors hover:border-primary">
<CardHeader>
<a
@@ -128,17 +114,16 @@
{/if}
</div>
<IconButton
shape="round"
variant="ghost"
color="secondary"
icon={mdiDotsVertical}
aria-label={$t('menu')}
onclick={(event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
showWorkflowMenu(event, workflow);
}}
<ContextMenuButton
position="top-left"
items={[
ToggleEnabled,
Edit,
Duplicate,
getWorkflowShowSchemaAction($t, expandedIds.has(workflow.id), () => onToggleExpand(workflow.id)),
MenuItemType.Divider,
Delete,
]}
/>
</a>
@@ -152,7 +137,7 @@
fullWidth
variant="ghost"
color="secondary"
onclick={() => toggleExpanded(workflow.id)}
onclick={() => onToggleExpand(workflow.id)}
>
{$t('close')}
</Button>
@@ -6,7 +6,7 @@
import WorkflowEditStepModal from '$lib/modals/WorkflowEditStepModal.svelte';
import WorkflowTriggerPicker from '$lib/modals/WorkflowTriggerPicker.svelte';
import { Route } from '$lib/route';
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
import { getWorkflowActions, handleUpdateWorkflow } from '$lib/services/workflow.service';
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
import type { WorkflowResponseDto, WorkflowStepDto, WorkflowUpdateDto } from '@immich/sdk';
import {
@@ -83,7 +83,7 @@
let dropTargetIndex = $state<number | null>(null);
const workflowSummary = $derived({ name, description, trigger, steps });
const workflowJsonContent = $derived<WorkflowJsonContent>({ description, enabled, name, steps, trigger });
const workflowJsonContent = $derived<WorkflowJsonContent>({ name, description, enabled, trigger, steps });
const hasChanges = $derived(
enabled !== savedWorkflow.enabled ||
@@ -217,6 +217,12 @@
}
};
const onWorkflowDelete = async (response: WorkflowResponseDto) => {
if (id === response.id) {
await goto(Route.workflows());
}
};
const confirmNavigation = async () => {
if (!hasChanges) {
return true;
@@ -273,60 +279,73 @@
}
});
});
const { Download, Duplicate, CopyJson, Delete } = $derived(
getWorkflowActions($t, { ...savedWorkflow, name, description, enabled, trigger, steps }),
);
</script>
<OnEvents {onWorkflowUpdate} />
<OnEvents {onWorkflowUpdate} {onWorkflowDelete} />
<AppShell class="">
<AppShellBar>
<ActionBar static {onClose} translations={{ close: $t('back') }} closeIcon={mdiArrowLeft}>
<ActionBar
shape="round"
static
{onClose}
translations={{ close: $t('back') }}
closeIcon={mdiArrowLeft}
actions={[Duplicate, CopyJson, Download, Delete].map((item) => ({ ...item, color: undefined }))}
>
<ControlBarHeader>
<ControlBarTitle>{data.workflow.name}</ControlBarTitle>
<ControlBarDescription>{data.workflow.description}</ControlBarDescription>
</ControlBarHeader>
<ControlBarContent class="flex items-center justify-end gap-6">
<div class="flex gap-1 rounded-full border border-light-200 bg-light p-1" role="group">
{#if hasChanges}
<Button
variant={editMode === 'visual' ? 'filled' : 'ghost'}
color={editMode === 'visual' ? 'primary' : 'secondary'}
variant="filled"
size="small"
leadingIcon={mdiFormatListBulletedSquare}
aria-pressed={editMode === 'visual'}
onclick={() => (editMode = 'visual')}
shape="round"
color="primary"
leadingIcon={mdiContentSave}
disabled={!hasChanges || isSaving}
loading={isSaving}
onclick={saveWorkflow}
>
{$t('visual')}
{$t('save')}
</Button>
<Button
variant={editMode === 'json' ? 'filled' : 'ghost'}
color={editMode === 'json' ? 'primary' : 'secondary'}
size="small"
leadingIcon={mdiCodeJson}
aria-pressed={editMode === 'json'}
onclick={() => (editMode = 'json')}
shape="round"
>
JSON
</Button>
</div>
<Button
variant="filled"
size="small"
color="primary"
leadingIcon={mdiContentSave}
disabled={!hasChanges || isSaving}
loading={isSaving}
onclick={saveWorkflow}
>
{$t('save')}
</Button>
{/if}
</ControlBarContent>
</ActionBar>
</AppShellBar>
<Container size="medium" class="pt-8 pb-24" center>
<VStack gap={4}>
<div class="flex gap-1 rounded-full border border-light-200 bg-light p-1" role="group">
<Button
variant={editMode === 'visual' ? 'filled' : 'ghost'}
color={editMode === 'visual' ? 'primary' : 'secondary'}
size="small"
leadingIcon={mdiFormatListBulletedSquare}
aria-pressed={editMode === 'visual'}
onclick={() => (editMode = 'visual')}
shape="round"
>
{$t('visual')}
</Button>
<Button
variant={editMode === 'json' ? 'filled' : 'ghost'}
color={editMode === 'json' ? 'primary' : 'secondary'}
size="small"
leadingIcon={mdiCodeJson}
aria-pressed={editMode === 'json'}
onclick={() => (editMode = 'json')}
shape="round"
>
JSON
</Button>
</div>
{#if editMode === 'visual'}
<Card class="shadow-none" expandable>
<CardHeader>
@@ -354,9 +373,8 @@
bind:value={() => name ?? '', (value) => (name = value || null)}
/>
</Field>
<Field label={$t('description')} for="workflow-description">
<Field label={$t('description')}>
<Textarea
id="workflow-description"
grow
placeholder={$t('workflow_description')}
bind:value={() => description ?? '', (value) => (description = value || null)}