Compare commits

...

3 Commits

Author SHA1 Message Date
Alex Tran
7ed646178e chore: clean up 2025-12-06 03:04:58 +00:00
Alex Tran
3d771127d2 fix: new schemaformfield has value of the same type 2025-12-06 02:49:00 +00:00
Alex Tran
5156438336 more refactor 2025-12-05 20:56:31 +00:00
5 changed files with 182 additions and 156 deletions

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { formatLabel, getComponentFromSchema } from '$lib/utils/workflow';
import { Field, Input, MultiSelect, Select, Switch, Text, type SelectItem } from '@immich/ui';
import { getComponentDefaultValue, getComponentFromSchema } from '$lib/utils/workflow';
import { Field, Input, MultiSelect, Select, Switch, Text } from '@immich/ui';
import WorkflowPickerField from './WorkflowPickerField.svelte';
type Props = {
@@ -25,60 +25,37 @@
config = configKey ? { ...config, [configKey]: { ...actualConfig, ...updates } } : { ...config, ...updates };
};
let selectValue = $state<SelectItem>();
let switchValue = $state<boolean>(false);
let multiSelectValue = $state<SelectItem[]>([]);
// Derive which keys need initialization (missing from actualConfig)
const uninitializedKeys = $derived.by(() => {
if (!components) {
return [];
}
return Object.entries(components)
.filter(([key]) => actualConfig[key] === undefined)
.map(([key, component]) => ({ key, component, defaultValue: getComponentDefaultValue(component) }));
});
// Derive the batch updates needed
const pendingUpdates = $derived.by(() => {
const updates: Record<string, unknown> = {};
for (const { key, defaultValue } of uninitializedKeys) {
updates[key] = defaultValue;
}
return updates;
});
// Initialize config namespace if needed
$effect(() => {
// Initialize config for actions/filters with empty schemas
if (configKey && !config[configKey]) {
config = { ...config, [configKey]: {} };
}
});
if (components) {
const updates: Record<string, unknown> = {};
for (const [key, component] of Object.entries(components)) {
// Only initialize if the key doesn't exist in config yet
if (actualConfig[key] === undefined) {
// Use default value if available, otherwise use appropriate empty value based on type
const hasDefault = component.defaultValue !== undefined;
if (hasDefault) {
updates[key] = component.defaultValue;
} else {
// Initialize with appropriate empty value based on component type
if (
component.type === 'multiselect' ||
(component.type === 'text' && component.subType === 'people-picker')
) {
updates[key] = [];
} else if (component.type === 'switch') {
updates[key] = false;
} else {
updates[key] = '';
}
}
// Update UI state for components with default values
if (hasDefault) {
if (component.type === 'select') {
selectValue = {
label: formatLabel(String(component.defaultValue)),
value: String(component.defaultValue),
};
}
if (component.type === 'switch') {
switchValue = Boolean(component.defaultValue);
}
}
}
}
if (Object.keys(updates).length > 0) {
updateConfigBatch(updates);
}
// Apply pending config updates
$effect(() => {
if (Object.keys(pendingUpdates).length > 0) {
updateConfigBatch(pendingUpdates);
}
});
@@ -104,6 +81,8 @@
{@const options = component.options?.map((opt) => {
return { label: opt.label, value: String(opt.value) };
}) || [{ label: 'N/A', value: '' }]}
{@const currentValue = actualConfig[key]}
{@const selectedItem = options.find((opt) => opt.value === String(currentValue)) ?? options[0]}
<Field
{label}
@@ -111,7 +90,7 @@
description={component.description}
requiredIndicator={component.required}
>
<Select data={options} onChange={(opt) => updateConfig(key, opt.value)} bind:value={selectValue} />
<Select data={options} onChange={(opt) => updateConfig(key, opt.value)} value={selectedItem} />
</Field>
{/if}
@@ -128,6 +107,8 @@
{@const options = component.options?.map((opt) => {
return { label: opt.label, value: String(opt.value) };
}) || [{ label: 'N/A', value: '' }]}
{@const currentValues = (actualConfig[key] as string[]) ?? []}
{@const selectedItems = options.filter((opt) => currentValues.includes(opt.value))}
<Field
{label}
@@ -142,20 +123,21 @@
key,
opt.map((o) => o.value),
)}
bind:values={multiSelectValue}
values={selectedItems}
/>
</Field>
{/if}
<!-- Switch component -->
{:else if component.type === 'switch'}
{@const checked = Boolean(actualConfig[key])}
<Field
{label}
description={component.description}
requiredIndicator={component.required}
required={component.required}
>
<Switch bind:checked={switchValue} onCheckedChange={(check) => updateConfig(key, check)} />
<Switch {checked} onCheckedChange={(check) => updateConfig(key, check)} />
</Field>
<!-- Text input -->

