refactor: workflow queries (#32654)

(cherry picked from commit 98680ad6993ed917e630f8f4d1dbd1fbb6c41936)
This commit is contained in:
Jason Rasmussen
2026-10-22 20:43:14 +03:00
committed by Codex
parent 99a7830f6c
commit 1e20bdd74e
13 changed files with 195 additions and 178 deletions
@@ -15,7 +15,6 @@ import type {
AdminConfigDto,
TagResponseDto,
UserAdminResponseDto,
WorkflowResponseDto,
} from '@immich/sdk';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
@@ -96,10 +95,6 @@ export type Events = {
JobCreate: [{ dto: JobCreateDto }];
WorkflowCreate: [WorkflowResponseDto];
WorkflowUpdate: [WorkflowResponseDto];
WorkflowDelete: [WorkflowResponseDto];
ReleaseEvent: [ReleaseEventV1];
WebsocketConnect: [];
@@ -1,7 +1,4 @@
import {
getWorkflowTriggers,
searchPluginMethods,
searchPluginTemplates,
WorkflowTrigger,
type PluginMethodResponseDto,
type PluginTemplateResponseDto,
@@ -12,6 +9,9 @@ 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';
import { queryClient } from '$lib/queries/client';
import { searchPluginMethodsQuery, searchPluginTemplatesQuery } from '$lib/queries/plugin.query';
import { getWorkflowTriggersQuery } from '$lib/queries/workflow.query';
class PluginManager {
#loading: Promise<void> | undefined;
@@ -78,9 +78,9 @@ class PluginManager {
private async load() {
const [methods, triggers, templates] = await Promise.all([
searchPluginMethods({}),
getWorkflowTriggers(),
searchPluginTemplates(),
queryClient.query(searchPluginMethodsQuery()),
queryClient.query(getWorkflowTriggersQuery()),
queryClient.query(searchPluginTemplatesQuery()),
]);
this.#methods = methods;
+16 -8
View File
@@ -1,7 +1,10 @@
<script lang="ts">
import { searchPluginMethods, WorkflowTrigger, type PluginMethodResponseDto } from '@immich/sdk';
import { WorkflowTrigger, type PluginMethodResponseDto } from '@immich/sdk';
import { Badge, BasicModal, ListButton, LoadingSpinner, Stack, Text } from '@immich/ui';
import { createQuery } from '@tanstack/svelte-query';
import { t } from 'svelte-i18n';
import { queryClient } from '$lib/queries/client';
import { searchPluginMethodsQuery } from '$lib/queries/plugin.query';
type Props = {
trigger: WorkflowTrigger;
@@ -10,16 +13,17 @@
};
const { trigger, selectedKey, onClose }: Props = $props();
const query = createQuery(
() => searchPluginMethodsQuery({ trigger }),
() => queryClient,
);
</script>
<BasicModal title={$t('add_step')} onClose={() => onClose()} size="medium">
{#await searchPluginMethods({ trigger })}
<div class="flex w-full place-content-center place-items-center">
<LoadingSpinner />
</div>
{:then methods}
{#if query.data}
<Stack>
{#each methods as method (method.key)}
{#each query.data 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"
@@ -37,5 +41,9 @@
</ListButton>
{/each}
</Stack>
{/await}
{:else}
<div class="flex w-full place-content-center place-items-center">
<LoadingSpinner />
</div>
{/if}
</BasicModal>
@@ -1,7 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { executeMutation } from '$lib/queries/client';
import { createWorkflowMutation } from '$lib/queries/workflow.query';
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';
@@ -18,18 +19,16 @@
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();
}
await executeMutation(
createWorkflowMutation,
[{ workflowCreateDto: { name, description, trigger, steps: workflow.steps, enabled: false } }],
{
onSuccess: (response) => {
onClose();
void goto(Route.viewWorkflow(response));
},
},
);
};
</script>
+9 -9
View File
@@ -21,7 +21,8 @@
import { locale } from '$lib/stores/preferences.store';
import { DateTime } from 'luxon';
import { Route } from '$lib/route';
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
import { executeMutation } from '$lib/queries/client';
import { updateWorkflowMutation } from '$lib/queries/workflow.query';
type Props = {
workflow: WorkflowResponseDto;
@@ -37,14 +38,13 @@
let hasNext = $state(true);
let loading = $state(false);
const setLogging = async (logging: boolean) => {
const success = await handleUpdateWorkflow(workflow.id, { logging });
if (!success) {
return;
}
workflow = { ...workflow, logging };
reset();
};
const setLogging = (logging: boolean) =>
executeMutation(updateWorkflowMutation, [{ id: workflow.id, workflowUpdateDto: { logging } }], {
onSuccess: () => {
workflow = { ...workflow, logging };
reset();
},
});
const reset = () => {
entries = [];
@@ -1,6 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { handleCreateWorkflow } from '$lib/services/workflow.service';
import { executeMutation } from '$lib/queries/client';
import { createWorkflowMutation } from '$lib/queries/workflow.query';
import { Route } from '$lib/route';
import { type PluginTemplateResponseDto } from '@immich/sdk';
import { Badge, FormModal, Icon, ListButton, Text } from '@immich/ui';
import { mdiFlashOutline } from '@mdi/js';
@@ -19,17 +22,15 @@
return;
}
const success = await handleCreateWorkflow({
trigger: selected.trigger,
steps: selected.steps,
name: selected.title,
description: selected.description,
enabled: false,
});
const { trigger, steps, title: name, description } = selected;
const workflowCreateDto = { trigger, steps, name, description, enabled: false };
if (success) {
onClose();
}
await executeMutation(createWorkflowMutation, [{ workflowCreateDto }], {
onSuccess: (response) => {
onClose();
void goto(Route.viewWorkflow(response));
},
});
};
const isSelected = (template: PluginTemplateResponseDto) => selected?.key === template.key;
+14
View File
@@ -0,0 +1,14 @@
import { searchPluginMethods, searchPluginTemplates, type WorkflowTrigger } from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query';
export const searchPluginMethodsQuery = (filter: { trigger?: WorkflowTrigger } = {}) =>
queryOptions({
queryKey: ['plugins', 'methods', filter],
queryFn: ({ signal }) => searchPluginMethods(filter, { signal }),
});
export const searchPluginTemplatesQuery = () =>
queryOptions({
queryKey: ['plugins', 'templates'],
queryFn: ({ signal }) => searchPluginTemplates({ signal }),
});
+73
View File
@@ -0,0 +1,73 @@
import {
createWorkflow,
deleteWorkflow,
getWorkflowForShare,
getWorkflowTriggers,
searchWorkflows,
updateWorkflow,
type WorkflowResponseDto,
} from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query';
import { asMutation, queryClient } from '$lib/queries/client';
const findOrFail = (workflows: WorkflowResponseDto[], id: string) => {
const workflow = workflows.find((workflow) => workflow.id === id);
if (!workflow) {
throw new Error(`Workflow ${id} not found`);
}
return workflow;
};
export const searchWorkflowsQuery = () =>
queryOptions({
queryKey: ['workflows'],
queryFn: ({ signal }) => searchWorkflows({}, { signal }),
});
export const getWorkflowQuery = (id: string) =>
queryOptions({
...searchWorkflowsQuery(),
select: (workflows) => findOrFail(workflows, id),
});
export const getWorkflowForShareQuery = (id: string) =>
queryOptions({
queryKey: ['workflows', id, 'share'],
queryFn: ({ signal }) => getWorkflowForShare({ id }, { signal }),
staleTime: 0,
});
export const getWorkflowTriggersQuery = () =>
queryOptions({
queryKey: ['workflows', 'triggers'],
queryFn: ({ signal }) => getWorkflowTriggers({ signal }),
});
export const createWorkflowMutation = () =>
asMutation(createWorkflow, {
onSuccess: (workflow) =>
queryClient.setQueryData(searchWorkflowsQuery().queryKey, (workflows) =>
workflows ? [...workflows, workflow] : workflows,
),
meta: { success: { toast: true }, error: { toast: 'errors.unable_to_create' } },
});
export const updateWorkflowMutation = () =>
asMutation(updateWorkflow, {
onSuccess: (workflow) =>
queryClient.setQueryData(searchWorkflowsQuery().queryKey, (workflows) =>
workflows?.map((item) => (item.id === workflow.id ? workflow : item)),
),
meta: { success: { toast: 'workflow_update_success' }, error: { toast: 'errors.unable_to_update_workflow' } },
});
export const deleteWorkflowMutation = () =>
asMutation(deleteWorkflow, {
onSuccess: (_, [{ id }]) => {
queryClient.setQueryData(searchWorkflowsQuery().queryKey, (workflows) =>
workflows?.filter((workflow) => workflow.id !== id),
);
queryClient.removeQueries({ queryKey: getWorkflowForShareQuery(id).queryKey });
},
meta: { success: { toast: 'workflow_deleted' }, error: { toast: 'errors.unable_to_delete_workflow' } },
});
+21 -56
View File
@@ -1,13 +1,5 @@
import {
createWorkflow,
deleteWorkflow,
updateWorkflow,
WorkflowTrigger,
type WorkflowCreateDto,
type WorkflowResponseDto,
type WorkflowUpdateDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { WorkflowTrigger, type WorkflowResponseDto } from '@immich/sdk';
import { modalManager, type ActionItem } from '@immich/ui';
import {
mdiCodeJson,
mdiContentCopy,
@@ -23,13 +15,14 @@ import {
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { page } from '$app/state';
import WorkflowDuplicateModal from '$lib/modals/WorkflowDuplicateModal.svelte';
import WorkflowLogsModal from '$lib/modals/WorkflowLogsModal.svelte';
import WorkflowTemplatePickerModal from '$lib/modals/WorkflowTemplatePickerModal.svelte';
import { executeMutation } from '$lib/queries/client';
import { createWorkflowMutation, deleteWorkflowMutation, updateWorkflowMutation } from '$lib/queries/workflow.query';
import { Route } from '$lib/route';
import { copyToClipboard, downloadJson } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
export const getWorkflowsActions = ($t: MessageFormatter) => {
@@ -37,11 +30,11 @@ export const getWorkflowsActions = ($t: MessageFormatter) => {
title: $t('create_workflow'),
icon: mdiPlus,
onAction: () =>
handleCreateWorkflow({
trigger: WorkflowTrigger.AssetCreate,
steps: [],
enabled: false,
}),
executeMutation(
createWorkflowMutation,
[{ workflowCreateDto: { trigger: WorkflowTrigger.AssetCreate, steps: [], enabled: false } }],
{ onSuccess: (workflow) => void goto(Route.viewWorkflow(workflow)) },
),
};
const UseTemplate: ActionItem = {
@@ -57,7 +50,8 @@ export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowRespo
const ToggleEnabled: ActionItem = {
title: workflow.enabled ? $t('disable') : $t('enable'),
icon: workflow.enabled ? mdiPause : mdiPlay,
onAction: () => handleUpdateWorkflow(workflow.id, { enabled: !workflow.enabled }),
onAction: () =>
executeMutation(updateWorkflowMutation, [{ id: workflow.id, workflowUpdateDto: { enabled: !workflow.enabled } }]),
};
const CopyJson: ActionItem = {
@@ -133,34 +127,7 @@ export const getWorkflowShowSchemaAction = (
onAction: onToggle,
});
export const handleCreateWorkflow = async (dto: WorkflowCreateDto) => {
const $t = await getFormatter();
try {
const response = await createWorkflow({ workflowCreateDto: dto });
eventManager.emit('WorkflowCreate', response);
toastManager.success();
return response;
} catch (error) {
handleError(error, $t('errors.unable_to_create'));
}
};
export const handleUpdateWorkflow = async (id: string, dto: WorkflowUpdateDto) => {
const $t = await getFormatter();
try {
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;
}
};
export const handleDeleteWorkflow = async (workflow: WorkflowResponseDto): Promise<boolean> => {
const handleDeleteWorkflow = async (workflow: WorkflowResponseDto) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({
@@ -169,16 +136,14 @@ export const handleDeleteWorkflow = async (workflow: WorkflowResponseDto): Promi
});
if (!confirmed) {
return false;
return;
}
try {
await deleteWorkflow({ id: workflow.id });
eventManager.emit('WorkflowDelete', workflow);
toastManager.primary($t('workflow_deleted'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_delete_workflow'));
return false;
}
await executeMutation(deleteWorkflowMutation, [{ id: workflow.id }], {
onSuccess: () => {
if (page.params.workflowId === workflow.id) {
void goto(Route.workflows());
}
},
});
};
+5 -19
View File
@@ -1,12 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import emptyWorkflows from '$lib/assets/empty-workflows.svg';
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
import { createPreloadedQuery, queryClient } from '$lib/queries/client';
import { getWorkflowForShareQuery, searchWorkflowsQuery } from '$lib/queries/workflow.query';
import { Route } from '$lib/route';
import { getWorkflowActions, getWorkflowsActions, getWorkflowShowSchemaAction } from '$lib/services/workflow.service';
import { getWorkflowForShare, type WorkflowResponseDto } from '@immich/sdk';
import {
Badge,
Button,
@@ -31,7 +30,8 @@
let { data }: Props = $props();
let workflows = $state<WorkflowResponseDto[]>(data.workflows);
const query = createPreloadedQuery(searchWorkflowsQuery);
const workflows = $derived(query.data);
const expandedIds = new SvelteSet<string>();
@@ -44,22 +44,8 @@
};
const { Create, UseTemplate } = $derived(getWorkflowsActions($t));
const onWorkflowCreate = async (response: WorkflowResponseDto) => {
await goto(Route.viewWorkflow(response));
};
const onWorkflowUpdate = (response: WorkflowResponseDto) => {
workflows = workflows.map((workflow) => (workflow.id === response.id ? response : workflow));
};
const onWorkflowDelete = (response: WorkflowResponseDto) => {
workflows = workflows.filter(({ id }) => id !== response.id);
};
</script>
<OnEvents {onWorkflowCreate} {onWorkflowUpdate} {onWorkflowDelete} />
<UserPageLayout title={data.meta.title} actions={[UseTemplate, Create]} scrollbar={false}>
<section class="flex place-content-center sm:mx-4">
<Container center size="large" class="pb-28">
@@ -129,7 +115,7 @@
</a>
{#if expandedIds.has(workflow.id)}
{#await getWorkflowForShare({ id: workflow.id }) then result}
{#await queryClient.query(getWorkflowForShareQuery(workflow.id)) then result}
<div class="border-t border-gray-200 p-4 dark:border-gray-800">
<CodeBlock code={JSON.stringify(result, null, 2)} lineNumbers />
<Button
+3 -3
View File
@@ -1,5 +1,6 @@
import { searchWorkflows } from '@immich/sdk';
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { queryClient } from '$lib/queries/client';
import { searchWorkflowsQuery } from '$lib/queries/workflow.query';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
@@ -7,11 +8,10 @@ import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const [workflows] = await Promise.all([searchWorkflows({}), pluginManager.ready()]);
await Promise.all([queryClient.query(searchWorkflowsQuery()), pluginManager.ready()]);
const $t = await getFormatter();
return {
workflows,
meta: {
title: $t('workflows'),
},
@@ -1,14 +1,15 @@
<script lang="ts">
import { beforeNavigate, goto, invalidate } from '$app/navigation';
import OnEvents from '$lib/components/OnEvents.svelte';
import { beforeNavigate, goto } from '$app/navigation';
import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte';
import WorkflowEditStepModal from '$lib/modals/WorkflowEditStepModal.svelte';
import WorkflowTriggerPicker from '$lib/modals/WorkflowTriggerPicker.svelte';
import { createPreloadedQuery, executeMutation } from '$lib/queries/client';
import { getWorkflowQuery, updateWorkflowMutation } from '$lib/queries/workflow.query';
import { Route } from '$lib/route';
import { getWorkflowActions, handleUpdateWorkflow } from '$lib/services/workflow.service';
import { getWorkflowActions } from '$lib/services/workflow.service';
import { generateId } from '$lib/utils/generate-id';
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
import type { WorkflowResponseDto, WorkflowUpdateDto } from '@immich/sdk';
import type { WorkflowUpdateDto } from '@immich/sdk';
import {
ActionBar,
AppShell,
@@ -46,7 +47,7 @@
import { cloneDeep, isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { flip } from 'svelte/animate';
import type { PageData } from './$types';
import type { PageProps } from './$types';
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
import WorkflowStepCard from './WorkflowStepCard.svelte';
import WorkflowSummary from './WorkflowSummary.svelte';
@@ -57,15 +58,14 @@
type EditMode = 'visual' | 'json';
type Props = {
data: PageData;
};
let { params }: PageProps = $props();
let { data }: Props = $props();
const query = createPreloadedQuery(() => getWorkflowQuery(params.workflowId));
const workflow = $derived(query.data);
let { id, enabled, name, description, trigger } = $state(data.workflow);
let steps = $state(data.workflow.steps.map((step) => ({ ...step, id: generateId() })));
let savedWorkflow = $derived(cloneDeep(data.workflow));
let { id, enabled, name, description, trigger } = $state(query.data);
let steps = $state(query.data.steps.map((step) => ({ ...step, id: generateId() })));
const savedWorkflow = $derived(cloneDeep(workflow));
let allowNavigation = $state(false);
let isShowingNavigationDialog = $state(false);
let isSaving = $state(false);
@@ -188,22 +188,6 @@
}
};
const onWorkflowUpdate = async (response: WorkflowResponseDto) => {
if (id !== response.id) {
return;
}
data.workflow = response;
savedWorkflow = cloneDeep(response);
await invalidate('workflow:data');
};
const onWorkflowDelete = async (response: WorkflowResponseDto) => {
if (id === response.id) {
await goto(Route.workflows());
}
};
const confirmNavigation = async () => {
if (!hasChanges) {
return true;
@@ -231,12 +215,9 @@
isSaving = true;
try {
const submitted = { enabled, name, description, trigger, steps: cloneDeep(steps) };
const saved = await handleUpdateWorkflow(id, submitted);
if (saved) {
Object.assign(savedWorkflow, submitted);
}
await executeMutation(updateWorkflowMutation, [
{ id, workflowUpdateDto: { enabled, name, description, trigger, steps: cloneDeep(steps) } },
]);
} finally {
isSaving = false;
}
@@ -270,8 +251,6 @@
);
</script>
<OnEvents {onWorkflowUpdate} {onWorkflowDelete} />
<AppShell class="">
<AppShellBar>
<ActionBar
@@ -283,8 +262,8 @@
actions={[Logs, Duplicate, CopyJson, Download, Delete].map((item) => ({ ...item, color: undefined }))}
>
<ControlBarHeader>
<ControlBarTitle>{data.workflow.name}</ControlBarTitle>
<ControlBarDescription>{data.workflow.description}</ControlBarDescription>
<ControlBarTitle>{workflow.name}</ControlBarTitle>
<ControlBarDescription>{workflow.description}</ControlBarDescription>
</ControlBarHeader>
<ControlBarContent class="flex items-center justify-end gap-6">
{#if hasChanges}
@@ -1,24 +1,21 @@
import { searchWorkflows } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { queryClient } from '$lib/queries/client';
import { getWorkflowQuery } from '$lib/queries/workflow.query';
import { Route } from '$lib/route';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url, params, depends }) => {
export const load = (async ({ url, params }) => {
await authenticate(url);
const [[workflow]] = await Promise.all([searchWorkflows({ id: params.workflowId }), pluginManager.ready()]);
await Promise.all([
queryClient.query(getWorkflowQuery(params.workflowId)).catch(() => redirect(307, Route.workflows())),
pluginManager.ready(),
]);
const $t = await getFormatter();
if (!workflow) {
redirect(307, Route.workflows());
}
depends('workflow:data');
return {
workflow,
meta: {
title: $t('edit_workflow'),
},