View File

@@ -2,8 +2,9 @@
import WorkflowPickerItemCard from '$lib/components/workflows/WorkflowPickerItemCard.svelte';
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte';
import { fetchPickerMetadata, type PickerMetadata } from '$lib/services/workflow.service';
import type { ComponentConfig } from '$lib/utils/workflow';
import { getAlbumInfo, getPerson, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
import { Button, Field, modalManager } from '@immich/ui';
import { mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -22,7 +23,7 @@
const isAlbum = $derived(subType === 'album-picker');
const multiple = $derived(component.type === 'multiselect' || Array.isArray(value));
let pickerMetadata = $state<AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[]>();
let pickerMetadata = $state<PickerMetadata | undefined>();
$effect(() => {
if (!value) {
@@ -30,34 +31,20 @@
return;
}
void fetchMetadata();
if (!pickerMetadata) {
void loadMetadata();
}
});
const fetchMetadata = async () => {
if (!value || pickerMetadata) {
return;
}
try {
if (Array.isArray(value) && value.length > 0) {
// Multiple selection
pickerMetadata = await (isAlbum
? Promise.all(value.map((id) => getAlbumInfo({ id })))
: Promise.all(value.map((id) => getPerson({ id }))));
} else if (typeof value === 'string' && value) {
// Single selection
pickerMetadata = await (isAlbum ? getAlbumInfo({ id: value }) : getPerson({ id: value }));
}
} catch (error) {
console.error(`Failed to fetch metadata for ${configKey}:`, error);
}
const loadMetadata = async () => {
pickerMetadata = await fetchPickerMetadata(value, subType);
};
const handlePicker = async () => {
if (isAlbum) {
const albums = await modalManager.show(AlbumPickerModal, { shared: false });
if (albums && albums.length > 0) {
const newValue = multiple ? albums.map((a) => a.id) : albums[0].id;
const newValue = multiple ? albums.map((album) => album.id) : albums[0].id;
onchange(newValue);
pickerMetadata = multiple ? albums : albums[0];
}
@@ -66,7 +53,7 @@
const excludedIds = multiple ? currentIds : [];
const people = await modalManager.show(PeoplePickerModal, { multiple, excludedIds });
if (people && people.length > 0) {
const newValue = multiple ? people.map((p) => p.id) : people[0].id;
const newValue = multiple ? people.map((person) => person.id) : people[0].id;
onchange(newValue);
pickerMetadata = multiple ? people : people[0];
}

View File

@@ -6,8 +6,12 @@ import { getFormatter } from '$lib/utils/i18n';
import {
createWorkflow,
deleteWorkflow,
getAlbumInfo,
getPerson,
PluginTriggerType,
updateWorkflow,
type AlbumResponseDto,
type PersonResponseDto,
type PluginActionResponseDto,
type PluginContextType,
type PluginFilterResponseDto,
@@ -21,6 +25,9 @@ import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiCodeJson, mdiDelete, mdiPause, mdiPencil, mdiPlay } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
export type PickerSubType = 'album-picker' | 'people-picker';
export type PickerMetadata = AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[];
export interface WorkflowPayload {
name: string;
description: string;
@@ -50,42 +57,71 @@ export const getActionsByContext = (
return availableActions.filter((action) => action.supportedContexts.includes(context));
};
/**
* Initialize filter configurations from existing workflow
*/
export const initializeFilterConfigs = (
workflow: WorkflowResponseDto,
availableFilters: PluginFilterResponseDto[],
export const remapConfigsOnReorder = (
configs: Record<string, unknown>,
prefix: 'filter' | 'action',
fromIndex: number,
toIndex: number,
totalCount: number,
): Record<string, unknown> => {
const configs: Record<string, unknown> = {};
const newConfigs: Record<string, unknown> = {};
if (workflow.filters) {
for (const workflowFilter of workflow.filters) {
const filterDef = availableFilters.find((f) => f.id === workflowFilter.pluginFilterId);
if (filterDef) {
configs[filterDef.methodName] = workflowFilter.filterConfig ?? {};
}
}
// Create an array of configs in order
const configArray: unknown[] = [];
for (let i = 0; i < totalCount; i++) {
configArray.push(configs[`${prefix}_${i}`] ?? {});
}
return configs;
// 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;
};
/**
* Initialize action configurations from existing workflow
* Remap configs when an item is removed
* Shifts all configs after the removed index down by one
*/
export const initializeActionConfigs = (
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,
availableActions: PluginActionResponseDto[],
): Record<string, unknown> => {
const configs: Record<string, unknown> = {};
if (workflow.actions) {
for (const workflowAction of workflow.actions) {
const actionDef = availableActions.find((a) => a.id === workflowAction.pluginActionId);
if (actionDef) {
configs[actionDef.methodName] = workflowAction.actionConfig ?? {};
}
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 ?? {};
}
}
@@ -94,6 +130,7 @@ export const initializeActionConfigs = (
/**
* Build workflow payload from current state
* Uses index-based keys to support multiple filters/actions of the same type
*/
export const buildWorkflowPayload = (
name: string,
@@ -105,12 +142,12 @@ export const buildWorkflowPayload = (
filterConfigs: Record<string, unknown>,
actionConfigs: Record<string, unknown>,
): WorkflowPayload => {
const filters = orderedFilters.map((filter) => ({
[filter.methodName]: filterConfigs[filter.methodName] ?? {},
const filters = orderedFilters.map((filter, index) => ({
[filter.methodName]: filterConfigs[`filter_${index}`] ?? {},
}));
const actions = orderedActions.map((action) => ({
[action.methodName]: actionConfigs[action.methodName] ?? {},
const actions = orderedActions.map((action, index) => ({
[action.methodName]: actionConfigs[`action_${index}`] ?? {},
}));
return {
@@ -123,9 +160,6 @@ export const buildWorkflowPayload = (
};
};
/**
* Parse JSON workflow and update state
*/
export const parseWorkflowJson = (
jsonString: string,
availableTriggers: PluginTriggerResponseDto[],
@@ -148,33 +182,30 @@ export const parseWorkflowJson = (
try {
const parsed = JSON.parse(jsonString);
// Find trigger
const trigger = availableTriggers.find((t) => t.type === parsed.triggerType);
// Parse filters
const filters: PluginFilterResponseDto[] = [];
const filterConfigs: Record<string, unknown> = {};
if (Array.isArray(parsed.filters)) {
for (const filterObj of 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[methodName] = (filterObj as Record<string, unknown>)[methodName];
filterConfigs[`filter_${index}`] = (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) {
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[methodName] = (actionObj as Record<string, unknown>)[methodName];
actionConfigs[`action_${index}`] = (actionObj as Record<string, unknown>)[methodName];
}
}
}
@@ -200,9 +231,6 @@ export const parseWorkflowJson = (
}
};
/**
* Check if workflow has changes compared to previous version
*/
export const hasWorkflowChanged = (
previousWorkflow: WorkflowResponseDto,
enabled: boolean,
@@ -213,57 +241,42 @@ export const hasWorkflowChanged = (
orderedActions: PluginActionResponseDto[],
filterConfigs: Record<string, unknown>,
actionConfigs: Record<string, unknown>,
availableFilters: PluginFilterResponseDto[],
availableActions: PluginActionResponseDto[],
): 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.pluginFilterId) ?? [];
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.pluginActionId) ?? [];
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 = availableFilters.find((f) => f.id === wf.pluginFilterId);
if (filterDef) {
previousFilterConfigs[filterDef.methodName] = wf.filterConfig ?? {};
}
for (const [index, wf] of (previousWorkflow.filters ?? []).entries()) {
previousFilterConfigs[`filter_${index}`] = 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 = availableActions.find((a) => a.id === wa.pluginActionId);
if (actionDef) {
previousActionConfigs[actionDef.methodName] = wa.actionConfig ?? {};
}
for (const [index, wa] of (previousWorkflow.actions ?? []).entries()) {
previousActionConfigs[`action_${index}`] = wa.actionConfig ?? {};
}
if (JSON.stringify(previousActionConfigs) !== JSON.stringify(actionConfigs)) {
return true;
@@ -272,9 +285,6 @@ export const hasWorkflowChanged = (
return false;
};
/**
* Update a workflow via API
*/
export const handleUpdateWorkflow = async (
workflowId: string,
name: string,
@@ -286,14 +296,14 @@ export const handleUpdateWorkflow = async (
filterConfigs: Record<string, unknown>,
actionConfigs: Record<string, unknown>,
): Promise<WorkflowResponseDto> => {
const filters = orderedFilters.map((filter) => ({
const filters = orderedFilters.map((filter, index) => ({
pluginFilterId: filter.id,
filterConfig: filterConfigs[filter.methodName] ?? {},
filterConfig: filterConfigs[`filter_${index}`] ?? {},
})) as WorkflowFilterItemDto[];
const actions = orderedActions.map((action) => ({
const actions = orderedActions.map((action, index) => ({
pluginActionId: action.id,
actionConfig: actionConfigs[action.methodName] ?? {},
actionConfig: actionConfigs[`action_${index}`] ?? {},
})) as WorkflowActionItemDto[];
const updateDto: WorkflowUpdateDto = {
@@ -412,3 +422,30 @@ export const handleDeleteWorkflow = async (workflow: WorkflowResponseDto): Promi
export const handleNavigateToWorkflow = async (workflow: WorkflowResponseDto): Promise<void> => {
await goto(`${AppRoute.WORKFLOWS}/${workflow.id}`);
};
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;
};

View File

@@ -28,6 +28,22 @@ interface JSONSchema {
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;

View File

@@ -15,9 +15,10 @@
getFiltersByContext,
handleUpdateWorkflow,
hasWorkflowChanged,
initializeActionConfigs,
initializeFilterConfigs,
initializeConfigs,
parseWorkflowJson,
remapConfigsOnRemove,
remapConfigsOnReorder,
type WorkflowPayload,
} from '$lib/services/workflow.service';
import { handleError } from '$lib/utils/handle-error';
@@ -93,8 +94,8 @@
),
);
let filterConfigs: Record<string, unknown> = $derived(initializeFilterConfigs(editWorkflow, supportFilters));
let actionConfigs: Record<string, unknown> = $derived(initializeActionConfigs(editWorkflow, supportActions));
let filterConfigs: Record<string, unknown> = $derived(initializeConfigs('filter', editWorkflow));
let actionConfigs: Record<string, unknown> = $derived(initializeConfigs('action', editWorkflow));
$effect(() => {
editWorkflow.triggerType = triggerType;
@@ -127,7 +128,6 @@
actionConfigs,
);
// Update the previous workflow state to the new values
previousWorkflow = updated;
editWorkflow = updated;
@@ -195,12 +195,9 @@
selectedActions,
filterConfigs,
actionConfigs,
filters,
actions,
),
);
// Drag and drop handlers
let draggedFilterIndex: number | null = $state(null);
let draggedActionIndex: number | null = $state(null);
let dragOverFilterIndex: number | null = $state(null);
@@ -222,6 +219,9 @@
return;
}
// Remap configs to follow the new order
filterConfigs = remapConfigsOnReorder(filterConfigs, 'filter', draggedFilterIndex, index, selectedFilters.length);
const newFilters = [...selectedFilters];
const [draggedItem] = newFilters.splice(draggedFilterIndex, 1);
newFilters.splice(index, 0, draggedItem);
@@ -249,6 +249,8 @@
return;
}
actionConfigs = remapConfigsOnReorder(actionConfigs, 'action', draggedActionIndex, index, selectedActions.length);
const newActions = [...selectedActions];
const [draggedItem] = newActions.splice(draggedActionIndex, 1);
newActions.splice(index, 0, draggedItem);
@@ -260,12 +262,12 @@
dragOverActionIndex = null;
};
const handleAddStep = async (type?: 'action' | 'filter') => {
const result = (await modalManager.show(AddWorkflowStepModal, {
const handleAddStep = async (type: 'action' | 'filter') => {
const result = await modalManager.show(AddWorkflowStepModal, {
filters: supportFilters,
actions: supportActions,
type,
})) as { type: 'filter' | 'action'; item: PluginFilterResponseDto | PluginActionResponseDto } | undefined;
});
if (result) {
if (result.type === 'filter') {
@@ -277,10 +279,12 @@
};
const handleRemoveFilter = (index: number) => {
filterConfigs = remapConfigsOnRemove(filterConfigs, 'filter', index, selectedFilters.length);
selectedFilters = selectedFilters.filter((_, i) => i !== index);
};
const handleRemoveAction = (index: number) => {
actionConfigs = remapConfigsOnRemove(actionConfigs, 'action', index, selectedActions.length);
selectedActions = selectedActions.filter((_, i) => i !== index);
};
@@ -473,7 +477,7 @@
<SchemaFormFields
schema={filter.schema}
bind:config={filterConfigs}
configKey={filter.methodName}
configKey={`filter_${index}`}
/>
</div>
<div class="flex flex-col gap-2">
@@ -542,7 +546,7 @@
<SchemaFormFields
schema={action.schema}
bind:config={actionConfigs}
configKey={action.methodName}
configKey={`action_${index}`}
/>
</div>
<div class="flex flex-col gap-2">